Proven C Book←↑→

72 동적 가상 테이블 — 짓고, 봉인하고, 찾는다

먼저 알아야 할 것

71장 표를 짓는 법 · 다형성, 기반 표를 품은 파생 표, 크기 필드
70장 객체 지향을 흉내 내기 · 표의 빈칸과 기본값 묶음
95장 쪼개지지 않는 연산 · release/acquire
94장 갈래를 나누어 돌리기 · 데이터 경쟁

돌아보기

71장의 함수 포인터 표는 모두 static const 였다. 표가 컴파일 때 정해지지 않는 프로그램도 있는가? 있다면 그런 표는 어떻게 지어야 안전한가?

답. 있다. 플러그인과 드라이버는 실행 중에 들어오고, CPU 를 보고 구현을 고르는 라이브러리는 실행 때에야 표를 채우고, 그래픽 API 의 함수는 장치마다 다르다. 그런 표는 실행 때 짓고, 빈칸이 없는지 검증하고, 다 지은 뒤에는 고치지 못하게 봉인하고, 필요하면 이름으로 찾는다. 그런 표를 거치는 호출이 얼마인지는 다음 장(73장)이 잰다.

이 장의 필요성과 맥락

실행 때 짓는 표는 컴파일러가 대신 지켜 주던 것을 사람이 지켜야 한다. 빈칸을 남기면 어느 날 NULL 을 부르고, 여러 스레드가 함께 지으면 반쯤 찬 표를 보고, 쓸 수 있는 메모리에 둔 표는 공격의 표적이 된다. 짓는 순서와 봉인과 찾는 법을 알아야 플러그인 하나를 받는 프로그램도 안전하게 짤 수 있다.

이 장이 끝나면

동적 표가 무엇이고 왜 필요한지부터 본다. 표를 짓는 다섯 단계 — 등록, 채우기, 검증, 봉인, 사용 — 와, 여러 스레드가 있어도 표를 한 번만 짓는 법을 본다. 표 포인터를 객체 안에 둘지 포인터 옆에 둘지, 한 객체의 여러 인터페이스를 실행 때 어떻게 찾는지, 이름으로 함수를 찾는 디스패치를 차례로 다룬다. 끝으로 판이 바뀌어도 견디는 표를 본다.

이 장에서 답할 질문

  1. 쌍마다 표를 지으면 표가 너무 많아지지 않는가?

72.1 정적 표와 동적 표#

정적 표는 컴파일 때 내용이 다 정해진 표다. static const struct shape_ops circle_ops = { … }; 처럼 소스에 적혀 있고, 실행 중에는 아무도 고치지 않는다. 동적 표(dynamic virtual table)는 실행 중에 짓거나, 고치거나, 찾는 표다. 칸에 들어갈 함수가 실행 때에야 정해지거나, 표의 모양 — 어느 부모를 잇고 어느 칸을 덮을지 — 이 실행 때에야 정해질 때 쓴다.

C23 에는 동적 표라는 기능이 없다. 정적 표도 없다 — 둘 다 구조체와 함수 포인터로 짓는 흉내다. 이 장이 기대는 표준의 조항은 셋이다.

동적 표가 필요한 자리는 넷이다.

자리실행 때 정해지는 것예
플러그인 · 드라이버어떤 객체 종류가 있는지 — 실행 중에 적재한다dlopen/dlsym(POSIX), LoadLibrary/GetProcAddress(Windows)
기계를 보고 고르기같은 칸에 어느 구현을 넣을지glibc 의 memcpy · strlen(아래), __builtin_cpu_supports(GCC · Clang 확장)
실행 때 상속부모의 표가 무엇인지 — 부모를 복사한 뒤 덮어쓴다GObject 의 class_init, Xt 의 클래스 초기화(74장)
API 로더함수의 주소 — 장치나 드라이버마다 다르다Vulkan 의 vkGetDeviceProcAddr, OpenGL 함수 로더

표 72.1 — 동적 표가 필요한 자리

둘째 줄의 예는 이 기계에도 있다. glibc 2.41 의 libc.so.6 를 readelf --dyn-syms 로 보면 memcpy 와 strlen 의 기호 종류가 보통 함수(FUNC)가 아니라 IFUNC 다. 프로그램이 적재될 때 glibc 가 CPU 가 지원하는 명령을 보고 구현 여럿 가운데 하나를 골라, 그 주소를 호출 자리에 채운다 — 프로그램마다 한 번 짓는 동적 표다.

네 자리 모두 실행 때에만 아는 것이 있다. 그것이 없으면 동적 표는 필요 없다 — 다음 장의 끝에서 이 말로 다시 돌아온다.

72.2 표를 짓는 다섯 단계#

동적 표는 다섯 단계로 짓는다.

table-lifecycle

그림 72.1 — 표를 짓는 다섯 단계. 봉인 뒤로는 읽기만 한다.

  1. 등록. 어떤 객체 종류(클래스)가 있고, 부모가 누구이고, 자기 칸에 무엇을 넣을지를 자료로 받는다. 플러그인 목록, 설정 파일, 공유 라이브러리가 내놓는 설명 구조체(이름 · 부모 · 함수 주소) 같은 것이다.
  2. 채우기. 기본값 → 부모 → 자기 칸 순서로 채운다. 70장의 기본값 묶음을 실행 때 하는 셈이다 — 종류는 부모와 다른 칸만 적는다.
  3. 검증. 빈칸이 없는지, 판의 크기가 맞는지 확인한다. 빈칸이 있는 종류는 내놓지 않는다.
  4. 봉인. 다 지은 표는 더는 고치지 않는다. 읽기 전용 포인터로만 내놓고, 할 수 있으면 표가 든 메모리를 운영체제에 부탁해 읽기 전용으로 바꾼다.
  5. 사용. 봉인한 표만 부른다.

