Proven C Book←↑→

71 표를 짓는 법 — 다형성, 상속, 오버로딩, 판, 수명

먼저 알아야 할 것

70장 객체 지향을 흉내 내기 · 세 덩어리, 표의 빈칸, 불투명 타입
48장 구조체를 부리는 법 · container_of, 첫 멤버
67장 가변 인자 함수 · _Generic
58장 메모리를 다스리는 법 · 소유, 참조 계수, 세대 핸들

돌아보기

70장에서 함수 포인터 표를 객체와 가르고 static const 로 두라고 했다. 표 하나로 무엇을 얻고, 그 표는 어떻게 자라야 하는가?

답. 표 하나로 다형성을 얻는다 — 부르는 쪽이 객체의 종류를 몰라도 된다. 대신 호출마다 표를 한 번 거쳐야 하고, 코드를 읽는 사람은 어느 함수가 불릴지 한눈에 알 수 없다. 표는 자란다 — 파생 종류는 기반의 표를 품고 칸을 더하고, 판이 바뀌면 칸이 끝에 붙는다. 그리고 표가 무엇을 부르든 객체의 수명은 사람이 다스린다.

이 장의 필요성과 맥락

표를 짓는 일은 한 번으로 끝나지 않는다. 새 종류가 오고, 기반의 행동을 조금만 바꾼 파생이 생기고, 라이브러리의 판이 오르고, 객체는 언젠가 죽는다. 이 변화들을 견디는 규칙을 모르면, 표는 처음 몇 종류까지만 깔끔하다.

이 장이 끝나면

다형성이 주는 것과 치르는 것, 경계할 것을 먼저 센다. 그다음 파생 표가 기반 표를 첫 멤버로 품는 상속을 짓고, 기반의 행동을 먼저 부르는 상위 호출을 본다. 오버로딩과 재정의를 가르고, 판이 바뀌어도 살아남는 구조체의 규칙을 세운 다음, C++ 의 RAII 같은 자동 정리가 없는 C 에서 수명을 다스리는 규약으로 닫는다.

이 장에서 답할 질문

  1. C 로 다중 상속도 흉내 낼 수 있는가?

71.1 다형성 — 얻는 것, 치르는 것, 경계할 것#

표를 거쳐 함수를 부르는 것을 동적 디스패치(dynamic dispatch)라 한다. 어느 함수가 불릴지가 실행 때 객체의 표로 정해진다.

얻는 것은 분명하다.

치르는 것도 분명하다.

컴파일러는 어느 표인지 알 수 있는 자리에서는 간접 호출을 직접 호출로 바꾸기도 한다(탈가상화, devirtualisation). C 에서는 표가 static const 이고 객체가 그 자리에서 보일 때 그럴 여지가 생긴다 — 그 조건이 얼마나 좁은지는 73장에서 기계어로 확인한다. 그러나 그것에 기대어 설계하지는 않는다.

경계할 것은 셋이다.

  1. 종류가 더 늘지 않으면(닫혀 있으면) 표가 필요 없다. 도형이 원·사각형·삼각형으로 끝이라면, 태그 하나와 switch 가 더 단순하다(75장의 「요즘의 흐름」). 어느 쪽이 빠른지는 각 갈래의 함수가 인라인되는지와 종류가 섞이는 모양에 달렸다(73장). 표는 종류가 열려 있을 때 — 바깥 사람이 새 종류를 더할 때 — 쓸모가 있다.
  2. 파생이 기반의 약속을 깨지 않게 한다. 「정사각형은 직사각형의 일종이니 직사각형 표를 물려받는다」고 했는데, 직사각형의 「너비만 바꾸기」를 정사각형이 지킬 수 없다면, 직사각형을 받는 코드는 정사각형을 만나 틀린다. Barbara Liskov 가 1987년 객체 지향 학회 OOPSLA 의 기조연설에서 내놓은 이 생각은 뒤에 「리스코프 치환 원칙」이라는 이름으로 불리게 되었다. C 에서는 그것을 문서와 시험으로만 지킨다.
  3. 표의 칸은 한 번 내놓으면 계약이다. 칸의 순서와 인자를 바꾸면, 옛 표를 채운 모든 바깥 코드가 틀린다(아래 「판이 바뀌어도 살아남는 구조체」).

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

예제에서 상속이 드러나는 자리 셋을 본다 — 물려받은 칸, 상위 호출, 파생만 아는 칸.

  1. 덮어쓰지 않은 칸은 기반의 것을 적는다. square_ops 의 describe 는 shape_describe 다. C 에는 「물려받는다」는 문법이 없으므로, 물려받기는 기반의 함수를 그 칸에 적는 것이다. 표가 하나둘일 때는 손으로 적고, 많아지면 70장의 기본값 묶음이 그 일을 한다.
  2. 상위 호출. circle_describe 는 기반의 shape_describe 를 먼저 부르고 제 몫을 덧붙인다. C++ 의 Base::describe() 와 같은 일이다. 기반의 함수를 이름으로 부르므로 직접 호출이다 — 표를 거치지 않는다.
  3. 파생만 아는 칸. 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 표에 넣을 함수와 넣지 않을 함수#

종류마다 달라지는 행동만 표에 넣는다. 나머지는 평범한 함수로 둔다.

평범한 함수는 직접 호출되고, 인라인되고, 디버거에서 한 번에 찾아간다. 「혹시 나중에 바꿀지도 모르니」 모든 함수를 표에 넣으면, 위의 「치르는 것」을 모든 호출에서 치른다. 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"

그리고 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 지켜야 할 규칙#

크기 필드는 마법이 아니다. 다음 규칙을 지킬 때만 동작한다.

  1. 멤버는 끝에만 붙인다. 가운데에 끼우면 옛 멤버의 오프셋이 바뀐다. 예제의 static_assert 가 그것을 지킨다.
  2. 옛 멤버의 뜻을 바꾸지 않는다. 같은 자리, 같은 타입이어도 단위가 바뀌면 옛 프로그램은 틀린다.
  3. 받는 쪽은 아는 만큼만 읽고, 모르는 끝은 기본값으로 채운다.
  4. 크기의 목록을 정해 둔다. 아무 값이나 받지 말고, 아는 판의 크기만 받는다.
  5. 그리고 이 방식은 부르는 프로그램과 라이브러리가 같은 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장은 표를 거치는 호출 하나가 정말 얼마인지를 이 기계에서 잰다.

주

  1. Microsoft Learn, 「NONCLIENTMETRICSW structure (winuser.h)」, Remarks. ↩