프로그래밍

포인터를 가리키는 포인터

변수의 값을 바꾸려고 그 변수의 주소를 전달했습니다.
그렇다면 포인터를 바꾸려면 무엇을 전달해야 할까요?

학습 목표

  1. 포인터도 변수이므로 자신의 주소가 따로 있음을 확인할 수 있다.
  2. 별표(*)를 하나 더 붙여 이중 포인터를 선언하고, 두 번 역참조해 값을 읽고 바꿀 수 있다.
  3. 함수가 호출한 쪽의 포인터 자체를 바꾸게 하려면 무엇을 전달해야 하는지 설명할 수 있다.
  4. 행마다 따로 할당한 표를 만들고 올바른 순서로 해제할 수 있다.

포인터의 주소

포인터 자신의 주소를 출력해 봅니다.

포인터도 변수이므로 자신의 메모리 공간과 주소가 따로 있습니다. p는 「저기」라고 적어 둔 쪽지와 같은데, 그 쪽지도 어딘가에 놓여 있어야 합니다.

#include <stdio.h>

int main(void) {
    int num = 60;
    int *p = &num;

    printf("num의 값       %d\n", num);
    printf("p에 저장된 값  %p\n", (void *) p);    // num의 주소
    printf("p 자신의 주소  %p\n", (void *) &p);   // 다른 주소다

    return 0;
}

주소가 두 개 출력됩니다

p에는 num의 주소가 저장되어 있고, &p는 p 자신의 주소입니다. 둘은 서로 다른 주소입니다.

변수 앞에 주소 연산자 &를 붙이면 그 변수의 주소를 얻습니다. 포인터도 변수이니 예외가 아닙니다. 특별한 규칙이 새로 생긴 것이 아니라 이미 있는 규칙을 한 번 더 적용한 것뿐입니다.

그렇다면 &p로 얻은 주소도 어딘가에 저장할 수 있어야 합니다. 주소를 저장하는 변수가 포인터이니, 포인터의 주소를 저장하는 포인터도 있어야 합니다.

스스로 확인

p가 num을 가리키게 한 뒤 p = &other;로 바꾸면, &p로 얻는 주소도 함께 달라질까요?

별표를 하나 더

역참조를 한 번 더 하면 됩니다.

int *p가 「int를 가리키는 포인터」였으니, int **q는 「int를 가리키는 포인터를 가리키는 포인터」입니다. 변수 이름 q에서 왼쪽으로 읽어 나가면 그대로 뜻이 됩니다. 이런 포인터를 이중 포인터(double pointer)라고 부릅니다.

#include <stdio.h>

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

    printf("num = %d\n", num);     // 60
    printf("*p  = %d\n", *p);      // 60   한 번 역참조한다
    printf("**q = %d\n", **q);     // 60   두 번 역참조한다

    printf("p  = %p\n", (void *) p);
    printf("*q = %p\n", (void *) *q);   // p에 저장된 것과 같은 주소

    **q = 99;                      // 두 번 역참조해 num을 바꾼다
    printf("num = %d\n", num);     // 99

    return 0;
}
int num = 60;  int *p = &num;  int **q = &p; q p의 주소 p num의 주소 num 60 *q — 한 걸음 **q — 두 걸음, 값 60에 닿는다
식 값 읽는 방법
q p의 주소 역참조하지 않으면 q에 저장된 주소 그대로
*q num의 주소 한 번 역참조하면 p에 저장된 값이 나온다
**q 60 두 번 역참조하면 num의 값이 나온다

별표 하나가 한 걸음입니다

역참조할 횟수만큼 별표를 붙인다고 보면 헷갈리지 않습니다. num까지는 두 번 역참조해야 하니 **q이고, p까지는 한 번이면 되니 *q입니다.

값을 읽을 뿐 아니라 바꿀 수도 있습니다. **q = 99;는 두 번 역참조해 num의 값을 바꿉니다. *q = &other;라고 쓰면 한 번만 역참조해 p에 저장된 주소를 바꿉니다 — 별표의 개수가 접근하는 대상을 정합니다.

별표 세 개, 네 개도 문법에 맞습니다

int ***r도 선언할 수 있습니다. 다만 실제로 쓰는 일은 드뭅니다. 단계가 늘수록 코드를 읽는 사람이 따라가기가 급격히 어려워지고, 그만큼 여러 단계가 필요하다면 구조를 다르게 설계하는 편이 나은 경우가 많기 때문입니다.

스스로 확인

*q = NULL;을 실행하면 무엇이 NULL이 될까요? num은 어떻게 될까요?

