Proven C Book←↑→

74 C 로 지은 객체들 — 사례와 논문

먼저 알아야 할 것

72장 동적 가상 테이블 · 실행 때 짓는 표, 셀렉터와 캐시
73장 간접 호출의 비용 · 측정, 인라인 캐시, 이진 분기
71장 표를 짓는 법 · 기반 표를 품은 파생 표, 크기 필드
68장 함수를 값으로 · GObject 의 뼈대

돌아보기

앞의 네 장에서 함수 포인터 표를 짓고, 상속을 잇고, 실행 때 표를 찾고, 그 비용을 쟀다. 이것들은 이 책이 처음 생각한 것인가?

답. 아니다. 거의 모두 수십 년 전에 누군가 C 로, 또는 C 위에서 지었다. Objective-C 는 C 에 메시지를 붙인 전처리기에서 시작했고, X 윈도의 위젯은 1980년대에 이미 부모를 먼저 초기화하는 클래스 레코드를 썼고, SQLite 는 판 번호를 단 표로 이십 년 가까이 버텼다. 그리고 그 비용을 잰 논문들이 있다. 이 장은 그것들을 둘러본다 — 쓰는 법이 아니라 무엇을 생각했는가를.

이 장의 필요성과 맥락

남이 지은 것을 알면 두 가지를 얻는다. 하나는 이름이다 — 「이것은 인라인 캐시다」, 「이것은 판 번호를 단 표다」라고 이름을 댈 수 있으면 남의 코드를 빨리 읽고, 코드 검토에서 긴 설명이 필요 없다. 다른 하나는 실패의 기록이다 — 큰 체계가 어디서 대가를 치렀는지 알면 같은 실수를 미리 피한다.

이 장이 끝나면

사례마다 셋을 적는다: 무엇을 지었는가, 주목할 생각 하나, 이 부의 어느 절과 이어지는가. C 위에 언어를 얹은 사람들(Cfront, Cox 의 전처리기, Schreiner, Vala)에서 시작해 Objective-C 를 조금 자세히 본다. 그다음 실제 시스템에 들어간 표 — Xt, GObject, CPython, 커널, COM, SQLite, OpenSSL — 를 보고, 다른 생각을 시험한 라이브러리와, 디스패치의 비용을 잰 논문을 본다. 끝에 주목할 생각들을 한 표에 모은다. 남의 코드는 옮겨 싣지 않는다 — 출처를 밝히고 풀어 쓴다.

이 장에서 답할 질문

  1. C 를 내는 번역기와 C 로 짓는 흉내 가운데 무엇이 나은가?

74.1 C 위에 언어를 얹은 사람들#

C 로 객체를 흉내 내다 보면 누구나 한 번은 「이 반복을 기계가 대신 써 주면」 하고 생각한다. 그 생각을 끝까지 밀고 간 사람들이 있다. 결과는 둘로 갈렸다 — C 를 내는 번역기에 머문 것과, 끝내 다른 언어가 된 것.

문. C 를 내는 번역기와 C 로 짓는 흉내 가운데 무엇이 나은가?

답. 번역기는 반복을 없애고, 사람이 틀릴 자리를 컴파일러의 검사로 바꾼다. 대신 도구가 하나 늘고, 오류 메시지와 디버거가 번역된 C 를 가리키며, 팀 전원이 그 언어를 배워야 한다. 75장의 「매크로로 클래스 선언 언어를 만들지 말라」는 경고는 매크로로 반쯤 만든 번역기를 겨냥한 것이다. 끝까지 만들 생각이면 Cfront 나 Vala 처럼 진짜 번역기를 만들고, 그럴 생각이 없으면 C 로 읽히는 흉내에 머문다.

74.2 Objective-C 를 조금#

Objective-C 는 Cox 와 Tom Love 가 세운 회사(Productivity Products International, 뒤의 Stepstone)에서 다듬어졌다. 1988년 NeXT 가 그 사용권을 얻어 NEXTSTEP 의 언어로 삼았고, NeXT 를 거쳐 Apple 의 macOS 와 iOS 에서 수십 년 동안 쓰였다. 카테고리와 프로토콜 같은 기능은 NeXT 시절에 더해졌다.4

