포인터 타입 변환 규칙

2023년 2월 4일

용어 정의

값을 나타낼 수 있는 데이터(비트열 혹은 바이트열)를 지닌 메모리 상의 구역을 대상체(object)라고 합니다.
값은 타입(type)을 가지는데, 타입은 값을 데이터로 나타내는 방법을 결정합니다. 이 방법은 타입의 표현(representation)이라고 합니다.
대상체가 값을 가질 때에는 타입에 따라 메모리 상에서 특정 배수 주소에만 위치할 수 있는데, 이 제한을 정렬(alignment)이라고 합니다.
어떤 대상체를 지정하는 식(expression)을 좌변값(lvalue)라고 합니다.원래 lvalue라는 이름은 대입 연산에서 좌변에 올 수 있는 식이라고 해서 left-value를 의도하고 지은 이름이지만, C 표준에서는 lvalue의 정의에 대입 연산을 쓰지 않고 대상체와 결부해서만 정의하고 있기 때문에 엄밀히 보면 '좌변값'이라는 이름이 썩 좋지는 않습니다.
타입은 하나 이상의 한정자(qualifier)를 가질 수 있습니다. C의 한정자는 const, volatile, restrict, _Atomic이 있습니다. 타입이 const, volatile, restrict 한정자를 가지는 경우 타입이 한정되었다(qualified)고 말합니다.타입이 한정되었다고 할 때 _Atomic을 포함하지 않는 관계로 엄밀하게 표현할 때에는 흔히 cvr 한정되었다(cvr-qualified)고 말합니다.
대상체가 가지는 값은 유효 타입(effective type)을 가집니다. 변수 선언으로 생성된 대상체의 유효 타입은 선언한 타입입니다. 동적 할당으로 생성된 대상체의 유효 타입은 생성 직후에는 없습니다. 이때에는 문자 타입(char, signed char, unsigned char)이 아닌 좌변값이나 memcpy 또는 memmove로 대상체에 값을 수정해넣으면 대상체의 유효 타입이 다음 수정 전까지는 수정한 값의 타입이 됩니다. 그 외의 접근엔(유효 타입이 없거나 문자 타입의 좌변값을 사용해 접근할 경우) 유효 타입을 접근 시 사용한 좌변값의 타입으로 처리합니다.
두 타입은 특정 조건을 만족하면 서로 호환된다(compatible)고 말합니다. 호환되는 타입들끼리는 선언, 함수 호출, 대상체 접근 등의 상황에서 바꿔 쓸 수 있습니다. 호환 조건은 매우 복잡하므로 이 글에서는 다루지 않겠습니다. 자세한 목록은 cppreference를 참조하세요.

포인터 타입 변환

포인터의 타입 변환은 항상 어렵습니다. 원래 C의 타입 변환 규칙이 헷갈리기 쉬운데다 안 그래도 어렵기로 유명한 포인터인지라 더욱 헷갈리는 게 큽니다. 이 글에서는 C17 표준의 final draft인 n2176에 따라 포인터의 타입 변환 규칙을 살펴보겠습니다.

포인터 간의 변환

포인터를 포인터로 변환하는 규칙은 C17 표준에서 다음과 같이 나열하고 있습니다.

C17 §6.3.2.3 Pointers ¶1

A pointer to void may be converted to or from a pointer to any object type. A pointer to any object type may be converted to a pointer to void and back again; the result shall compare equal to the original pointer.

모든 포인터는 void를 가리키는 포인터로 변환 가능하고 그 반대도 가능합니다. 또한 포인터를 void를 가리키는 포인터로 변환한 뒤 다시 원래의 포인터 타입으로 변환하면 원래 포인터와 비교 시 동일합니다.


int *p = /* 초기화 */;
void *pv = (void *)p;           // void를 가리키는 포인터로 변환
int *q = (int *)pv;             // 다시 int를 가리키는 포인터로 변환
p == q;                         // 참

C17 §6.3.2.3 Pointers ¶2

For any qualifier q, a pointer to a non-q-qualified type may be converted to a pointer to the q-qualified version of the type; the values stored in the original and converted pointers shall compare equal.

한정자 q에 대해 q로 한정되지 않은 타입을 가리키는 포인터는 q로 한정된 타입을 가리키는 포인터로 변환 가능하고, 변환 후에도 원래 포인터와 비교 시 동일한 값을 가집니다.


int *p = /* 초기화 */;
const int *cp = (const int *)p; // const로 한정된 int를 가리키는 포인터로 변환
p == cp;                        // 참

C17 §6.3.2.3 Pointers ¶4

Conversion of a null pointer to another pointer type yields a null pointer of that type. Any two null pointers shall compare equal.

널 포인터는 다른 포인터 타입으로 변환할 수 있고, 변환 결과는 널 포인터이며 두 널 포인터는 비교 시 동일합니다.