포인터를 바꾸는 함수

이중 포인터가 실제로 필요해지는 경우입니다.

메모리를 할당하는 일을 함수에 맡기고 싶다고 해 봅시다. 함수 안에서 malloc을 호출하고, 그 반환값(주소)을 호출한 쪽의 포인터가 받아야 합니다. 그런데 void make(int *out)이라고 선언하고 함수 안에서 out = malloc(…)을 실행하면 바뀌는 것은 사본뿐입니다 — C의 인자 전달은 값에 의한 전달이라, out은 호출한 쪽 포인터의 값을 복사해 받은 매개변수이기 때문입니다.

규칙은 언제나 같습니다

「함수 안에서 어떤 변수를 바꾸고 싶으면 그 변수의 주소를 전달한다」입니다. int 변수를 바꾸고 싶으면 int *를 전달합니다. 이번에 바꾸고 싶은 것은 int * 포인터이니 그 주소, 곧 int **를 전달합니다.

#include <stdio.h>
#include <stdlib.h>

void make(int **out, int n) {          // 결과를 저장할 포인터의 주소를 전달받는다
    *out = (int *) malloc(n * sizeof(int));

    if (*out == NULL) {
        return;
    }

    for (int i = 0; i < n; i = i + 1) {
        (*out)[i] = (i + 1) * 10;      // 괄호를 빼면 뜻이 달라진다
    }
}

int main(void) {
    int *data = NULL;

    make(&data, 5);                    // data의 주소를 전달한다

    if (data == NULL) {
        printf("메모리를 할당하지 못했습니다.\n");
        return 1;
    }

    for (int i = 0; i < 5; i = i + 1) {
        printf("%d ", data[i]);        // 10 20 30 40 50
    }
    printf("\n");

    free(data);

    return 0;
}
make(&data, 5) — data의 주소를 전달한다 data 처음에는 NULL make 안의 out data의 주소 out은 data를 가리킨다 *out = malloc(…) — data에 새 주소를 저장한다 data 바꾸고 싶은 것이 포인터라 매개변수를 int **로 선언했다.

&data를 전달하고 *out에 대입합니다

make(&data, 5)는 data의 주소를 전달합니다. 함수 안의 *out = malloc(…)은 그 주소에 있는 변수, 곧 호출한 쪽의 data에 할당한 메모리의 주소를 저장하므로 호출한 쪽의 data가 바뀝니다.

data를 NULL로 초기화하고 시작한 것도 눈여겨보세요. 초기화하지 않은 포인터에는 알 수 없는 주소가 들어 있으므로, 함수가 data를 바꾸지 못하고 끝나는 경우가 생기면 호출한 쪽이 실패를 알아볼 방법이 없기 때문입니다.

(*out)[i]의 괄호를 빼면 안 됩니다

*out[i]라고 쓰면 대괄호의 우선순위가 역참조 연산자보다 높아 *(out[i])로 해석됩니다. out을 포인터의 배열로 보고 인덱스 i인 요소를 역참조하라는 뜻이 되어 i가 1 이상이면 전혀 다른 메모리 위치에 접근하게 됩니다.

먼저 out을 역참조해 data를 얻은 다음, data가 가리키는 메모리에서 인덱스 i인 요소에 접근해야 하므로 (*out)[i]처럼 괄호로 순서를 정해 줍니다. 두 식 모두 컴파일되므로 컴파일러가 알려 주지 않습니다.

스스로 확인

사용을 마친 메모리를 해제하는 일까지 함수에 맡긴다면, 그 함수도 int **를 전달받아야 할까요? 무엇을 바꾸고 싶은지로 생각해 보세요.

행마다 따로 할당한 표

크기를 실행 중에 정하는 표를 만들어 봅니다.

