59 함수를 값으로 — 함수 포인터
먼저 알아야 할 것
돌아보기
38장에서 배열 이름이 첫 원소의 주소로 무너진다고 했고, 58장에서는 가변 인자 함수가 타입 정보를 잃는다고 했다. 그러면 함수 이름은 수식에서 무엇으로 평가되는가?
답. 함수를 가리키는 포인터로 평가된다 — 배열과 같은 무너짐(decay)이 함수에도 있다. 예외는 sizeof와 &의 피연산자일 때뿐인데, 함수에 sizeof를 쓰는 것은 애초에 금지이므로 사실상 &만 예외다. 그리고 &함수도 결국 같은 포인터를 준다. 그래서 f, &f, *f가 전부 같은 값이 되는 기묘한 상황이 벌어진다 — 이 장의 첫 예제가 그것이다.
이 장의 필요성과 맥락
이 장이 끝나면
이 장에서 답할 질문
- 그러면
dlsym은 잘못 설계된 것인가? 오늘 POSIX 를 다시 만든다면 어떻게 할까? - 그러면 애초에 아무 함수 포인터나 담는 범용 타입 하나를 정해 쓰면 되지 않나? 이를테면
typedef void *(*generic_func_ptr_t)(void);처럼. - 그러면 C++의 가상 함수와 무엇이 다른가?
59.1 이름은 포인터로 무너진다
examples/ch59/funcptr.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static int add(int a, int b) { return a + b; }
static int mul(int a, int b) { return a * b; }
/* 함수 포인터를 받는 함수 — 콜백의 기본형 */
static int apply(int (*op)(int, int), int a, int b) { return op(a, b); }
int main(void)
{
/* ① 함수 이름은 값으로 쓰이는 순간 포인터로 무너진다 */
int (*p)(int, int) = add; /* & 없이도 된다 */
int (*q)(int, int) = &add; /* & 를 붙여도 같다 */
printf("add == &add : %s\n", p == q ? "same" : "different");
/* ② 별표를 아무리 붙여도 결과는 같다 — 역참조가 다시 무너지기 때문 */
printf("p(2,3)=%d (*p)(2,3)=%d (***p)(2,3)=%d (*******p)(2,3)=%d\n",
p(2, 3), (*p)(2, 3), (***p)(2, 3), (*******p)(2, 3));
/* ③ 반면 & 는 한 번만 쓸 수 있다: &&add 는 문법 오류다
(&add 는 값일 뿐, 그 값의 주소를 다시 얻을 수는 없다) */
/* ④ 디스패치 표 — switch 대신 배열로 고르기 */
struct { const char *name; int (*fn)(int, int); } table[] = {
{ "add", add }, { "mul", mul },
};
for (size_t i = 0; i < sizeof table / sizeof table[0]; i++)
printf("%s(6,7) = %d\n", table[i].name, apply(table[i].fn, 6, 7));
/* ⑤ 함수 포인터의 크기는 데이터 포인터와 같다는 보장이 없다 */
printf("sizeof(void*)=%zu, sizeof(int(*)(int,int))=%zu\n",
sizeof(void *), sizeof(int (*)(int, int)));
/* ⑥ qsort 의 비교자도 함수 포인터다 */
int v[] = { 5, 2, 9, 1 };
int cmp(const void *a, const void *b); /* 아래에 정의 */
qsort(v, 4, sizeof v[0], cmp);
printf("sorted: %d %d %d %d\n", v[0], v[1], v[2], v[3]);
return 0;
}
int cmp(const void *a, const void *b)
{
int x = *(const int *)a, y = *(const int *)b;
return (x > y) - (x < y);
}
실행 결과
add == &add : same
p(2,3)=5 (*p)(2,3)=5 (***p)(2,3)=5 (*******p)(2,3)=5
add(6,7) = 13
mul(6,7) = 42
sizeof(void*)=8, sizeof(int(*)(int,int))=8
sorted: 1 2 5 9
출력의 첫 두 줄이 이 장의 핵심이다.
add == &add — 함수 이름은 포인터로 무너지고, &를 붙여도 같은 포인터를 얻는다. 그래서 둘은 같다.
(****p)(2,3)이 컴파일된다 — 별표를 아무리 붙여도 결과가 같은 이유는 이렇다. *p는 포인터를 역참조해 함수 지시자를 얻는데, 그 지시자가 값으로 쓰이는 순간 다시 포인터로 무너진다. 즉 * 하나가 붙을 때마다 “포인터 → 함수 → 포인터”를 한 바퀴 돌 뿐, 제자리다.
반대 방향은 성립하지 않는다. &&add는 문법 오류다 — &add는 이미 값 (포인터)이고, C에서 값의 주소는 얻을 수 없기 때문이다(주소를 얻으려면 이름이 붙은 자리, 즉 lvalue여야 한다). *add는 되는데 &&add는 안 되는 비대칭이 여기서 나온다.
흔한 오해. “함수 포인터를 호출하려면 (*p)(x)처럼 역참조해야 한다”
p(x)와 (*p)(x)는 완전히 같고, 표준은 둘 다 허용한다. (*p)(x)를 선호하는 코드베이스가 있는 이유는 읽는 사람에게 이것이 함수 포인터임을 알리려는 문서 목적이지 문법 요구가 아니다. 어느 쪽이든 일관되게 쓰면 된다.59.2 타입이 곧 계약이다
함수 포인터의 타입은 반환형과 매개변수 목록으로 정해진다.
int (*p)(int, int); /* int 둘을 받아 int 를 주는 함수의 포인터 */
void (*q)(void); /* 인자도 반환도 없는 함수의 포인터 */
int (*r[4])(int); /* 그런 포인터 네 개의 배열 */
int (*(*s)(void))(int); /* 읽기 어렵다 — typedef 를 쓴다 */마지막 줄이 보여 주듯 선언이 금세 험해진다. 실무의 관행은 typedef다.
typedef int (*binop_fn)(int, int);
binop_fn table[] = { add, mul };다른 타입의 함수 포인터로 캐스트해 호출하는 것은 계약 밖이다. 예를 들어 void (*)(void)로 캐스트해 둔 것을 int (*)(int)로 되돌려 부르면 안 된다. 저장했다가 원래 타입으로 되돌려 부르는 것만 보장된다.
반례. 비교자 시그니처를 캐스트로 맞추기
int cmp_int(const int *a, const int *b); /* 편해 보이지만 */
qsort(v, n, sizeof v[0], (int (*)(const void *, const void *))cmp_int);qsort는 const void * 두 개를 넘기는데, 실제 함수는 const int *를 기대한다. 두 타입이 같은 표현을 갖는 것이 보장되지 않으므로 이 호출은 계약 밖이다. 옳은 방법은 시그니처를 그대로 맞추고 안에서 캐스트하는 것이다 — 예제의 cmp가 그렇게 했다.
59.3 void *와 함수 포인터는 다른 세계다
35장에서 void *가 “어떤 데이터 포인터든 담는 그릇”이라고 배웠다. 그런데 함수 포인터는 그 그릇에 담기지 않는다. 표준은 데이터 포인터와 함수 포인터 사이의 변환을 정의하지 않는다.
이유는 역사와 하드웨어에 있다. 하버드 구조(Harvard architecture)를 쓰는 기계에서는 코드와 데이터가 서로 다른 주소 공간에 있다 — AVR 마이크로 컨트롤러가 대표적이고, 여기서는 “0번지”가 코드 영역에도 데이터 영역에도 따로 존재한다. 주소의 폭조차 다를 수 있다. 그런 기계에서 두 포인터를 같은 그릇에 담는 것은 애초에 불가능하다.
그래서 예제가 두 크기를 함께 찍은 것이다. 이 기계에서는 우연히 같았지만, 같다는 보장은 어디에도 없다.
이 갈라짐이 탁상공론이 아니라는 증거가 유닉스 한복판에 있다. 동적 라이브러리에서 함수를 찾아오는 POSIX 함수 dlsym 이 void * 를 돌려주는데, 부르는 쪽이 원하는 것은 함수 포인터다 — 즉 널리 쓰이는 API 하나가 표준이 정의하지 않은 변환을 요구하고 있다. 이 이야기는 이 장 뒤쪽, 함수 포인터를 인쇄하는 절에서 끝까지 따라간다.
59.4 함수 포인터를 인쇄하는 법
앞 절이 「void *와 함수 포인터는 다른 세계」라고 했다. 그 사실이 가장 자주 발목을 잡는 자리가 로그다 — 「지금 어느 콜백이 불렸나」를 남기고 싶은데, 35장에서 배운 printf("%p", (void *)p)가 여기서는 통하지 않는다.
examples/ch59/print_funcptr.c
/* 함수 포인터를 인쇄하는 법 — %p 에 넘길 수 없는 값을 다루는 세 가지 길. */
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
static int add(int a, int b) { return a + b; }
static int sub(int a, int b) { return a - b; }
static int mul(int a, int b) { return a * b; }
typedef int (*binop)(int, int);
/* ── 길 ①: 바이트로 떠서 16진수로 ────────────────────────────
함수 포인터를 void* 로 캐스트하는 것은 표준 밖이다. memcpy 로 바이트를
옮기는 것은 어디서나 계약 안이다 — 크기만 맞으면 된다. */
static void fmt_funcptr(char *out, size_t cap, binop f)
{
unsigned char raw[sizeof f];
memcpy(raw, &f, sizeof raw);
size_t k = 0;
k += (size_t)snprintf(out + k, cap - k, "0x");
for (size_t i = sizeof raw; i-- > 0 && k + 2 < cap; )
k += (size_t)snprintf(out + k, cap - k, "%02X", raw[i]);
}
/* ── 길 ②: 이름표를 함께 들고 다닌다 (실무의 정답) ───────────── */
struct named_op {
const char *name;
binop fn;
};
static const struct named_op ops[] = {
{ "add", add }, { "sub", sub }, { "mul", mul },
};
static const char *name_of(binop f)
{
for (size_t i = 0; i < sizeof ops / sizeof *ops; i++)
if (ops[i].fn == f) return ops[i].name; /* 함수 포인터 비교는 합법 */
return "(unknown function)";
}
int main(void)
{
printf("sizeof(function pointer) = %zu, sizeof(void *) = %zu\n\n",
sizeof(binop), sizeof(void *));
puts("[road 1] print the bytes - portable, but a riddle to a human");
char buf[2 + 2 * sizeof(binop) + 1];
fmt_funcptr(buf, sizeof buf, add);
printf(" length of the address string for add: %zu characters (the value differs per run, so it is not shown)\n",
strlen(buf));
printf(" does it start with \"0x\": %s\n", strncmp(buf, "0x", 2) == 0 ? "yes" : "no");
puts("\n[road 2] print the label - this is what belongs in a log");
binop chosen[] = { mul, add, sub };
for (size_t i = 0; i < sizeof chosen / sizeof *chosen; i++)
printf(" call %zu: %s(7, 3) = %d\n",
i, name_of(chosen[i]), chosen[i](7, 3));
puts("\n[comparing function pointers is inside the contract]");
binop f = add, g = add, h = sub;
printf(" same function, so equal: %s\n", f == g ? "yes" : "no");
printf(" different function, so unequal: %s\n", f != h ? "yes" : "no");
puts(" so 'which function is this' can be answered without printing an address.");
puts("\n[what not to do]");
puts(" printf(\"%p\", f); <- passing a function pointer to %p: outside the contract");
puts(" printf(\"%p\", (void *)f); <- outside ISO C (POSIX allows it). -Wpedantic catches it");
puts(" printf(\"%p\", (void *)&f); <- compiles, but that is *the address of the variable*, not what you wanted");
return 0;
}
실행 결과
sizeof(function pointer) = 8, sizeof(void *) = 8
[road 1] print the bytes - portable, but a riddle to a human
length of the address string for add: 18 characters (the value differs per run, so it is not shown)
does it start with "0x": yes
[road 2] print the label - this is what belongs in a log
call 0: mul(7, 3) = 21
call 1: add(7, 3) = 10
call 2: sub(7, 3) = 4
[comparing function pointers is inside the contract]
same function, so equal: yes
different function, so unequal: yes
so 'which function is this' can be answered without printing an address.
[what not to do]
printf("%p", f); <- passing a function pointer to %p: outside the contract
printf("%p", (void *)f); <- outside ISO C (POSIX allows it). -Wpedantic catches it
printf("%p", (void *)&f); <- compiles, but that is *the address of the variable*, not what you wanted
59.4.1 왜 %p에 넘길 수 없는가
이유가 세 겹으로 쌓여 있다. 하나씩 걷어 내면 이렇다.
첫째, %p가 받는 것은 void *(또는 문자 포인터)뿐이다(35장). 함수 포인터는 그 목록에 없다.
둘째, 가변 인자 함수는 대신 바꿔 주지 않는다. 58장에서 본 대로 ... 로 넘어가는 인자에는 기본 인자 승격만 일어난다 — 정수는 int 로, float 는 double 로. 포인터에는 그런 승격이 없다. 그래서 %p 가 요구하는 void * 를 부르는 쪽이 손수 캐스트해서 넘겨야 한다. 「알아서 맞춰 주겠지」가 통하지 않는 자리다.
셋째, 그런데 그 캐스트 자체가 표준 밖이다. 앞 절에서 본 그대로다. 그래서 아래 줄은 컴파일러가 받아 주더라도 표준의 문장으로는 근거가 없다.
printf("%p", (void *)f); /* ISO C 가 정의하지 않는 변환 */이 책이 직접 확인한 GCC의 반응이 그것을 그대로 말해 준다 — -Wpedantic을 켜면 “ISO C forbids conversion of function pointer to object pointer type”라는 경고가 나온다.
★ 세 겹을 한 줄로 줄이면 이렇다. %p 는 void * 를 요구하고, 가변 인자는 변환을 대신해 주지 않으며, 손수 하는 그 변환은 정의되어 있지 않다. 그리고 마지막 고리가 가장 깊다 — 앞 절에서 본 대로 함수 포인터는 객체 포인터와 독립된 계열이라, 하버드 구조나 함수 서술자를 쓰는 기계에서는 void * 가 함수 주소를 온전히 담을 수 있다는 보장조차 없다. 문법의 문제가 아니라 그릇의 문제다.
반례. 세 가지 흔한 오답
printf("%p", f); /* ① 함수 포인터를 그대로 — 계약 밖 */
printf("%p", (void *)f); /* ② 표준 밖의 변환(POSIX 에서는 통한다) */
printf("%p", (void *)&f); /* ③ 컴파일도 되고 경고도 없는데… */셋째가 특히 고약하다. &f는 함수가 아니라 함수 포인터 변수의 주소다. 컴파일러는 아무 말도 하지 않고, 출력은 그럴듯한 16진수이며, 값은 완전히 엉뚱하다. 「경고가 없으니 맞겠지」가 통하지 않는 자리다.
플랫폼 노트. POSIX 는 이 구멍을 메운다 — dlsym 이라는 실제 사례
유닉스 계열에서는 사정이 다르다. POSIX 가 dlsym() 이 함수의 주소를 void * 로 돌려주도록 정해 두었기 때문에, 그 시스템에서는 함수 포인터와 void * 사이의 변환이 동작해야 한다. 그래서 리눅스·macOS·BSD 만 겨냥하는 코드에서 (void *)f 는 관행이 아니라 규격에 가깝다.
★ 그런데 이 「메움」에는 값이 붙어 있다. 규격이 요구하는 것은 표준 C 가 정의하지 않은 변환이고, 컴파일러는 여전히 경고를 낸다. 그래서 실무에서 이런 관용구가 굳었다.
void (*fn)(void);
*(void **)&fn = dlsym(handle, "do_work"); /* 표준의 틈을 우회하는 관용구 */이 줄이 하는 일을 보라 — 변환을 하는 대신 함수 포인터 변수의 바이트를 객체 포인터인 척 덮어쓴다. 경고는 사라지고 계약은 그대로 밖이다. 「표준이 정의하지 않은 것을 플랫폼이 요구하고, 그래서 사람들이 표준을 한 겹 더 우회한다」 — 12장에서 말한 회색지대의 전형이다.
규율은 그대로다: 쓰기로 했다면 그 자리에 왜 괜찮은지를 한 줄로 적어 두고, 이식이 필요해질 때 갈아탈 길을 알아 둔다. 바로 다음 문답이 그 「갈아탈 길」이다.
문. 그러면 dlsym은 잘못 설계된 것인가? 오늘 POSIX 를 다시 만든다면 어떻게 할까?
답. ★ 잘못이라기보다, 그때는 그것이 자연스러운 선택이었다. 1980년대 후반 유닉스가 dlopen/dlsym 을 내놓을 무렵, 그 계열이 도는 기계에서 함수 주소와 데이터 주소는 같은 폭의 같은 번지 공간에 있었고 「주소는 주소다」가 현실이었다. void * 하나로 무엇이든 돌려주는 것이 검소한 설계였다.
깨진 것은 뒤였다. ISO C 가 함수 포인터와 객체 포인터를 다른 세계로 못박고, 실제로 그 둘이 다른 기계(하버드 구조, 함수 서술자를 쓰는 ABI)가 흔해지면서 dlsym 의 서명이 표준이 정의하지 않는 변환을 요구하는 API 가 되어 버렸다. POSIX 자신도 이것을 인정한다 — 규격의 「응용 사용법」이 이렇게 적는다.
“ void * 포인터에서 함수 포인터로의 변환은 ISO C 표준이 정의하지 않는다. … 이 표준은 적합한 구현에서 이 변환이 올바로 동작할 것을 요구한다.1 ”
★ 「표준이 정의하지 않는 것을 우리가 요구한다」 — 규격이 이렇게까지 적는 일은 드물다. 땜질임을 스스로 밝힌 것이다.
그러면 오늘이라면. 답은 이미 나와 있고, 심지어 구현된 곳도 있다. 반환 타입을 함수 포인터 쪽으로 두면 된다. FreeBSD 가 dlfunc() 라는 이름으로 그렇게 했다.
dlfunc_t dlfunc(void *restrict handle, const char *restrict symbol);매뉴얼의 설명이 정확하다 — 「dlsym() 의 모든 동작을 구현하되, 컴파일러 진단을 일으키지 않고 함수 포인터로 캐스트할 수 있는 반환 타입을 갖는다」. 그리고 그 반환 타입이 무엇인지는 규격 차원에서 일부러 정하지 않았다 — 「dlfunc() 의 정확한 반환 타입은 미명세이며, 응용은 알맞은 함수 포인터 타입으로 캐스트해야 한다」.2
★ 이것이 표준 안인 근거는 §6.3.2.3 이다. 함수 포인터는 다른 함수 포인터 타입으로 바꿨다가 되돌리면 원래 값과 같음이 보장된다. 그러니 호출하는 쪽이 자기 서명으로 한 번만 되돌려 캐스트하면 되고, 그 캐스트는 함수 포인터끼리의 변환이라 계약 안이다.
| 설계 | 반환 | 표준과의 관계 |
|---|---|---|
dlsym (1988~) | void * | ★ 표준 밖의 변환을 요구한다 |
dlfunc (FreeBSD) | dlfunc_t — 미명세 함수 포인터 | 함수 포인터끼리의 변환 = 표준 안 |
| 두 벌로 나누기 | dlsym_object / dlsym_function | 가장 정직하나 API 가 늘어난다 |
표 59.1
그런데 왜 표준이 되지 않았나. 표준화 단체는 선례 없는 인터페이스를 규격에 넣지 않는다. 그래서 FreeBSD 가 먼저 구현해 선례를 만들려 했다 — 이것은 짐작이 아니라 당사자의 기록이다. dlfunc 를 넣은 커밋과, 세 시간 뒤 타입 이름을 __dlfunc_t 에서 dlfunc_t 로 고친 커밋이 남아 있고, 뒤엣것은 「내가 오스틴 그룹에 제안한 것과 맞추려고」라고 적었다.3
★ 그런데 그 선례가 FreeBSD 밖으로 퍼지지 않았고, dlfunc 는 24년째 규격에 없다. 여기에 뼈아픈 순환이 있다 — 퍼지지 않아서 표준이 못 되고, 표준이 아니라서 아무도 안 쓴다. 더 나은 설계가 이겨서 남는 것이 아니라, 이미 퍼진 것이 남는다.
★★ 이 이야기의 교훈은 dlsym 이 아니라 API 설계 일반에 있다. 「지금 이 기계에서 같으니 하나로 합치자」는 결정이 30년 뒤 표준이 갈라놓은 두 개념을 억지로 잇는 빚이 되었다. 이 책이 되풀이해 말하는 것 — 타입은 크기가 아니라 계약이다(26장) — 의 가장 값비싼 사례다.
문. 그러면 애초에 아무 함수 포인터나 담는 범용 타입 하나를 정해 쓰면 되지 않나? 이를테면 typedef void *(*generic_func_ptr_t)(void); 처럼.
답. ★ 생각이 맞다. 그리고 그것이 바로 dlfunc 가 한 일이다. 다만 왜 맞는지를 정확히 짚어야 하고, 어떤 철자를 고르느냐가 실제로 차이를 만든다.
먼저 왜 맞는가. 흔한 설명은 「함수 포인터는 어차피 다 같은 크기니까」인데 — ★ 그 근거는 표준에 없다. §6.2.5 는 표현이 서로 같다고 약속하는 대상을 깐깐하게 열거한다.
“ 모든 구조체 타입에 대한 포인터는 서로 같은 표현과 정렬 요구를 갖는다. 모든 공용체 타입에 대한 포인터도 서로 같은 표현과 정렬 요구를 갖는다. 그 밖의 타입에 대한 포인터는 같은 표현이나 정렬 요구를 갖지 않을 수 있다. ”
함수 포인터는 「그 밖」에 든다. 그러니 크기가 같다는 보장은 없다.
진짜 근거는 다른 조항, §6.3.2.3 이다 — 그리고 이쪽이 더 세다.
“ 어떤 타입의 함수에 대한 포인터는 다른 타입의 함수에 대한 포인터로 변환했다가 되돌릴 수 있다. 그 결과는 원래 포인터와 같게 비교된다. 변환된 포인터로, 참조된 타입과 호환되지 않는 함수를 호출하면 동작은 정의되지 않는다. ”
★ 차이가 중요하다. 이것은 표현의 약속이 아니라 값의 약속이다. 표현이 서로 다른 기계에서도 왕복은 성립한다 — 그래서 하버드 구조에서도 이 설계가 무너지지 않는다. 게다가 객체 포인터의 왕복(§6.3.2.3 7항)에는 「정렬이 맞아야 한다」는 단서가 붙는데, 함수 포인터의 왕복에는 단서가 없다.
즉 함수 포인터는 서로 닫힌 계열이고, 그 계열 안에서는 아무 타입이나 보관함으로 쓸 수 있다. 이 책이 직접 확인했다 — int(*)(int,int)·double(*)(double)· const char *(*)(void) 셋을 하나의 범용 타입에 담았다가 되돌리니 세 번 다 원래 포인터와 같게 비교되었고, 되돌린 뒤 호출도 정상이었다.
그런데 어떤 철자로 쓸 것인가. 여기서 갈린다. 세 후보를 실제로 재 보았다.
| 범용 타입의 철자 | 왕복 | 캐스트를 깜빡하고 그냥 호출하면 | -Wcast-function-type |
|---|---|---|---|
void (*)(void) | 된다 | ★ 컴파일된다 — 조용한 미정의 동작 | 경고 없음 |
void *(*)(void) (제안하신 꼴) | 된다 | ★ 컴파일된다 — 조용한 미정의 동작 | 캐스트마다 경고 |
void (*)(struct __dlfunc_arg) | 된다 | 컴파일되지 않는다 | 캐스트마다 경고 |
표 59.2
두 가지가 드러난다.
하나 — 반환 타입을 void * 로 둘 이유가 없다. 그 자리에 무엇을 적든 왕복은 똑같이 성립하는데, void * 라고 적으면 「이 함수는 포인터를 돌려준다」는 거짓 정보를 타입에 새기게 된다. 게다가 GCC 는 void (*)(void) 만 범용 함수 포인터로 인정해 -Wcast-function-type 에서 빼 준다 — 제안하신 꼴은 쓸 때마다 경고가 난다. 관용의 철자는 void (*)(void) 다.
둘 — 그럼에도 void (*)(void) 조차 완벽하지 않다. 그것은 실제로 있을 법한 서명이기 때문이다. 되돌려 캐스트하는 것을 깜빡하고 그냥 f() 라고 부르면 컴파일러가 아무 말도 하지 않고, 진짜 함수가 void(void) 가 아닌 한 그것은 미정의 동작이다.
★ 그래서 FreeBSD 가 무엇을 골랐는지 헤더에서 직접 확인해 볼 값어치가 있다.
struct __dlfunc_arg { int __dlfunc_dummy; };
typedef void (*dlfunc_t)(struct __dlfunc_arg); /* FreeBSD <dlfcn.h> */매개변수가 아무도 진짜로 쓰지 않을 사설 구조체다. 이 타입은 어떤 실제 함수와도 호환되지 않으므로, 깜빡하고 그냥 부르는 실수를 애초에 쓸 수가 없다 — 위 표의 마지막 줄이 그것이다. 되돌려 캐스트하는 일을 예의가 아니라 컴파일 오류로 강제한 것이다.
★★ 정리하면 이렇다. 범용 함수 포인터라는 착상 자체가 옳고, 표준이 §6.3.2.3 으로 그것을 떠받친다. 다만 좋은 범용 타입에는 조건이 하나 더 붙는다 — 그 자체로는 호출할 수 없어야 한다. 보관함은 보관함처럼 생겨야지, 열어 보지 않고도 쓸 수 있을 것처럼 생기면 안 된다.
59.4.2 이식성 있는 길 — 바이트로 떠서 찍기
표준만으로 해결하려면 포인터를 값으로 읽지 말고 바이트로 옮긴다. memcpy는 어떤 타입에서든 계약 안이다.
unsigned char raw[sizeof f];
memcpy(raw, &f, sizeof raw);
for (size_t i = sizeof raw; i-- > 0; ) printf("%02X", raw[i]);시연의 fmt_funcptr가 이 형태다. 얻는 것과 잃는 것이 분명하다 — 어디서나 컴파일되고 경고도 없다. 대신 사람에게는 수수께끼다. 어느 플랫폼에서는 그 바이트가 함수의 진짜 진입점이 아니라 디스크립터의 주소이기도 하다(뒤의 플랫폼 노트).
59.4.3 가장 좋은 답 — 주소 대신 이름
실무의 답은 사실 셋째 길이다. 주소를 찍지 말고 이름을 찍는다.
시연의 struct named_op가 그 무늬다. 함수 표에 이름 문자열을 함께 담아 두면, 로그에는 mul(7, 3) = 21처럼 사람이 읽는 줄이 남는다. 함수 포인터끼리의 비교는 표준이 보장하므로(같은 함수를 가리키면 같다), 표를 훑어 이름을 찾는 것도 계약 안이다.
| 방법 | 이식성 | 로그로서의 값 |
|---|---|---|
| 이름표를 함께 들고 다니기 | ★ 어디서나 | ★ 사람이 바로 읽는다 |
memcpy 로 바이트 인쇄 | ★ 어디서나 | 수수께끼 — 심볼과 대조해야 한다 |
(void *)f 로 %p | POSIX 에서만 | 수수께끼 — 위와 같다 |
dladdr·SymFromAddr 로 이름 찾기 | 플랫폼별 | ★ 이름이 나오면 최고 |
표 59.3
실제 사례. 주소에서 이름을 되찾는 길, 그리고 그 한계
이미 돌고 있는 프로그램에서 주소만 들고 이름을 알아내는 방법이 있다. 유닉스는 dladdr(), Windows 는 DbgHelp 의 SymFromAddr(), 리눅스 커널은 앞서 본 %pS 지정자다.
이 책이 dladdr을 직접 돌려 본 결과가 그 한계를 그대로 보여 준다.
| 대상 | 결과 |
|---|---|
static 함수 | 이름을 찾지 못했다 — 심볼 표에 없다 |
| 보통의 전역 함수 | -rdynamic 없이는 못 찾고, 붙이면 찾았다 |
printf | 찾았지만 내부 별칭인 _IO_printf 로 나왔다 |
표 59.4
즉 이름이 나오면 운이 좋은 것이다. 심볼을 남기지 않는 빌드(릴리스 빌드가 대개 그렇다)에서는 아무것도 나오지 않고, 나오더라도 소스에 적은 이름과 다를 수 있다. 그래서 「주소를 이름으로 되돌리는 것」은 디버깅 도구의 일이고, 프로그램이 스스로 남기는 로그는 처음부터 이름을 들고 있는 편이 낫다.
플랫폼 노트. 함수 포인터가 주소 하나가 아닌 기계들
이 절이 표준을 유난히 조심스럽게 따르는 데는 이유가 있다. 함수 포인터가 단순한 주소가 아닌 플랫폼이 실제로 있었다.
- 세그먼트가 있던 시절의 x86 에서는
far함수 포인터가 세그먼트와 오프셋의 쌍이었고, 데이터 포인터와 크기부터 달랐다. - IBM AIX 나 옛 Itanium ABI 에서는 함수 포인터가 디스크립터를 가리켰다 — 진입점 주소와 전역 데이터 포인터가 함께 든 구조체다. 두 함수 포인터의 「주소」를 찍으면 진입점이 아니라 그 구조체의 주소가 나온다.
- 하버드 구조의 마이크로컨트롤러에서는 코드와 데이터의 주소 공간이 아예 다르다.
그래서 표준은 「함수 포인터를 void *로 바꿀 수 있다」고 말하지 않았고, 앞으로도 말하지 않을 것이다. 오늘날의 평범한 데스크톱만 보면 과한 조심으로 보이지만, 임베디드로 내려가면 지금도 살아 있는 이야기다.
59.5 디스패치 표 — switch 대신 배열
예제의 ④가 그것이다. 이름과 함수를 짝지어 배열에 늘어놓으면, 분기 대신 자료로 고르게 된다. 항목을 늘릴 때 코드가 아니라 표만 고치면 되고, 57장의 X 매크로와 결합하면 목록 하나에서 표까지 만들어 낼 수 있다.
이 무늬가 커지면 상태 기계, 명령 해석기, 플러그인 구조가 된다. 그리고 더 커지면 — 다음 절의 이야기다.
59.6 가상 함수 표 — C로 짓는 객체지향
C에는 클래스가 없다. 그러나 구조체의 첫 멤버에 함수 포인터들의 표를 가리키는 포인터를 두면 객체지향의 핵심인 다형성을 얻을 수 있다.
examples/ch59/vtable.c
#include <stdio.h>
#include <string.h>
/* C 에서 손으로 만드는 객체지향: 가상 함수 표(vtable)를 자료와 분리한다.
GTK 의 GObject 가 쓰는 방식과 같은 뼈대다. */
struct shape; /* 앞선 선언 */
/* ① 가상 함수 표 — 타입마다 하나만 존재한다 (인스턴스마다가 아니다) */
struct shape_vtable {
const char *name;
double (*area)(const struct shape *self);
void (*describe)(const struct shape *self);
};
/* ② 기반 "클래스": 모든 객체의 첫 멤버가 표를 가리킨다 */
struct shape {
const struct shape_vtable *vt;
};
/* ③ 파생 "클래스": 기반을 첫 멤버로 두면 포인터를 서로 변환할 수 있다 */
struct circle { struct shape base; double r; };
struct rect { struct shape base; double w, h; };
static double circle_area(const struct shape *s)
{
const struct circle *c = (const struct circle *)s; /* 첫 멤버라 주소가 같다 */
return 3.14159265358979 * c->r * c->r;
}
static double rect_area(const struct shape *s)
{
const struct rect *r = (const struct rect *)s;
return r->w * r->h;
}
static void generic_describe(const struct shape *s)
{
printf(" %-6s area %.2f\n", s->vt->name, s->vt->area(s));
}
/* ④ 표는 상수이고 타입마다 하나 — 인스턴스는 포인터만 갖는다 */
static const struct shape_vtable circle_vt = { "circle", circle_area, generic_describe };
static const struct shape_vtable rect_vt = { "rect", rect_area, generic_describe };
static struct circle make_circle(double r) { return (struct circle){ { &circle_vt }, r }; }
static struct rect make_rect(double w, double h) { return (struct rect){ { &rect_vt }, w, h }; }
int main(void)
{
struct circle c = make_circle(2.0);
struct rect r = make_rect(3.0, 4.0);
/* ⑤ 같은 코드가 서로 다른 타입을 다룬다 — 다형성 */
const struct shape *objs[] = { &c.base, &r.base };
for (size_t i = 0; i < sizeof objs / sizeof objs[0]; i++)
objs[i]->vt->describe(objs[i]);
/* ⑥ 비용을 눈으로 본다 */
printf("object size: circle=%zu, rect=%zu (including the %zu-byte table pointer)\n",
sizeof(struct circle), sizeof(struct rect), sizeof(void *));
printf("table size : %zu (one per type, no more)\n", sizeof(struct shape_vtable));
return 0;
}
실행 결과
circle area 12.57
rect area 12.00
object size: circle=16, rect=24 (including the 8-byte table pointer)
table size : 24 (one per type, no more)
설계의 뼈대는 넷이다.
- 표는 타입마다 하나다(
static const). 인스턴스마다 함수 포인터를 복사해 두면 객체가 커지고 캐시가 나빠진다 — 그래서 표는 분리해서 한 벌만 두고, 객체는 그 표를 가리키는 포인터 하나만 갖는다. - 기반 구조체를 첫 멤버로 둔다. 표준이 “구조체의 첫 멤버는 구조체 자신과 같은 주소에서 시작한다”고 보장하므로,
struct circle *와struct shape *를 안전하게 오갈 수 있다(46장). - 호출은 표를 통해 한다 —
s->vt->area(s). 이 한 줄이 C++의 가상 함수 호출이 하는 일과 정확히 같다. - 자기 자신을 첫 인자로 넘긴다. C++이 감춰 주는
this를 손으로 적는 것이다.
출력의 마지막 두 줄이 비용을 보여 준다. 객체마다 늘어나는 것은 포인터 하나(8바이트)뿐이고, 표는 타입당 한 번만 존재한다.
실제 사례. GTK의 GObject — 실제로 쓰이는 손수 지은 객체 체계
이 방식이 장난감이 아니라는 증거가 GTK다. 리눅스 데스크톱의 주요 툴킷인 GTK는 순수 C로 쓰였고, 그 밑에 GObject라는 객체 체계를 두고 있다. 구조는 방금 본 것과 같은 뼈대 위에 서 있다.
- 인스턴스 구조체의 첫 멤버가 클래스 구조체(우리의 vtable)를 가리킨다.
- 클래스 구조체에는 함수 포인터들이 늘어서 있고, 파생 클래스는 그중 일부를 자기 함수로 덮어써서 “재정의”한다.
- 상속은 기반 구조체를 첫 멤버로 두는 것으로 표현하고, 타입 변환은 검사를 곁들인 매크로(
GTK_WIDGET(x)같은)로 감싼다. - 여기에 참조 계수, 시그널(관찰자 패턴), 속성 체계가 얹힌다.
같은 설계를 다른 곳에서도 볼 수 있다. 리눅스 커널의 struct file_operations —파일 시스템마다 다른 read·write 함수를 담은 표— 가 정확히 vtable이고, Windows의 COM은 아예 이 규약을 ABI 수준에서 못박은 것이다.
문. 그러면 C++의 가상 함수와 무엇이 다른가?
답. 개념은 같고 자동화의 정도가 다르다. C++은 컴파일러가 표를 만들고, 포인터를 심고, 생성자에서 연결하고, 타입 변환을 검사해 준다. C에서는 그 전부가 손일이고, 그래서 실수할 자리도 많다 — 표를 연결하지 않은 객체, 첫 멤버 규칙을 어긴 구조체, 파생 타입을 잘못 캐스트하는 코드.
대신 얻는 것도 분명하다. 무엇이 어디에 있는지 전부 보인다. 표가 몇 개인지, 호출 한 번에 포인터를 몇 번 따라가는지, 객체가 몇 바이트인지 세어 볼 수 있다. 이 책이 내내 말해 온 “비용이 보이는 언어”의 성격이 객체지향에서도 그대로 나타나는 자리다.
덧붙여, C++의 가상 함수 표 배치는 표준이 정하지 않는다(ABI가 정한다). 그래서 C와 C++을 섞을 때 클래스 객체를 경계 너머로 넘기지 않고 extern "C" 함수와 단순 구조체만 주고받는 것이다(96장).
복습 정리
함수 포인터 요약.
| 규칙 | 내용 |
|---|---|
| 무너짐 | 함수 이름은 값으로 쓰이면 포인터가 된다 |
f·&f·*f | 모두 같은 포인터. &&f는 문법 오류 |
| 호출 표기 | p(x)와 (*p)(x)는 동일 |
| 타입 | 반환형+매개변수. 다른 타입으로 캐스트해 호출하면 계약 밖 |
void * | 함수 포인터와 서로 변환된다는 보장이 없다(하버드 구조) |
dlsym | POSIX가 따로 보장. *(void *)&fn 관용구 |
| 디스패치 표 | 분기 대신 자료로 고르기 |
| vtable | 표는 타입당 하나, 객체는 포인터 하나. 첫 멤버 규칙이 근거 |
| 실물 | GObject(GTK), 커널의 file_operations, COM |
표 59.5
함수를 값으로 다루는 법까지 갖췄다. 그런데 이 장에서 몇 번, 선언 자체가 읽기 어려워 typedef로 도망친 자리가 있었다 — int (*(*s)(void))(int); 같은 것들이다. 다음 장이 그 빚을 갚는다: C에서 가장 악명 높은 자리인 선언 읽기(reading declarations)를, 두 가지 독법과 typedef로 정면 돌파한다.
주
- The Open Group Base Specifications,
dlsym, APPLICATION USAGE.pubs.opengroup.org/…/dlsym.html↩ - FreeBSD
dlfunc(3).man.freebsd.org/…dlfunc(3)↩ - Garrett Wollman, FreeBSD 소스 커밋
dc12134a8및fda230194, 2002-05-29. 앞의 것은 새 반환 타입을 「컴퓨터를 개구리로 만들지 않고서 함수 포인터로 캐스트할 수 있는 것」이라고 적었다.github.com/freebsd/freebsd-src↩