함수 포인터를 구조체에 묶으면(VTable), "타입마다 뭘 할지" 고르는 if-else가 사라진다.
부르는 쪽은 상대가 누구인지 몰라도 된다.
이게 C로 만드는 다형성이다.
함수 포인터 기초는 이전 글 함수_포인터에서 다뤘다.
추상화를 간단하게 설명해보겠다.
자세히 궁금하다면 찾아보길 바란다.
추상화는 "어떻게"를 숨기고 "무엇을"만 드러내는 것이다.
자동차를 떠올려 보자.
운전자는 핸들과 페달만 안다.
엔진이 가솔린이든 전기든, 밟으면 간다는 것만 알면 된다.
코드도 같다.
부르는 쪽은 "그려라"만 알면 되고, Button을 어떻게 그리는지, Dialog를 어떻게 그리는지는 몰라도 된다.
이렇게 나누면 좋은 점이 하나 있다.
안쪽(구현)을 바꾸거나 새로 추가해도 바깥(쓰는 쪽)은 그대로다.
전기차가 새로 나와도 운전 방법을 다시 배우지 않는 것처럼.
Java라면 interface가 이 역할을 한다.
C에는 그런 문법이 없어서, 함수 포인터로 직접 만든다.
이 글은 그 과정을 따라간다.
화면에 Button, Dialog 같은 위젯이 있다고 하자. 함수 포인터 없이 그리면 이렇다.
void screen_render(Screen *s) {
for (int i = 0; i < s->count; i++) {
Widget *w = s->items[i];
if (w->type == BUTTON) button_render(w);
else if (w->type == DIALOG) dialog_render(w);
}
}
문제는 이 if-else가 그리기에만 있는 게 아니라는 것이다.
이벤트 처리, 크기 계산에도 똑같은 분기가 생긴다.
그래서 Slider를 하나 추가하면 그 분기들을 전부 찾아서 고쳐야 한다.
하나라도 빠뜨리면 컴파일러는 아무 말 안 하고, 실행 중에 문제가 터진다.
"Button이면 button_render"라는 판단을 부를 때마다 하지 말고, 만들 때 한 번만 해서 위젯 안에 적어두자.
void screen_render(Screen *s) {
for (int i = 0; i < s->count; i++)
s->items[i]->vtbl->render(s->items[i]); // 어떤 위젯이든 한 줄
}
이제 구조를 하나씩 만들어 보자.
typedef struct Widget Widget; // 아래 VTable에서 Widget을 먼저 쓰기 때문에 미리 선언
typedef enum { EV_CLICK, EV_CLOSE } EventCode;
// 이 타입은 "이렇게 그리고, 이렇게 반응한다"는 행동 목록
typedef struct {
void (*render)(Widget *self);
void (*on_event)(Widget *self, EventCode code);
} VTable;
struct Widget {
const VTable *vtbl; // 내 행동 목록의 주소 = "나는 어떤 타입인가"
int id;
int closed;
char label[24];
};
self는 "지금 어떤 위젯에 대해 실행 중인지"를 알려주는 인자다. Java의 this가 숨기고 있는 게 바로 이것이다.
static void button_render(Widget *self) {
printf("[Button #%d] %s\n", self->id, self->label);
}
static void dialog_render(Widget *self) {
if (!self->closed) printf("<<Dialog #%d>> %s\n", self->id, self->label);
}
static void noop_event(Widget *self, EventCode code) {
(void)self; (void)code; // 아무것도 안 함
}
static void dialog_on_event(Widget *self, EventCode code) {
if (code == EV_CLOSE) self->closed = 1;
}
Button은 이벤트를 무시하고, Dialog는 닫기 이벤트를 받으면 닫힌다.
static const VTable BUTTON_VT = { button_render, noop_event };
static const VTable DIALOG_VT = { dialog_render, dialog_on_event };
Button이 100개여도 BUTTON_VT는 1개다.
위젯들은 목록을 복사하지 않고 주소만 들고 있다.
const라서 실행 중에 누가 바꿀 수도 없다.
static Widget *widget_new(const VTable *vt, int id, const char *label) {
Widget *w = malloc(sizeof *w);
if (!w) return NULL;
w->vtbl = vt; // ← 여기서 "나는 Button이다"가 정해진다
w->id = id;
w->closed = 0;
snprintf(w->label, sizeof w->label, "%s", label);
return w;
}
Widget *btn = widget_new(&BUTTON_VT, 11, "OK");
Widget *dlg = widget_new(&DIALOG_VT, 12, "Are you sure?");
if-else가 사라진 게 아니라, "어떤 목록을 넣어 주느냐" 한 번으로 줄어든 것이다.
static void screen_dispatch(Screen *s, EventCode code) {
for (int i = 0; i < s->count; i++)
s->items[i]->vtbl->on_event(s->items[i], code);
}
같은 on_event 호출인데 Button은 무시하고, Dialog는 닫힌다.
처음:
[Button #11] OK
<<Dialog #12>> Are you sure?
EV_CLOSE 후:
[Button #11] OK
이제 Slider를 추가하려면 slider_render, slider_on_event, SLIDER_VT만 만들면 된다.
screen_render와 screen_dispatch는 한 글자도 안 바뀐다.
w->vtbl->render(w)는 이렇게 따라간다.
위젯 BUTTON_VT 코드
┌──────────┐ ┌────────────┐ ┌───────────────┐
vtbl ───────▶ render ───────▶ button_render
id = 11 on_event └───────────────┘
└──────────┘ └────────────┘
위젯에서 목록 주소를 읽고, 목록에서 함수 주소를 읽고, 그 주소로 점프한다.
보통 함수 호출은 목적지가 고정인데, 여기는 어떤 위젯이냐에 따라 목적지가 바뀐다.
이게 다형성의 정체다.
1. 조금 느리다. 컴파일러가 어떤 함수가 불릴지 몰라서 최적화(인라이닝)를 못 한다.
보통은 무시해도 되지만, 엄청 많이 도는 반복문에서는 차이가 난다.
2. 타입 추가는 쉽지만, 행동 추가는 어렵다.
| 변경 | if-else 방식 | VTable 방식 |
|---|---|---|
| 새 타입 추가 (Slider) | 모든 분기 수정 ❌ | 목록 하나 추가 ✅ |
새 행동 추가 (layout) | 함수 하나 추가 ✅ | 모든 목록 수정 ❌ |
VTable은 if-else 방식의 장단점을 정확히 뒤집은 것이다. 타입이 자주 늘어나면 VTable, 행동이 자주 늘어나면 if-else가 낫다.
3. 빈칸 검사를 안 해준다. 목록에 NULL을 넣어 두고 부르면 프로그램이 바로 죽는다. 그래서 할 일이 없어도 noop_event처럼 "아무것도 안 하는 함수"를 채워 넣는다. Java가 메서드를 구현 안 하면 컴파일 에러를 내는 건, 이 검사를 언어가 대신 해주는 것이다.
virtual — 지금 손으로 만든 구조를 컴파일러가 자동으로 만들어 준다. 거의 똑같다.C++의 virtual, Java의 인터페이스가 뒤에서 하는 일이 바로 이 구조다. 언어가 숨겨 주느냐, C처럼 직접 만드느냐의 차이일 뿐이다.
#include <stdio.h>
#include <stdlib.h>
typedef struct Widget Widget;
typedef enum { EV_CLICK, EV_CLOSE } EventCode;
typedef struct {
void (*render)(Widget *self);
void (*on_event)(Widget *self, EventCode code);
} VTable;
struct Widget {
const VTable *vtbl;
int id;
int closed;
char label[24];
};
typedef struct {
Widget *items[16];
int count;
} Screen;
static void button_render(Widget *self) {
printf("[Button #%d] %s\n", self->id, self->label);
}
static void dialog_render(Widget *self) {
if (!self->closed) printf("<<Dialog #%d>> %s\n", self->id, self->label);
}
static void noop_event(Widget *self, EventCode code) {
(void)self; (void)code;
}
static void dialog_on_event(Widget *self, EventCode code) {
if (code == EV_CLOSE) self->closed = 1;
}
static const VTable BUTTON_VT = { button_render, noop_event };
static const VTable DIALOG_VT = { dialog_render, dialog_on_event };
static Widget *widget_new(const VTable *vt, int id, const char *label) {
Widget *w = malloc(sizeof *w);
if (!w) return NULL;
w->vtbl = vt;
w->id = id;
w->closed = 0;
snprintf(w->label, sizeof w->label, "%s", label);
return w;
}
static void screen_render(Screen *s) {
for (int i = 0; i < s->count; i++)
s->items[i]->vtbl->render(s->items[i]);
}
static void screen_dispatch(Screen *s, EventCode code) {
for (int i = 0; i < s->count; i++)
s->items[i]->vtbl->on_event(s->items[i], code);
}
int main(void) {
Screen s = { .count = 0 };
s.items[s.count++] = widget_new(&BUTTON_VT, 11, "OK");
s.items[s.count++] = widget_new(&DIALOG_VT, 12, "Are you sure?");
puts("처음:");
screen_render(&s);
screen_dispatch(&s, EV_CLOSE);
puts("EV_CLOSE 후:");
screen_render(&s);
for (int i = 0; i < s.count; i++) free(s.items[i]);
return 0;
}
좋댓하고 가요 ~~