75 한계와 쓰지 않을 때 — 매크로, SOLID, 그리고 처음으로
먼저 알아야 할 것
돌아보기
73장에서 종류가 섞인 객체를 표로 부르면 이 기계에서 직접 호출의 네다섯 배가 걸렸다. 그러면 객체 지향은 비싸니 쓰지 말라는 말인가?
답. 아니다. 그 비용이 드러나는 것은 같은 일을 아주 많은 객체에 하는, 자주 도는 반복문이다. 물을 것은 「객체 지향이 좋은가」가 아니라 「이 자리에 그 도구가 맞는가」다. 이 장은 그 판단의 기준을 세운다 — 매크로를 어디까지 쓸지, 원칙을 어디까지 따를지, 객체 지향 자체가 어디서 힘을 잃는지.
이 장의 필요성과 맥락
이 장이 끝나면
이 장에서 답할 질문
- 그렇다면 객체 지향은 느리니 쓰지 말라는 말인가?
75.1 매크로 — 쓸 만한 것, 대가, 피할 것#
C 의 객체 체계에는 매크로가 따라온다. 손으로 쓰기에 반복적인 것을 줄이고, 타입 검사를 되살리는 데 쓸모가 있다. 그리고 같은 이유로 남용된다.
examples/oop/macros.c
/* 객체 체계를 돕는 매크로와, 해치는 매크로 하나.
(1) 타입이 틀린 포인터를 거절하는 container_of
(2) GObject 의 GTK_WIDGET(x) 같은 결의 검사하는 하향 캐스트
(3) 인자를 두 번 평가하는 「표를 거쳐 부르기」 매크로 --- 그리고 그 고침 */
#include <stddef.h>
#include <stdio.h>
/* (1) sizeof 안의 비교는 평가되지 않지만 타입 검사는 받는다:
다른 타입의 포인터를 비교하는 것은 제약 위반이라 컴파일러가 진단해야 하고,
-Werror 를 켜면 틀린 `ptr` 이 빌드를 멈춘다. */
#define container_of(ptr, type, member) \
((void)sizeof((ptr) == &((type *)nullptr)->member), \
(type *)((char *)(ptr) - offsetof(type, member)))
enum kind { KIND_CIRCLE = 0xC1, KIND_RECT = 0x4E };
struct shape;
struct shape_ops { enum kind kind; double (*area)(const struct shape *); };
struct shape { const struct shape_ops *ops; };
/* base 가 첫 멤버가 아니어도 container_of 는 된다 */
struct circle { double r; struct shape base; };
static double circle_area(const struct shape *s)
{
const struct circle *c = container_of(s, const struct circle, base);
return 3.0 * c->r * c->r;
}
static const struct shape_ops circle_ops = { KIND_CIRCLE, circle_area };
static const struct shape_ops rect_ops = { KIND_RECT, nullptr };
/* (2) 캐스트를 믿기 전에 종류를 본다. 틀린 캐스트는 쓰레기가 아니라 nullptr 가 된다.
(`s` 를 세 번 적으므로 이것에도 평범한 변수만 넘긴다 --- s++ 는 안 된다.) */
#define AS_CIRCLE(s) ((s) && (s)->ops->kind == KIND_CIRCLE \
? container_of((s), struct circle, base) : (struct circle *)nullptr)
/* (3) 무해해 보이지만… */
#define SHAPE_AREA(s) ((s)->ops->area(s))
/* …함수로 쓰는 편이 안전하다: 인자를 정확히 한 번 평가하고, 타입도 검사한다 */
static inline double shape_area(const struct shape *s) { return s->ops->area(s); }
static int evaluations;
static struct shape *next_shape(struct shape *s) { evaluations++; return s; }
int main(void)
{
struct circle c = { .r = 2.0, .base.ops = &circle_ops };
struct shape *s = &c.base;
struct shape rect = { &rect_ops }, *other = ▭
printf("container_of: back to the circle, r = %g\n",
container_of(s, struct circle, base)->r);
/* container_of(&c.r, struct circle, base) 는 안 된다: &c.r 은 double * 다. */
struct circle *ok = AS_CIRCLE(s), *no = AS_CIRCLE(other);
printf("AS_CIRCLE(circle) : %s\n", ok ? "a circle" : "nullptr");
printf("AS_CIRCLE(rectangle) : %s\n", no ? "a circle" : "nullptr");
evaluations = 0;
double a = SHAPE_AREA(next_shape(s));
printf("SHAPE_AREA(next_shape(s)) = %g, next_shape ran %d times\n", a, evaluations);
evaluations = 0;
a = shape_area(next_shape(s));
printf("shape_area(next_shape(s)) = %g, next_shape ran %d time\n", a, evaluations);
return 0;
}
실행 결과
container_of: back to the circle, r = 2
AS_CIRCLE(circle) : a circle
AS_CIRCLE(rectangle) : nullptr
SHAPE_AREA(next_shape(s)) = 12, next_shape ran 2 times
shape_area(next_shape(s)) = 12, next_shape ran 1 time
75.1.1 쓸 만한 매크로#
| 매크로 | 하는 일 | 예 |
|---|---|---|
타입을 검사하는 container_of | 멤버 포인터에서 바깥 구조체를 되찾되, 포인터의 타입이 틀리면 컴파일러가 알린다 | 위 예제의 (1), 리눅스 커널 |
| 검사하는 형 변환 | 하향 캐스트 전에 종류를 확인한다 — 예제는 틀리면 nullptr, GObject 는 경고 | 위 예제의 (2), GTK_WIDGET(x) |
| 상용구 생성 | 종류 하나를 정의하는 반복 코드를 만든다 | G_DEFINE_TYPE |
| 기본값 묶음 | 표의 기본 칸을 지정 초기자로 묶는다 | 커널의 …_DEFAULTS |
| X 매크로로 메서드 목록 | 목록 하나에서 표의 칸·이름 문자열·검사 코드를 만든다 | 65장 |
_Generic 갈래 | 타입에 따라 함수를 고른다 | <tgmath.h> |
표 75.1 — 객체 체계를 돕는 매크로
예제의 container_of 는 sizeof 안의 비교로 타입을 검사한다. sizeof 의 피연산자는 평가되지 않으므로 실행 비용은 없지만, 타입 검사는 받는다 — 서로 다른 타입의 포인터를 비교하는 것은 제약 위반이라 컴파일러는 진단을 내야 하고, -Werror 를 켜면 빌드가 멈춘다.
75.1.2 매크로의 대가#
- 오류 메시지가 펼친 뒤의 코드를 가리킨다. 매크로 세 겹 안에서 난 타입 오류는 사람이 쓴 적 없는 식을 보여 준다.
- 디버거가 매크로를 모른다. 한 줄짜리 매크로 호출 안으로 걸어 들어갈 수 없다.
- 인자를 여러 번 평가한다. 예제의 (3)이 그것이다.
SHAPE_AREA(next_shape(s))는next_shape를 두 번 부른다 — 부수효과가 있는 인자라면 결과가 틀린다. 같은 일을 하는static inline함수는 인자를 한 번만 평가하고, 타입을 검사하고, 디버거에서 보인다. - 제어 흐름을 숨긴다. 안에
return·goto가 든 매크로는 호출하는 자리에서 보이지 않는 출구를 만든다.
75.1.3 피할 매크로#
반례. 매크로로 「클래스 선언 언어」를 만들기
CLASS(Circle, EXTENDS(Shape),
FIELD(double, r)
METHOD(double, area)
OVERRIDE(Shape, draw))이런 매크로는 처음 쓸 때는 우아해 보인다. 그러나 그 결과는 C 가 아니라 그 매크로만의 작은 언어다. 새로 온 사람은 C 를 알아도 이 코드를 읽지 못하고, 편집기와 정적 분석 도구와 디버거는 멤버와 함수를 찾지 못하고, 오류 메시지는 펼친 뒤의 수백 줄을 가리킨다. 토큰 붙이기(##)로 만든 이름은 검색해도 나오지 않는다 — Circle_vtbl_area 라는 이름이 소스 어디에도 적혀 있지 않기 때문이다. 객체 지향 언어가 필요하면 객체 지향 언어를 쓴다. C 로 쓰기로 했다면 C 로 읽히게 쓴다.
매크로를 쓰는 규칙은 셋이다.
- 매크로 하나는 기계적인 일 하나. 「멤버에서 바깥을 되찾기」, 「상용구 한 벌 만들기」.
- 펼친 결과를 사람이 손으로 쓸 수 있어야 한다. 펼친 결과가 무엇인지 설명할 수 없는 매크로는 쓰지 않는다.
- 타입 검사를 지우지 않는다.
(T *)캐스트를 숨기는 매크로는 틀린 타입도 조용히 받아들인다. 매크로는 검사를 더하는 쪽에 쓴다.
함수로 할 수 있는 일은 함수로 한다. static inline 함수는 C99 부터 있고, 대부분의 「작고 빠른 매크로」를 대신한다.
75.2 설계 원칙 — SOLID 를 C 로 읽기#
객체 지향 설계를 이야기할 때 가장 자주 불려 나오는 이름은 SOLID 다. Robert C. Martin 이 1990년대 글과 2000년의 「Design Principles and Design Patterns」에서 정리한 설계 원칙 다섯의 머리글자이고, SOLID 라는 묶음 이름은 2004년 무렵 Michael Feathers 가 붙였다. 다섯 가운데 둘은 더 오래되었다 — 열림·닫힘 원칙은 Bertrand Meyer 의 1988년 책 『Object-Oriented Software Construction』에서, 치환 원칙은 71장에서 본 Liskov(1987)에서 왔다.
| 원칙 | 뜻 | C 에서의 모양(이 부) |
|---|---|---|
| S — 단일 책임 원칙(single responsibility principle) | 한 모듈이 바뀌는 까닭은 하나여야 한다 | 접두어 하나 · 헤더 하나 · 일 하나. 한 헤더에 까닭이 다른 일이 섞이면 가른다 |
| O — 열림·닫힘 원칙(open–closed principle) | 확장에는 열리고 수정에는 닫힌다 | 새 종류는 새 static const 표를 더해 만들고, 부르는 쪽 코드는 그대로 둔다 |
| L — 리스코프 치환 원칙(Liskov substitution principle) | 파생은 기반의 약속을 깨지 않는다 | 표를 채우는 쪽이 기반의 사전·사후 조건을 지킨다. 컴파일러는 검사하지 않는다 |
| I — 인터페이스 분리 원칙(interface segregation principle) | 쓰지 않는 연산에 기대게 하지 않는다 | 커다란 표 하나 대신 작은 표 여럿. 표의 빈칸이 늘어나면 가를 때라는 신호다 |
| D — 의존 역전 원칙(dependency inversion principle) | 위 수준은 아래의 구현이 아니라 추상에 기댄다 | 불투명 타입과 함수 포인터 표를 매개변수로 받는다 — 할당자를 매개변수로 넘기는 104장가 그 예다 |
표 75.2 — SOLID 의 다섯 원칙과 그것이 C 에서 띠는 모양
표의 오른쪽 칸이 보여 주듯, 다섯 원칙은 이 부가 이미 다룬 기법에 그대로 얹힌다. C 에 새 문법이 필요한 원칙은 하나도 없다. 쓸모도 분명하다 — 이름이 붙은 원칙은 코드 검토의 말을 짧게 해 준다. 「이 표는 인터페이스 분리를 어긴다」는 한 문장이 긴 설명을 대신한다.
다만 SOLID 는 원칙이지 법칙이 아니다. 다섯 가운데 넷(S 를 뺀 나머지)은 한 단계를 더 거치게 만드는 쪽으로 기운다 — 표 하나, 포인터 하나, 추상 하나. 그 한 단계의 비용이 바로 다음 두 절의 주제(기억과 캐시, 흐름)다. 바뀔 일이 없는 곳에 열림·닫힘과 의존 역전을 미리 세우면 읽어야 할 곳만 는다. 두 번째 구현이 실제로 생길 때 추상을 세워도 늦지 않다. 그리고 C 에서는 이 원칙을 지켜 주는 것이 컴파일러가 아니라 문서와 검토라는 점을 다시 기억한다 — 「잘하면 된다」를 경계한 70장와 같은 이야기다.
75.3 객체 지향의 한계 — 기억과 캐시#
지금까지는 C 로 흉내를 잘 내는 법이었다. 이제 흉내의 대상, 객체 지향 자체의 한계를 본다. C 로 흉내 내든 C++ 로 쓰든 같이 오는 것들이다.
객체 지향의 기본 모양은 「객체 하나 = 따로 할당한 구조체 하나, 그것을 포인터로 가리킨다」이다. 그 모양이 기억과 캐시에 남기는 것을 51장의 눈으로 세면 이렇다.
- 흩어진 힙. 객체마다
malloc하면 객체는 힙의 이곳저곳에 놓인다. 객체의 목록을 훑으려면 포인터를 하나씩 따라가며 기억의 이곳저곳을 읽어야 한다(포인터 쫓기). - 객체 머리. 표 포인터, 참조 계수, 종류 번호 — 객체마다 붙는 머리가 실제 자료보다 클 때가 있다. 점 하나(8바이트)를 객체로 만들면 머리가 그만큼 붙는다.
- 객체의 배열은 곧 AoS 다. 객체는 자기 상태를 한데 모은다. 많은 객체의 한 필드만 훑는 일에는 불리한 배치다.
- 간접 호출과 막힌 인라인. 71장의 「다형성」 절, 그리고 그 비용을 잰 73장.
같은 일을 세 모양으로 재어 본다 — 도형 백만 개의 넓이 합을 ① 객체와 표로 ② 태그 달린 구조체 배열과 switch 로 ③ 종류별 배열로.
examples/oop/dispatch/dispatch.c
/* 도형 백만 개의 넓이 합을 세 가지로 구한다.
(1) 객체: 도형마다 따로 할당하고, 메서드 표를 거쳐 부른다
(2) 태그 달린 구조체를 한 배열에, 태그로 switch
(3) 도형의 종류마다 배열 하나, 배열마다 평범한 반복문
★ 이 예제는 시간을 잰다. 배수만 인쇄한다. */
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#define N (1u << 20)
#define ROUNDS 5
/* (1) 객체의 길 */
struct shape;
struct shape_ops { double (*area)(const struct shape *); };
struct shape { const struct shape_ops *ops; };
struct circle { struct shape base; double r; };
struct rect { struct shape base; double w, h; };
static double circle_area(const struct shape *s)
{ const struct circle *c = (const struct circle *)s; return 3.14159 * c->r * c->r; }
static double rect_area(const struct shape *s)
{ const struct rect *r = (const struct rect *)s; return r->w * r->h; }
static const struct shape_ops circle_ops = { circle_area }, rect_ops = { rect_area };
/* (2) 태그의 길 */
enum kind { CIRCLE, RECT };
struct tagged { enum kind kind; double a, b; };
/* (3) 종류별 배열의 길 */
static double *radius, *width, *height;
static uint32_t n_circles, n_rects;
static struct shape **objs;
static struct tagged *tags;
static volatile double sink;
static double now(void) { return (double)clock() / CLOCKS_PER_SEC; }
static double by_table(void)
{
double t = now(), s = 0;
for (uint32_t i = 0; i < N; i++) s += objs[i]->ops->area(objs[i]);
sink = s; return now() - t;
}
static double by_switch(void)
{
double t = now(), s = 0;
for (uint32_t i = 0; i < N; i++)
switch (tags[i].kind) {
case CIRCLE: s += 3.14159 * tags[i].a * tags[i].a; break;
case RECT: s += tags[i].a * tags[i].b; break;
}
sink = s; return now() - t;
}
static double by_kind(void)
{
double t = now(), s = 0;
for (uint32_t i = 0; i < n_circles; i++) s += 3.14159 * radius[i] * radius[i];
for (uint32_t i = 0; i < n_rects; i++) s += width[i] * height[i];
sink = s; return now() - t;
}
static int cmp(const void *a, const void *b)
{ double x = *(const double *)a, y = *(const double *)b; return (x > y) - (x < y); }
static double median(double (*job)(void))
{
double r[ROUNDS];
job();
for (int i = 0; i < ROUNDS; i++) r[i] = job();
qsort(r, ROUNDS, sizeof *r, cmp);
return r[ROUNDS / 2];
}
int main(void)
{
objs = malloc(N * sizeof *objs); tags = malloc(N * sizeof *tags);
radius = malloc(N * sizeof *radius);
width = malloc(N * sizeof *width);
height = malloc(N * sizeof *height);
if (!objs || !tags || !radius || !width || !height)
{ puts("out of memory"); return 1; }
uint64_t rng = 0x9E3779B97F4A7C15u;
for (uint32_t i = 0; i < N; i++) {
rng ^= rng << 13; rng ^= rng >> 7; rng ^= rng << 17;
double x = (double)(i % 100) / 10.0;
if (rng >> 63) { /* 종류가 무작위 순서로 온다 */
struct circle *c = malloc(sizeof *c);
if (!c) { puts("out of memory"); return 1; }
*c = (struct circle){ { &circle_ops }, x };
objs[i] = &c->base;
tags[i] = (struct tagged){ CIRCLE, x, 0 };
radius[n_circles++] = x;
} else {
struct rect *r = malloc(sizeof *r);
if (!r) { puts("out of memory"); return 1; }
*r = (struct rect){ { &rect_ops }, x, 2 };
objs[i] = &r->base;
tags[i] = (struct tagged){ RECT, x, 2 };
width[n_rects] = x; height[n_rects++] = 2;
}
}
double t = median(by_table), s = median(by_switch), k = median(by_kind);
printf("%u shapes, circles and rectangles in random order\n", N);
if (k > 0) {
printf(" method table : %5.1f times as long as per-kind arrays\n", t / k);
printf(" tag + switch : %5.1f times as long as per-kind arrays\n", s / k);
} else puts(" the clock is too coarse on this machine to tell them apart");
for (uint32_t i = 0; i < N; i++) free(objs[i]);
free(objs); free(tags); free(radius); free(width); free(height);
return 0;
}
실행 결과
== -O0
1048576 shapes, circles and rectangles in random order
method table : 4.3 times as long as per-kind arrays
tag + switch : 3.1 times as long as per-kind arrays
== -O2
1048576 shapes, circles and rectangles in random order
method table : 5.9 times as long as per-kind arrays
tag + switch : 3.5 times as long as per-kind arrays
플랫폼 노트. 잰 기계와 조건
-O2 에서 표가 종류별 배열의 6배 안팎, switch 가 3.5배 안팎이었다. 다른 기계와 다른 자료에서는 다른 수가 나온다.결과를 정직하게 읽어야 한다. ①이 느린 까닭은 「가상 호출이 느려서」만이 아니다. 객체가 따로 할당되어 포인터를 따라가야 하고, 종류가 무작위로 섞여 있어 간접 분기의 예측이 자주 틀리고, 컴파일러가 반복문을 펼치거나, 여러 값을 한 명령으로 계산하게 바꾸지(벡터화) 못한다. ②도 같은 무작위 분기 때문에 느려진다. ③은 종류마다 같은 연산을 촘촘한 배열에 하므로 분기도 간접 호출도 없다. 원인은 여럿이고, 그 여럿이 모두 「객체 하나씩」이라는 모양에서 온다.
문. 그렇다면 객체 지향은 느리니 쓰지 말라는 말인가?
답. 아니다. 이 차이가 문제가 되는 것은 같은 일을 아주 많은 객체에 하는, 자주 도는 반복문이다 — 게임의 개체 갱신, 물리 계산, 대량의 자료 처리. 창 열 개, 설정 파일 하나, 연결 몇백 개를 다루는 코드에서 표 호출의 비용은 보이지도 않는다. 그런 자리에서는 객체 지향이 주는 것 — 바꾸기 쉬움, 새 종류를 더하기 쉬움 — 이 훨씬 크다. 문제는 모든 것을 같은 모양으로 만드는 것이다. 2023년에 Casey Muratori 가 「“Clean” Code, Horrible Performance」라는 글과 영상에서 비슷한 비교를 공개해 논쟁이 일었다. 이 책은 그 수치를 옮기지 않는다 — 위와 같이 직접 재었고, 결론도 「어디서」를 붙여야 성립한다.
75.4 객체 지향의 한계 — 프로그램의 흐름#
객체 지향의 다른 대가는 프로그램의 흐름을 읽기 어렵게 만드는 것이다.
- 요요 문제. 상속이 깊으면 메서드 하나가 무엇을 하는지 알려고 파생과 기반 사이를 오르내려야 한다 — 여기서는 파생의 것이, 저기서는 기반의 것이, 그 안의 호출에서는 다시 파생의 재정의가 불린다. 1989년 Taenzer·Ganti·Podar 가 이 현상에 이름을 붙였다.
- 깨지기 쉬운 기반 클래스. 기반의 내부를 고쳤을 뿐인데 파생이 깨진다. 파생이 기반의 구현에 기대고 있었기 때문이다(기반의 어느 메서드가 어느 메서드를 부르는지 같은 것). 상속은 구현을 물려주므로, 물려받은 쪽은 물려준 쪽의 내부와 묶인다.
- 흐름의 역전. 콜백과 재정의로 짠 체계에서는 「누가 언제 나를 부르는가」를 내 코드에서 알 수 없다. 프레임워크(틀)가 부른다. 디버거로 따라가야 흐름이 보인다.
- 숨은 상태. 객체는 상태를 감춘다. 그 덕에 불변식을 지키지만, 같은 메서드를 두 번 불러 다른 결과가 나오는 까닭이 바깥에서 보이지 않는다. 시험하기 어렵고, 여러 스레드가 함께 만지면 더 어렵다.
이 문제들의 상당수는 상속에서 온다. 그래서 1994년의 『디자인 패턴』(Gamma·Helm·Johnson·Vlissides)은 「클래스 상속보다 객체 합성을 택하라」를 원칙으로 적었다. 합성은 다른 객체를 멤버로 쥐고 그 공개된 행동만 쓴다 — 내부와 묶이지 않는다. C 의 흉내에서도 같다. 기반 구조체를 첫 멤버로 두는 「상속」보다, 필요한 부품을 멤버로 쥐고 그 함수를 부르는 「합성」이 대개 더 단순하다.
75.5 요즘의 흐름 — 객체 지향은 만능이 아니다#
1990년대에는 객체 지향이 프로그래밍의 정답으로 여겨졌다. 오늘의 언어들은 그것을 도구 가운데 하나로 다룬다.
| 언어 | 무엇을 택했는가 | 객체 지향과 다른 점 |
|---|---|---|
| Go | 인터페이스. 타입이 메서드를 갖추면 선언 없이 그 인터페이스를 만족한다 | 클래스 상속이 없다. 합성(구조체 끼워 넣기)을 쓴다 |
| Rust | 트레이트(trait). 제네릭으로 쓰면 컴파일 때 고르고(정적 디스패치), dyn 으로 쓰면 표를 거친다 | 상속이 없다. 정적·동적 디스패치를 사람이 고른다 |
| Rust, Swift | 합 타입(enum)과 match — 닫힌 종류를 태그로 가르고, 빠진 갈래를 컴파일러가 잡는다 | 종류를 표가 아니라 자료로 가른다 |
| Java 21 | sealed 타입과 switch 의 패턴 매칭(JEP 441). 봉인된 종류를 다 다뤘는지 컴파일러가 판단한다 | 객체 지향의 대표 언어인 Java 도 닫힌 종류의 길을 들였다 |
표 75.3 — 요즘의 언어들이 택한 길
75.5.1 표현 문제 — 무엇이 자주 바뀌는가#
객체와 표를 쓸지, 태그와 switch(합 타입)를 쓸지 — 둘 가운데 무엇을 고를지에는 이름이 붙은 기준이 있다. Philip Wadler 가 1998년에 「표현 문제」(expression problem)라 부른 것이다 — 자료를 경우별로 정의해 두고, 새 경우와 새 연산을 기존 코드를 다시 컴파일하지 않고, 타입 안전성을 지킨 채 더할 수 있는가.1
- 객체와 표(다형성)는 새 경우(새 종류의 도형)를 더하기 쉽다. 새 표 하나면 된다. 대신 새 연산(모든 도형에 「둘레」)을 더하려면 모든 종류를 고친다.
- 태그와
switch(합 타입)는 새 연산을 더하기 쉽다. 새switch함수 하나면 된다. 대신 새 경우를 더하려면 모든switch를 고친다.
그래서 물을 것은 「어느 쪽이 더 객체 지향적인가」가 아니라 「이 프로그램에서 무엇이 더 자주 더해지는가」다.
75.5.2 C 의 enum-match#
C 에도 합 타입의 흉내가 있다 — 태그 달린 공용체(49장)와 switch. 그리고 C 컴파일러도 빠진 갈래를 잡아 준다.
examples/oop/enum_match/area.c
/* 종류가 닫혀 있을 때 메서드 표 대신 쓰는 길: 태그, 공용체, 그리고 switch.
default 를 일부러 두지 않았다 --- 그래야 새 종류의 도형이 더해졌는데 이 함수가
그것을 모를 때 컴파일러가 알려 준다. */
#include <stdio.h>
enum shape_kind {
SHAPE_CIRCLE,
SHAPE_RECT,
#ifdef ADD_TRIANGLE
SHAPE_TRIANGLE, /* 나중에 더한 새 종류 */
#endif
};
struct shape {
enum shape_kind kind;
union {
struct { double r; } circle;
struct { double w, h; } rect;
#ifdef ADD_TRIANGLE
struct { double base, height; } triangle;
#endif
};
};
static double area(const struct shape *s)
{
switch (s->kind) {
case SHAPE_CIRCLE: return 3.0 * s->circle.r * s->circle.r;
case SHAPE_RECT: return s->rect.w * s->rect.h;
}
return 0.0; /* 올바른 종류라면 닿지 않는다 */
}
int main(void)
{
struct shape a[] = {
{ .kind = SHAPE_CIRCLE, .circle = { 1.0 } },
{ .kind = SHAPE_RECT, .rect = { 2.0, 3.0 } },
};
double total = 0;
for (size_t i = 0; i < sizeof a / sizeof a[0]; i++) total += area(&a[i]);
printf("total area %g\n", total);
return 0;
}
실행 결과
two kinds : total area 9
three kinds : the build stops ---
enumeration value ‘SHAPE_TRIANGLE’ not handled in switch [-Werror=switch]
switch 에 default 를 두지 않은 것이 요령이다. 그러면 열거형에 새 값(삼각형)이 더해졌을 때, -Wall 에 든 -Wswitch 가 「이 switch 는 삼각형을 다루지 않는다」고 알린다. default 를 두면 컴파일러는 「나머지는 여기서 다룬다」고 읽고 경고하지 않는다. Rust 의 match 만큼 엄격하지는 않지만 — 범위 밖의 정수가 태그에 들어오는 것은 막지 못한다 — 닫힌 종류에는 충분히 쓸 만하다.
75.6 객체 지향에 몰입하지 말 것#
객체 지향은 도구다. 도구에 몰입하면 문제를 도구의 모양으로 보게 된다 — 망치를 든 사람에게는 모든 것이 못으로 보인다는 말 그대로다. 모든 것을 객체로, 모든 관계를 상속으로, 모든 함수를 표로 만들면, 위의 한계를 모든 코드에서 치른다.
| 상황 | 어울리는 도구 |
|---|---|
| 종류가 열려 있다 — 바깥 사람이 새 종류를 더한다(플러그인, 드라이버) | 함수 포인터 표(다형성) |
| 종류나 표가 실행 때 정해진다 | 동적 표 — 짓고, 검증하고, 봉인한다(72장) |
| 종류가 닫혀 있다 — 몇 가지로 끝난다 | 태그 달린 공용체 + switch(enum-match) |
| 같은 연산을 아주 많은 원소에 | 종류별 배열, SoA(51장) |
| 바깥에 내놓는 라이브러리 객체 | 불투명 타입 + 팩토리 + 접두어 |
| 부르는 쪽이 만드는 옵션 묶음 | 공개 구조체 + 크기 필드(또는 판 번호) |
| 공통 부분을 나누고 싶다 | 먼저 합성. 상속(기반을 첫 멤버로 끼워 넣기)은 그다음 |
| 타입마다 같은 뜻의 함수 | 이름을 따로 짓기, 또는 _Generic |
| 작고 많은 값(점, 색, 시각) | 그냥 구조체. 객체로 만들지 않는다 |
표 75.4 — 무엇을 쓸 것인가 — 판단 표
마지막 줄을 강조한다. C 의 구조체는 그 자체로 훌륭한 도구다. 모든 구조체가 객체일 필요는 없다.
75.7 처음으로 돌아가기#
이 부의 처음(70장)에서 Alan Kay 의 그림을 미뤄 두었다. 2003년, 한 독자가 「객체 지향이란 무엇인가」를 묻자 Kay 는 전자우편으로 이렇게 답했다.
「OOP to me means only messaging, local retention and protection and hiding of state-process, and extreme late-binding of all things.」2
옮기면 이렇다 — 「내게 OOP 는 오직 메시지 주고받기, 상태와 과정을 안에 두고 지키고 감추기, 그리고 모든 것을 극단적으로 늦게 묶기를 뜻한다.」 늦게 묶기(late binding)는 어느 코드를 부를지를 실행할 때에야 정한다는 뜻이다. 같은 글에서 그는 상속을 「더 잘 이해할 때까지」 기본 기능에서 뺐다고 적었다. 그가 처음 그린 객체는 클래스 계층이 아니라, 생물의 세포처럼 막으로 둘러싸여 안을 감추고 메시지로만 바깥과 통하는 것이었다.
이 그림으로 이 부를 다시 보면, 가장 소박한 흉내인 불투명 타입이 달리 보인다. C 의 불투명 타입과 그 함수 묶음은 클래스도 상속도 없지만 — 안을 감추고(크기조차 보이지 않는다), 정해진 함수로만 통하고, 판이 바뀌어도 바깥을 다시 컴파일하지 않아도 된다(늦게 묶기). 흉내 가운데 가장 소박한 것이, 객체 지향의 처음 생각에 가장 가깝다.
객체 지향의 핵심은 문법이 아니라 경계다. 무엇을 안에 두고 무엇을 바깥에 내놓을지, 경계를 넘는 약속을 무엇으로 할지. 그 질문은 C 로도 충분히 물을 수 있다. 그리고 C 로 물을 때는, 그 경계를 구조로 지키게 만드는 것까지가 프로그래머의 일이다.
75.8 정리#
| 주제 | 요점 |
|---|---|
| 매크로 | 기계적인 일 하나, 손으로 쓸 수 있는 결과, 검사를 더하는 쪽으로. 작은 언어를 만들지 않는다 |
| SOLID | 다섯 원칙 모두 새 문법 없이 C 에 얹힌다. 넷은 한 단계를 더 거치게 하는 쪽 — 두 번째 구현이 생길 때 세운다 |
| 객체 지향의 한계 | 흩어진 힙, 객체 머리, AoS, 간접 호출. 요요 문제, 깨지기 쉬운 기반, 흐름의 역전 — 합성을 먼저 |
| 요즘의 흐름 | 인터페이스, 트레이트, 합 타입과 match. 무엇이 자주 더해지는가(표현 문제)로 고른다 |
| 판단 | 열린 종류는 함수 포인터 표, 닫힌 종류는 switch, 많은 원소는 종류별 배열, 작고 많은 값은 그냥 구조체 |
| 처음으로 | 메시지, 감추기, 늦게 묶기 — 핵심은 문법이 아니라 경계 |
표 75.5 — 한계와 쓰지 않을 때 — 한 줄씩
75.9 제11부를 닫으며#
C 로 객체 지향을 흉내 내는 부가 끝났다. 「객체」라는 낱말의 두 뜻을 가르고 흉내의 맨손 도구를 세운 다음(70장), 그 도구로 다형성과 상속을 짓고 판과 수명을 견디게 했다(71장). 표를 실행 때 짓고 봉인하고 찾는 법을 보고(72장), 표를 거치는 호출 하나의 비용을 이 기계에서 쟀다(73장). 남들이 C 로 지은 객체 체계와 그것을 잰 논문을 둘러보고(74장), 어디서 멈출지를 정했다(이 장). 여섯 장을 꿰는 주제는 하나다: 흉내의 부품은 모두 약속이고, 좋은 흉내는 그 약속을 사람이 아니라 구조가 지키게 만든다.
다음 부는 C 가 처음부터 내놓은 가장 큰 약속의 묶음 — 표준 라이브러리 — 를 정독한다. 첫 장은 그 전체 모습을 한눈에 본다. 어떤 도구 상자들이 있고, 이 라이브러리가 왜 얇고 오래된 모습인지부터 본다.