프로그래밍

포인터의 기초

「값이 어디에 있는가」도 값입니다.
그 값을 저장하는 변수가 포인터입니다.

학습 목표

  1. 메모리의 바이트마다 주소가 있다는 것을 알고 주소 연산자 &로 변수의 주소를 확인할 수 있다.
  2. 주소를 저장하는 변수인 포인터를 선언하고 가리킬 대상을 정할 수 있다.
  3. 역참조 연산자 *로 포인터가 가리키는 곳의 값을 읽고 바꿀 수 있다.
  4. 포인터를 선언할 때 쓴 자료형이 무엇을 정하는지 설명할 수 있다.

바이트마다 주소가 있다

변수가 저장되는 메모리 공간에 관한 이야기입니다.

변수를 선언하면 값을 저장할 공간이 컴퓨터의 메모리에 마련됩니다. 메모리는 1바이트짜리 저장 단위가 길게 이어진 것이고, 바이트마다 번호가 매겨져 있습니다. 이 번호를 주소(address)라고 합니다.

번호가 없으면 찾아갈 수 없습니다

도서관 책장에 번호가 없다면 책이 있어도 꺼내 올 방법이 없습니다. 번호가 있어야 「거기 있는 것을 가져오라」고 말할 수 있습니다. 메모리도 같습니다. 주소가 있기 때문에 원하는 위치에 값을 저장하고 읽을 수 있습니다.

자료형마다 메모리에서 차지하는 크기가 다른 것도 바이트 수로 나타냅니다. char는 언제나 1바이트이고, int는 요즘 흔히 쓰는 컴퓨터에서 4바이트입니다. int의 크기는 C 표준이 하나로 정해 두지 않아서 환경에 따라 다를 수 있습니다. 이 강의노트의 그림과 실행 결과는 4바이트인 환경을 기준으로 합니다. 여러 바이트를 차지하는 변수는 그 가운데 가장 앞 바이트의 주소가 그 변수의 주소가 됩니다.

배열의 요소와 바이트는 구분해 씁니다. 요소는 배열에 저장된 값 하나이고, 바이트는 메모리를 나누는 단위입니다. 요소 하나가 몇 바이트를 차지하는지는 자료형에 따라 다릅니다. int 배열이라면 요소 하나가 4바이트를 차지합니다.

변수의 주소를 알아내는 연산자를 우리는 이미 쓰고 있었습니다. scanf에서 변수 앞에 붙이던 &입니다. 변수 앞에 &를 붙이면 그 변수의 주소를 얻습니다. 그래서 &를 주소 연산자(address-of operator)라고 합니다. %p로 출력하면 주소를 눈으로 확인할 수 있습니다. 주소 앞에 붙인 (void *)는 %p가 void * 인자를 받도록 정해져 있어서 붙이는 강제 형 변환이며, void *가 무엇인지는 void 포인터를 배울 때 다룹니다.

#include <stdio.h>

int main(void) {
    int n = 10;
    char c = 'A';
    double d = 3.5;

    printf("n의 값 %d, n의 주소 %p\n", n, (void *) &n);
    printf("c의 값 %c, c의 주소 %p\n", c, (void *) &c);
    printf("d의 값 %.1f, d의 주소 %p\n", d, (void *) &d);
    // 출력되는 주소는 실행할 때마다 달라질 수 있다

    return 0;
}
메모리는 바이트가 이어진 것이고 바이트마다 번호가 있다 100 101 102 103 104 105 106 107 108 109 110 111 int n c &n = 104 &c = 109 여러 바이트를 쓰는 변수는 첫 바이트의 번호가 주소다.

출력되는 주소를 외울 필요는 없습니다

주소는 실행할 때마다 달라지는 일이 많습니다. 변수가 메모리의 어디에 놓일지는 프로그램을 실행할 때 정해지는 일이라 코드를 쓰는 단계에서는 미리 알 수 없습니다. 중요한 것은 주소의 값 자체가 아니라 「주소가 있다」는 사실입니다.

%p가 출력하는 것은 대개 16진수입니다. 0~9 다음에 a~f를 이어 쓰는 표기라 낯선 문자가 섞여 나옵니다. 주소를 나타낼 때 흔히 쓰는 표기일 뿐이니 지금은 「긴 번호가 출력된다」고 이해하면 됩니다.

