[C] 2. "void* 의 정체"
void*가 무엇을 의미하는지 당신은 알고 있는가? malloc(), calloc(), realloc() 함수의 반환형이 왜 void*인지 알고 있는가? 어떻게 C언어가 void*를 이용해 다형성을 구현하는지 알고 있는가?
지금 이 질문에 대답할 수 없는 당신! 당신을 위한 글을 남긴다.
void type 과 void* 타입의 이해void 타입은, "값의 공집합(empty set of values)"이다[1]. 구체적으로는 다음과 같이 기술되어 있다.
The
voidtype comprises an empty set of values; it is an incomplete object type that cannot be completed.
세미콜론 뒤의 문장을 보라. imcomplete object type 이라 말하고 있다. 이는, 본질적으로 void x; 와 같은 void타입의 객체를 선언할 수 없다는 것을 의미한다.
void* typevoid타입은 불완전하지만, void* 타입은 완전한 타입이다. 왜냐하면, 명확한 크기(1byte)를 갖고, 구조체의 멤버가 되거나 변수에 할당될 수 있기 때문이다.
일반적으로 프로그래머는 void*타입을 '타입이 아직 확정되지 않은 포인터'로 인식하는데, 그것이 옳다. 이를 통해 프로그래머는 '객체의 타입을 명시하지 않은 채로 주소만을 저장'할 수 있게 된다. C언어의 제네릭이 void*에 기반한다는 뜻이다.
void type의 불완전성에 의한 void*의 완전성void* 에는 역참조와 산술연산이 금지되어 있다. 이것은 void 가 '크기가 없고 값이 비어 있는 불완전한 타입'이기 때문이다. 예시 코드를 보자.
int* p;
p++;
// 위의 코드는 다음과 같이 번역된다.
p + 1 * sizeof(*p);
// 잘 알고 있겠지만, 부연하자면,
// 이때 p는 포인터 변수 p에 저장된 주소이며, *p는 'p에 저장된 주소에 위치한 객체'이다.
이때, sizeof(void)가 불가능하기에, 다음과 같은 코드는 성립하지 않는다.
void* p;
p++;
본질적으로 sizeof(*p) 는 sizeof(void)인데, 이것은 정의되지 않았기 때문이다. 마찬가지로 *p 또한 불가능하다. 컴파일러에게는 타입 정보가 주어지지 않았고, 한번에 몇 바이트를 읽어들여서 어떻게 해석해야 하는지를 알 수가 없다.
그렇기에 void*는 역참조와 산술연산이 금지되어 있는 완전한 타입이다.
void*의 기원char* (C89 이전)ANSI C89 이전에는 공식적인 제네릭 포인터 타입(void*)이 없었다. 이 당시에는 void*의 개념적 대안으로 char*를 사용했다. char 또한 1byte 이므로, 기능적으로는 충분하다. 그러나 이는 심각한 모호성을 야기했다.
핵심적인 문제는, char*가 나타낼 수 있는 것이 너무 많다는 점이다. 단일 문자에 대한 포인터, 다른 자료구조에 대한 제네릭 포인터(기본타입 또는 구조체 모두 포함) 등의 다양한 것을 char*로 관리할 수 있다는 뜻이다. 그렇기에 많은 프로그래머들은 문맥과 주석에 의존할 수 밖에 없었다.
void* (C89/90 이후)ANSI C 위원회는 드디어 void*를 도입하였다. 이것은 '알 수 없는 타입의 데이터에 대한 포인터'를 명시적으로 표현한다.
기존에 char*로 수행 가능한 작업(역할)은 다음 두 가지이다.
char*를 통한 바이트 단위의 메모리 접근. (sizeof(char)는 1이다.)char* 타입에 대한 산술 연산을 응용하여 제네릭 객체를 포인팅.)이때 두 번째의 역할을 void*로 이전해낸 것이다. char* 가 제네릭 객체 포인터로 사용되던 상황에 프로그래머가 실수로 산술연산을 수행할 가능성이 원천적으로 차단된 것이다. (char*가 바이트 단위 접근을 위한 타입으로만 여겨지게 되었다. 모호성이 감소하고, 버그 발생 가능성이 하락했다.)
그리하여 프로그래머는 포인터의 산술연산 이전에, void*를 구체적으로 형변환 해야만 하게 되었다. 제네릭 포인터의 잘못된 조작이 형변환 작업의 강제를 통해 차단된 것이다.
ISO/IEC 9899:2011 (N1570 초안)의 6.3.2.3 절을 보라. 해당 문서에서 다음과 같이 void*의 핵심 규칙을 명시한다.
- 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. [2]
모든 객체 타입 포인터가 void에 대한 포인터로 변환되었다가 본래의 타입으로 다시 변환될 수 있으며, 그 결과는 원래의 포인터와 동일해야 한다. *즉, `void`는 모든 객체 포인터에 대한 '임시적인' 저장공간으로서 기능**한다는 것이다.
[항목 4.1.]에서 인용한 문서의 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 undefined.
중요한 부분에 굵게 표시해 보았다. 이전 글에서 설명한 UB(Undefined Behavior)가 다시 등장한다. 함수 포인터가 다른 타입의 함수에 대한 포인터로 변환될 수는 있으나, 변환된 포인터로 참조된 타입과 호환되지 않는 타입의 함수를 호출하면, 어떤 일이 일어날지 보장하지 않는다. (UB이다.)
[항목 4.1.]에서 인용한 문서의 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 aligned68) 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.
중요한 부분에 굵게 표시해 보았다. 우리는 메모리를 할당할 때, 특정한 타입으로 포인터를 정렬(align)한다. 이때 형변환 된 새로운 포인터가, 새로운 타입에 대하여 올바르게 정렬되지 않았을 수 있다. 이 경우는 하드웨어 오류 또는 UB를 유발한다.
다음은 Gemini 2.5Pro 에게 부탁하여 뽑아낸 포인터 변환 표이다.
| 원본 타입 \ 대상 타입 | void * |
char * |
int * |
struct S * |
int (*f)(void) |
|---|---|---|---|---|---|
void \* |
- | 암시적 변환 (C) | 암시적 변환 (C) | 암시적 변환 (C) | UB (표준 C), POSIX 허용 |
char \* |
암시적 변환, 왕복 보장 | - | 명시적 형변환 필요, 역참조 시 정렬 UB 가능 | 명시적 형변환 필요, 역참조 시 정렬 UB 가능 | UB (표준 C) |
int \* |
암시적 변환, 왕복 보장 | 명시적 형변환 필요, 왕복 보장 | - | 명시적 형변환 필요, 역참조 시 정렬 UB 가능 | UB (표준 C) |
struct S \* |
암시적 변환, 왕복 보장 | 명시적 형변환 필요, 왕복 보장 | 명시적 형변환 필요, 역참조 시 정렬 UB 가능 | - | UB (표준 C) |
int (\*f)(void) |
UB (표준 C), POSIX 허용 | UB (표준 C) | UB (표준 C) | UB (표준 C) | - |
중요한 것은, void*, int*, char*의 상호 변환에서는 정렬 문제만 해결된다면 상호 교환이 자유롭고, 구조체나 함수 포인터의 경우에는 상호 변환에서 UB가 발생할 가능성이 높다.
익히 알다시피, <stdlib.h> 에 정의된 malloc(), calloc(), realloc() 함수는 void*를 반환한다. 이는 해당 함수들이 어떤 타입의 데이터를 저장할지를 생각하지 않아도 괜찮음을 보장한다. 우리는 적절히 void*를 캐스팅 하여, 원하는 타입으로 변환해 사용하면 되는 것이다. [항목 4.3.]에서 보여주는 것 처럼, void*의 캐스팅은 함수 포인터를 제외한 모든 종류의 포인터 타입에 대하여 UB 없이 안전한 변환을 보장한다. 정렬 문제 또한 신경 쓰지 않아도 좋다!
// malloc은 void*를 반환하므로 어떤 타입이든 할당 가능
void* ptr = malloc(sizeof(int) * 10); // void* 반환
int* int_array = (int*)ptr; // int*로 캐스팅
// 구조체도 동일하게 처리
typedef struct { int x, y; } Point;
Point* points = (Point*)malloc(sizeof(Point) * 5);
위의 예시와 같이, malloc() 함수가 반환하는 void*는 모든 타입에 대해 적절히 정렬된 메모리 주소를 가리키므로, 이를 다른 포인터 타입으로 형변환해도 정렬 요구사항이 위반되지 않는다.
먼저 qsort의 원형을 보자.
void qsort(
void *base, // 배열 시작 포인터. void* 타입으로, 배열 요소의 타입에 구애받지 않도록 보장.
size_t nitems,
size_t size,
int (*compar)(const void *, const void *) // 함수 포인터가 void* 타입의 두 인자를 받는다.
);
위의 코드에서 프로그래머는 비교 함수의 매개변수를, 적절한 형변환을 통해 구체화 할 수 있다. qsort() 함수가 타입에 대한 정보를 사용자가 입력한 비교함수에 의존한다는 뜻이다.
<string.h>에 정의된 memcpy(), memmove(), memset()은 void*를 기반으로 하여, 원시 메모리 블록을 조작한다. 중요한 것은 해당 메모리에 '무엇이 존재하든' 신경쓰지 않는다는 점이다. void*를 통해 타입에 무관한 원시 메모리 조작이 가능하다는 뜻이다.
int src[] = {1, 2, 3, 4, 5};
int dest[5];
// void*를 받아 타입에 관계없이 메모리 복사
memcpy(dest, src, sizeof(src));
// 구조체도 동일하게 처리
Point p1 = {10, 20};
Point p2;
memcpy(&p2, &p1, sizeof(Point)); // 구조체 전체를 바이트 단위로 복사
// memset으로 메모리 초기화 (타입 무관)
memset(dest, 0, sizeof(dest));
위의 예시에서 제네릭이 달성된 부분을 볼 수 있다. memcpy()의 첫 매개변수는 void* 로 선언되어 있고, 입력값인 dest 에 의해 int*로 암시적 형변환이 이루어진다.
sizeof(void)는 이들 컴파일러에서 1로 취급된다. 이로 인하여 void*에 대한 산술연산이 가능해지며, char*와의 의미론적인 역할 구분이 사라진다.
이식 가능한 코드를 작성하기 위해서는, 꼭 void*를 (명시적으로)char*로 형변환 해야 한다. ANSI에서 제거하려고 시도한 char*의 모호성이, 위와 같은 비표준 확장에 의해 다시 부활한다는 것이다!
char*는 바이트 단위의 메모리 접근과, 제네릭 객체 포인터로 사용될 수 있다.char*를 제네릭 객체 포인터로 사용할 때, 예기치 못한 모호성이 발생한다. 프로그래머는 이 char*가 메모리 산술연산을 위한 것인지, 제네릭 객체 포인터인지를 주석과 문맥에 의존하여 판단해야만 했다.void*는 완전한 타입이며, char*가 갖고 있던 모호성을 해소하기 위해 등장했다.void*는 모든 객체 타입 포인터와 상호 변환될 수 있다.void*와의 상호 변환 과정에서 발생할 수 있는 문제 두 가지가 있다.
void*를 통하여 C언어의 제네릭이 달성된다. 대표적인 예시로 qsort가 있다.void*를 통해 타입에 무관한 원시 메모리 조작이 가능하다. <string.h>의 메모리 조작 함수들과 <stdlib.h>의 메모리 동적 할당 함수들의 동작이 그렇다.