이 부의 눈으로 보면 Objective-C 는 C 에 한 가지를 더한 언어다. C 의 코드는 그대로 받고, 그 위에 Smalltalk 의 메시지 보내기를 얹었다.

@interface Shape
- (double)area;                 /* 메서드 선언 */
@end
@interface Circle : Shape       /* 상속 */
@end
@interface Circle (Describing)  /* 카테고리: 이미 있는 클래스에 메서드를 더한다 */
- (void)describe;
@end

double total(Shape *s) { return [s area]; }   /* 메시지 보내기 */

마지막 줄의 [s area] 가 이 언어의 전부라고 해도 지나치지 않다. 컴파일러는 이 식을 런타임 함수 호출로 바꾼다. Apple 의 런타임에서는 objc_msgSend(s, @selector(area)) 다 — 수신자와 셀렉터를 주면, 그 함수가 메서드를 찾아 그리로 뛴다. 찾는 길은 72장의 lookup.c 그대로다. 객체의 머리에 있는 isa 로 클래스를 얻고, 그 클래스의 (셀렉터 → 함수) 캐시를 먼저 보고, 없으면 메서드 목록을 훑고 부모로 올라가 찾은 것을 캐시에 넣고, 끝내 없으면 메시지 포워딩으로 넘긴다.5

같은 문법이 런타임에 따라 다른 C 호출이 된다. 이 기계(x86-64 리눅스)의 Clang 19.1 은 기본으로 GCC 와 같은 GNU 런타임을 겨냥하고, 위의 total 을 objc_msg_lookup(s, sel) 로 함수를 얻은 뒤 그 함수로 뛰는 두 단계로 냈다. -fobjc-runtime=gnustep-2.0 이나 -fobjc-runtime=macosx-10.15 를 주면 objc_msgSend 하나로 냈다. 찾기와 부르기를 한 함수에 묶느냐 가르느냐의 차이다 — 어느 쪽이든 메시지는 C 의 함수 호출 하나다.

이 언어가 C 에 남긴 것은 셋이다.

74.3 1980년대의 클래스 레코드 — X Toolkit Intrinsics#

X 윈도의 X Toolkit Intrinsics(Xt)는 위젯을 C 로 지은 객체 체계다. 1980년대 끝에 정해진 이 체계는 이 부의 거의 모든 부품을 이미 갖추고 있었다.6

class-records

그림 74.1 — Xt 의 클래스 레코드. 파생 레코드는 부모 레코드와 같은 모양을 맨 앞에 둔다.

부모를 먼저, 그다음 자기 — 72장의 다섯 단계 가운데 「채우기」의 순서를, 이 체계는 1980년대에 표준 문서로 적어 두었다.

74.4 GObject — 가장 큰 모범 사례#

GLib 의 GObject 는 C 로 지은 객체 체계 가운데 가장 크고 오래 쓰인 것이다. GTK 와 GNOME 의 수많은 프로그램이 그 위에 서 있고, Python·JavaScript·Rust 같은 다른 언어에서도 같은 객체를 부른다. 68장에서 뼈대를 보았으니, 여기서는 무엇을 잘했는가와 표를 어떤 순서로 짓는가를 본다.7