examples/dynamic-vtables/build_table/build_table.c

/* 표를 실행 때 다섯 단계로 짓는다: 등록, 채우기, 검증, 봉인, 사용.
   종류는 `static const` 표가 아니라 자료로 들어온다(플러그인 목록이 그렇듯이).
   채우기는 기본값 -> 부모 -> 자기 칸 순서라, 종류는 다른 것만 적는다.
   봉인은 표가 든 쪽을 읽기 전용으로 바꾼다(POSIX mprotect).
   --write-after-seal 을 주면 봉인 뒤에 칸 하나를 고쳐 본다. */
#define _DEFAULT_SOURCE        /* MAP_ANONYMOUS: 리눅스와 BSD, POSIX 2024 */
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>

struct stream;
typedef int (*slot)(struct stream *);
enum { WRITE, FLUSH, CLOSE, SLOTS };
static const char *const slot_names[SLOTS] = { "write", "flush", "close" };
struct stream_ops { slot s[SLOTS]; };
struct stream { const struct stream_ops *ops; const char *kind; };

/* 행동들 --- 저마다 자기가 누구인지만 말한다 */
#define SAY(fn) static int fn(struct stream *st) { (void)st; return 0; }
SAY(stream_flush_nothing) SAY(stream_close) SAY(file_write) SAY(file_flush)
SAY(gzip_write) SAY(gzip_close) SAY(memory_write)
static const struct { slot fn; const char *name; } fn_names[] = {
    { stream_flush_nothing, "stream_flush_nothing" }, { stream_close, "stream_close" },
    { file_write, "file_write" }, { file_flush, "file_flush" },
    { gzip_write, "gzip_write" }, { gzip_close, "gzip_close" },
    { memory_write, "memory_write" },
};
static const char *name_of(slot f)
{
    for (size_t i = 0; i < sizeof fn_names / sizeof fn_names[0]; i++)
        if (fn_names[i].fn == f) return fn_names[i].name;
    return "(empty)";
}

/* 1. 등록: 플러그인 목록이 적을 만한 것 --- 이름, 부모, 자기 칸 */
struct kind_desc { const char *name, *parent; bool abstract; slot own[SLOTS]; };
static const struct kind_desc descs[] = {
    { "gzip-file", "file",   false, { [WRITE] = gzip_write, [CLOSE] = gzip_close } },
    { "file",      "stream", false, { [WRITE] = file_write, [FLUSH] = file_flush } },
    { "memory",    "stream", false, { [WRITE] = memory_write } },
    { "broken",    "stream", false, { [CLOSE] = gzip_close } },       /* write 빠짐 */
    { "stream",    nullptr,  true,
      { [FLUSH] = stream_flush_nothing, [CLOSE] = stream_close } },
};
#define KINDS (sizeof descs / sizeof descs[0])

static struct stream_ops *tables;       /* 따로 한 쪽에 둔다 --- 봉인할 수 있게 */
static bool built[KINDS], published[KINDS];
static size_t page;

static int find(const char *name)
{
    for (size_t i = 0; i < KINDS; i++)
        if (strcmp(descs[i].name, name) == 0) return (int)i;
    return -1;
}

/* 2. 채우기: 부모 먼저(재귀로) → 복사 → 자기 칸 */
static bool fill(int k, int depth)
{
    if (built[k]) return true;
    if (depth > (int)KINDS) return false;               /* 부모가 돌고 돈다 */
    struct stream_ops t = {};
    if (descs[k].parent) {
        int p = find(descs[k].parent);
        if (p < 0 || !fill(p, depth + 1)) return false;
        t = tables[p];
    }
    for (int s = 0; s < SLOTS; s++)
        if (descs[k].own[s]) t.s[s] = descs[k].own[s];
    tables[k] = t;
    built[k] = true;
    return true;
}