int *p = NULL;
double *q = (double *)p;        // int를 가리키는 널 포인터를 double을 가리키는 포인터로 변환
q == NULL;                      // 결과가 널 포인터이므로 참
p == q;                         // 둘 다 널 포인터이므로 참

C17 §6.3.2.3 Pointers ¶7

A pointer to an object type may be converted to a pointer to a different object type. If the resulting pointer is not correctly aligned for the referenced type, the behavior is undefined. Otherwise, when converted back again, the result shall compare equal to the original pointer. When a pointer to an object is converted to a pointer to a character type, the result points to the lowest addressed byte of the object. Successive increments of the result, up to the size of the object, yield pointers to the remaining bytes of the object.

대상체 타입을 가리키는 포인터(즉, 함수 포인터가 아닌 포인터)는 다른 대상체 타입을 가리키는 포인터로 변환 가능하고, 원래 포인터 타입으로 다시 변환했을 때 원래 포인터와 비교 시 동일합니다. 단, 변환 결과가 타입에 맞게 정렬되지 않으면 정의되지 않은 동작입니다.


int *p = /* 초기화 */;
double *q = (double *)p;        // int를 가리키는 포인터를 double을 가리키는 포인터로 변환
                                // p가 가리키는 공간의 주소가 double의 정렬에 맞지 않는다면 정의되지 않음
double *r = (int *)q;           // 다시 int를 가리키는 포인터로 변환
p == r;                         // 참

포인터를 문자 타입을 가리키는 포인터로 변환할 수 있습니다. 이때 변환된 포인터는 대상체의 가장 낮은 바이트를 가리키고, 포인터를 계속 증가시키면 대상체의 나머지 바이트를 가리키게 할 수 있습니다.

C17 §6.3.2.3 Pointers ¶8

A pointer to a function of one type may be converted to a pointer to a function of another type and back again; the result shall compare equal to the original pointer. If a converted pointer is used to call a function whose type is not compatible with the referenced type, the behavior is undefine

함수 포인터는 다른 함수 포인터로 변환할 수 있고, 원래 포인터 타입으로 다시 변환했을 때 원래 포인터와 비교 시 동일합니다. 단, 호환되지 않는 타입의 함수로 변환하고 호출하면 정의되지 않은 동작입니다.


void (*f)(int) = /* 초기화 */;
void (*g)(double) = (void (*)(double))f;
void (*h)(int) = (void (*)(int))g;          // 원래 타입으로 변환
f == h;                                     // 참
g(1.0);                                     // void(int)와 void(double)은 호환되지 않으므로 정의되지 않음

추가로 표준에서는 포인터 타입에 관하여 다음과 같이 명시하고 있습니다.

C17 §6.2.5 Types ¶28

A pointer to void shall have the same representation and alignment requirements as a pointer to a character type.48) Similarly, pointers to qualified or unqualified versions of compatible types shall have the same representation and alignment requirements. All pointers to structure types shall have the same representation and alignment requirements as each other. All pointers to union types shall have the same representation and alignment requirements as each other. Pointers to other types need not have the same representation or alignment requirements.

48)The same representation and alignment requirements are meant to imply interchangeability as arguments to functions, return values from functions, and members of unions.

요약하자면 아래 포인터 타입들은 동일한 표현과 정렬을 갖습니다. 여기서 표현과 정렬이 동일하다는 것은 함수의 인자 및 반환값 또는 공용체 멤버에서 타입끼리 바꿔 써도 된다는 의미입니다. 또한 표현이 동일하므로 크기는 당연히 같고, memcpy 등으로 복사해도 괜찮습니다.

  1. void를 가리키는 포인터와 문자 타입을 가리키는 포인터
  2. 호환되는 타입들의 한정되거나 한정되지 않은 타입을 가리키는 포인터
  3. 구조체를 가리키는 포인터
  4. 공용체를 가리키는 포인터

struct S; struct T;
union U; union V;

sizeof(void *) == sizeof(char *);                       // 참
sizeof(int *) == sizeof(const int *);                   // 참
sizeof(int *const *) == sizeof(const int *const *);     // 참
sizeof(struct S *) == sizeof(struct T *);               // 참
sizeof(union U *) == sizeof(union V *);                 // 참

sizeof(void *) == sizeof(int *);                        // 보장되지 않음
sizeof(int *) == sizeof(double *);                      // 보장되지 않음
sizeof(int *) == sizeof(struct S *);                    // 보장되지 않음
sizeof(void *) == sizeof(void (*)(int));                // 보장되지 않음

구조체를 가리키는 포인터 간의 변환에는 특별한 규칙이 하나 더 있습니다.

C17 §6.7.2.1 Structures and union specifiers ¶15

