포인터는 주소를 담는 변수입니다. 그런데 그 포인터 변수 자체를 함수가 바꿔야 하거나, 어떤 동작(함수)을 값처럼 주고받아야 하는 순간이 옵니다. 앞은 포인터를 가리키는 포인터인 이중 포인터로, 뒤는 함수를 가리키는 함수 포인터로 풉니다. 별표가 두 개 붙거나 괄호가 얽혀 처음엔 어지럽지만, 짧은 예제를 직접 쳐서 출력을 확인하면 왜 필요한지가 분명해집니다. 아래 예제는 gcc 파일.c -o prog 로 컴파일해 하나씩 돌려 보세요.


19.1 포인터를 바꾸려면 그 주소를 넘긴다

17편에서 int 변수의 원본을 함수가 바꾸려면 그 주소(int 포인터)를 넘겼습니다. 같은 논리를 한 단계 올리면, 포인터 변수의 원본을 바꾸려면 그 포인터의 주소, 즉 이중 포인터를 넘겨야 합니다.


#include <stdio.h>


void point(int **pp, int *target) {

*pp = target; // 바깥 포인터가 target을 가리키게

}


int main(void) {

int a = 100;

int *p = NULL; // 아직 아무것도 안 가리킴

point(&p, &a); // p의 주소를 넘김

printf("*p = %d\n", *p);

return 0;

}


실행하면 이렇게 나옵니다.


*p = 100


함정. 만약 point(p, &a)처럼 p를 그냥 넘겼다면, 함수 안 pp는 p의 사본이라 사본만 target을 가리키고 끝납니다. main의 p는 여전히 NULL이라 다음 줄에서 *p가 널 포인터 역참조로 정의되지 않은 동작(undefined behavior)이 됩니다. 포인터를 바꾸려면 반드시 &p로 그 주소를 넘깁니다.


19.2 함수가 메모리를 할당해 돌려줄 때

이중 포인터가 억지 예제가 아니라 실무에서 꼭 필요한 대표 상황이 이것입니다. 함수가 malloc으로 배열을 새로 만들어 바깥에 넘겨 주고 싶은데, return 하나로는 배열 주소와 성공 여부를 동시에 돌려주기 어렵습니다. 그래서 성공/실패는 return으로, 만든 주소는 이중 포인터 매개변수로 내보냅니다.


#include <stdio.h>

#include <stdlib.h>


int makeArray(int **out, int n) {

int *p = malloc(n * sizeof(int));

if (p == NULL) return 0; // 확보 실패

for (int i = 0; i < n; i++) p[i] = i * 10;

*out = p; // 바깥 포인터가 새 배열을 가리키게

return 1; // 성공

}


int main(void) {

int *data = NULL;

if (makeArray(&data, 4)) {

for (int i = 0; i < 4; i++) printf("%d ", data[i]);

printf("\n");

free(data); // 다 쓰면 반납

}

return 0;

}


출력은 다음과 같습니다.


0 10 20 30


주의. makeArray가 &data를 받아 *out에 채워 넣기에, 돌아오면 main의 data가 새 메모리를 가리킵니다. 여기서 out을 안 쓰고 int makeArray(int *p) 식으로 짜면 함수 안 p만 바뀌고 main의 data는 계속 NULL입니다. 할당한 메모리는 마지막에 free로 반납해야 메모리 누수가 없습니다.


19.3 함수를 담는 포인터

이제 방향을 바꿔, 함수 자체를 값처럼 다뤄 봅니다. 함수 이름은 그 함수가 놓인 주소나 마찬가지라, 알맞은 모양의 포인터에 담아 두었다가 나중에 그 포인터로 호출할 수 있습니다.


#include <stdio.h>


int add(int a, int b) { return a + b; }


int main(void) {

int (*op)(int, int) = add; // 함수를 가리키는 포인터

printf("%d\n", op(3, 4));

return 0;

}


출력은 7입니다.


7


