자료형을 미뤄 두기
어떤 자료형의 주소가 올지 모르는 채로 전달받아야 할 때가 있습니다.
포인터를 선언할 때 쓴 자료형은 가리키는 대상에 관한 정보입니다.
역참조할 때 int *는 int 크기만큼(int가 4바이트인 환경에서는 4바이트) 읽어 정수로, char *는 1바이트를 읽어 문자로 해석합니다.
그래서 저장할 수 있는 주소도 그 자료형의 주소로 제한됩니다.
double 변수의 주소를 int *에 대입하면 컴파일러가 경고나 오류를 냅니다.
그런데 「아직 모를 때」가 있습니다
정수든 실수든 같은 일을 하는 함수를 만든다고 해 봅시다.
매개변수를 int *로 선언하면 int의 주소만 전달받고,
double *로 선언하면 double의 주소만 전달받습니다.
자료형마다 같은 함수를 따로 만들어야 할까요?
「주소는 지금 저장해 두고, 그 주소를 어떻게 읽을지는 나중에 정하겠다」고
할 수 있으면 좋겠습니다. 이렇게 가리킬 대상의 자료형을 정하지 않은 포인터가
void 포인터(void pointer)입니다.
#include <stdio.h>
int main(void) {
int n = 10;
double d = 3.5;
void *p; // 가리킬 대상의 자료형을 정하지 않는다
p = &n; // int 변수의 주소를 저장해도 되고
printf("정수: %d\n", *(int *) p);
p = &d; // double 변수의 주소를 저장해도 된다
printf("실수: %.1f\n", *(double *) p);
return 0;
}자료형이 다른 주소를 같은 변수에 저장했습니다
void *p 하나에 &n도 &d도 그대로 대입됩니다.
가리킬 대상의 자료형을 아예 적지 않았기 때문입니다.
함수의 반환형에 쓴 void가 「반환값이 없다」는 뜻이었다면,
여기서는 「자료형을 정하지 않았다」는 뜻입니다.
저장하는 것은 여전히 주소입니다 — 포인터에는
대상의 값이 아니라 주소가 저장되고, 무엇을 가리키든 주소라는 점은 같습니다.
C 표준은 어떤 변수의 주소든 void *에 저장했다가
원래 자료형의 포인터로 되돌리면 처음 주소와 같아진다고 보장합니다.
스스로 확인
char c = 'A';와 double d = 2.5;가 있을 때 char *q = &d;와 void *p = &c; 가운데 컴파일러가 경고나 오류를 내는 쪽은 어느 것일까요? 이유도 말해 보세요.
읽을 때 정해 준다
미뤄 둔 결정은 언젠가 내려야 합니다.
「자료형을 미뤄 두기」 절의 코드에서 값을 읽을 때 *p가 아니라
*(int *) p라고 썼습니다.
*p라고만 쓰면 컴파일 오류가 납니다.
몇 바이트를 읽을지 모르니 역참조할 수 없습니다
역참조는 「그 주소에서부터 자료형의 크기만큼 읽어 오라」는 뜻입니다.
void 포인터에는 그 자료형이 정해져 있지 않으니
주소를 알아도 어디까지 읽어야 할지 알 수 없습니다.
그래서 컴파일러가 「void를 잘못 사용했다」는 오류를 냅니다.
포인터 연산도 사정이 같습니다. p + 1은 「가리키는 자료형 하나의 크기만큼 주소를 이동」하는 것인데,
그 크기를 모르니 표준 C는 void 포인터의 포인터 연산을 허용하지 않습니다.
일부 컴파일러가 확장 기능으로 1바이트씩 이동하게 해 주기도 하지만 표준이 아니므로 기대어 쓰면 안 됩니다.
괄호로 자료형을 지정합니다
(int *) p는 「이 주소를 int를 가리키는 주소로 다뤄라」는 뜻입니다.
값의 자료형을 바꾸는 (double) sum / n과 같은 문법의 강제 형 변환(type casting)이고,
여기서는 값이 아니라 포인터의 자료형을 바꿉니다.
*(int *) p는 먼저 p를 int *로 형 변환하고, 그 결과를 역참조합니다.
p에 가까이 붙은 형 변환이 먼저 적용되기 때문입니다.
그러니 *(int *) p를 「int로 보고 읽어라」라는 뜻으로 한 번에 읽으면 됩니다.
포인터의 형 변환은 「어떻게 읽을지」를 정할 뿐, 메모리에 저장된 값을 바꾸지 않습니다. 그래서 자료형을 잘못 지정해도 컴파일러가 막지 못합니다.
#include <stdio.h>
int main(void) {
double d = 3.5;
void *p = &d;
printf("제대로 읽기: %.1f\n", *(double *) p);
printf("잘못 읽기 : %d\n", *(int *) p); // 8바이트 가운데 앞의 4바이트만 읽었다
return 0;
}3.5도 3도 아닌 값이 나옵니다
실수 3.5가 저장된 메모리 공간을 int로 보고 앞의 4바이트만 읽었습니다.
이 강의노트를 만든 컴퓨터에서는 0이 출력되었습니다.
3.5를 저장한 바이트를 정수의 저장 방식으로 잘못 해석한 값이라, 이 값으로는 3.5를 알아볼 수 없습니다.
실수와 정수는 메모리에 저장하는 방식이 애초에 다르기 때문입니다.
게다가 C 표준은 double 변수를 int *로 읽었을 때 무엇이 나올지 정해 두지 않았습니다.
어떤 메모리 공간이든 바이트 단위로 읽어도 된다고 C가 보장하는 것은 char *로 읽을 때뿐입니다.
포인터의 형 변환은 「이렇게 읽겠다」는 표시일 뿐, 저장된 값을 바꾸지 않습니다.
그래서 void 포인터를 쓸 때는 「어떤 자료형의 주소를 저장해 두었는지」를
프로그래머가 기억하고 있어야 합니다. 컴파일러가 대신 확인해 주지 않습니다.
스스로 확인
int 변수의 주소를 저장한 void 포인터를 *(double *)로 읽으면 몇 바이트를 읽게 될까요? 그 가운데 몇 바이트가 그 변수에 속하지 않는 메모리 공간일까요?
같은 메모리를 다르게 읽기
잘못 읽는 것이 아니라 일부러 다르게 읽어 봅니다.
「읽을 때 정해 준다」 절에서는 잘못 읽어 엉뚱한 값이 나왔지만, 일부러 다르게 읽어 얻는 것도 있습니다.
정수 하나가 4바이트에 어떻게 나뉘어 저장되어 있는지 보려면
char *로 1바이트씩 읽으면 됩니다. C 표준이 보장하는 읽기 방법입니다.
#include <stdio.h>
int main(void) {
int num = 0x12345678; // 16진수로 쓴 4바이트 정수. 12, 34, 56, 78이 한 바이트씩 나뉘어 저장된다
char *p = (char *) # // 같은 주소를 1바이트씩 읽겠다고 정한다
for (int i = 0; i < 4; i = i + 1) {
printf("%02X ", (unsigned char) *(p + i));
}
printf("\n");
return 0;
}먼저 0x 표기를 확인합니다
0x12345678은 16진수로 쓴 수입니다.
주소를 %p로 출력할 때도 대개 16진수로 나옵니다. 앞의 0x가 「16진수」라는 표시입니다.
16진수가 편리한 이유는 16진수 두 자리가 정확히 1바이트이기 때문입니다 —
12 34 56 78이 4바이트에 하나씩 대응합니다.
%02X는 「16진수 두 자리로, 자릿수가 모자라면 앞을 0으로 채워」 출력하라는 뜻입니다.
그래야 바이트마다 자릿수가 같아 나란히 비교해 볼 수 있습니다.
78 56 34 12로 거꾸로 나옵니다
쓴 순서대로라면 12 34 56 78이 나와야 할 것 같은데 순서가 뒤집혀 출력됩니다.
낮은 자릿수를 앞 바이트(주소가 작은 쪽)에 저장하는 방식이기 때문입니다.
흔히 쓰는 개인용 컴퓨터가 이 방식이고, 반대 순서로 저장하는 컴퓨터도 있습니다.
어느 쪽이 옳고 그른 것이 아니라 약속이 다른 것입니다. 평소에는 알 필요가 없다가, 바이트 단위로 직접 살펴볼 때에야 드러납니다. 포인터의 자료형은 결국 「해석하는 방법」이라는 것이 여기서 분명해집니다 — 메모리에 저장된 값은 그대로인데 읽는 방법이 결과를 정합니다.
스스로 확인
반복 조건을 i < 8로 늘리면 무엇을 읽게 될까요? 정수는 4바이트뿐이었습니다.
자료형을 가리지 않는 함수
「자료형을 미뤄 두기」 절의 물음으로 돌아갑니다.
#include <stdio.h>
void show(void *data, char kind) { // 어떤 자료형의 주소든 전달받는다
if (kind == 'i') {
printf("정수 %d\n", *(int *) data);
} else if (kind == 'd') {
printf("실수 %.2f\n", *(double *) data);
} else if (kind == 'c') {
printf("문자 %c\n", *(char *) data);
} else {
printf("모르는 자료형입니다\n");
}
}
int main(void) {
int n = 10;
double d = 3.5;
char c = 'A';
void *slot[3] = {&n, &d, &c}; // 자료형이 다른 세 변수의 주소를 저장한다
char kind[3] = {'i', 'd', 'c'}; // 각 주소의 자료형은 따로 적어 둔다
for (int i = 0; i < 3; i = i + 1) {
show(slot[i], kind[i]);
}
return 0;
}자료형이 다른 세 주소를 한 배열에 저장했습니다
배열에는 자료형이 같은 값만 저장한다는 규칙은 그대로입니다 —
slot은 void * 값만 저장하는 배열입니다.
다만 void *는 어떤 변수든 가리킬 수 있으니
결과적으로 정수·실수·문자를 한 배열로 다루게 됩니다.
show는 어떤 자료형의 주소가 올지 모르는 채로 전달받습니다.
그래서 kind를 함께 전달받습니다 —
「이 주소를 어떤 자료형으로 읽어야 하는지」를 알려 주는 값입니다.
지켜 주는 장치가 사라집니다
kind를 'd'로 잘못 써도 컴파일은 그대로 됩니다.
void 포인터의 장점은 「자료형을 가리지 않는 것」인데,
그 장점이 곧 대가이기도 합니다 —
자료형이 맞는지 확인하던 컴파일러의 검사를 스스로 꺼 버린 셈입니다.
그러니 꼭 필요할 때만 씁니다.
다루는 자료형이 하나로 정해져 있다면 int *처럼 자료형을 분명히 적는 편이
낫습니다. 그래야 잘못 쓰면 컴파일러가 알려 줍니다.
%p로 출력할 때도 형 변환합니다
주소를 출력할 때는 printf("%p", (void *) &n)처럼
void *로 형 변환합니다.
%p는 「무엇을 가리키든 상관없이 주소만 출력하는」 서식 지정자라서
void * 인자를 받도록 정해져 있기 때문입니다.
스스로 확인
show에서 kind를 없애고 주소만 전달받게 하면 무엇이 불가능해질까요?
핵심 정리
자료형을 미뤄 둔 포인터
void *는 가리킬 대상의 자료형을 정하지 않은 포인터다.
어떤 변수의 주소든 저장할 수 있지만, 몇 바이트를 어떻게 읽을지에 관한 정보는 없다.
읽으려면 정해 주어야
몇 바이트를 읽을지 모르므로 그대로는 역참조할 수 없고, 표준 C에서는 포인터 연산도 할 수 없다.
*(int *) p처럼 형 변환으로 「이렇게 읽겠다」고 지정한 뒤 역참조한다.
읽는 방법이 결과를 정한다
포인터의 형 변환은 저장된 값을 바꾸지 않는다.
같은 메모리 공간도 char *로 읽으면 바이트별로 보인다.
자료형을 잘못 지정하면 무엇이 나올지 C가 정해 두지 않았고, 대개 저장된 바이트를 잘못 해석한 값이 나온다.
바이트 단위로 읽어도 된다고 C가 보장하는 것은 char *뿐이다.
검사를 스스로 끄는 일
자료형을 가리지 않는 만큼 컴파일러도 자료형을 검사하지 않는다. 어떤 자료형의 주소를 저장했는지 프로그래머가 기억해야 하므로 꼭 필요할 때만 쓴다.
확인 퀴즈
배운 내용을 확인해 봅시다.
1. void *p = &n; 다음에 printf("%d", *p);라고 쓰면?
2. double 변수를 *(int *)로 읽으면?
3. void 포인터를 쓸 때 프로그래머가 함께 챙겨야 하는 것은?