71 표를 짓는 법 — 다형성, 상속, 오버로딩, 판, 수명
먼저 알아야 할 것
container_of, 첫 멤버_Generic돌아보기
70장에서 함수 포인터 표를 객체와 가르고 static const 로 두라고 했다. 표 하나로 무엇을 얻고, 그 표는 어떻게 자라야 하는가?
답. 표 하나로 다형성을 얻는다 — 부르는 쪽이 객체의 종류를 몰라도 된다. 대신 호출마다 표를 한 번 거쳐야 하고, 코드를 읽는 사람은 어느 함수가 불릴지 한눈에 알 수 없다. 표는 자란다 — 파생 종류는 기반의 표를 품고 칸을 더하고, 판이 바뀌면 칸이 끝에 붙는다. 그리고 표가 무엇을 부르든 객체의 수명은 사람이 다스린다.
이 장의 필요성과 맥락
이 장이 끝나면
이 장에서 답할 질문
- C 로 다중 상속도 흉내 낼 수 있는가?
71.1 다형성 — 얻는 것, 치르는 것, 경계할 것#
표를 거쳐 함수를 부르는 것을 동적 디스패치(dynamic dispatch)라 한다. 어느 함수가 불릴지가 실행 때 객체의 표로 정해진다.
얻는 것은 분명하다.
- 부르는 쪽이 객체의 종류를 몰라도 된다. 도형을 그리는 코드는 원인지 사각형인지 묻지 않는다.
- 새 종류를 더할 때 부르는 쪽 코드를 고치지 않는다. 새 표 하나와 함수 몇 개를 더하면 된다. 플러그인 구조, 파일 시스템, 장치 드라이버가 이 성질 위에 서 있다.
치르는 것도 분명하다.
- 간접 호출. 컴파일러는 어느 함수가 불릴지 모르므로 그 함수를 호출하는 자리에 펼쳐 넣을(인라인) 수 없다. 인라인이 막히면 그 뒤의 최적화 — 상수 전파(컴파일 때 알 수 있는 값을 미리 계산하기), 벡터화(여러 값을 한 명령으로 계산하기), 불필요한 계산 지우기 — 도 함께 막힌다. 호출 자체의 비용보다 이쪽이 크다.
- 읽기의 비용.
s->ops->area(s)를 읽는 사람은 그 줄이 무엇을 하는지 알 수 없다. 표를 채운 자리를 찾아가야 한다. - 표를 잊은 객체. 표 포인터를 채우지 않은 객체를 부르면 아무 자리로나 뛴다.
컴파일러는 어느 표인지 알 수 있는 자리에서는 간접 호출을 직접 호출로 바꾸기도 한다(탈가상화, devirtualisation). C 에서는 표가 static const 이고 객체가 그 자리에서 보일 때 그럴 여지가 생긴다 — 그 조건이 얼마나 좁은지는 73장에서 기계어로 확인한다. 그러나 그것에 기대어 설계하지는 않는다.
경계할 것은 셋이다.
- 종류가 더 늘지 않으면(닫혀 있으면) 표가 필요 없다. 도형이 원·사각형·삼각형으로 끝이라면, 태그 하나와
switch가 더 단순하다(75장의 「요즘의 흐름」). 어느 쪽이 빠른지는 각 갈래의 함수가 인라인되는지와 종류가 섞이는 모양에 달렸다(73장). 표는 종류가 열려 있을 때 — 바깥 사람이 새 종류를 더할 때 — 쓸모가 있다. - 파생이 기반의 약속을 깨지 않게 한다. 「정사각형은 직사각형의 일종이니 직사각형 표를 물려받는다」고 했는데, 직사각형의 「너비만 바꾸기」를 정사각형이 지킬 수 없다면, 직사각형을 받는 코드는 정사각형을 만나 틀린다. Barbara Liskov 가 1987년 객체 지향 학회 OOPSLA 의 기조연설에서 내놓은 이 생각은 뒤에 「리스코프 치환 원칙」이라는 이름으로 불리게 되었다. C 에서는 그것을 문서와 시험으로만 지킨다.
- 표의 칸은 한 번 내놓으면 계약이다. 칸의 순서와 인자를 바꾸면, 옛 표를 채운 모든 바깥 코드가 틀린다(아래 「판이 바뀌어도 살아남는 구조체」).
71.2 상속을 표로 잇기 — 기반 표를 품은 파생 표#
70장에서 상속의 흉내는 「기반 구조체를 첫 멤버로」였다. 상태의 상속이다. 행동의 상속은 표가 한다 — 그리고 같은 방법이 표에도 통한다. 파생 종류의 표가 기반 종류의 표를 첫 멤버로 품는다.
examples/oop/inherit.c
/* 표로 잇는 상속: 파생 표가 기반 표를 첫 멤버로 품는다.
- square 는 칸 하나(area)를 덮어쓰고 describe 는 shape 의 것을 물려받는다.
- circle 은 describe 를 덮어쓰되 기반의 것을 먼저 부르고(상위 호출),
circle 코드만 닿는 자기 칸(radius)을 더한다. */
#include <stdio.h>
struct shape;
struct shape_ops {
double (*area)(const struct shape *);
void (*describe)(const struct shape *);
};
struct shape { const struct shape_ops *ops; const char *name; };
static void shape_describe(const struct shape *s) /* 기반의 행동 */
{
printf("%-7s area %6.2f", s->name, s->ops->area(s));
}
/* square: 평범한 파생 --- 칸 하나만 바꾸고 나머지는 기반의 것을 적는다 */
struct square { struct shape base; double side; };
static double square_area(const struct shape *s)
{
const struct square *q = (const struct square *)s; /* base 가 첫 멤버다 */
return q->side * q->side;
}
static const struct shape_ops square_ops = {
.area = square_area, .describe = shape_describe,
};
/* circle: 기반 표를 품고 칸 하나를 더한 파생 표 */
struct circle;
struct circle_ops {
struct shape_ops base; /* 반드시 첫 자리 */
double (*radius)(const struct circle *);
};
struct circle { struct shape base; double r; };
static double circle_area(const struct shape *s)
{
const struct circle *c = (const struct circle *)s;
return 3.0 * c->r * c->r;
}
static double circle_radius(const struct circle *c) { return c->r; }
static void circle_describe(const struct shape *s)
{
shape_describe(s); /* 상위 호출: 기반 먼저 */
const struct circle_ops *ops = (const struct circle_ops *)s->ops;
printf(" radius %.1f", ops->radius((const struct circle *)s));
}
static const struct circle_ops circle_ops = {
.base = { .area = circle_area, .describe = circle_describe },
.radius = circle_radius,
};
int main(void)
{
struct square sq = { { &square_ops, "square" }, 2 };
struct circle ci = { { &circle_ops.base, "circle" }, 1.5 };
const struct shape *all[] = { &sq.base, &ci.base };
for (size_t i = 0; i < sizeof all / sizeof all[0]; i++) {
all[i]->ops->describe(all[i]); /* 부르는 쪽은 shape 만 본다 */
putchar('\n');
}
printf("circle's table starts with the shape table: %s\n",
(const void *)&circle_ops == (const void *)&circle_ops.base ? "yes" : "no");
return 0;
}
실행 결과
square area 4.00
circle area 6.75 radius 1.5
circle's table starts with the shape table: yes
예제에서 상속이 드러나는 자리 셋을 본다 — 물려받은 칸, 상위 호출, 파생만 아는 칸.
- 덮어쓰지 않은 칸은 기반의 것을 적는다.
square_ops의describe는shape_describe다. C 에는 「물려받는다」는 문법이 없으므로, 물려받기는 기반의 함수를 그 칸에 적는 것이다. 표가 하나둘일 때는 손으로 적고, 많아지면 70장의 기본값 묶음이 그 일을 한다. - 상위 호출.
circle_describe는 기반의shape_describe를 먼저 부르고 제 몫을 덧붙인다. C++ 의Base::describe()와 같은 일이다. 기반의 함수를 이름으로 부르므로 직접 호출이다 — 표를 거치지 않는다. - 파생만 아는 칸.
circle_ops는shape_ops를 첫 멤버로 품고radius를 더했다. 객체는&circle_ops.base를 가리키므로 부르는 쪽은shape_ops만 본다. 원의 코드는 그 포인터를const struct circle_ops *로 되돌려 자기 칸에 닿는다.
마지막 되돌림이 옳은 까닭은 표준에 있다. 구조체를 가리키는 포인터는 알맞게 변환하면 그 첫 멤버를 가리키고, 그 거꾸로도 성립한다(C23 §6.7.3.2p17). 예제의 마지막 줄이 두 주소가 같음을 보인다.
흔한 오해. “첫 멤버 규칙이 있으니 하향 캐스트는 안전하다”
circle_ops 로 되돌리면 주소는 맞게 계산되지만 radius 칸은 표 끝을 넘어 읽는다. 되돌리기 전에 종류를 확인하는 것은 여전히 사람의 몫이다 — GObject 의 검사하는 캐스트가 그 확인을 매크로로 붙인 것이다(74장).문. C 로 다중 상속도 흉내 낼 수 있는가?
답. 상태의 다중 상속은 어렵다. 첫 멤버는 하나뿐이라, 기반 구조체를 둘 품으면 둘째 기반은 객체의 맨 앞에 있지 않다. 둘째 기반을 가리키는 포인터에서 객체를 되찾으려면 container_of 처럼 오프셋을 빼야 하고, 그 계산이 맞는지는 사람이 지킨다. 행동의 다중 상속은 사정이 낫다 — 표를 인터페이스마다 따로 두고, 객체에서 필요한 표를 찾는다. 그 길은 72장의 「여러 인터페이스와 찾기」에서 짓는다.
기반 표를 첫 멤버로 품는 이 모양에는 한계가 하나 있다. 표의 모양이 컴파일 때 정해진다. 부모가 무엇인지, 어느 칸을 덮어쓸지를 소스에 적어 둔다. 플러그인처럼 종류가 실행 때 들어오거나, 부모의 표가 실행 때에야 정해지면 파생 표를 실행 때 지어야 한다. 그 이야기는 72장에서 한다.
71.3 오버로딩 — 이름 하나에 몸 여럿#
먼저 오버로딩과 재정의, 두 낱말의 뜻을 바로 세운다. 자주 섞여 쓰이기 때문이다.
| 낱말 | 뜻 | 언제 고르는가 |
|---|---|---|
| 오버로딩(overloading) | 같은 이름의 함수가 인자 타입에 따라 여럿 있다 | 컴파일 때 — 인자의 타입을 보고 |
| 재정의(overriding) | 파생 종류가 기반 종류의 같은 행동을 자기 것으로 바꾼다 | 실행 때 — 객체의 표를 보고(다형성) |
표 71.1 — 오버로딩과 재정의는 다른 것이다
C 로 객체를 지을 때 「어떤 함수를 표에 넣고 어떤 함수를 넣지 않는가」는 재정의의 문제다. 그리고 「같은 이름으로 타입마다 다른 몸을 둘 것인가」는 오버로딩의 문제다. 둘을 따로 정한다.
71.3.1 표에 넣을 함수와 넣지 않을 함수#
종류마다 달라지는 행동만 표에 넣는다. 나머지는 평범한 함수로 둔다.
- 도형의
area는 원과 사각형에서 다르다 → 표. - 도형의
move(dx, dy)는 모든 도형에서 「위치에 더하기」다 → 평범한 함수shape_move. - 객체의 이름을 돌려주는
shape_name은 기반 구조체의 필드를 읽을 뿐이다 → 평범한 함수.
평범한 함수는 직접 호출되고, 인라인되고, 디버거에서 한 번에 찾아간다. 「혹시 나중에 바꿀지도 모르니」 모든 함수를 표에 넣으면, 위의 「치르는 것」을 모든 호출에서 치른다. C++ 가 함수를 기본으로 비가상으로 두고 virtual 을 따로 적게 한 것도 같은 판단이다.
71.3.2 C 에서 오버로딩을 하는 두 길#
C 에는 이름이 같은 함수를 두 개 둘 수 없다. 길은 둘이다.
첫째, 이름을 나눈다. 표준 라이브러리 자신이 이 길을 택했다 — sqrtf·sqrt·sqrtl, abs·labs·llabs. 이름에 타입이 드러나므로 읽는 사람이 무엇이 불리는지 알고, 검색과 링크에서도 헷갈리지 않는다.
둘째, _Generic 으로 고른다(67장). C11 이 들인 _Generic 은 식의 타입에 따라 갈래를 고르는 컴파일 시간의 장치다. <tgmath.h> 의 sqrt(x) 는 x 의 타입을 보고 sqrtf 나 sqrtl 로 간다 — 이 헤더는 C99 부터 있었고 _Generic 보다 먼저지만, 오늘의 구현은 대개 _Generic 으로 짓는다.
71.3.3 _Generic 의 장단점과 경계할 것#
_Generic 은 편하지만, 그것이 보는 타입은 사람이 생각한 타입과 다를 때가 있다.
examples/oop/generic_pick.c
/* C 의 오버로딩: _Generic 은 식의 타입으로 갈래를 고른다 --- 컴파일 때.
그것이 보는 타입이 늘 사람이 생각한 타입은 아니다. */
#include <stdint.h>
#include <stdio.h>
#define type_name(x) _Generic((x), \
char: "char", signed char: "signed char", unsigned char: "unsigned char", \
short: "short", unsigned short: "unsigned short", \
int: "int", unsigned: "unsigned", long: "long", unsigned long: "unsigned long", \
long long: "long long", unsigned long long: "unsigned long long", \
float: "float", double: "double", long double: "long double", \
char *: "char *", const char *: "const char *", int *: "int *", \
default: "(something else)")
/* 타입으로 오버로딩한 「print」 --- 모든 갈래가 호출마다 컴파일되어야 한다 */
static void print_int(long long v) { printf("%lld", v); }
static void print_uint(unsigned long long v) { printf("%llu", v); }
static void print_dbl(double v) { printf("%g", v); }
static void print_str(const char *s) { printf("\"%s\"", s); }
#define print(x) _Generic((x), \
int: print_int, long: print_int, long long: print_int, \
unsigned: print_uint, unsigned long: print_uint, unsigned long long: print_uint, \
float: print_dbl, double: print_dbl, \
char *: print_str, const char *: print_str)(x)
int main(void)
{
char c = 'a';
uint8_t byte = 200;
const int ci = 5;
int arr[3] = { 0 };
/* C 에서 문자 상수는 int 다 */
printf("%-16s -> %s\n", "'a'", type_name('a'));
printf("%-16s -> %s\n", "c (a char)", type_name(c));
printf("%-16s -> %s\n", "c + 1", type_name(c + 1)); /* 진급된다 */
printf("%-16s -> %s\n", "byte", type_name(byte)); /* uint8_t 는 typedef 다 */
printf("%-16s -> %s\n", "ci (const)", type_name(ci)); /* 한정자가 떨어진다 */
printf("%-16s -> %s\n", "arr", type_name(arr)); /* 배열이 무너진다 */
/* const char * 가 아니라 char * */
printf("%-16s -> %s\n", "\"text\"", type_name("text"));
printf("%-16s -> %s\n", "1.0f * 2", type_name(1.0f * 2));
printf("\nprint: ");
print(42); printf(", "); print(3u); printf(", ");
print(2.5); printf(", "); print("hi");
printf("\n");
/* print(c) 는 컴파일되지 않는다: char 갈래가 없다. 빠진 갈래가 오류라는 것은
좋은 소식이고, `c + 1` 이 조용히 int 갈래로 간다는 것은 나쁜 소식이다. */
return 0;
}
실행 결과
'a' -> int
c (a char) -> char
c + 1 -> int
byte -> unsigned char
ci (const) -> int
arr -> int *
"text" -> char *
1.0f * 2 -> float
print: 42, 3, 2.5, "hi"
- 문자 상수
'a'는 C 에서int다(C++ 와 다르다). c + 1은 정수 진급으로int가 된다(30장).uint8_t는typedef일 뿐이라 이 기계에서는unsigned char갈래로 간다 — 다른 구현에서는 다른 갈래일 수 있다.const int는int갈래로 간다. 고르는 식은 먼저 값으로 바뀌면서(좌변값 변환)const같은 한정자를 잃기 때문이다.- 배열은 첫 원소를 가리키는 포인터로 바뀐다. 문자열 리터럴은
const char *가 아니라char *다.
그리고 print(c) 는 컴파일되지 않는다 — char 갈래가 없기 때문이다. 빠진 갈래가 오류라는 것은 좋은 소식이고, c + 1 이 조용히 int 갈래로 간다는 것은 나쁜 소식이다. 오류 메시지는 매크로가 펼쳐진 뒤의 긴 식을 가리켜 읽기 어렵고, _Generic 의 모든 갈래는 실제로 고르지 않아도 문법과 타입 검사를 통과해야 한다.
그래서 판단은 이렇다. 오버로딩은 정말로 같은 뜻일 때만 쓴다. 「제곱근」은 타입이 달라도 같은 뜻이라 <tgmath.h> 가 어울린다. 「출력한다」처럼 타입마다 형식이 다른 일을 이름 하나로 감추면, 읽는 사람은 어느 형식으로 나갈지 알 수 없다. 의심스러우면 이름을 나눈다.
71.4 판이 바뀌어도 살아남는 구조체 — 크기를 구조체 자신에 적기#
라이브러리는 판이 바뀐다. 구조체에 멤버가 더해진다. 그런데 그 구조체를 부르는 쪽이 만들어서 넘기는 API 라면 문제가 생긴다. 옛 헤더로 컴파일된 프로그램은 옛 크기의 구조체를 넘기고, 새 라이브러리는 새 크기만큼 읽는다 — 남의 기억을 읽는다.
Windows API 는 이 문제를 구조체의 크기를 구조체 자신에 적는 방식으로 다룬다. WNDCLASSEX 의 cbSize, NONCLIENTMETRICS 의 cbSize, OPENFILENAME 의 lStructSize 같은 첫 멤버가 그것이다. 부르는 쪽은 그 자리에 sizeof 를 채우고, 라이브러리는 그 값을 보고 「어느 판의 구조체를 받았는가」를 안다.
examples/oop/versioned.c
/* 자기 크기를 지니고 다니는 구조체 --- Windows API 의 cbSize 방식.
라이브러리 판 2 가 끝에 멤버를 하나 더했다. 판 1 로 컴파일한 프로그램도 그대로 돈다 ---
라이브러리는 부른 쪽이 말한 바이트만큼만 읽는다. */
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
/* 판 1 헤더에 있던 것 */
struct win_opts_v1 { uint32_t size; int32_t width, height; };
/* 판 2 헤더: 끝에 멤버 하나 더, 다른 것은 움직이지 않았다 */
struct win_opts { uint32_t size; int32_t width, height; int32_t dpi; };
static_assert(offsetof(struct win_opts, height) == offsetof(struct win_opts_v1, height),
"old members must stay where they were");
/* 라이브러리(판 2) */
static bool win_open(const void *opts)
{
uint32_t size;
memcpy(&size, opts, sizeof size); /* 첫 멤버는 언제나 크기 */
struct win_opts o = { .dpi = 96 }; /* 부른 쪽에 없는 것은 기본값으로 */
if (size == sizeof(struct win_opts_v1) || size == sizeof(struct win_opts))
memcpy(&o, opts, size); /* 부른 쪽이 가진 것보다 더 읽지 않는다 */
else {
printf(" size %u: not a version this library knows --- refused\n",
(unsigned)size);
return false;
}
printf(" size %2u: %d x %d at %d dpi\n", (unsigned)size, o.width, o.height, o.dpi);
return true;
}
int main(void)
{
puts("an old program (built with the version-1 header):");
struct win_opts_v1 old = { .size = sizeof old, .width = 640, .height = 480 };
win_open(&old);
puts("a new program:");
struct win_opts now = { .size = sizeof now, .width = 1920, .height = 1080,
.dpi = 144 };
win_open(&now);
puts("a program that forgot to fill in the size:");
struct win_opts careless = { .width = 800, .height = 600 }; /* 크기는 0 */
win_open(&careless);
return 0;
}
실행 결과
an old program (built with the version-1 header):
size 12: 640 x 480 at 96 dpi
a new program:
size 16: 1920 x 1080 at 144 dpi
a program that forgot to fill in the size:
size 0: not a version this library knows --- refused
옛 판으로 컴파일된 프로그램은 12바이트를 넘기고, 라이브러리는 12바이트만 읽고 나머지(dpi)를 기본값으로 채운다. 새 프로그램은 16바이트를 넘긴다. 크기를 잊은 프로그램은 0 을 넘겨 거절된다.
이 방식의 비용은 바이트 몇 개다 — 그리고 부르는 쪽이 크기 채우기를 잊는 사고다. 그 비용을 치를 만한 까닭은, 이 몇 바이트로 라이브러리가 판을 올려도 옛 프로그램을 다시 컴파일하지 않아도 된다는 것이다. 수십 년 된 프로그램이 오늘의 Windows 에서 도는 까닭 가운데 하나가 이것이다.
71.4.1 지켜야 할 규칙#
크기 필드는 마법이 아니다. 다음 규칙을 지킬 때만 동작한다.
- 멤버는 끝에만 붙인다. 가운데에 끼우면 옛 멤버의 오프셋이 바뀐다. 예제의
static_assert가 그것을 지킨다. - 옛 멤버의 뜻을 바꾸지 않는다. 같은 자리, 같은 타입이어도 단위가 바뀌면 옛 프로그램은 틀린다.
- 받는 쪽은 아는 만큼만 읽고, 모르는 끝은 기본값으로 채운다.
- 크기의 목록을 정해 둔다. 아무 값이나 받지 말고, 아는 판의 크기만 받는다.
- 그리고 이 방식은 부르는 프로그램과 라이브러리가 같은 ABI(application binary interface)로 구조체를 놓는다는 것에 기댄다(61장). 옛 판과 새 판의 구조체는 같은 ABI 를 따를 때에만 같은 오프셋에 같은 멤버를 둔다.
실제 사례. NONCLIENTMETRICS — 새 헤더로 빌드한 프로그램이 옛 Windows 에서 돌 때
Windows Vista 는 NONCLIENTMETRICS 의 끝에 iPaddedBorderWidth 를 더했다. _WIN32_WINNT 를 0x0600 이상으로 두고 컴파일하면 구조체가 4바이트 커진다. Microsoft 의 문서는 이렇게 적는다 — 그렇게 빌드한 프로그램이 Windows XP·2000·Server 2003 에서도 돌아야 한다면, 실행 때 운영체제 판을 확인하고 옛 판이면 cbSize 에서 그 멤버의 크기를 빼고 SystemParametersInfo 를 불러야 한다.1
크기 필드는 「옛 프로그램이 새 라이브러리에서 도는」 방향을 지켜 준다. 반대 방향 — 새 프로그램이 옛 라이브러리에서 도는 것 — 은 옛 라이브러리가 새 크기를 모르므로 거절한다. 어느 방향을 지키는지 알고 쓰는 것이 이 방식의 조건이다.
71.4.2 다른 길과 견주기#
| 길 | 어떻게 | 언제 어울리는가 |
|---|---|---|
| 공개 구조체 + 크기 필드 | 부르는 쪽이 sizeof 를 채운다 | 부르는 쪽이 구조체를 만들어야 할 때(스택에 두는 옵션 묶음 등) |
| 공개 구조체 + 판 번호 필드 | 크기 대신 판 번호를 적는다 | 크기가 같아도 뜻이 바뀌는 경우까지 가려야 할 때 |
| 불투명 타입 + 팩토리 | 구조체의 크기와 멤버를 바깥에 아예 드러내지 않는다 | 대부분의 라이브러리 객체 — 판 문제가 처음부터 없다 |
| 함수 표의 판 | 표에도 같은 규칙: 칸은 끝에만 더한다. Microsoft 의 COM(74장) 처럼 객체가 여러 인터페이스를 내놓고 부르는 쪽이 필요한 것을 물어 얻게 하는 길도 있다 | 플러그인처럼 바깥 사람이 표를 채울 때 |
표 71.2 — 판이 바뀌는 구조체를 다루는 길
셋째 줄이 C 라이브러리의 기본 선택이어야 한다. 바깥 코드가 구조체의 크기를 모르면, 크기가 바뀌어도 바깥 코드는 다시 컴파일할 필요가 없다. 크기 필드는 불투명 타입을 쓸 수 없을 때 — 부르는 쪽이 할당을 피하고 싶거나, 구조체가 옵션을 담는 단순한 묶음일 때 — 의 차선이다.
71.5 수명 — RAII 는 없다#
C++ 의 RAII(resource acquisition is initialisation)는 객체가 유효범위를 벗어날 때 소멸자를 저절로 부른다. 표준 C 에는 그런 장치가 없다. 만든 것은 사람이 지운다. 그것도 정확히 한 번, 모든 출구에서.
58장에서 수명을 다스리는 기법을 보았다. 객체 체계에서는 그 기법이 규약의 모양을 띤다.
| 규약 | 모양 | 언제 |
|---|---|---|
| 팩토리와 짝 | T *t_new(…) / void t_free(T *) | 불투명 타입 — 라이브러리가 할당한다 |
| 초기화와 마무리 | bool t_init(T *, …) / void t_fini(T *) | 부르는 쪽이 자리를 댄다(스택, 배열, 다른 구조체 안) |
| 할당자를 인자로 | t_new(alloc, …) | 아레나·풀에서 만들고 한꺼번에 버릴 때(104장) |
| 참조 계수 | t_ref / t_unref, 0 이 되면 해제 | 여러 곳이 나눠 쥐고 언제 끝날지 모를 때 |
| 두 단계 해제 | dispose(쥐고 있던 다른 객체를 놓기) → finalize(자기를 지우기) | 객체끼리 서로 쥐어 순환이 생길 수 있을 때 |
| 컬렉션이 소유 | 그릇이 원소를 만들고, 그릇을 지우면 원소도 지운다 | 원소의 수명이 그릇과 같을 때 |
표 71.3 — 객체 체계의 수명 규약
마지막 줄이 가장 싸고 가장 덜 쓰인다. 객체 하나하나의 수명을 따로 다스리는 대신, 수명이 같은 것을 한 그릇에 모으고 그릇만 다스린다. 요청 하나를 처리하는 동안 만든 객체는 요청이 끝날 때 함께 죽는다 — 아레나 하나로 끝난다. 게임에서 한 라운드 동안 만든 개체는 그 라운드가 끝날 때 함께 죽는다. 객체가 백 개여도 지우는 호출은 하나다.
71.5.1 표준 밖의 도움#
GCC 와 Clang 은 변수가 유효범위를 벗어날 때 함수를 부르게 하는 확장 __attribute__((cleanup(f))) 를 둔다. GLib 의 g_autoptr(GObject) 같은 매크로가 이 확장 위에 지어져 있다 — 그래서 GCC·Clang 에서만 동작하고 MSVC 에서는 쓸 수 없다. 표준 쪽에서는 블록이 끝날 때 할 일을 적어 두는 defer 를 기술 명세(ISO/IEC TS 25755)로 만드는 작업이 진행 중이다(58장) — C23 에는 없다. 어느 쪽이든 「잊지 않게」 도와줄 뿐, 누가 소유하는가는 여전히 사람이 정한다.
71.6 정리#
| 주제 | 요점 |
|---|---|
| 다형성 | 종류가 바깥에서 늘어날 때(열린 종류) 쓸모가 있다. 대가는 막힌 인라인과 읽기 어려움. 종류가 더 늘지 않으면 switch |
| 상속 | 파생 표가 기반 표를 첫 멤버로 품는다. 물려받기는 기반 함수를 칸에 적는 것, 상위 호출은 이름으로 부르는 직접 호출. 하향 캐스트 전의 종류 확인은 사람의 몫 |
| 오버로딩 | 재정의와 다르다. 표에는 달라지는 행동만. 타입마다의 몸은 이름 나누기나 _Generic — 같은 뜻일 때만 |
| 판 관리 | 크기 필드는 멤버를 끝에만 더하고 옛 멤버의 뜻을 바꾸지 않을 때만 동작한다. 기본 선택은 불투명 타입 |
| 수명 | RAII 는 없다. 짝 맞춘 생성·해제, 참조 계수, 두 단계 해제, 그릇이 소유 |
표 71.4 — 표를 짓는 법 — 한 줄씩
이 장의 함수 포인터 표는 모두 소스에 적혀 있었다. 다음 장은 표를 실행 때 짓고, 73장은 표를 거치는 호출 하나가 정말 얼마인지를 이 기계에서 잰다.
주
- Microsoft Learn, 「NONCLIENTMETRICSW structure (winuser.h)」, Remarks. ↩