2차원 배열은 행과 열의 개수를 미리 정해야 합니다. 크기를 실행 중에 정해야 한다면 동적으로 할당해야 하는데, 여기서는 표를 행마다 나누어 할당하므로 할당도 두 단계로 합니다.

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int rows = 3;
    int cols = 4;

    int **grid = (int **) malloc(rows * sizeof(int *));   // 행을 가리킬 포인터 세 개

    if (grid == NULL) {
        return 1;
    }

    for (int r = 0; r < rows; r = r + 1) {
        grid[r] = (int *) malloc(cols * sizeof(int));     // 행마다 「따로」 할당한다

        if (grid[r] == NULL) {
            for (int k = 0; k < r; k = k + 1) {           // 앞서 할당한 행들을 해제한다
                free(grid[k]);
            }
            free(grid);
            return 1;
        }
    }

    for (int r = 0; r < rows; r = r + 1) {
        for (int c = 0; c < cols; c = c + 1) {
            grid[r][c] = (r + 1) * 10 + c;
        }
    }

    for (int r = 0; r < rows; r = r + 1) {
        for (int c = 0; c < cols; c = c + 1) {
            printf("%4d", grid[r][c]);
        }
        printf("\n");
    }

    printf("0번 행 %p\n", (void *) grid[0]);
    printf("1번 행 %p\n", (void *) grid[1]);   // 이어져 있지 않을 수 있다

    for (int r = 0; r < rows; r = r + 1) {     // 각 행을 「먼저」 해제한다
        free(grid[r]);
    }
    free(grid);                                // grid를 먼저 해제하면 각 행의 주소를 잃는다

    return 0;
}
int a[3][4] — 열두 칸이 빈틈없이 이어진다 int **grid — 행마다 따로 할당하니 흩어져 놓일 수 있다 grid[0] grid[1] grid[2] 대신 행마다 길이를 다르게 할 수 있다.

먼저 「행을 가리킬 포인터」를, 그다음 각 행을 할당합니다

malloc(rows * sizeof(int *))은 정수를 저장할 메모리가 아니라 「int를 가리키는 포인터」 세 개를 저장할 메모리를 할당합니다. sizeof(int)가 아니라 sizeof(int *)를 쓰는 이유입니다.

그다음 행마다 malloc을 한 번씩 더 호출해 반환된 주소를 grid[0]·grid[1]·grid[2]에 저장합니다. 모두 마치면 grid[r][c]처럼 2차원 배열과 똑같이 쓸 수 있습니다.

2차원 배열과 겉모습만 같습니다

int a[3][4]는 요소 열두 개가 빈틈없이 이어져 있습니다. 그런데 위 프로그램을 실행해 보면 0번 행과 1번 행의 주소가 대개 바로 이어지지 않습니다. 행마다 따로 할당했으니 서로 다른 곳에 흩어져 놓일 수 있기 때문입니다.

그래서 포인터 하나로 처음부터 끝까지 이어서 읽는 방법은 여기서 통하지 않습니다. 「요소 몇 개마다 다음 행이 시작되는지」라는 규칙 자체가 없습니다 — 각 행이 어디에 있는지는 grid[r]에 저장된 주소가 알려 줍니다. 그 대신 행마다 길이를 다르게 할당할 수도 있습니다.

해제하는 순서가 정해져 있습니다

각 행을 먼저 해제하고 grid를 나중에 해제합니다. grid를 먼저 해제하면 각 행의 주소가 저장되어 있던 메모리가 해제되어 각 행을 해제할 방법이 없어집니다.

중간에 할당에 실패했을 때도 마찬가지입니다. 위 코드는 실패한 행 앞까지 할당해 둔 행을 하나씩 해제하고 나서 grid를 해제하고 끝냅니다. 할당한 메모리가 여럿이면 실패하는 경로에서도 빠짐없이 해제해야 합니다.

스스로 확인

행마다 길이가 다른 표(0번 행은 요소 두 개, 1번 행은 요소 다섯 개)를 만들려면 무엇만 바꾸면 될까요? 그 길이들은 어디에 저장해 두어야 할까요?

핵심 정리

포인터에도 주소가 있다

포인터도 변수이므로 &p로 자신의 주소를 얻을 수 있다. 새 규칙이 아니라 있던 규칙을 한 번 더 적용한 것이다.

별표 하나가 한 걸음

**q는 두 번 역참조해 값에, *q는 한 번 역참조해 포인터 p에 접근한다. 별표의 개수가 접근하는 대상을 정한다.

바꾸려면 그 변수의 주소를

int 변수를 바꾸려면 int *, 포인터를 바꾸려면 int **를 전달한다. 함수가 메모리를 할당해 호출한 쪽의 포인터에 그 주소를 저장해 줄 때 쓰인다.

겉모습만 2차원 배열

행마다 따로 할당하므로 행들이 이어져 있다는 보장이 없다. 대신 행마다 길이를 다르게 할 수 있다. 각 행을 먼저, grid를 나중에 해제한다.

확인 퀴즈

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

1. int num; int *p = &num; int **q = &p;일 때 *q의 값은?

2. 함수가 호출한 쪽의 포인터 변수를 바꾸게 하려면 무엇을 전달해야 하는가?

3. int **grid로 만든 표가 int a[3][4]와 다른 점은?