Within a structure object, the non-bit-field members and the units in which bit-fields reside have addresses that increase in the order in which they are declared. A pointer to a structure object, suitably converted, points to its initial member (or if that member is a bit-field, then to the unit in which it resides), and vice versa. There may be unnamed padding within a structure object, but not at its beginning.

구조체를 가리키는 포인터는 구조체의 첫 번째 멤버를 가리키는 포인터로 변환할 수 있으며, 그 반대도 성립합니다. (구조체의 첫 번째 멤버가 비트 필드라면 비트 필드가 포함된 공간을 가리킵니다.)


struct { int a; char b; } *p = /* 초기화 */;
int *q = (int *)p;
*q;                                             // p->a와 동일함

포인터를 다른 대상체 타입을 가리키는 포인터로 변환한 뒤에 가리키는 대상체에 접근하는 것은 포인터 변환과는 또 다른 규칙들이 적용됩니다.

C17 §6.5 Expressions ¶7

An object shall have its stored value accessed only by an lvalue expression that has one of the following types:89)

  • a type compatible with the effective type of the object,
  • a qualified version of a type compatible with the effective type of the object,
  • a type that is the signed or unsigned type corresponding to the effective type of the object,
  • a type that is the signed or unsigned type corresponding to a qualified version of the effective type of the object,
  • an aggregate or union type that includes one of the aforementioned types among its members (including, recursively, a member of a subaggregate or contained union), or
  • a character type.

89)The intent of this list is to specify those circumstances in which an object may or may not be alias.

좌변값으로 대상체에 접근할 때에는 좌변값의 타입을 고려해야 합니다. 좌변값의 타입이 다음과 같은 상황에서는 아무 문제 없이 접근 가능합니다.


int a;                                      // 유효 타입이 int인 대상체 a 생성
*(&a) = 0;                                  // 문제 없음
*(const int *)(&a) = 0;                     // 문제 없음
*(unsigned int *)(&a) = 0;                  // 문제 없음
*(const unsigned int *)(&a) = 0;            // 문제 없음

struct S { int x, y; } b;                   // 유효 타입이 int인 대상체 b.x와 b.y 생성
b = (struct S){ 0, 0 };                     // b.x를 수정할 때 int를 멤버로 포함하는 구조체 S를 사용했으므로 문제 없음

*(double *)(&a) = 0.0;                      // int와 double은 호환되지 않으므로 정의되지 않은 행동
char c = *(char *)(&b.x);                   // 문제 없음

그러므로, 위에 해당하지 않는 상황에서는 포인터 변환이 대상체 접근 시의 문제만 일으킬 뿐 아무런 의미가 없습니다.포인터 변환을 통해 특정 타입의 데이터를 다른 타입으로 해석하는 일이 잦다보니(type punning이라고 합니다) 대부분의 구현체에서는 이러한 코드도 의도대로 동작합니다만, 이식성을 떨어뜨릴 수 있습니다. 이 주제도 굉장히 긴 글 하나를 적을 수 있을 만큼 복잡하기 때문에 이 글에서는 생략하겠습니다.

포인터와 배열 간의 변환

C에서 배열과 포인터는 그 상호 관계가 굉장한 혼돈을 일으킵니다. 특히나 둘을 섞어쓰기도 하다보니 더욱 그렇습니다. 표준에서 엄밀하게는 배열과 포인터의 관계를 다음과 같이 정의합니다.

C17 §6.3.2.1 Lvalues, arrays, and function designators ¶3

Except when it is the operand of the sizeof operator, or the unary & operator, or is a string literal used to initialize an array, an expression that has type “array of type” is converted to an expression with type “pointer to type” that points to the initial element of the array object and is not an lvalue. If the array object has register storage class, the behavior is undefined.

기본적으로 모든 배열 타입의 식은 해당 배열의 첫 원소를 가리키는 포인터 타입의 식으로 변환됩니다. 단, 다음 세 가지 경우에는 예외적으로 변환이 일어나지 않습니다.


int arr[4];
arr;                                        // arr[0]를 가리키는 int * 타입의 포인터
sizeof(arr);                                // sizeof(int *)가 아니라 sizeof(int) * 4와 같음
&arr;                                       // arr을 가리키는 int (*)[4] 타입의 포인터

int mdarr[3][4][5];
mdarr;                                      // mdarr[0]를 가리키는 int (*)[4][5] 타입의 포인터

char s[] = "Hello, world!";                 // 문자열 리터럴로 문자 배열 초기화
char t[] = (char *)"Hello, world!";         // 변환이 일어난다면 char *로 문자 배열을 초기화하는 셈
                                            // 문법 오류

포인터와 함수 간의 변환

함수에도 배열과 비슷한 관계가 성립합니다.

C17 §6.3.2.1 Lvalues, arrays, and function designators ¶4