int main(int argc, char **argv)
{
    bool attack = argc > 1 && strcmp(argv[1], "--write-after-seal") == 0;
    long ps = sysconf(_SC_PAGESIZE);
    page = ps > 0 ? (size_t)ps : 4096;
    if (KINDS * sizeof *tables > page) { puts("too many tables for a page"); return 1; }
    tables = mmap(nullptr, page, PROT_READ | PROT_WRITE,
                  MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (tables == MAP_FAILED) { puts("mmap failed"); return 1; }

    for (int k = 0; k < (int)KINDS; k++)
        if (!fill(k, 0)) { printf("cannot build %s\n", descs[k].name); return 1; }
    puts("built (defaults -> parent -> own slots):");
    for (size_t k = 0; k < KINDS; k++) {
        printf("  %-10s", descs[k].name);
        for (int s = 0; s < SLOTS; s++)
            printf(s + 1 < SLOTS ? " %s=%-20s" : " %s=%s",
                   slot_names[s], name_of(tables[k].s[s]));
        puts(descs[k].abstract ? "  (abstract)" : "");
    }

    /* 3. 검증: 빈칸이 있는 종류는 내놓지 않는다. 추상 종류는 늘 */
    for (size_t k = 0; k < KINDS; k++) {
        published[k] = !descs[k].abstract;
        for (int s = 0; s < SLOTS && published[k]; s++)
            if (!tables[k].s[s]) {
                printf("verify: %s has no %s --- not published\n",
                       descs[k].name, slot_names[s]);
                published[k] = false;
            }
    }

    /* 4. 봉인: 여기부터 표는 읽기만 한다 */
    if (mprotect(tables, page, PROT_READ) != 0) { puts("mprotect failed"); return 1; }
    const struct stream_ops *sealed = tables;
    puts("sealed: the page holding the tables is read-only");

    /* 5. 사용 */
    for (size_t k = 0; k < KINDS; k++) {
        if (!published[k]) continue;
        struct stream st = { &sealed[k], descs[k].name };
        int r = st.ops->s[WRITE](&st) | st.ops->s[FLUSH](&st) | st.ops->s[CLOSE](&st);
        printf("use: a %s stream wrote, flushed and closed%s\n",
               st.kind, r ? " (error)" : "");
    }

    if (attack) {
        puts("now overwriting gzip-file's write slot...");
        fflush(stdout);
        tables[find("gzip-file")].s[WRITE] = memory_write;   /* 읽기 전용 쪽 */
        puts("the write went through");
    }
    munmap(tables, page);
    return 0;
}

실행 결과

built (defaults -> parent -> own slots):
  gzip-file  write=gzip_write           flush=file_flush           close=gzip_close
  file       write=file_write           flush=file_flush           close=stream_close
  memory     write=memory_write         flush=stream_flush_nothing close=stream_close
  broken     write=(empty)              flush=stream_flush_nothing close=gzip_close
  stream     write=(empty)              flush=stream_flush_nothing close=stream_close  (abstract)
verify: broken has no write --- not published
sealed: the page holding the tables is read-only
use: a gzip-file stream wrote, flushed and closed
use: a file stream wrote, flushed and closed
use: a memory stream wrote, flushed and closed
== again, with --write-after-seal
built (defaults -> parent -> own slots):
  gzip-file  write=gzip_write           flush=file_flush           close=gzip_close
  file       write=file_write           flush=file_flush           close=stream_close
  memory     write=memory_write         flush=stream_flush_nothing close=stream_close
  broken     write=(empty)              flush=stream_flush_nothing close=gzip_close
  stream     write=(empty)              flush=stream_flush_nothing close=stream_close  (abstract)
verify: broken has no write --- not published
sealed: the page holding the tables is read-only
use: a gzip-file stream wrote, flushed and closed
use: a file stream wrote, flushed and closed
use: a memory stream wrote, flushed and closed
now overwriting gzip-file's write slot...
stopped by signal 11 (SIGSEGV)

종류 다섯이 자료로 들어온다. gzip-file 은 file 을 잇고 file 은 stream 을 잇는데, 목록에는 자식이 부모보다 먼저 온다 — 실제 플러그인 목록도 순서를 지켜 주지 않는다. fill 은 부모의 표를 먼저(재귀로) 지은 뒤 그 표를 복사하고 자기 칸을 덮는다. 그래서 gzip-file 의 flush 는 아무도 적지 않았는데도 부모에게서 온 file_flush 다. A 의 부모가 B 이고 B 의 부모가 A 인 잘못된 목록이면 재귀가 끝나지 않으므로, 몇 단계 올라갔는지 세어 종류 수를 넘으면 멈춘다.

검증이 두 종류를 걸렀다. stream 은 write 가 없는 추상 종류라 처음부터 내놓지 않고, broken 은 write 를 잊어서 내놓지 않았다. 빈칸을 부르는 자리마다 확인하는 대신, 내놓는 자리에서 한 번 막은 것이다 — 70장에서 표의 빈칸을 다룬 판단을 실행 때로 옮겼다.

72.2.1 봉인#

봉인은 두 겹이다.

첫째 겹은 타입이다. 다 지은 표를 const struct stream_ops * 로만 내놓으면, 그 포인터를 받은 코드가 칸을 고치려 할 때 컴파일러가 오류를 낸다. 그러나 이것은 그 포인터를 거친 쓰기만 막는다. 표를 지을 때 쓴 원래의 포인터나, 옆 버퍼가 넘쳐서 생긴 쓰기는 막지 못한다.

둘째 겹은 메모리다. 운영체제는 메모리를 페이지라는 단위(이 기계에서 4 KiB)로 나눠 페이지마다 「읽기만」 「읽고 쓰기」 같은 권한을 둔다. 예제는 표를 따로 한 페이지에 짓고, 다 지은 뒤 mprotect 로 그 페이지를 읽기 전용으로 바꿨다. 그 뒤로는 어떤 포인터로 써도 CPU 가 막는다 — 출력의 둘째 실행(--write-after-seal)은 봉인한 칸에 쓰려다 SIGSEGV 로 멈췄다.

플랫폼 노트. mmap · mprotect

POSIX 함수다. 파일 없이 메모리만 받는 MAP_ANONYMOUS 는 리눅스와 BSD 에 오래 있었고 POSIX 에는 2024년 판에 들어왔다. 예제는 _DEFAULT_SOURCE 로 glibc 에서 그 이름을 연다. Windows 의 짝은 VirtualAlloc · VirtualProtect 다 — 이 책은 시험하지 않았다. 페이지 크기는 sysconf(_SC_PAGESIZE) 로 묻는다.

흔한 오해. “표를 읽기 전용으로 봉인했으니 표를 거치는 호출은 안전하다”

봉인한 것은 표다. 객체 안의 표 포인터는 여전히 쓸 수 있는 메모리에 있다. 공격자는 표를 고칠 필요가 없다 — 객체의 표 포인터를 자기가 만든 가짜 표로 돌리면 된다. 봉인은 공격할 수 있는 곳을 줄일 뿐 없애지 않는다. 남은 길을 막는 장치가 73장에서 볼 CFI 다.

72.2.2 여러 스레드에서 한 번만 짓기#

표를 처음 쓸 때 짓는 게으른 짓기에는 함정이 있다.

static struct ops table;
static bool built;
const struct ops *get_table(void)
{
    if (!built) { fill(&table); built = true; }   /* 두 스레드가 함께 들어오면? */
    return &table;
}

두 스레드가 함께 들어오면 둘 다 표를 짓는다. 더 나쁘게는, 한 스레드가 built 가 참인 것을 보았을 때 다른 스레드가 채우던 표는 아직 반쯤 채워져 있을 수 있다. 같은 변수를 동기화 없이 한쪽은 쓰고 한쪽은 읽는 것은 데이터 경쟁이고, 정의되지 않은 동작이다(94장). 컴파일러는 스레드 하나만 놓고 보면 결과가 같으므로 built = true 를 표를 채우는 쓰기보다 앞으로 옮겨도 된다.

길은 둘이다. 첫째는 call_once 다. 짓는 함수를 정확히 한 번 돌리고, 부른 쪽은 모두 그 함수가 끝난 뒤에야 돌아온다.

examples/dynamic-vtables/once.c

/* 한 번 짓고 한 번 내놓는다: 스레드 여덟이 한꺼번에 표를 달라고 한다.
   call_once 는 짓는 함수를 정확히 한 번 돌리고, 부른 쪽은 모두 그것이 끝난 뒤에야
   돌아온다 --- 그래서 반쯤 채운 표를 보는 스레드가 없다. */
#include <stdatomic.h>
#include <stdio.h>
#include <threads.h>

struct ops { int (*area)(int); int (*perimeter)(int); };
static int sq_area(int s) { return s * s; }
static int sq_perimeter(int s) { return 4 * s; }

static struct ops table;                /* 실행 때 채우고, 그 뒤로는 읽기만 */
static once_flag table_once = ONCE_FLAG_INIT;
static atomic_int builds;

static void build(void)
{
    atomic_fetch_add(&builds, 1);
    table.area = sq_area;
    thrd_sleep(&(struct timespec){ .tv_nsec = 20'000'000 }, nullptr);  /* 느린 짓기 */
    table.perimeter = sq_perimeter;
}
static const struct ops *get_table(void)
{
    call_once(&table_once, build);
    return &table;
}

enum { THREADS = 8 };
static const struct ops *seen[THREADS];
static int answer[THREADS];
static int worker(void *arg)
{
    int i = *(int *)arg;
    seen[i] = get_table();
    answer[i] = seen[i]->area(3) + seen[i]->perimeter(3);  /* 두 칸 다 있다 */
    return 0;
}

int main(void)
{
    thrd_t t[THREADS];
    int id[THREADS];
    for (int i = 0; i < THREADS; i++) {
        id[i] = i;
        if (thrd_create(&t[i], worker, &id[i]) != thrd_success) {
            puts("thrd_create failed");
            return 1;
        }
    }
    for (int i = 0; i < THREADS; i++) thrd_join(t[i], nullptr);

    bool same = true, right = true;
    for (int i = 0; i < THREADS; i++) {
        same = same && seen[i] == seen[0];
        right = right && answer[i] == 9 + 12;
    }
    printf("%d threads asked; the builder ran %d time(s)\n",
           THREADS, atomic_load(&builds));
    printf("every thread got the same table: %s\n", same ? "yes" : "no");
    printf("every answer was area 9 + perimeter 12 = 21, both slots filled: %s\n",
           right ? "yes" : "no");
    return 0;
}

실행 결과

8 threads asked; the builder ran 1 time(s)
every thread got the same table: yes
every answer was area 9 + perimeter 12 = 21, both slots filled: yes

일부러 느리게 지었다 — 첫 칸(area)을 채운 뒤 20 ms 를 쉬고 둘째 칸(perimeter)을 채운다. 그래도 스레드 여덟이 모두 같은 표를 받았고, 모두 두 칸을 다 불러 9 + 12 = 21 을 얻었다. 반쯤 찬 표를 본 스레드가 있었다면 둘째 칸이 비어 있었을 것이다.

둘째 길은 원자적 게시다. 표를 다 지은 뒤 그 주소를 memory_order_release 로 원자 포인터에 넣고, 읽는 쪽은 memory_order_acquire 로 꺼낸다(95장). 그러면 주소가 보이는 스레드에는 그 앞의 쓰기 — 표의 모든 칸 — 도 보인다. call_once 보다 손이 가지만, 표를 바꿔 끼울 수 있다 — 새 표를 따로 다 지은 뒤 주소만 바꾸면 된다. 다만 옛 표를 언제 지울지는 따로 풀어야 한다 — 아직 그 표를 쥔 스레드가 있을 수 있다.

플랫폼 노트. ThreadSanitizer 와 <threads.h>

위의 틀린 코드(get_table)를 ThreadSanitizer(-fsanitize=thread)로 잡아 보이려 했으나, 이 기계(glibc 2.41, GCC 14.2 · Clang 19.1)에서는 thrd_create 로 만든 스레드 아래에서 TSan 자신이 멈췄다. pthread_create 로 바꾸면 두 컴파일러 모두 「data race」를 알렸다.

72.3 표 포인터를 어디에 두는가#

지금까지는 표 포인터가 객체 안에 있었다 — 첫 멤버 ops. C++ 의 흔한 구현, GObject, 리눅스 커널이 이 모양이다. 다른 모양은 표 포인터를 객체를 가리키는 포인터 옆에 두는 것이다. 자료를 가리키는 포인터와 표를 가리키는 포인터를 한 쌍으로 묶어 들고 다닌다. 보통 포인터의 두 배 크기라서 이 쌍을 뚱뚱한 포인터(fat pointer)라 부른다. Go 의 인터페이스 값과 Rust 의 &dyn Trait 가 이 모양이다.

vptr-vs-fat

그림 72.2 — 표 포인터를 객체 안에 둘 때(위)와 포인터 옆에 둘 때(아래).

examples/dynamic-vtables/fat.c

/* 뚱뚱한 포인터: 자료를 가리키는 포인터와 표를 가리키는 포인터를 함께 들고 다닌다.
   표가 객체 바깥에 있으니, 고칠 수 없는 타입에도 인터페이스를 붙인다 ---
   여기서는 표준 라이브러리의 struct tm 과 그냥 double.
   객체는 아무것도 치르지 않고, 그것을 가리키는 포인터마다 두 배로 넓어진다. */
#include <stdio.h>
#include <time.h>

struct printable_ops { void (*print)(const void *self); const char *type; };
struct printable { const void *self; const struct printable_ops *ops; };   /* 포인터 둘 */

static void print_tm(const void *self)
{
    const struct tm *t = self;
    printf("%04d-%02d-%02d", t->tm_year + 1900, t->tm_mon + 1, t->tm_mday);
}
static void print_double(const void *self) { printf("%.3f", *(const double *)self); }

/* 견주려고 둔 「vptr」 길: 표 포인터가 객체 안에 있다 */
struct point_ops;
struct point { const struct point_ops *ops; double x, y; };
struct point_ops { void (*print)(const struct point *); };
static void print_point(const struct point *p) { printf("(%g, %g)", p->x, p->y); }
static const struct point_ops point_ops = { print_point };
static void print_point_any(const void *self) { print_point(self); }

static const struct printable_ops tm_printable     = { print_tm, "struct tm" };
static const struct printable_ops double_printable = { print_double, "double" };
static const struct printable_ops point_printable  = { print_point_any, "struct point" };

static void print_all(const struct printable *xs, size_t n)
{
    for (size_t i = 0; i < n; i++) {
        printf("  %-13s ", xs[i].ops->type);
        xs[i].ops->print(xs[i].self);
        putchar('\n');
    }
}

int main(void)
{
    struct tm day = { .tm_year = 2026 - 1900, .tm_mon = 10, .tm_mday = 6 };
    double ratio = 1.618;
    struct point p = { &point_ops, 3, 4 };

    struct printable xs[] = {
        { &day, &tm_printable }, { &ratio, &double_printable }, { &p, &point_printable },
    };
    puts("one interface, three types --- two of them never heard of it:");
    print_all(xs, sizeof xs / sizeof xs[0]);

    printf("sizeof(struct tm) %zu, unchanged; sizeof(double) %zu, unchanged\n",
           sizeof day, sizeof ratio);
    printf("sizeof(struct point) %zu --- it carries its own table pointer\n", sizeof p);
    printf("a plain pointer %zu bytes, a fat pointer %zu bytes\n",
           sizeof(void *), sizeof(struct printable));
    return 0;
}

실행 결과

one interface, three types --- two of them never heard of it:
  struct tm     2026-11-06
  double        1.618
  struct point  (3, 4)
sizeof(struct tm) 56, unchanged; sizeof(double) 8, unchanged
sizeof(struct point) 24 --- it carries its own table pointer
a plain pointer 8 bytes, a fat pointer 16 bytes

한 인터페이스 printable 로 세 타입을 찍었다. struct tm 은 표준 라이브러리의 구조체이고, double 은 구조체조차 아니다 — 둘 다 우리가 멤버를 더할 수 없다. 그래도 인터페이스를 바깥에서 붙였고, 두 타입의 크기는 그대로다. 셋째 struct point 는 우리 타입이라 견주려고 표 포인터를 객체 안에 두었다 — 그만큼 8바이트 커져 24바이트다. 뚱뚱한 포인터 쪽이 치른 것은 마지막 줄에 나온다 — 포인터가 8바이트에서 16바이트로 넓어졌다.

견줄 것객체 안에 둔다 — vptr포인터 옆에 둔다 — 뚱뚱한 포인터
객체의 크기표 포인터 하나만큼 커진다그대로
포인터의 크기보통 포인터 하나포인터 둘
고칠 수 없는 타입에 인터페이스못 붙인다 — 멤버를 더해야 한다바깥에서 붙인다
한 객체, 여러 인터페이스표를 여럿 품거나 찾기가 필요하다(다음 절)인터페이스마다 다른 쌍을 만들면 된다
포인터 하나만 받는 API(콜백의 void *)그대로 넘긴다쌍을 어딘가에 두고 그 주소를 넘겨야 한다

표 72.2 — 표 포인터의 두 자리

어느 쪽이 낫다고 말할 수 없다 — 맞바꾸는 것이다. 메모리만 따지면 셈은 간단하다. 객체 백만 개를 포인터 하나씩으로 가리키면, 객체 안의 표 포인터는 8 MB 를 더 쓰고 뚱뚱한 포인터도 8 MB 를 더 쓴다. 객체 하나를 포인터 열 개가 가리키면 뚱뚱한 쪽이 열 배를 더 쓰고, 객체가 많고 포인터가 적으면 거꾸로다.

72.4 여러 인터페이스와 찾기#

객체 하나가 여러 역할을 할 때가 있다. 도형이면서, 파일에 저장할 수 있고, 다른 도형과 크기를 비교할 수 있다. 역할마다 표 하나 — 인터페이스 — 를 두면, 부르는 쪽은 이 객체에서 이 역할의 표를 얻어야 한다. 그것을 찾는 길이 셋 있다.

마지막 길을 C 로 지어 본다.

examples/dynamic-vtables/itab.c

/* (인터페이스, 구체 타입) 쌍마다 표 하나 --- 처음 쓸 때 실행 때 짓고 해시 표에 둔다.
   Go 의 itab 의 생각을 C 로.
   타입은 메서드를 이름으로 늘어놓을 뿐, 어느 인터페이스를 만족하는지 말하지 않는다.
   맞지 않은 쌍도 캐시한다 --- 다시 물으면 다시 짓지 않고 찾기만 한다. */
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef double (*fn)(const void *self);
struct method { const char *name; fn f; };
struct type  { const char *name; const struct method *methods; size_t n; };
struct iface { const char *name; const char *const *wants; size_t n; };
struct itab  { const struct iface *in; const struct type *ty; bool ok; fn fun[4]; };

/* ---- 캐시: 열린 주소법, 열쇠는 포인터 쌍 ---- */
enum { SLOTS = 64 };
static struct itab *cache[SLOTS];
static unsigned builds, hits;

static size_t hash(const struct iface *in, const struct type *ty)
{
    uintptr_t h = (uintptr_t)in * 31 ^ (uintptr_t)ty;
    return (size_t)(h ^ h >> 7) % SLOTS;
}
static struct itab *build(const struct iface *in, const struct type *ty)
{
    struct itab *t = calloc(1, sizeof *t);
    if (!t) return nullptr;
    *t = (struct itab){ in, ty, true, {} };
    builds++;
    for (size_t w = 0; w < in->n && w < 4; w++) {          /* 이름으로, 차례대로 맞춘다 */
        fn found = nullptr;
        for (size_t m = 0; m < ty->n; m++)
            if (strcmp(ty->methods[m].name, in->wants[w]) == 0) found = ty->methods[m].f;
        if (!found) { t->ok = false; break; }
        t->fun[w] = found;
    }
    return t;
}
static const struct itab *get_itab(const struct iface *in, const struct type *ty)
{
    size_t i = hash(in, ty);
    for (size_t probes = 0; probes < SLOTS; probes++, i = (i + 1) % SLOTS) {
        if (!cache[i]) return cache[i] = build(in, ty);   /* 처음이면 짓고 둔다 */
        if (cache[i]->in == in && cache[i]->ty == ty) { hits++; return cache[i]; }
    }
    return nullptr;                                         /* 캐시가 찼다 */
}

/* ---- 인터페이스를 모르는 타입들 ---- */
struct circle { double r; };
struct label  { const char *text; };
static double circle_area(const void *s)
{ const struct circle *c = s; return 3.0 * c->r * c->r; }
static double circle_perimeter(const void *s)
{ const struct circle *c = s; return 6.0 * c->r; }
static double label_length(const void *s)
{ const struct label *l = s; return (double)strlen(l->text); }

static const struct method circle_m[] = {
    { "area", circle_area }, { "perimeter", circle_perimeter },
};
static const struct method label_m[]  = { { "length", label_length } };
static const struct type circle_t = { "circle", circle_m, 2 };
static const struct type label_t  = { "label", label_m, 1 };

static const char *const shape_wants[] = { "area", "perimeter" };
static const struct iface shape_i = { "Shape", shape_wants, 2 };

/* 인터페이스 값: itab 과 자료 --- 뚱뚱한 포인터 */
struct shape_value { const struct itab *tab; const void *data; };
static bool as_shape(const struct type *ty, const void *data, struct shape_value *out)
{
    const struct itab *t = get_itab(&shape_i, ty);
    if (!t || !t->ok) return false;
    *out = (struct shape_value){ t, data };
    return true;
}

int main(void)
{
    struct circle cs[] = { { 1 }, { 2 }, { 0.5 } };
    struct label lb = { "hello" };
    for (size_t i = 0; i < 3; i++) {
        struct shape_value v;
        if (as_shape(&circle_t, &cs[i], &v))
            printf("circle %.1f as Shape: area %.2f, perimeter %.2f\n",
                   cs[i].r, v.tab->fun[0](v.data), v.tab->fun[1](v.data));
    }
    for (int i = 0; i < 2; i++) {
        struct shape_value v;
        bool ok = as_shape(&label_t, &lb, &v);
        printf("label as Shape: %s\n", ok ? "yes" : "no --- it has no area");
    }
    printf("tables built %u (one per pair, the failure too), cache hits %u\n",
           builds, hits);
    for (size_t i = 0; i < SLOTS; i++) free(cache[i]);
    return 0;
}

실행 결과

circle 1.0 as Shape: area 3.00, perimeter 6.00
circle 2.0 as Shape: area 12.00, perimeter 12.00
circle 0.5 as Shape: area 0.75, perimeter 3.00
label as Shape: no --- it has no area
label as Shape: no --- it has no area
tables built 2 (one per pair, the failure too), cache hits 3

원 셋을 Shape 로 쓰는 동안 표는 한 번만 지어졌고 나머지 둘은 캐시에서 나왔다. label 은 area 가 없어 Shape 가 아니다. 「맞지 않음」도 캐시에 남았으므로 두 번째 질문은 다시 짓지 않고 캐시에서 끝났다 — 마지막 줄의 tables built 2 는 원 하나, 레이블 하나이고, cache hits 3 은 원 둘과 레이블 하나다.

문. 쌍마다 표를 지으면 표가 너무 많아지지 않는가?

답. 짓는 것은 실제로 만난 쌍뿐이다. 인터페이스가 백 개, 타입이 천 개여도 프로그램이 실제로 묻는 쌍은 그보다 훨씬 적다. 처음 한 번은 이름을 맞추느라 비싸지만, 그 뒤로는 해시 찾기 한 번이다. 더 줄이려면 호출 자리마다 「지난번 쌍」을 기억해 둔다 — 73장에서 잴 인라인 캐시의 생각이다.

72.5 이름으로 찾는 디스패치#

지금까지의 표는 칸의 자리로 함수를 골랐다 — ops->area 는 「표의 첫 칸」이고, 그 자리는 컴파일 때 정해진다. Smalltalk 와 Objective-C 는 이름으로 고른다. 객체에 메시지 area 를 보내면, 런타임이 그 객체의 클래스에서 area 라는 이름의 메서드를 찾는다. 이름을 그때마다 문자열로 비교하면 느리므로, 이름마다 번호를 하나씩 붙여 두고 번호로 찾는다. 이 번호를 셀렉터(selector)라 부른다. 70장에서 본 Alan Kay 의 생각 — 객체는 메시지로만 서로 통한다 — 에 가장 가까운 모양이다.

msgsend-path

그림 72.3 — 메시지 하나가 함수를 찾아가는 길. 대부분은 클래스의 캐시에서 끝난다.

examples/dynamic-vtables/lookup.c

/* 이름(셀렉터)으로 찾는 디스패치 --- Smalltalk · Objective-C 의 길을 C 로.
   1. 메시지 이름은 한 번 인턴해 정수 셀렉터로 바꾼다.
   2. 클래스의 메서드 목록을 훑고, 없으면 부모로 올라간다.
   3. 찾은 것은 클래스별 캐시에 넣는다. 다음부터는 캐시에서 끝난다.
   4. 끝내 없으면 「모르는 메시지」 처리기로 넘긴다(포워딩).
   5. 실행 때 메서드를 더하면 세대 번호를 올려 모든 캐시를 한꺼번에 버린다. */
#include <stdio.h>
#include <string.h>

#define MAX_SELECTORS 32
#define MAX_METHODS   8
#define CACHE_SLOTS   8

struct object;
typedef double (*imp)(struct object *self);

struct method { unsigned sel; imp fn; };
struct cache_slot { unsigned sel; imp fn; unsigned epoch; };
struct class {
    const char *name;
    struct class *parent;
    struct method methods[MAX_METHODS];
    unsigned n_methods;
    struct cache_slot cache[CACHE_SLOTS];
};
struct object { struct class *isa; double size; };

/* ---- 셀렉터 인턴 ---- */
static const char *selector_names[MAX_SELECTORS];
static unsigned n_selectors;
static unsigned intern(const char *name)
{
    for (unsigned i = 0; i < n_selectors; i++)
        if (strcmp(selector_names[i], name) == 0) return i + 1;
    if (n_selectors == MAX_SELECTORS) return 0;
    selector_names[n_selectors++] = name;
    return n_selectors;                       /* 0 은 「셀렉터 없음」으로 남긴다 */
}

/* ---- 찾기 ---- */
static unsigned epoch = 1;                    /* 메서드가 바뀔 때마다 오른다 */
static unsigned slow_lookups, cache_hits;

static imp lookup_slow(const struct class *c, unsigned sel)
{
    slow_lookups++;
    for (; c; c = c->parent)
        for (unsigned i = 0; i < c->n_methods; i++)
            if (c->methods[i].sel == sel) return c->methods[i].fn;
    return nullptr;
}
static imp lookup(struct class *c, unsigned sel)
{
    struct cache_slot *slot = &c->cache[sel % CACHE_SLOTS];
    if (slot->sel == sel && slot->epoch == epoch) { cache_hits++; return slot->fn; }
    imp fn = lookup_slow(c, sel);
    *slot = (struct cache_slot){ sel, fn, epoch };     /* 「없음」도 캐시한다 */
    return fn;
}
static bool add_method(struct class *c, const char *name, imp fn)
{
    if (c->n_methods == MAX_METHODS) return false;
    c->methods[c->n_methods++] = (struct method){ intern(name), fn };
    epoch++;                                  /* 모든 클래스의 캐시가 한꺼번에 낡는다 */
    return true;
}

/* ---- 보내기 ---- */
static double does_not_understand(struct object *self, unsigned sel)
{
    printf("  %s does not understand '%s'\n", self->isa->name,
           sel ? selector_names[sel - 1] : "(selector table full)");
    return 0;
}
static double send(struct object *self, const char *message)
{
    unsigned sel = intern(message);
    imp fn = sel ? lookup(self->isa, sel) : nullptr;
    return fn ? fn(self) : does_not_understand(self, sel);
}

/* ---- 클래스들 ---- */
static double shape_describe(struct object *self)
{ printf("  a %s of size %g\n", self->isa->name, self->size); return 0; }
static double circle_area(struct object *self) { return 3.0 * self->size * self->size; }
static double square_area(struct object *self) { return self->size * self->size; }
static double shape_perimeter(struct object *self) { return 4.0 * self->size; }

static struct class shape  = { .name = "Shape" };
static struct class circle = { .name = "Circle", .parent = &shape };
static struct class square = { .name = "Square", .parent = &shape };

int main(void)
{
    add_method(&shape, "describe", shape_describe);
    add_method(&circle, "area", circle_area);
    add_method(&square, "area", square_area);

    struct object things[] = { { &circle, 2 }, { &square, 3 }, { &circle, 1 } };
    puts("send 'describe' (inherited) and 'area' (own) to each:");
    for (size_t i = 0; i < sizeof things / sizeof things[0]; i++) {
        send(&things[i], "describe");
        printf("  area = %g\n", send(&things[i], "area"));
    }
    printf("slow lookups %u, cache hits %u\n\n", slow_lookups, cache_hits);

    puts("send 'perimeter', which nobody defines yet:");
    send(&things[0], "perimeter");
    send(&things[0], "perimeter");            /* 「없음」도 캐시에서 나온다 */
    printf("slow lookups %u, cache hits %u\n\n", slow_lookups, cache_hits);

    puts("add 'perimeter' to Shape at run time, then send again:");
    add_method(&shape, "perimeter", shape_perimeter);
    printf("  perimeter = %g\n", send(&things[0], "perimeter"));
    printf("  perimeter = %g\n", send(&things[1], "perimeter"));
    printf("slow lookups %u, cache hits %u\n", slow_lookups, cache_hits);
    return 0;
}

실행 결과

send 'describe' (inherited) and 'area' (own) to each:
  a Circle of size 2
  area = 12
  a Square of size 3
  area = 9
  a Circle of size 1
  area = 3
slow lookups 4, cache hits 2

send 'perimeter', which nobody defines yet:
  Circle does not understand 'perimeter'
  Circle does not understand 'perimeter'
slow lookups 5, cache hits 3

add 'perimeter' to Shape at run time, then send again:
  perimeter = 8
  perimeter = 12
slow lookups 7, cache hits 3

출력에서 셀렉터 디스패치의 다섯 가지 성질을 본다.

  1. 이름은 처음 한 번만 비교한다. intern("area") 는 처음 불릴 때 이름 목록에 area 를 넣고 번호를 준다(이렇게 이름을 한 번만 등록해 번호로 바꾸는 것을 인턴이라 부른다). 그 뒤로 같은 이름은 늘 같은 번호다.
  2. 부모 사슬. describe 는 Circle 에 없고 Shape 에 있다. 찾기는 클래스에서 부모로 올라간다 — 상속이 찾는 순서가 되었다.
  3. 클래스별 캐시. 찾은 것은 그 클래스의 캐시에 넣는다. 출력의 첫 단락 끝에 있는 slow lookups 4 는 Circle 과 Square 가 메시지 두 개씩을 처음 찾은 것이고, 셋째 객체(다시 Circle)의 두 메시지는 캐시에서 끝났다(cache hits 2).
  4. 모르는 메시지. perimeter 는 아무도 정의하지 않았다. 표의 칸이었다면 컴파일러가 막았을 일이 여기서는 실행 때 처리기로 간다. Objective-C 는 이 자리에서 메시지를 다른 객체에 넘길 수 있는데, 이것을 포워딩(forwarding)이라 한다. 「없음」도 캐시하므로 두 번째 perimeter 는 느린 찾기를 하지 않았다(slow lookups 5, cache hits 3).
  5. 실행 때 메서드 더하기. 프로그램은 메서드 목록이 바뀔 때마다 하나씩 올리는 전역 번호(세대 번호)를 두고, 캐시의 각 칸에는 넣을 때의 세대 번호를 적어 둔다. Shape 에 perimeter 를 더하자 세대 번호가 올라 모든 캐시 칸이 한꺼번에 낡은 것이 되었다. 그래서 다음 두 메시지는 다시 느린 찾기를 했다(slow lookups 7) — 낡은 「없음」을 믿지 않는다.

이 방식의 유연함은 분명하다. 클래스를 다시 컴파일하지 않고 메서드를 더하고, 모르는 메시지를 받아 넘기고, 남이 만든 클래스에 메서드를 붙인다(Objective-C 의 카테고리, 74장). 비용은 찾기다. 캐시에 있으면 해시 한 번이고, 없으면 목록을 훑고 부모로 오른다 — 그 차이를 다음 장이 잰다. 그리고 컴파일러가 틀린 이름을 잡지 못한다 — 오타는 실행 때 「모르는 메시지」가 된다.

72.6 판이 바뀌는 표#

71장에서 구조체에 크기를 적어 판이 바뀌어도 견디게 했다. 표에도 같은 규칙이 통한다. 표의 머리에 크기나 판 번호를 두고, 칸은 끝에만 붙이고, 모르는 칸은 기본값으로 둔다. SQLite 의 VFS 표가 이 길을 걸었고, 그 표가 한 번 규칙을 어긴 이야기는 74장에 있다.

실행 때 표가 장치마다 다른 예도 있다. Vulkan 에서 응용과 그래픽 드라이버 사이에는 로더라는 라이브러리가 끼어 있다. vkGetDeviceProcAddr 로 그 장치의 함수 주소를 받아 두면, 로더가 호출마다 드라이버를 고르는 단계를 건너뛴다. 그렇게 받은 주소는 그 장치와 그 장치에서 만든 객체에만 쓸 수 있다 — 장치마다 따로 지은 동적 표다.2

판을 칸의 자리가 아니라 칸의 번호로 맞추는 길도 있다. OpenSSL 3 에서 알고리즘 구현을 담아 끼우는 모듈(공급자)은 (함수 번호, 함수 포인터) 쌍의 배열을 내놓고, 받는 쪽은 모르는 번호를 건너뛴다. 칸의 자리가 없으니 「끝에만 붙인다」는 규칙도 필요 없다 — 대신 처음에 한 번 배열을 훑어야 한다(74장).

72.7 정리#

주제요점
동적 표실행 때 짓고 고치고 찾는 표. 플러그인, 기계를 보고 고르기(glibc 의 IFUNC), 실행 때 상속, API 로더
다섯 단계등록 → 채우기(기본값 → 부모 → 자기) → 검증(빈칸은 내놓지 않는다) → 봉인 → 사용
봉인const 포인터와 읽기 전용 페이지, 두 겹. 객체 안의 표 포인터는 여전히 공격의 표적
한 번만 짓기call_once 나 release/acquire 게시. 게으른 짓기의 if (!built) 는 데이터 경쟁
표 포인터의 자리객체 안(포인터가 작다) 또는 뚱뚱한 포인터(고칠 수 없는 타입에도 붙는다). 어느 쪽이 낫다기보다 맞바꿈이다
여러 인터페이스QueryInterface, g_type_interface_peek, 쌍마다 짓고 캐시하는 itab
셀렉터이름으로 찾기, 부모 사슬, 클래스별 캐시, 포워딩, 세대 번호로 캐시 비우기. 오타는 실행 때 드러난다
판이 바뀌는 표머리에 크기나 판 번호, 칸은 끝에만. 장치마다 따로 받는 표(Vulkan), 번호로 맞추는 칸(OpenSSL 3)

표 72.3 — 동적 가상 테이블 — 한 줄씩

표를 짓고, 봉인하고, 찾는 법을 갖췄다. 남은 물음은 그 표를 거치는 호출 하나가 얼마인가다. 다음 장은 함수를 고르는 방법 열한 가지를 이 기계에서 재고, 그 비용을 줄이는 법과 보안 장치가 더하는 비용을 본다.

주

  1. Go 런타임 src/runtime/iface.go. 해시 표의 세부(충돌 처리, 키우는 시점)는 판마다 다르다. ↩
  2. Vulkan 명세, vkGetDeviceProcAddr. LunarG, 「Architecture of the Vulkan Loader Interfaces」 가운데 「Application Interface to Loader」. ↩