스스로 확인

위 프로그램을 두 번 실행하면 출력되는 주소가 같을까요, 다를까요? 그렇게 생각한 이유도 말해 보세요.

주소를 저장하는 변수

주소도 값이므로 변수에 저장할 수 있습니다.

&n의 결과가 하나의 값이라면, 그 값을 변수에 저장할 수도 있어야 합니다. 정수를 저장하는 변수, 문자를 저장하는 변수가 있었듯 주소를 저장하는 변수도 있습니다. 이것을 포인터(pointer)라고 합니다.

포인터는 「주소를 저장하는 변수」입니다

포인터도 변수입니다. 특별한 무엇이 아니라 int n·char c와 같은 변수이고, 저장하는 값이 정수나 문자가 아니라 주소일 뿐입니다.

포인터가 어떤 변수의 주소를 저장하고 있을 때, 「포인터가 그 변수를 가리킨다」고 말합니다.

#include <stdio.h>

int main(void) {
    int n = 10;
    int *p = &n;                      // n의 주소를 저장한다

    printf("n  = %d\n", n);           // 10   n에 저장된 값
    printf("&n = %p\n", (void *) &n); // n의 주소
    printf("p  = %p\n", (void *) p);  // 같은 주소가 출력된다
    printf("*p = %d\n", *p);          // 10   p가 가리키는 곳의 값

    return 0;
}
int n = 10;    int *p = &n; p n의 주소 n 10 *p p = &m; 화살표를 다른 곳으로 옮긴다 *p = 99; 화살표 끝에 있는 값을 바꾼다 p도 메모리 공간을 따로 차지한다. 그 주소는 &p로 얻는다.
선언의 부분 의미
int 가리킬 대상의 자료형. 포인터 p 자신의 자료형은 int *입니다.
* p가 주소를 저장하는 변수, 곧 포인터라는 표시.
p 변수 이름. 다른 변수와 같은 규칙으로 짓습니다.
= &n 초기값. n의 주소입니다.

p와 &n이 같은 값을 출력합니다

p에 &n을 저장해 두었으므로 두 줄에 같은 주소가 출력됩니다. p에는 n의 값이 아니라 n의 주소가 저장되어 있습니다.

p도 변수이므로 메모리 공간을 따로 차지합니다. n이 저장된 곳과 p가 저장된 곳은 서로 다른 위치입니다. p는 「n은 저기 있다」고 주소를 적어 둔 쪽지를 들고 있는 셈입니다.

스스로 확인

int *p = &n; 아래에 printf("%p\n", (void *) &p);를 더하면 무엇이 출력될까요? p에 저장된 값과 같을까요?

가리키는 곳의 값 다루기

포인터가 쓸모 있어지는 것은 여기서부터입니다.

주소를 저장해 두기만 해서는 할 수 있는 일이 없습니다. 그 주소로 찾아가 값을 읽을 수 있어야 쓸모가 생깁니다. 포인터 앞에 *를 붙이면 「저장된 주소로 가서 그곳의 값을 다뤄라」라는 뜻이 됩니다. 이것을 역참조(dereference)라고 하고, 이때의 *를 역참조 연산자라고 합니다.

같은 *가 두 곳에서 다른 뜻으로 쓰입니다

쓰는 곳 예시 의미
선언할 때 int *p = &n; 「p는 주소를 저장하는 변수, 곧 포인터다」
식에서 쓸 때 printf("%d", *p); 「p에 저장된 주소로 가서 그곳의 값을 다뤄라」

기호는 같지만 하는 일이 전혀 다릅니다. 배열의 [ ] 안의 수가 선언에서는 배열의 크기, 사용할 때는 인덱스인 것과 같습니다. 앞에 자료형이 있으면 선언, 없으면 역참조로 구분합니다.

읽기만 하는 것이 아닙니다. 역참조한 곳에 값을 저장할 수도 있습니다.

#include <stdio.h>

int main(void) {
    int n = 10;
    int *p = &n;

    *p = 99;                   // p가 가리키는 곳에 99를 저장한다

    printf("n  = %d\n", n);    // 99 — n을 쓰지 않았는데 바뀌었다
    printf("*p = %d\n", *p);   // 99

    return 0;
}