구조GObject 의 방식
상태와 표의 분리모든 인스턴스 구조체는 첫 멤버로 GTypeInstance 를 두고, 그 첫 바이트들이 클래스 구조체를 가리킨다. 클래스 구조체(우리의 표)는 종류마다 한 벌
표의 상속클래스 구조체는 그 종류의 첫 인스턴스가 만들어질 때 한 번 만들어지고, 부모 클래스 구조체를 복사해서 시작한다. 파생은 바꿀 칸만 덮어쓴다
상태의 상속파생 인스턴스의 첫 멤버가 부모 인스턴스다
생성g_object_new 하나가 모든 종류를 만든다. 참조 계수는 1 로 시작
수명g_object_ref·g_object_unref. 계수가 0 이 되면 dispose 다음 finalize
형 변환VIEWER_FILE(obj) 같은 검사하는 캐스트 매크로(틀리면 경고)와 VIEWER_IS_FILE(obj) 같은 종류 확인 매크로
상용구G_DECLARE_FINAL_TYPE 이 캐스트 매크로와 종류 확인 매크로를 선언하고, G_DEFINE_TYPE 이 …_get_type 함수와 부모 클래스 포인터를 만든다

표 74.1 — GObject 의 구조

잘한 점은 이 부의 원칙과 그대로 겹친다.

치르는 것도 분명하다. 종류 하나에 상용구가 수십 줄이고, 실행 때 타입 체계의 비용이 있고, 배우는 데 시간이 든다. 그리고 GObject 는 큰 객체 체계가 정말로 필요한 자리 — 수백 가지 위젯, 여러 언어의 바인딩, 수십 년의 호환 — 를 위한 것이다. 작은 라이브러리에 GObject 의 모양을 그대로 옮기면 그 비용만 옮겨 온다. 배울 것은 원칙이다.

74.4.1 표를 짓는 순서#

GObject 의 클래스 구조체는 72장의 동적 표다. 그 종류가 처음 쓰일 때 타입 체계가 짓는다. 순서는 이렇다.8

  1. 부모의 클래스 구조체를 먼저 짓는다(그것도 처음이라면).
  2. 새 클래스 구조체의 앞부분에 부모의 클래스 구조체를 복사한다. 덮어쓰지 않은 칸은 그래서 부모의 것이다.
  3. 조상들이 등록해 둔 base_init 을 맨 위 조상부터 차례로 부른다.
  4. 이 종류의 class_init 을 부른다. 파생은 여기서 바꿀 칸만 덮어쓰고, 새 칸을 채우고, 속성과 신호를 등록한다.

72장의 build_table.c 가 한 일 — 부모를 먼저 짓고, 복사하고, 자기 칸을 덮는다 — 과 같은 순서다. 다른 점은 검증과 봉인이다. 타입 체계는 빈칸을 검사하지 않고 — 칸을 채울지는 저마다의 class_init 이 정한다 — 다 지은 클래스 구조체를 읽기 전용으로 바꾸지도 않는다.

74.5 파이썬의 타입 객체 — CPython#

CPython 의 모든 객체는 머리에 참조 계수와 타입 포인터를 둔다(PyObject 의 ob_refcnt 와 ob_type). 그 타입 포인터가 가리키는 PyTypeObject 는 tp_ 로 시작하는 칸이 수십 개인 표다 — 해시, 비교, 문자열로 바꾸기, 속성 찾기, 호출. C 로 새 타입을 지을 때는 바꿀 칸만 채우고 나머지는 NULL 로 둔 채 PyType_Ready 를 부른다. 그러면 그 함수가 부모 타입(tp_base)에서 빈칸을 물려받아 채운다.9

PyType_Ready 의 이 채우기는 70장의 「표의 빈칸」에 대한 또 하나의 답이다. NULL 칸을 부르는 쪽마다 확인하게 하지 않고, 한 번 짓는 자리에서 부모의 것으로 채운다 — 실행 때 하는 기본값 묶음이다.

74.6 커널의 표 — 리눅스와 FreeBSD#

리눅스 커널은 C 로 지은 가장 큰 객체 체계 가운데 하나다. 파일 시스템, 장치, 네트워크 규약마다 함수 포인터 표가 있다(file_operations, inode_operations, net_device_ops). Neil Brown 은 2011년 LWN 의 두 편의 글에서 그 패턴을 정리했다 — 함수 포인터만 든 표를 기본으로 하고, 표 포인터를 객체에 두고, 기반 구조체를 품은 객체에서 container_of 로 바깥을 되찾는다.10 70장의 「순수한 표」와 「표의 빈칸」도 그 글에 기댔다.