A function designator is an expression that has function type. Except when it is the operand of the sizeof operator,66) or the unary & operator, a function designator with type “function returning type” is converted to an expression that has type “pointer to function returning type

66)Because this conversion does not occur, the operand of the sizeof operator remains a function designator and violates the constraints in 6.5.3.4.

배열과 마찬가지로 함수 타입의 식(함수 지시자, function designator)은 해당 함수를 가리키는 포인터 타입의 식으로 변환됩니다. 단, 다음 두 가지 경우에는 예외적으로 변환이 일어나지 않습니다.


void f(int x) { /* ... */ }
f;                              // f를 가리키는 void (*)(int) 타입의 포인터
sizeof(f);                      // 문법 오류
&f;                             // f를 가리키는 void (*)(int) 타입의 포인터
sizeof(&f)                      // void (*)(int) 타입의 포인터의 크기
*f;                             // *f는 함수 타입이고 다시 void (*)(int) 타입의 포인터로 변환됨
*************f;                 // 역시 void (*)(int) 타입의 포인터
sizeof(*f);                     // 함수 타입에 sizeof를 적용했으므로 문법 오류

포인터와 정수 간의 변환

자주 사용하지는 않지만 포인터와 정수 간에도 변환이 가능합니다(임베디드라면 꽤 볼 지도 모르겠습니다).

C17 §6.3.2.3 Pointers ¶5

An integer may be converted to any pointer type. Except as previously specified, the result is imple- mentation-defined, might not be correctly aligned, might not point to an entity of the referenced type, and might be a trap representation.68)

68)The mapping functions for converting a pointer to an integer or an integer to a pointer are intended to be consistent with the addressing structure of the execution environment.

정수는 포인터로 변환할 수 있습니다. 어떻게 변환되는지는 구현에 따라 다르고, 변환 결과가 정확히 정렬되지 않거나 해당 타입의 대상체를 가리키지 않을 수 있으며 애초에 불가능한 포인터일 수도 있습니다. 다만 포인터와 정수 간의 변환은 실행 환경의 주소 체계 내에서는 일관적이어야 합니다.

C17 §6.3.2.3 Pointers ¶6

Any pointer type may be converted to an integer type. Except as previously specified, the result is implementation-defined. If the result cannot be represented in the integer type, the behavior is undefined. The result need not be in the range of values of any integer type.

포인터는 정수로 변환 가능하고 그 결과는 구현 의존적입니다. 변환 결과가 정수 타입으로 표현할 수 없다면 동작이 정의되지 않습니다. 결과가 정수 타입이 나타낼 수 있는 값의 범위 내에 있을 필요는 없습니다.

또한 표준에서는 안전한 변환을 위해 특수한 정수 타입 두 가지를 정의하고 있습니다.

C17 §7.20.1.4 Integer types capable of holding object pointers ¶1

The following type designates a signed integer type with the property that any valid pointer to void can be converted to this type, then converted back to pointer to void, and the result will compare equal to the original pointer:

intptr_t

The following type designates an unsigned integer type with the property that any valid pointer to void can be converted to this type, then converted back to pointer to void, and the result will compare equal to the original pointer:

uintptr_t

intptr_tuintptr_t는 각각 부호 있는 정수 및 부호 없는 정수 타입으로서, void를 가리키는 유효한 포인터라면 이들 타입으로 변환할 수 있고 다시 void를 가리키는 포인터로 변환했을 때 원래 포인터와 비교 시 동일합니다.

C 표준을 넘어서

이상은 전부 C 표준의 내용이지만, 일부 구현체 및 환경에서는 여기에 더해 다른 표준을 또 적용하기도 합니다. 대표적으로 UNIX 계열에서 준용하는 POSIX 표준에서는 포인터에 대해 다음과 같은 내용을 정의하고 있습니다.

POSIX.1-2017 § dlsym ¶ Application Usage

(…) Note that conversion from a void * pointer to a function pointer as in:

fptr = (int (*)(int))dlsym(handle, "my_function");

is not defined by the ISO C standard. This standard requires this conversion to work correctly on conforming implementations.

즉, void를 가리키는 포인터를 문제 없이 함수 포인터로 변환할 수 있어야 한다는 내용입니다.

이외에도 임베디드 시스템 등에서 다양한 표준 및 시스템 의존적인 규약 등을 적용하고 있으므로 반드시 사용하는 시스템의 명세를 확인하시기 바랍니다.

결론

C 표준상 모든 포인터 타입 변환은 수많은 함정을 내포하고 있습니다. 그러니 일단 포인터를 명시적으로 타입 변환하려는 행위는 다음과 같은 경우에만 허용하는 것이 좋습니다.

나머지 경우는 시스템 명세를 참고하여 어떤 것들이 안전한지 반드시 짚고 넘어가시길 바랍니다.

C