프로그래밍

void 포인터

포인터를 선언할 때 쓴 자료형은 「몇 바이트를 읽을지」를 정합니다.
그 결정을 미뤄 두면 어떻게 될까요?

학습 목표

  1. void 포인터가 가리킬 대상의 자료형을 정하지 않은 포인터임을 설명할 수 있다.
  2. void 포인터를 형 변환한 뒤 역참조해 값을 읽을 수 있다.
  3. 같은 메모리 공간을 다른 자료형으로 읽으면 왜 다른 값이 나오는지 설명할 수 있다.
  4. 여러 자료형을 함께 다루는 함수를 void 포인터로 만들 수 있다.

자료형을 미뤄 두기

어떤 자료형의 주소가 올지 모르는 채로 전달받아야 할 때가 있습니다.

포인터를 선언할 때 쓴 자료형은 가리키는 대상에 관한 정보입니다. 역참조할 때 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;
}
double d = 3.5;   8바이트에 나뉘어 저장되어 있다 *(double *) p → 8바이트를 다 읽는다 → 3.5 *(int *) p → 앞의 4바이트만 나머지 4바이트는 읽지 않는다 3.5를 저장한 바이트를 정수로 잘못 해석하니 3.5로 알아볼 수 없다. 포인터 형 변환은 읽는 방법만 정하고 저장된 값은 그대로 둔다.

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 *) &num;        // 같은 주소를 1바이트씩 읽겠다고 정한다

    for (int i = 0; i < 4; i = i + 1) {
        printf("%02X ", (unsigned char) *(p + i));
    }
    printf("\n");

    return 0;
}
int num = 0x12345678;    저장된 값은 그대로다 int * 로 읽으면 — 4바이트를 묶어 정수 하나 78 0번 바이트 56 1번 바이트 34 2번 바이트 12 3번 바이트 char * 로 읽으면 — 1바이트씩 따로 넷 이 컴퓨터는 낮은 자릿수를 앞 바이트에 놓아 78 56 34 12 순으로 나온다

먼저 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 포인터를 쓸 때 프로그래머가 함께 챙겨야 하는 것은?