FreeBSD 의 kobj(9) 는 다른 길을 갔다. 클래스는 (메서드 이름표, 함수) 쌍의 목록을 내놓는다. 그 클래스가 처음 쓰일 때 커널이 그 목록을 빨리 찾을 수 있는 꼴로 바꿔 둔다 — 매뉴얼은 이것을 클래스를 「컴파일한다」고 부른다. 매뉴얼은 그 뒤의 디스패치가 「직접 함수 호출보다 조금만 비싸다」고 적는다 — 그들의 말이고, 이 책이 잰 것은 아니다.11 메서드를 이름표로 찾되 그 준비를 처음 한 번에 몰아 하는 길이다 — 셀렉터 찾기와 정적 표 사이의 자리다.

74.7 C 에서 쓰는 COM#

Microsoft 의 COM 은 언어와 무관한 객체 규약이다. 객체에 닿는 길은 인터페이스뿐이고, 인터페이스는 함수 포인터 표다. C 에서 보면 모든 COM 객체는 첫 멤버가 표 포인터(lpVtbl)인 구조체이고, 모든 표의 첫 세 칸은 늘 QueryInterface · AddRef · Release 다.12

/* C 에서 COM 객체를 부르는 모양 (Windows 헤더의 C 판) */
hr = obj->lpVtbl->QueryInterface(obj, &IID_IStream, (void **)&stream);
stream->lpVtbl->Release(stream);

이 규약에서 주목할 것은 둘이다.

74.8 판을 견디는 표 — SQLite 와 OpenSSL#

SQLite 는 운영체제와 만나는 모든 일을 VFS 라는 표(sqlite3_vfs)로 돌린다. 표의 첫 칸은 판 번호 iVersion 이고, 문서는 「새 칸은 끝에 붙을 수 있고, 그때마다 iVersion 이 오른다」고 적는다. iVersion 은 SQLite 3.5.0 에서 1, 3.7.0 에서 2, 3.7.6 에서 3 이 되었다. SQLite 는 판 2 에서 더한 xCurrentTimeInt64 를 「iVersion 이 2 이상이고 그 칸이 NULL 이 아닐 때만」 부른다 — 아는 만큼만 읽고 나머지는 옛 길로 간다.13

부르는 쪽을 줄여 쓰면 이런 모양이다 — SQLite 의 코드가 아니라, 같은 규칙을 이 책이 다시 쓴 것이다.

struct vfs {
    int version;                                           /* 칸을 붙이면 오른다 */
    int (*current_time)(struct vfs *, double *);           /* 판 1: 날 수 */
    int (*current_time_ms)(struct vfs *, long long *);     /* 판 2 */
};

long long now_ms(struct vfs *v)
{
    long long ms;
    if (v->version >= 2 && v->current_time_ms && v->current_time_ms(v, &ms) == 0)
        return ms;                             /* 새 칸을 아는 판 */
    double days = 0;
    v->current_time(v, &days);                 /* 옛 판의 길 */
    return (long long)(days * 86400000.0);
}

판 1 의 표에는 current_time_ms 칸이 아예 없다. 그래서 판 번호를 먼저 보고, 그 칸이 있는 판일 때만 칸을 읽는다.

같은 문서에는 규칙을 어긴 기록도 있다. 문서는 「실수로」 3.6.0 에서 이 구조체가 바뀌었는데 iVersion 은 오르지 않았다고 적는다. 판 번호만 보고는 3.6.0 앞뒤의 구조체 모양을 가릴 수 없다는 뜻이다. 크기나 판 번호를 적어 두라는 71장의 규칙이 왜 규칙이어야 하는지 — 한 번 어기면 그 번호를 다시는 온전히 믿을 수 없다 — 를 실제 프로그램이 보여 준다.