n을 쓰지 않았는데 n이 바뀌었습니다

*p = 99;는 「p가 가리키는 곳에 99를 저장하라」는 뜻입니다. 그곳이 바로 n이므로 n = 99;라고 쓴 것과 같은 일이 일어납니다.

변수 이름을 쓰지 않고 값을 바꾸는 방법이 하나 더 생긴 것입니다. 포인터로 할 수 있는 일이 바로 이것이고, 동시에 포인터를 조심해서 써야 하는 이유이기도 합니다. n을 바꾸는 코드가 어디에도 보이지 않는데 n이 바뀌기 때문입니다.

p = …와 *p = …는 다릅니다

p = &m;은 「가리킬 곳을 바꿔라」이고, *p = 99;는 「가리키는 곳의 값을 바꿔라」입니다. 앞의 것은 쪽지에 적힌 주소를 고치는 일이고, 뒤의 것은 그 주소로 찾아가 물건을 바꾸는 일입니다. * 하나 차이로 바뀌는 대상이 달라집니다.

#include <stdio.h>

int main(void) {
    int a;
    int b;

    printf("두 수를 입력하세요: ");
    scanf("%d %d", &a, &b);

    int *big = &a;             // 먼저 a를 가리키게 한다

    if (b > a) {
        big = &b;              // 가리키는 곳을 b로 바꾼다
    }

    printf("큰 쪽의 값: %d\n", *big);

    *big = 0;                  // 가리키는 곳의 값을 0으로 바꾼다

    printf("a = %d, b = %d\n", a, b);   // 큰 값을 저장한 변수만 0이 되었다

    return 0;
}

이 프로그램은 둘을 한 번에 보여 줍니다. big = &b;로 가리킬 곳을 바꾸고, *big = 0;으로 가리키는 곳의 값을 바꿉니다. a와 b 가운데 큰 값을 저장한 변수만 0이 되는 것을 확인해 보세요.

스스로 확인

큰 쪽을 0으로 만드는 프로그램에서 *big = 0;을 big = 0;으로 잘못 쓰면 무엇이 달라질까요?

자료형이 정하는 것

주소를 저장하는데 왜 int·char를 구별해 쓸까요?

주소는 바이트의 번호일 뿐입니다. 저장하는 데 자료형이 필요할 것 같지 않은데 int *·char *처럼 구별해 써야 합니다. 왜일까요?

#include <stdio.h>

int main(void) {
    char c = 'A';
    int n = 10;
    double d = 3.5;

    char *pc = &c;
    int *pn = &n;
    double *pd = &d;

    printf("가리키는 것의 크기: %d %d %d\n",
           (int) sizeof(*pc), (int) sizeof(*pn), (int) sizeof(*pd));   // 1 4 8

    printf("포인터 자신의 크기: %d %d %d\n",
           (int) sizeof(pc), (int) sizeof(pn), (int) sizeof(pd));      // 64비트 환경에서는 대개 8 8 8

    return 0;
}
같은 주소에서 출발해도 읽어 오는 바이트 수가 다르다 여기서 출발 char *  →  1바이트 int *  →  4바이트 double *  →  8바이트 자료형 = 읽을 바이트 수

「몇 바이트를 읽을지」를 정합니다

주소는 시작 바이트의 번호일 뿐이라 어디까지 읽어야 하는지는 알려 주지 않습니다. int *로 선언하면 역참조할 때 「그 주소부터 4바이트를 정수로 읽어라」, char *로 선언하면 「1바이트를 문자로 읽어라」가 됩니다. 포인터를 선언할 때 쓴 자료형은 가리키는 대상에 관한 정보입니다.

실행 결과의 「가리키는 것의 크기」 줄이 1 4 8인 것이 바로 이 크기입니다. char는 1바이트이고, 이 환경에서 int는 4바이트, double은 8바이트입니다.

포인터 자신의 크기는 셋 다 같습니다

