컴파일된 코드도 데이터처럼 메모리에 살고 있으므로, 함수에는 주소가 있습니다. 함수 포인터는 그 주소를 담는 변수이며, 함수를 저장할 수 있게 되는 순간 함수를 인자로 넘기고, 테이블로 보관하고, 런타임에 그중 하나를 고를 수 있습니다.
사용자 정의 순서로 정렬하기, 이벤트 핸들러, 플러그인 인터페이스, 상태 기계의 뒤에 있는 능력이 바로 그것입니다. 동시에 읽는 사람을 멈춰 세워 눈살을 찌푸리게 할 가능성이 가장 큰 C 문법이기도 하니, 거기서부터 시작합시다.
선언 문법
평범한 함수를 봅시다.
int add(int a, int b);
그 주소를 담을 수 있는 포인터를 선언하려면 이름을 (*name)으로 바꿉니다.
int (*op)(int, int);
안쪽에서부터 읽으세요. op는 포인터이고, (int, int)를 받아 int를 반환하는 함수를 가리킵니다.
괄호는 장식이 아닙니다. 괄호가 없으면 *가 반환 타입에 붙습니다.
int (*f)(int); // int를 받아 int를 반환하는 함수의 포인터
int *g(int); // int를 받아 int*를 반환하는 함수 - 완전히 다르다
처음부터 끝까지 동작하는 모습입니다.
두 호출 형태 모두 동작하는 것은 함수 지시자가 자동으로 포인터로 변환되기 때문입니다. op(10, 3)이라고 쓰세요. (*op)(10, 3) 표기는 과거의 잔재입니다.
마지막 printf에 대한 주석 하나. %p는 객체 포인터를 기대하고 함수 포인터는 형식적으로 별개의 계열이므로, 함수 포인터를 출력하는 것은 엄밀히 이식성이 없습니다. 캐스트는 어쨌든 동작하는 플랫폼에서 컴파일러를 조용하게 만들어 줍니다. 함수 포인터를 출력할 일은 거의 없습니다.
typedef가 읽기 쉽게 만들어 줍니다
선언 문법은 금세 흉해지고, typedef가 표준적인 처방입니다.
int apply(BinaryOp op, int x, int y)를 날것의 형태 int apply(int (*op)(int, int), int x, int y)와 비교해 보세요. 뜻은 같지만 한쪽만 한눈에 읽힙니다. 일반적인 규칙은 typedef를 보세요.
콜백: 동작을 호출자가 공급하게 하기
콜백은 다른 함수가 적절한 순간에 나를 다시 호출할 수 있도록 건네주는 함수입니다. 한쪽 코드가 일의 구조를 다루고 호출자가 판단을 공급하게 해 줍니다.
함수 포인터가 없다면 count_even, count_positive, count_big - 같은 반복문의 사본 셋 - 을 써야 합니다. 함수 포인터가 있으면 반복문은 한 번만 쓰고 검사 가 매개변수가 됩니다.
진짜 보상: qsort
표준 라이브러리의 qsort는 실제로 쓰게 될 함수 포인터 예제입니다. 무엇이든 담긴 배열을 정렬하는데, 원소에 대해서는 크기와 여러분이 주는 비교 함수 말고는 아무것도 모른 채 그 일을 해냅니다.
void qsort(void *base, size_t nmemb, size_t size,
int (*compar)(const void *, const void *));
비교 함수는 두 원소의 주소인 const void * 둘을 받고, 첫 번째가 앞에 오면 음수, 비기면 0, 뒤에 오면 양수를 반환합니다.
저 비교 함수에서 가져갈 습관이 둘 있습니다. 절대로 return x - y;라고 쓰지 마세요. 영리해 보이지만 크거나 음수인 값에서 오버플로가 나 부호가 틀리고 배열이 잘못 정렬됩니다. 그리고 역참조하기 전에 캐스트하세요. 나중이 아니라요. *(const int *)a인데, void *에 대한 *a는 읽을 것이 없기 때문입니다.
같은 장치가 원하는 어떤 필드로든 구조체를 정렬합니다.
(q->score > p->score) - (q->score < p->score) 관용구는 뺄셈 없이, 오버플로 없이 정확히 -1, 0, 1을 반환합니다. 그리고 strcmp는 이미 올바른 반환 규약을 가지고 있으므로 문자열 비교 함수는 한 줄짜리입니다.
함수 포인터 테이블
함수 포인터 배열은 if/else 연쇄를 조회로 바꿉니다. 인터프리터가 명령코드를 디스패치하고 메뉴 시스템이 명령을 실행하는 방식이 이것입니다.
이제 연산을 추가하는 것은 switch를 고치는 일이 아니라 행을 하나 추가하는 일입니다. 이를 구조체와 짝지으면 C의 표준적인 플러그인 형태가 됩니다. 이 언어에서 "인터페이스"란 함수 포인터로 이루어진 구조체를 뜻합니다.
물리는 지점들
시그니처는 정확히 일치해야 합니다. 함수 포인터의 타입에는 매개변수 타입과 반환 타입이 포함됩니다. 맞지 않는 함수를 대입하는 것은 제약 위반이고, 타입이 틀린 포인터를 통해 호출하는 것은 "동작하는" 경우에도 미정의 동작입니다. 경고를 잠재우려고 비교 함수의 시그니처를 캐스트하지 말고, 비교 함수를 고치세요.
널 함수 포인터도 여전히 널 포인터입니다. 데이터 포인터와 똑같이 NULL로 초기화하고 호출 전에 검사하세요.
if (handler != NULL) {
handler(event);
}
그 검사가 값을 하는 이유는 널 포인터를 보세요.
void 포인터는 타입 안전성을 잃게 하고, 타입 시스템은 여러분입니다. qsort 비교 함수 안에서는 엉뚱한 타입으로 캐스트하는 것을 아무것도 막지 않습니다. double 배열을 compare_ints로 정렬하면 깔끔하게 컴파일되고 말이 안 되는 결과가 나옵니다.
선언자 문법은 중첩됩니다. 함수 포인터를 반환하는 함수는 int (*get_op(char c))(int, int);입니다. 그런 걸 쓰고 있다면 typedef를 꺼내세요.
typedef int (*BinaryOp)(int, int);
BinaryOp get_op(char c); // 같은 것을, 읽을 수 있게
자주 묻는 질문
C에서 함수 포인터는 어떻게 선언하나요?
함수 이름이 올 자리에 (*name)을 넣어 함수의 시그니처를 씁니다. int (*op)(int, int);는 op를 int 두 개를 받아 int를 반환하는 함수의 포인터로 선언합니다. *op를 감싸는 괄호는 필수입니다. 괄호가 없으면 포인터를 반환하는 함수를 선언하게 됩니다.
포인터를 통해 함수를 어떻게 호출하나요?
op(3, 4) 또는 (*op)(3, 4) 둘 다 합법이고 같은 일을 합니다. 함수 지시자가 자동으로 포인터로 변환되기 때문입니다. 요즘 C 코드는 간결한 op(3, 4) 형태를 씁니다.
C에서 콜백이란 무엇인가요?
다른 함수가 나를 다시 호출할 수 있도록 건네주는 함수입니다. qsort가 고전적인 예입니다. 비교 함수를 넘기면, qsort는 두 원소 중 어느 쪽이 먼저인지 알아야 할 때마다 그것을 호출합니다. 그 덕분에 정렬 루틴 하나가 어떤 타입에도 통합니다.
함수의 주소를 얻을 때 &가 필요한가요?
아닙니다. 함수 이름은 이미 그 함수를 가리키는 포인터로 변환되므로 op = add;와 op = &add;는 동등합니다. 대부분의 C 코드는 &를 생략합니다.