OpenSSL 3 은 판 문제를 다르게 풀었다. 공급자는 함수를 OSSL_DISPATCH — (함수 번호, 함수 포인터) 쌍 — 의 배열로 내놓는다. 받는 쪽은 모르는 번호를 건너뛴다. 그래서 한 판의 OpenSSL 에 맞춰 지은 공급자가 다른 판과도 맞물린다. 칸의 자리가 없으므로 「끝에만 붙인다」는 규칙이 필요 없다. 비용은 찾기다 — 쓰기 전에 배열을 훑어 번호를 맞춘다. 배열의 끝 표시는 3.0 의 문서에서는 번호 0 과 NULL 의 쌍이었고, 3.3 의 문서부터는 OSSL_DISPATCH_END 매크로로 적는다.14

74.9 다른 생각들 — 라이브러리와 실험#

이 부는 「표 하나, 객체 하나」의 모양을 따랐다. 그 밖으로 나간 시도들이 있다. 쓸 만한가보다 무엇을 시험했는가로 본다.

줄은 a, 열은 b원사각형
원hit_circle_circlehit_circle_rect
사각형hit_rect_circlehit_rect_rect

표 74.2 — 다중 메서드 — 두 인자의 종류로 함수를 고른다

객체마다 표 하나를 두는 디스패치라면 hit(a, b) 는 a 의 표만 보고 고르고, b 의 종류는 그 함수 안에서 다시 switch 로 가려야 한다. 다중 메서드는 두 종류를 한 번에 보고 칸을 찾는다.

74.10 디스패치를 잰 논문#

73장의 측정은 이 기계 하나의 것이다. 같은 물음을 먼저, 더 넓게 물은 논문들이 있다. 그들의 수는 그들의 기계와 그해의 것이라 옮기지 않고, 무엇을 물었는지만 적는다.

74.11 사례에서 주목할 생각 — 한 표로#

생각처음 보인 곳(이 장)이 부의 자리
표를 객체와 가르고 const 로커널의 표, Xt 의 클래스 레코드70장의 세 덩어리
빈칸을 짓는 자리에서 채우기CPython 의 PyType_Ready70장의 표의 빈칸
기반 표를 품은 파생 표Xt 의 부분 레코드, GObject 의 클래스 구조체71장의 상속
부모 먼저, 그다음 자기Xt 의 class_part_initialize, GObject 의 class_init72장의 다섯 단계
표와 수명을 한 계약에COM 의 IUnknown71장의 수명
판 번호를 단 표SQLite 의 iVersion (그리고 그것을 어긴 3.6.0)71장의 크기 · 판 번호 필드
번호로 맞추는 칸OpenSSL 3 의 OSSL_DISPATCH72장의 판이 바뀌는 표
인터페이스를 따로 묻기COM 의 QueryInterface72장의 여러 인터페이스
이름으로 찾고 캐시Objective-C 의 objc_msgSend72장의 셀렉터
처음 한 번 표로 굳히기FreeBSD kobj 의 클래스 컴파일72장의 itab
호출 자리의 캐시Deutsch · Schiffman, Hölzle 외73장의 인라인 캐시
표 없는 디스패치Zendra 외의 SmallEiffel73장의 이진 분기
여러 인자로 고르기COS 의 다중 메서드— (이 부 밖의 생각)
표도 객체Piumarta · Warth— (이 부 밖의 생각)

표 74.3 — 이 부의 생각들과 그 처음

74.12 정리#