선언을 뜯어보면 반환형 int, (*op)로 op가 포인터임을 밝히고, 뒤의 (int, int)가 매개변수 목록입니다. 곧 op는 정수 둘을 받아 정수를 돌려주는 함수를 가리키는 포인터입니다.


함정. op를 감싼 괄호를 빼고 int *op(int, int)라고 쓰면, 이것은 포인터가 아니라 정수 포인터를 돌려주는 함수의 선언으로 해석됩니다. 함수 포인터에서 (*op) 괄호는 생략할 수 없습니다.


[C 19] 이중 포인터와 함수 포인터


19.4 함수 포인터 배열로 만드는 미니 계산기

함수 포인터가 진가를 내는 첫 장면은 여러 동작을 한 표에 모아 번호로 골라 부를 때입니다. 연산마다 if나 switch로 갈라 쓰는 대신, 같은 모양의 함수들을 배열에 담고 인덱스로 실행합니다.


#include <stdio.h>


int add(int a, int b) { return a + b; }

int sub(int a, int b) { return a - b; }

int mul(int a, int b) { return a * b; }


int main(void) {

int (*ops[3])(int, int) = {add, sub, mul};

printf("더하기: %d\n", ops[0](10, 3));

printf("빼기: %d\n", ops[1](10, 3));

printf("곱하기: %d\n", ops[2](10, 3));

return 0;

}


세 줄이 순서대로 찍힙니다.


더하기: 13

빼기: 7

곱하기: 30


ops[0]에 add가 담겨 있으니 ops[i](10, 3)은 그 자리의 함수를 실제로 호출합니다. 메뉴 번호나 연산 기호를 인덱스로 바꾸면, 호출 코드 한 줄로 동작을 갈아 끼울 수 있습니다.


19.5 콜백과 qsort

함수 포인터가 없어서는 안 되는 대표 사례가 표준 라이브러리의 정렬 함수 qsort입니다. qsort는 무엇을 어떤 기준으로 크다고 볼지 모릅니다. 그래서 두 원소를 비교하는 방법을 함수로 건네받습니다. 이렇게 호출하는 쪽이 넘겨 주면 라이브러리가 필요할 때 되불러 주는 함수를 콜백(callback)이라 합니다.


#include <stdio.h>

#include <stdlib.h>


int cmp(const void *a, const void *b) {

int x = *(const int *)a;

int y = *(const int *)b;

return (x > y) - (x < y); // 작을수록 앞

}


int main(void) {

int arr[5] = {30, 10, 50, 20, 40};

qsort(arr, 5, sizeof(int), cmp); // 비교 함수를 넘김

for (int i = 0; i < 5; i++) printf("%d ", arr[i]);

printf("\n");

return 0;

}


오름차순으로 정렬되어 출력됩니다.


10 20 30 40 50


cmp는 a가 b보다 작으면 음수, 크면 양수, 같으면 0을 돌려주기로 약속된 콜백입니다. qsort의 마지막 인자 cmp가 바로 함수 포인터이고, 이 함수만 바꿔 끼우면 같은 qsort로 내림차순이나 문자열 정렬도 할 수 있습니다.


주의. 콜백은 void 포인터로 값을 받으므로, 안에서 원래 타입인 const int 포인터로 되돌려 캐스팅한 뒤 역참조해야 합니다. 또 x - y로 크기를 비교하면 값이 아주 클 때 정수 뺄셈이 넘쳐 잘못된 부호가 나올 수 있어, (x > y) - (x < y)처럼 비교 결과를 빼는 방식이 안전합니다.


이중 포인터는 포인터의 원본을 바꾸거나 함수가 만든 메모리를 돌려받을 때, 함수 포인터는 동작을 값으로 담아 골라 끼우거나 콜백으로 넘길 때 씁니다. 넘기는 것이 값인지 주소인지, 데이터인지 동작인지만 또렷이 구분하면 두 기법 모두 손에 익습니다.