「포인터 자신의 크기」 줄을 보면 char *든 double *든 크기가 같습니다. 포인터가 저장하는 것은 대상의 값이 아니라 주소이고, 주소를 저장하는 데 필요한 바이트 수는 가리키는 대상이 무엇이든 같기 때문입니다. 엄밀히 말하면 C 표준이 이것까지 보장하지는 않지만, 요즘 쓰는 컴퓨터는 거의 모두 그렇습니다.

다만 그 값이 몇인지는 컴퓨터에 따라 다릅니다. 64비트 환경에서는 대개 8바이트이고 32비트 환경에서는 대개 4바이트입니다. 기억할 것은 「8이다」가 아니라 「셋이 서로 같다」입니다.

조심할 것이 하나 더 있습니다. 함수 안에서 선언하고 초기화하지 않은 포인터에는 알 수 없는 주소가 들어 있습니다. 그 상태에서 *p로 역참조하면 엉뚱한 메모리 위치에 접근하게 됩니다. 초기화하지 않은 변수를 읽으면 엉뚱한 값이 나오는 것과 같은 이야기인데, 포인터는 값이 틀리는 데서 그치지 않습니다. 프로그램이 비정상 종료되거나, 그 주소에 값을 저장해 다른 변수를 망가뜨릴 수 있습니다.

#include <stdio.h>

int main(void) {
    int *p = NULL;             // 「아직 아무 곳도 가리키지 않는다」는 표시

    if (p == NULL) {
        printf("아직 가리키는 곳이 없습니다.\n");
    }

    int n = 7;
    p = &n;                    // 이제 가리킬 곳이 생겼다

    if (p != NULL) {
        printf("가리키는 곳의 값: %d\n", *p);   // 7
    }

    return 0;
}

NULL은 「아직 아무 곳도 가리키지 않는다」는 뜻입니다

가리킬 곳이 아직 없다면 NULL을 저장해 둡니다. 이런 포인터를 널 포인터(null pointer)라고 합니다. 문자열 끝에 두는 널 문자 '\0'과는 다른 것입니다. 널 문자는 char 값 하나이고, NULL은 포인터에 저장하는 값입니다. 「어디인지 알 수 없는 주소」와 「아직 정하지 않았다」는 전혀 다릅니다. 앞의 것은 확인할 방법이 없고, 뒤의 것은 if (p == NULL)로 검사할 수 있습니다.

널 포인터를 역참조하면 대부분의 환경에서 프로그램이 그 줄에서 비정상 종료됩니다. 나쁜 일 같지만 엉뚱한 값을 낸 채 조용히 실행을 이어 가는 것보다는 낫습니다 — 어디가 잘못됐는지 바로 알 수 있기 때문입니다. 역참조하기 전에 NULL인지 검사하는 습관이 그래서 필요합니다.

스스로 확인

double 변수의 주소를 int *에 저장하고 *로 읽으면 무엇이 어긋날까요? 「몇 바이트를 읽는가」로 생각해 보세요.

핵심 정리

바이트마다 주소가 있다

메모리는 바이트가 이어진 것이고 바이트마다 번호(주소)가 있다. 변수의 주소는 주소 연산자 &로 얻으며, 실행할 때마다 달라질 수 있다.

포인터는 주소를 저장하는 변수

특별한 것이 아니라 저장하는 값이 주소인 변수다. 포인터 자신도 메모리 공간을 따로 차지한다. 어떤 변수의 주소를 저장하고 있으면 그 변수를 가리킨다고 한다.

*는 쓰는 곳에 따라 다르다

선언에서는 포인터라는 표시이고, 식에서는 가리키는 곳의 값을 다루는 역참조다. p = …는 가리킬 곳을, *p = …는 가리키는 곳의 값을 바꾼다.

자료형은 「몇 바이트」를 정한다

포인터를 선언할 때 쓴 자료형은 가리키는 대상의 자료형으로, 역참조할 때 몇 바이트를 읽을지 정한다. 포인터 자신의 크기는 흔히 쓰는 컴퓨터에서 대상이 무엇이든 같다.

확인 퀴즈

배운 내용을 확인해 봅시다.

1. int n = 10; int *p = &n;일 때 p에 저장된 값은?

2. int *p = &n; 다음에 *p = 50;을 실행하면?

3. char *pc와 double *pd의 크기를 sizeof로 구하면?