주제요점
C 위의 언어Cfront 는 C++ 를 C 로, Cox 의 전처리기는 Smalltalk 의 메시지를 C 로(뒤의 Objective-C), Vala 는 C# 를 닮은 문법을 GObject C 로. 반복을 기계가 쓰는 길 — 끝까지 만들거나 만들지 않거나
Objective-CC 에 메시지 보내기 하나를 더했다. [s area] 는 런타임의 함수 호출(objc_msgSend 또는 objc_msg_lookup), 찾기의 비용은 클래스별 캐시가 줄인다
Xt1980년대의 클래스 레코드: superclass, 부분 레코드, 한 번 초기화와 사슬 초기화
GObject규약을 매크로와 실행 때 검사가 지킨다. 클래스 구조체는 부모를 복사하고 덮는 동적 표
CPythonNULL 칸을 PyType_Ready 가 부모의 것으로 채운다
커널리눅스는 순수한 표와 container_of, FreeBSD kobj 는 메서드 목록을 처음 쓸 때 찾기 좋은 꼴로 바꾼다
COM모든 표의 첫 칸들이 수명(참조 계수)을 다룬다. 인터페이스는 바꾸지 않고 하나 더 만든다
SQLite · OpenSSL판 번호를 단 표와 그것을 어긴 기록, 번호로 맞추는 칸
실험과 논문다중 메서드, 표도 객체. 인라인 캐시, PIC, 가상 호출의 직접 비용, 표 없는 디스패치 — 수는 그들의 기계의 것

표 74.4 — C 로 지은 객체들 — 한 줄씩

남들이 지은 것을 둘러보았으니, 남은 물음은 하나다 — 언제 멈추는가. 마지막 장은 매크로와 원칙과 객체 지향 자체의 한계를 재고, 이 부를 처음 생각으로 돌려 닫는다.

주

  1. Computer History Museum, 「C++ Historical Sources Archive」(softwarepreservation.computerhistory.org/c_plus_plus). ↩
  2. B. J. Cox, 「The Object Oriented Pre-Compiler: Programming Smalltalk 80 Methods in C Language」, SIGPLAN Notices 18(1):15–22, 1983. ↩
  3. GNOME Wiki, 「Projects/Vala/About」. ↩
  4. B. J. Cox, S. Naroff, H. Hsu, 「The Origins of Objective-C at PPI/Stepstone and Its Evolution at NeXT」, HOPL IV, Proc. ACM Program. Lang. 4(HOPL), 2020. ↩
  5. Apple, 「Objective-C Runtime Programming Guide」 가운데 「Messaging」. 캐시를 보는 기계어는 M. Ash, 「Friday Q&A 2017-06-30: Dissecting objc_msgSend on ARM64」. ↩
  6. X Consortium, 「X Toolkit Intrinsics — C Language Interface」(X11R7.7 판)의 「Widget Classes」 장. ↩
  7. GObject 문서 「Type system concepts」(docs.gtk.org/gobject/concepts.html). ↩
  8. GObject 문서 「Type system concepts」 가운데 「Instantiatable classed types: objects」. ↩
  9. Python 문서, 「Type Object Structures」(c-api/typeobj). 칸마다 물려받는 규칙이 표로 있다 — 판에 따라 다르다. ↩
  10. N. Brown, 「Object-oriented design patterns in the kernel」, part 1 · 2, LWN.net, 2011-06. ↩
  11. FreeBSD, kobj(9). 장치 드라이버 틀 newbus 가 그 위에 서 있다. ↩
  12. Microsoft Learn, 「COM Objects and Interfaces」, 「The IUnknown Interface」. ↩
  13. SQLite, 「OS Interface Object」(c3ref/vfs.html). ↩
  14. OpenSSL 문서, OSSL_DISPATCH(3), 3.0 판과 3.3 판. ↩
  15. L. Deniau, 「The C Object System: Using C as a High-Level Object-Oriented Language」, arXiv:1003.2547, 2010. ↩
  16. I. Piumarta, A. Warth, 「Open, Extensible Object Models」, Self-Sustaining Systems(S3 2008), 2008. 줄 수는 VPRI 가 낸 판(objmodel2.pdf)의 본문에서 확인했다. ↩
  17. D. Holden, Cello 의 소개(libcello.org)와 저장소의 README. ↩
  18. D. Holden, 「A Fat Pointer Library」(libcello.org/learn). ↩
  19. K. Driesen, U. Hölzle, OOPSLA 1996, 306–323. ↩
  20. O. Zendra, D. Colnet, S. Collin, OOPSLA 1997, 125–141. ↩