57 전처리기와 번역 단계
먼저 알아야 할 것
돌아보기
19장에서 #으로 시작하는 줄은 문장이 아니고 세미콜론도 없다고 했고, 그 이유가 “전처리기는 C 문법이 아니라 줄 단위로 일하는 텍스트 도구라 서”라고 했다. 그러면 전처리기는 C의 무엇을 아는가?
답. 거의 아무것도 모른다 — 타입도, 스코프도, 함수도 모른다. 아는 것은 토큰(더 이상 쪼갤 수 없는 낱말 조각: 이름, 숫자, 연산자, 문자열) 뿐이고, 하는 일은 토큰을 지우고 끼우고 이어 붙이는 것이다. 이 무지가 전처리기의 힘이자(어떤 코드 조각이든 만들어 낼 수 있다) 위험이다 (문법과 의미를 지켜 주지 않는다). 이 장은 그 힘과 위험을 함께 본다.
이 장의 필요성과 맥락
이 장이 끝나면
#과 ##이라는 두 연산자의 원리와 관용구, 그 함정들, 그리고 소스가 프로그램이 되기까지 표준이 못박은 번역 단계의 전모 까지. C 소스 파일 안에 사는 “두 번째 언어”의 문법서다.이 장에서 답할 질문
#include xstr(INCFILE(2).h)는 어떻게"vers2.h"가 되는가?- 표준에는 더 심한 예제도 있다고 들었다.
- 목록 파일을 따로 두는 판도 있다고 들었다. 무엇이 다른가?
- 그러면 매크로 안에서 문법 오류를 내면 어디서 잡히는가?
57.1 매크로 — 이름을 토큰 열로 바꾸기
#define NAME 내용은 “이후 NAME이라는 토큰을 만나면 내용으로 바꿔 치우라”는 지시다. 인자를 받는 꼴(#define MAX(a,b) ...)도 있는데, 함수처럼 보이지만 함수가 아니다 — 호출이 아니라 치환이고, 타입 검사도 평가 순서 보장도 없다.
57.1.1 괄호 — 매크로를 쓰는 사람이 가장 먼저 배우는 것
함수라면 인자가 값 하나로 계산된 뒤 들어오지만, 매크로는 적힌 토큰이 그대로 본문에 박힌다. 그래서 인자 안의 연산자와 본문의 연산자가 한 자리에서 만나고, 20장의 우선순위가 그 사이를 갈라 버린다. 네 가지 함정을 실제 전개와 함께 본다.
examples/ch57/parens.c
/* 매크로 인자를 괄호로 감싸야 하는 이유 — 의도와 전개가 갈라지는 자리들.
xstr() 로 "그래서 무엇으로 펼쳐졌는가"를 나란히 찍어 확인한다. */
#include <stdio.h>
#define str(...) # __VA_ARGS__
#define xstr(...) str(__VA_ARGS__)
#define SQ_BAD(x) x * x /* 괄호 없음 */
#define SQ_HALF(x) (x) * (x) /* 인자만 감쌈 */
#define SQ(x) ((x) * (x)) /* 인자와 전체를 감쌈 — 정석 */
#define NEG_BAD(x) -x
#define NEG(x) (-(x))
/* 인자를 두 번 쓰는 매크로 — 부수효과가 있는 인자를 만나면 */
#define MAX(a, b) ((a) > (b) ? (a) : (b))
int main(void)
{
/* ── ① 인자를 안 감싸면 — 우선순위가 끼어든다 ─────────────── */
printf("SQ_BAD(1+2) -> %-16s = %d (9 was intended)\n",
xstr(SQ_BAD(1+2)), SQ_BAD(1+2));
printf("SQ_HALF(1+2) -> %-16s = %d\n",
xstr(SQ_HALF(1+2)), SQ_HALF(1+2));
/* ── ② 전체를 안 감싸면 — 바깥 연산자가 끼어든다 ──────────── */
printf("100/SQ_HALF(2) -> %-14s = %d (25 was intended)\n",
xstr(100/SQ_HALF(2)), 100/SQ_HALF(2));
printf("100/SQ(2) -> %-14s = %d\n",
xstr(100/SQ(2)), 100/SQ(2));
/* ── ③ 부호가 붙은 인자 ──────────────────────────────────── */
printf("NEG_BAD(-3) -> %-16s <- a compile error (`--3`)\n", xstr(NEG_BAD(-3)));
printf("NEG(-3) -> %-16s = %d\n", xstr(NEG(-3)), NEG(-3));
/* ── ④ 괄호로도 못 막는 것 — 인자를 두 번 평가한다 ────────── */
int i = 5, j = 3;
int m = MAX(i++, j);
printf("MAX(i++, j) -> i became %d (we expected one increment)\n", i);
printf(" result m = %d\n", m);
printf(" expansion: %s\n", xstr(MAX(i++, j)));
return 0;
}
실행 결과
SQ_BAD(1+2) -> 1+2 * 1+2 = 5 (9 was intended)
SQ_HALF(1+2) -> (1+2) * (1+2) = 9
100/SQ_HALF(2) -> 100/(2) * (2) = 100 (25 was intended)
100/SQ(2) -> 100/((2) * (2)) = 25
NEG_BAD(-3) -> --3 <- a compile error (`--3`)
NEG(-3) -> (-(-3)) = 3
MAX(i++, j) -> i became 7 (we expected one increment)
result m = 6
expansion: ((i++) > (j) ? (i++) : (j))
① 인자를 감싸지 않으면 인자 안의 연산이 흩어진다.
#define SQ_BAD(x) x * x
SQ_BAD(1+2) → 1+2 * 1+2 → 1 + (2*1) + 2 = 5 /* 9를 기대했다 */1+2가 하나의 값으로 묶여 들어가는 것이 아니라 세 토큰이 그대로 박히고, 그 위에 곱셈이 덧셈보다 강하다는 규칙이 적용된다. 인자를 괄호로 감싸면 해결된다 — (1+2) * (1+2) = 9.
② 전체를 감싸지 않으면 바깥 연산자가 끼어든다. 인자만 감싸는 것으로는 부족하다.
#define SQ_HALF(x) (x) * (x)
100 / SQ_HALF(2) → 100 / (2) * (2) = 100 /* 25를 기대했다 */나눗셈과 곱셈은 우선순위가 같고 왼쪽부터 묶이므로(부록 A), 100/2가 먼저 되고 거기에 2가 곱해진다. 본문 전체를 괄호로 감싸야 100 / ((2) * (2)) = 25가 된다. 그래서 정석은 둘 다 감싸는 것이다.
#define SQ(x) ((x) * (x))③ 부호가 붙은 인자에서는 토큰이 붙어 버리기도 한다.
#define NEG_BAD(x) -x
NEG_BAD(-3) → --3 /* 감소 연산자가 되어 컴파일 오류 */예제가 이 전개를 글자로만 찍는 이유가 그것이다 — 실제로 적으면 컴파일되지 않는다. (-(x))로 감싸면 (-(-3)) = 3으로 제대로 간다.
④ 괄호로도 막지 못하는 것 — 인자를 두 번 평가한다.
#define MAX(a, b) ((a) > (b) ? (a) : (b))
int i = 5, j = 3;
int m = MAX(i++, j); → ((i++) > (j) ? (i++) : (j))예제의 출력이 그 결과다 — i가 5에서 7로 뛰었고, m은 기대한 5가 아니라 6이다. i++가 본문에 두 번 박혔기 때문이다. 괄호는 우선순위 문제만 풀 뿐, 이것은 풀지 못한다. 33장에서 배운 “한 문장에서 한 변수는 한 번만”이 매크로 때문에 몰래 깨지는 자리다.
| 규칙 | 이유 |
|---|---|
| 인자를 모두 괄호로 감싼다 | 인자 안의 연산이 흩어지지 않게 |
| 본문 전체를 괄호로 감싼다 | 바깥 연산자가 끼어들지 않게 |
| 인자를 두 번 쓰지 않는다 | 부수효과가 두 번 일어난다 |
값 계산은 static inline 함수로 | 위 셋을 전부 신경 쓰지 않아도 된다 |
표 57.1
마지막 줄이 오늘의 답이다. #define으로 값을 계산하던 이유는 함수 호출 비용을 아끼려는 것이었는데, 요즘 컴파일러는 작은 함수를 알아서 인라인 하므로(13장) 그 이유가 거의 사라졌다. 괄호 규칙은 “매크로를 써야만 하는 자리”에서만 필요한 것이고, 그런 자리는 이 장의 나머지가 보여 주는 것들 — 토큰 조립, 조건부 컴파일, 소스 위치 포착 — 이다.
57.2 # — 토큰을 문자열로 (stringize)
매크로 인자 앞에 #을 붙이면, 그 인자가 글자 그대로 문자열 리터럴이 된다. 값이 아니라 적힌 모양이 문자열이 되는 것이 핵심이다.
examples/ch57/macro.c
#include <stdio.h>
/* # : 인자를 "글자 그대로" 문자열로 만든다 (stringize) */
#define SHOW(expr) printf(#expr " = %d\n", (expr))
/* ## : 두 토큰을 하나로 붙인다 (token paste) */
#define MAKE_NAME(prefix, n) prefix##n
/* 한 겹으로는 매크로가 안 풀린다 — 두 겹 우회가 정석이다 */
#define STR_RAW(x) #x
#define STR(x) STR_RAW(x)
#define WIDTH 80
int MAKE_NAME(value_, 1) = 11; /* -> int value_1 = 11; */
int MAKE_NAME(value_, 2) = 22;
int main(void)
{
SHOW(2 + 3 * 4);
SHOW(value_1 + value_2);
printf("STR_RAW(WIDTH) = %s\n", STR_RAW(WIDTH)); /* 안 풀린다 */
printf("STR(WIDTH) = %s\n", STR(WIDTH)); /* 풀린 뒤 문자열이 된다 */
return 0;
}
실행 결과
2 + 3 * 4 = 14
value_1 + value_2 = 33
STR_RAW(WIDTH) = WIDTH
STR(WIDTH) = 80
시연의 SHOW(2 + 3 * 4)가 그 실물이다 — #expr이 "2 + 3 * 4"라는 문자열이 되고, 옆의 (expr)은 계산되어 14가 된다. 하나의 인자가 글자 로도, 값으로도 쓰인 것이다. 이 무늬가 디버깅·검사 매크로의 기본형이다: assert(x > 0)가 실패했을 때 “x > 0”이라는 조건을 그대로 찍어 주는 것이 바로 이 원리다(51장에서 만난 그 assert의 속살이다).
57.3 ## — 토큰 두 개를 하나로 (token paste)
##은 양옆의 토큰을 붙여서 하나의 새 토큰으로 만든다. 시연의 MAKE_NAME(value_, 1)이 value_1이라는 식별자가 되는 것이 그 예다 — 문자열이 아니라 진짜 이름이라, 변수 선언에 쓸 수 있다.
용도는 이름을 조립하는 코드 생성이다. 같은 모양의 함수·구조체를 타입마다 찍어 내야 할 때(C에는 제네릭이 없으므로), 접두어와 타입 이름을 붙여 stack_int_push, stack_double_push 같은 이름들을 매크로 하나로 만들어 내는 것이 고전적 기법이다.
57.4 두 겹 우회(double expansion) — 왜 한 겹으로는 안 풀리는가
#과 ##을 쓸 때 반드시 만나는 규칙이 하나 있다. #이나 ##의 피연산자가 되는 매크로 인자는 먼저 펼쳐지지 않는다. 시연의 마지막 두 줄이 그 대조다 — STR_RAW(WIDTH)는 "WIDTH"가 되고, STR(WIDTH)는 "80"이 된다.
57.4.1 먼저 이름부터 — 규칙 넷
이 절에서 벌어지는 일은 표준이 네 조항으로 나눠 적어 둔 것이다. 이름을 알아 두면 머릿속에서 순서를 세우기가 훨씬 쉬워진다.
| 한국어 | 표준의 이름(영어) | 무슨 규칙인가 |
|---|---|---|
| 인수 치환 | argument substitution | 인자를 먼저 완전히 펼친 뒤 본문에 끼운다 |
문자열화 연산자 # | the # operator (stringize) | 피연산자 인자는 펼치지 않고 글자 그대로 문자열로 |
토큰 붙이기 연산자 ## | the ## operator (token pasting) | 양옆 인자를 펼치지 않고 하나의 토큰으로 붙인다 |
| 재검사와 추가 치환 | rescanning and further replacement | 치환 결과를 다시 훑어 남은 매크로를 마저 펼친다 |
표 57.2
핵심은 첫 줄과 가운데 두 줄의 충돌이다. 보통은 인자가 먼저 펼쳐지지만 (인수 치환), #이나 ##의 대상이 되는 인자만은 예외로 펼치지 않은 원래 토큰이 쓰인다. 그래서 값을 원하면 인자가 #·##을 만나기 전에 한 번 펼쳐지도록 한 겹을 더 두른다 — 이것이 두 겹 우회(double expansion), 또는 간접 매크로(indirection macro)라 부르는 관용구다.
한 가지 규칙이 더 필요하다. 이름은 표준이 아니라 사람들이 붙인 것인데, 파란 칠 규칙(blue paint rule)이라 부른다. 매크로를 펼치는 도중에 자기 이름이 다시 나오면 그 이름은 다시 펼치지 않고 그대로 둔다(파랗게 칠해 표시해 둔다는 비유다). 무한 재귀를 막는 장치이고, 아래 표준 예제의 LOW가 바로 이 규칙 위에서 돈다.
57.4.2 손으로 따라가는 전개 — 두 줄의 대조
머리로 시뮬레이션하기 까다로운 것은 “언제 펼치고 언제 안 펼치는가”가 자리마다 다르기 때문이다. 두 줄을 나란히 놓고 단계별로 따라가 보자. WIDTH는 80으로 정의되어 있다.
한 겹 — STR_RAW(WIDTH)
| 단계 | 지금 무엇을 하는가 | 결과 |
|---|---|---|
| 1 | STR_RAW의 인자 s에 WIDTH가 들어온다 | s ← WIDTH |
| 2 | 본문이 #s이므로 인수 치환의 예외 — 펼치지 않는다 | WIDTH(그대로) |
| 3 | 문자열화한다 | "WIDTH" |
| 4 | 재검사 — 문자열 안은 토큰이 아니므로 더 볼 것이 없다 | "WIDTH" |
표 57.3
두 겹 — STR(WIDTH)
| 단계 | 지금 무엇을 하는가 | 결과 |
|---|---|---|
| 1 | STR의 인자 x에 WIDTH가 들어온다 | x ← WIDTH |
| 2 | 본문이 STR_RAW(x)다 — #도 ##도 아니므로 인수 치환 규칙대로 먼저 펼친다 | x ← 80 |
| 3 | 본문에 끼워 넣는다 | STR_RAW(80) |
| 4 | 재검사 — STR_RAW가 매크로이므로 다시 펼친다 | #80 |
| 5 | 문자열화한다 | "80" |
표 57.4
차이가 생기는 자리는 오직 2단계다. 바깥에 한 겹을 두른 덕에 인자가 #을 만나기 전에 한 번 펼쳐졌고, 그래서 #이 보게 되는 토큰이 WIDTH가 아니라 80이 되었다. ##도 사정이 같아서 PASTE/PASTE_RAW 짝을 두는 것이 관용구다.
57.4.3 표준이 든 예 — glue와 xglue
이 관용구는 표준 문서가 직접 예로 든 것이기도 하다(C17 기준 §6.10.3.5 「매크로 정의의 유효범위」의 EXAMPLE. C23은 #embed가 6.10.3으로 들어오며 번호가 한 칸 밀렸다). 그 예제를 그대로 돌려 본다.
examples/ch57/glue.c
/* 두 겹 우회(double expansion) — C 표준이 직접 든 예제를 그대로 돌려 본다.
한 겹과 두 겹의 결과가 어떻게 갈리는지가 전부 여기 있다. */
#include <stdio.h>
/* ── 표준 예제의 매크로들 (C17 §6.10.3.5) ─────────────────────── */
#define str(s) # s /* 문자열화(stringize) */
#define xstr(s) str(s) /* 한 겹 더 두른 판 */
#define INCFILE(n) vers ## n /* 토큰 붙이기(token pasting) */
#define glue(a, b) a ## b
#define xglue(a, b) glue(a, b) /* 한 겹 더 두른 판 */
#define HIGHLOW "hello"
#define LOW LOW ", world" /* 자기 자신을 부른다 — 파란 칠 규칙의 무대 */
/* 조립된 이름이 헤더 이름이 된다: xstr(INCFILE(2).h) -> "vers2.h" */
#include xstr(INCFILE(2).h)
#define WIDTH 80
int main(void)
{
/* ── ① 문자열화: 한 겹은 안 풀리고, 두 겹은 풀린다 ────────── */
printf("str(WIDTH) = %s\n", str(WIDTH)); /* 인자가 # 의 피연산자 */
printf("xstr(WIDTH) = %s\n", xstr(WIDTH)); /* 먼저 펼친 뒤 문자열로 */
/* ── ② 붙이기: 표준 예제의 그 두 줄 ──────────────────────── */
printf("glue(HIGH, LOW) = %s\n", glue(HIGH, LOW));
printf("xglue(HIGH, LOW) = %s\n", xglue(HIGH, LOW));
/* ── ③ 조립한 이름으로 포함한 헤더가 실제로 왔는지 ───────── */
printf("VERS_TAG = %s\n", VERS_TAG);
/* ── ④ 표준 예제의 문자열화 두 가지 ──────────────────────── */
printf("%s\n", str(strncmp("abc\0d", "abc", '\4') == 0));
return 0;
}
실행 결과
str(WIDTH) = WIDTH
xstr(WIDTH) = 80
glue(HIGH, LOW) = hello
xglue(HIGH, LOW) = hello, world
VERS_TAG = vers2.h really was included
strncmp("abc\0d", "abc", '\4') == 0
출력의 세 번째·네 번째 줄이 이 절의 결론이다. 같은 인자를 주었는데 glue는 hello를, xglue는 hello, world를 내놓았다. 왜 그런지 한 단계씩 따라간다. 관련된 정의는 셋이다.
#define glue(a, b) a ## b
#define xglue(a, b) glue(a, b)
#define HIGHLOW "hello"
#define LOW LOW ", world"glue(HIGH, LOW)
| 단계 | 지금 무엇을 하는가 | 결과 |
|---|---|---|
| 1 | 인자를 받는다 | a ← HIGH, b ← LOW |
| 2 | 본문이 a ## b다 — 둘 다 ##의 피연산자이므로 펼치지 않는다 | HIGH, LOW(그대로) |
| 3 | 붙인다 | HIGHLOW(하나의 이름 토큰) |
| 4 | 재검사 — HIGHLOW가 매크로다 | "hello" |
표 57.5
HIGH라는 매크로는 정의된 적이 없다는 점을 눈여겨보자. 그런데도 오류가 나지 않는 이유는, HIGH가 혼자 쓰이기 전에 LOW와 붙어 HIGHLOW라는 다른 이름이 되어 버렸기 때문이다. ##은 이렇게 “존재하지 않는 이름 둘로 존재하는 이름 하나를 만드는” 일을 한다.
xglue(HIGH, LOW)
| 단계 | 지금 무엇을 하는가 | 결과 |
|---|---|---|
| 1 | 인자를 받는다 | a ← HIGH, b ← LOW |
| 2 | 본문이 glue(a, b)다 — #·##이 없으므로 인수 치환으로 먼저 펼친다 | 아래 2a·2b |
| 2a | HIGH는 매크로가 아니다 | HIGH(그대로) |
| 2b | LOW를 펼치면 LOW ", world"인데, 그 안의 LOW는 파란 칠 규칙으로 더 펼치지 않는다 | LOW ", world" |
| 3 | 본문에 끼워 넣는다 | glue(HIGH, LOW ", world") |
| 4 | 재검사 — glue를 펼친다. 이번엔 a가 HIGH, b가 LOW ", world" | HIGH ## LOW ", world" |
| 5 | ##은 양옆의 토큰 하나씩만 붙인다 — HIGH와 LOW | HIGHLOW ", world" |
| 6 | 재검사 — HIGHLOW가 매크로다 | "hello" ", world" |
| 7 | 번역 단계 6에서 인접한 문자열 리터럴이 이어진다(이 장의 표) | "hello, world" |
표 57.6
5단계가 특히 헷갈리는 자리다. ##은 토큰 두 개를 붙이는 연산자이지 “오른쪽 인자 전체”를 붙이는 것이 아니다. 그래서 b가 세 토큰 (LOW, ", world")으로 펼쳐져 있어도 붙는 것은 맨 앞의 LOW뿐이고, 나머지는 그 뒤에 그대로 따라간다.
그리고 7단계는 전처리기가 아니라 번역 단계 6의 일이라는 점도 짚어 둘 만하다. 전처리기가 내놓은 것은 어디까지나 "hello" ", world"라는 두 문자열 리터럴이고, 그것이 하나로 이어지는 것은 그다음 단계다.
문. #include xstr(INCFILE(2).h)는 어떻게 "vers2.h"가 되는가?
답. 같은 원리가 헤더 이름에 쓰인 것이다. 예제가 실제로 그 헤더를 포함해 VERS_TAG를 찍는 것으로 확인된다.
- 인자는
INCFILE(2).h다. 바깥이xstr이므로 먼저 펼친다. INCFILE(2)→vers ## 2→vers2. 그래서 인자는vers2 .h가 된다.xstr의 본문str(s)에 끼워 넣고 재검사하면str(vers2 .h).- 이번엔
#의 피연산자이므로 더 펼치지 않고 문자열화한다 →"vers2.h". #include가 그 문자열을 헤더 이름으로 삼는다.
str(INCFILE(2).h)라고 한 겹으로 적었다면 "INCFILE(2).h"라는 엉뚱한 이름이 되었을 것이다. 버전 번호나 플랫폼 이름으로 헤더를 고르는 코드가 이 관용구를 쓴다.
덧붙여 문자열화가 공백을 어떻게 다루는지도 이 예에 나온다. 인자 안의 토큰 사이 공백은 한 칸으로 줄어들고 양끝의 공백은 사라진다 — 그래서 vers2 .h가 "vers2.h"가 아니라 "vers2 .h"가 될까 걱정할 필요는 없다. 실제로 표준의 결과도, 이 예제의 실행 결과도 vers2.h다.
반례. 두 겹인 줄 알았는데 한 겹인 경우
#define CONCAT(a, b) a ## b
#define VERSION 2
int CONCAT(api_v, VERSION)(void); /* api_vVERSION — 원하던 것이 아니다 */CONCAT 자체가 ##을 직접 쓰는 매크로이므로 VERSION이 펼쳐지지 않는다. 한 겹을 더 둘러야 한다.
#define CONCAT_RAW(a, b) a ## b
#define CONCAT(a, b) CONCAT_RAW(a, b)
int CONCAT(api_v, VERSION)(void); /* api_v2 */이름 짓는 관행은 두 가지다 — 안쪽에 _RAW나 _IMPL을 붙이거나, 바깥에 X를 붙이거나(표준 예제의 xstr·xglue가 후자다). 어느 쪽이든 #·##을 직접 쓰는 매크로는 안쪽에 두고, 밖에서는 그것을 부르기만 한다는 규칙만 지키면 된다.
전처리기의 함정 중 가장 자주 사람을 붙잡는 규칙이고, 알고 나면 두 줄로 해결되는 문제이기도 하다.
57.5 표준이 든 기묘한 예들
표준 문서의 매크로 치환 절에는 “이런 것까지 정해 두었나” 싶은 예제가 여럿 있다. 실무에서 자주 쓸 것들은 아니지만, 전개 규칙을 어디까지 밀면 무슨 일이 벌어지는지를 보여 주므로 한 번 읽어 둘 값이 있다. 셋을 골라 실제로 돌려 본다.
examples/ch57/odd.c
/* 표준이 직접 든 기묘한 예제들 — 전개 결과를 글자로 찍어 확인한다.
xstr() 로 감싸면 "그래서 무엇이 되었는가"를 문자열로 볼 수 있다(두 겹 우회). */
#include <stdio.h>
/* 쉼표가 든 것도 통째로 문자열로 만들려면 가변 인자로 받는다 */
#define str(...) # __VA_ARGS__
#define xstr(...) str(__VA_ARGS__)
/* ── ① 자리표시자(placemarker) — 빈 인자를 ## 로 붙이면 ────────── */
#define t(x, y, z) x ## y ## z
int j[] = { t(1,2,3), t(,4,5), t(6,,7), t(8,9,),
t(10,,), t(,11,), t(,,12), t(,,) };
/* ── ② 표준이 "미지정"이라고 못 박은 전개 ─────────────────────── */
#define f(a) a*g
#define g(a) f(a)
int g = 1; /* g 는 함수형 매크로라 홀로 쓰면 펼쳐지지 않는다 */
/* ── ③ __VA_OPT__ — 인자가 "비었는지"를 언제 판정하는가 ───────── */
#define LOG(...) log(0 __VA_OPT__(,) __VA_ARGS__)
#define SDEF(name, ...) S name __VA_OPT__(= { __VA_ARGS__ })
#define EMP
int main(void)
{
printf("① placeholder\n");
printf(" j[] = {");
for (size_t i = 0; i < sizeof j / sizeof *j; i++)
printf(" %d", j[i]);
printf(" } (%zu elements)\n", sizeof j / sizeof *j);
printf(" t(,,) -> \"%s\" (not one token is left)\n", xstr(t(,,)));
printf(" t(6,,7)-> \"%s\"\n", xstr(t(6,,7)));
printf("\n② unspecified expansion\n");
printf(" f(2)(9) -> \"%s\"\n", xstr(f(2)(9)));
printf(" (the standard does not say whether it is \"2*9*g\" or \"2*9*f(9)\")\n");
printf("\n③ __VA_OPT__\n");
printf(" LOG(1,2) -> \"%s\"\n", xstr(LOG(1,2)));
printf(" LOG() -> \"%s\"\n", xstr(LOG()));
printf(" LOG(EMP) -> \"%s\" <- an argument was passed, yet there is no comma\n", xstr(LOG(EMP)));
printf(" SDEF(foo) -> \"%s\"\n", xstr(SDEF(foo)));
printf(" SDEF(bar,1,2) -> \"%s\"\n", xstr(SDEF(bar, 1, 2)));
return 0;
}
실행 결과
① placeholder
j[] = { 123 45 67 89 10 11 12 } (7 elements)
t(,,) -> "" (not one token is left)
t(6,,7)-> "67"
② unspecified expansion
f(2)(9) -> "2*9*g"
(the standard does not say whether it is "2*9*g" or "2*9*f(9)")
③ __VA_OPT__
LOG(1,2) -> "log(0 , 1,2)"
LOG() -> "log(0 )"
LOG(EMP) -> "log(0 )" <- an argument was passed, yet there is no comma
SDEF(foo) -> "S foo"
SDEF(bar,1,2) -> "S bar = { 1, 2 }"
예제가 전개 결과를 글자로 찍는 요령을 쓴 것도 눈여겨볼 만하다 — xstr(…)로 감싸면 “그래서 무엇이 되었는가”를 문자열로 볼 수 있다. 방금 배운 두 겹 우회가 전처리기를 디버깅하는 도구로 쓰이는 셈이다.
57.5.1 ① 자리표시자(placemarker) — 빈 인자를 붙이면
#define t(x, y, z) x ## y ## z
int j[] = { t(1,2,3), t(,4,5), t(6,,7), t(8,9,),
t(10,,), t(,11,), t(,,12), t(,,) };인자를 비워 두고 ##을 쓰면 어떻게 되는가. 붙일 것이 없으니 오류일 것 같지만, 표준은 빈 인자 자리에 자리표시자(placemarker)라는 보이지 않는 토큰이 있다고 정한다. 이 토큰은 무엇과 붙어도 상대를 그대로 두고, 전개가 끝나면 사라진다.
그래서 t(6,,7)은 6과 7이 붙어 67이 되고, t(,,)은 아무 토큰도 남기지 않는다. 예제의 배열이 원소 일곱 개로 끝나는 이유다 — 여덟 번째 t(,,)이 통째로 사라져 후행 쉼표만 남았다(그 쉼표가 합법인 것은 46장의 초기화 문법 덕이다).
이 규칙이 실무에서 값을 하는 자리는 인자가 있을 수도 없을 수도 있는 매크로다. 접두사를 붙일지 말지를 인자 하나로 고르는 코드 생성 매크로가 이 성질에 기댄다.
57.5.2 ② 표준이 “미지정”이라고 못 박은 전개
#define f(a) a*g
#define g(a) f(a)
f(2)(9)이 두 줄은 서로를 부른다. f(2)는 2*g가 되고, 그 뒤에 (9)가 붙어 있으니 g(9)로 읽힐 수도 있는데 — g를 펼치면 다시 f(9)가 되고, 그 f는 이미 펼치는 중이라 파란 칠 규칙에 걸린다.
여기서 표준은 답을 하나로 정하지 않았다. “결과는 2*9*g이거나 2*9*f(9)이며, 어느 쪽인지는 미지정(unspecified)”이라고 예제에 그대로 적어 두었다. 52장에서 배운 회색지대 중 미지정 동작의 교과서적인 사례다 — 둘 중 하나이긴 한데 어느 쪽인지는 구현이 정한다.
예제를 돌린 이 기계의 GCC는 2*9*g를 골랐다. 다른 구현에서 다른 답이 나와도 그것은 버그가 아니다. 실무의 교훈은 하나다 — 매크로끼리 서로를 부르게 만들지 말 것.
57.5.3 ③ __VA_OPT__ — “비었다”를 언제 판정하는가
C23이 들인 __VA_OPT__(82장)는 “가변 인자가 비어 있지 않을 때만” 무언가를 넣는다. 그런데 언제 비었는지 보는가가 미묘하다.
#define LOG(...) log(0 __VA_OPT__(,) __VA_ARGS__)
#define EMP /* 아무것도 아닌 것으로 펼쳐지는 매크로 */
LOG(1,2) → log(0 , 1,2)
LOG() → log(0 )
LOG(EMP) → log(0 ) ← 인자를 하나 넘겼는데도 쉼표가 없다LOG(EMP)가 핵심이다. 인자를 분명히 하나 넘겼지만, EMP는 펼치면 아무 토큰도 아니다. 판정이 펼치기 전이 아니라 펼친 뒤의 토큰으로 이뤄지기 때문에 “비어 있다”가 되어 쉼표가 붙지 않는다.
SDEF 쪽은 이 성질의 실용적인 쓰임을 보여 준다.
#define SDEF(name, ...) S name __VA_OPT__(= { __VA_ARGS__ })
SDEF(foo) → S foo /* 초기화 없이 */
SDEF(bar, 1, 2) → S bar = { 1, 2 } /* 초기화까지 */인자의 유무에 따라 문법 자체가 달라지는 매크로를 표준 문법만으로 쓸 수 있게 된 것이 C23의 보탬이다. 그전에는 GCC 확장(, ##__VA_ARGS__)에 기대야 했다.
문. 표준에는 더 심한 예제도 있다고 들었다.
답. 있다. 재검사 규칙을 다루는 절의 마지막 예제가 그것인데, 아홉 줄쯤 되는 정의 뭉치(#define z z[0], #define h g(~ 처럼 괄호가 짝이 안 맞는 것까지 있다)를 놓고 f(y+1) + f(f(z)) % t(t(g)(0) + t)(1); 한 줄이 무엇이 되는지를 묻는다. 답은 f(2 * (y+1)) + f(2 * (f(2 * (z[0])))) % f(2 * (0)) + t(1);이다.
이 예제의 목적은 사람을 시험하는 것이 아니라 구현을 시험하는 것이다. 전처리기를 만드는 쪽이 규칙을 하나라도 잘못 구현하면 이 한 줄에서 드러나도록 짜여 있다. 우리가 그것을 외울 이유는 없고, 다만 “여기까지가 정의된 세계다”라는 경계를 확인하는 데 쓰면 된다.
실제 사례. 매크로 하나가 만든 언어 — X 매크로
#과 ##의 힘을 보여 주는 고전 기법이 있다. 목록을 한 곳에만 적어 두고 여러 형태로 펼치는 X 매크로다. 이름은 관례적으로 쓰이는 매개변수 X에서 왔다. 다음 절에서 실물을 본다.57.6 X 매크로 — 목록 하나에서 코드 여러 벌을
C에는 코드 생성 기능이 없다. 그래서 “같은 목록을 여러 형태로 되풀이해 적어야 하는” 문제가 생긴다 — 오류 코드를 예로 들면, 열거형에 한 번, 이름 문자열 표에 한 번, 메시지를 돌려주는 switch에 한 번. 항목을 추가할 때 한 곳을 빠뜨리면 그때부터 표가 어긋난다.
X 매크로는 이 문제를 목록을 한 번만 적는 것으로 푼다. 요령은 두 단계다.
- 목록을 “아직 정의되지 않은 매크로
X를 호출하는 형태”로 적어 둔다. X를 그때그때 다르게 정의한 뒤 목록을 펼친다. 같은 목록이 매번 다른 코드가 되어 나온다.
examples/ch57/xmacro.c
#include <stdio.h>
/* 하나의 목록에서 열거형·이름표·검사 코드를 함께 만들어 낸다.
목록을 고칠 때 한 곳만 고치면 나머지가 따라온다. */
#define ERROR_LIST(X) \
X(OK, 0, "no error") \
X(NOT_FOUND, 2, "resource not found") \
X(DENIED, 5, "permission denied") \
X(TIMEOUT, 9, "operation timed out")
/* ① 열거형 만들기 — 이름과 값만 쓴다 */
#define AS_ENUM(name, code, text) ERR_##name = code,
enum error_code { ERROR_LIST(AS_ENUM) };
#undef AS_ENUM
/* ② 이름 문자열 표 만들기 — 이름을 문자열로 바꾼다(#) */
#define AS_NAME(name, code, text) [code] = #name,
static const char *const error_name[] = { ERROR_LIST(AS_NAME) };
#undef AS_NAME
/* ③ 설명 문자열을 돌려주는 함수 만들기 — switch 를 통째로 생성한다 */
#define AS_CASE(name, code, text) case ERR_##name: return text;
static const char *error_text(enum error_code e)
{
switch (e) {
ERROR_LIST(AS_CASE)
default: return "unknown error";
}
}
#undef AS_CASE
/* ④ 개수 세기 — 목록의 길이도 자동으로 얻는다 */
#define AS_COUNT(name, code, text) + 1
enum { ERROR_COUNT = 0 ERROR_LIST(AS_COUNT) };
#undef AS_COUNT
int main(void)
{
printf("%d error codes defined\n", ERROR_COUNT);
#define AS_ROW(name, code, text) \
printf(" %-10s code=%d name=%-9s text=%s\n", #name, (int)ERR_##name, \
error_name[code], error_text(ERR_##name));
ERROR_LIST(AS_ROW)
#undef AS_ROW
return 0;
}
실행 결과
4 error codes defined
OK code=0 name=OK text=no error
NOT_FOUND code=2 name=NOT_FOUND text=resource not found
DENIED code=5 name=DENIED text=permission denied
TIMEOUT code=9 name=TIMEOUT text=operation timed out
네 벌이 한 목록에서 나왔다. 하나씩 짚는다.
① 열거형 — AS_ENUM은 ERR_##name = code,로 펼쳐진다. ##가 ERR_와 이름을 이어 붙여 ERR_NOT_FOUND = 2,를 만든다.
② 이름 문자열 표 — #name이 이름을 그대로 문자열로 바꾼다. 게다가 [code] = #name이라는 지정 초기화(46장)를 써서, 코드 값이 곧 배열의 인덱스가 되게 했다. 값이 듬성듬성해도(0·2·5·9) 표가 알아서 맞는다.
③ switch 생성 — case ERR_##name: return text;가 항목 수만큼 펼쳐진다. 함수 본문 전체가 목록에서 만들어진 셈이다.
④ 개수 세기 — 0 + 1 + 1 + 1 + 1로 펼쳐지도록 만든 요령이다. 목록이 늘어도 ERROR_COUNT가 저절로 따라온다.
⑤ 그리고 본문에서 — main 안에서도 같은 목록을 한 번 더 펼쳐 표를 찍었다. 매크로를 쓰고 나서 #undef로 지우는 것이 관례다. 이름이 남아 있으면 다음 사용과 충돌하기 때문이다.
문. 목록 파일을 따로 두는 판도 있다고 들었다. 무엇이 다른가?
답. 같은 착상의 다른 포장이다. 목록을 errors.def 같은 파일에 적어 두고, 쓰는 쪽에서 X를 정의한 뒤 #include "errors.def"로 펼친다 — #include가 텍스트 붙여넣기라는 사실(54장)을 그대로 이용하는 것이다. 매크로 줄바꿈(\\)이 없어져 목록이 읽기 좋아지는 대신, 파일이 하나 늘고 IDE가 그 파일을 코드로 인식하지 못하는 불편이 있다. 리눅스 커널의 시스템 호출 표가 이 방식을 오래 써 왔다.
반례. X 매크로를 쓰지 않는 편이 나은 자리
/* 항목이 셋뿐이고 파생물도 하나뿐인 경우 */
#define COLOR_LIST(X) X(RED) X(GREEN) X(BLUE)이 정도면 그냥 열거형과 문자열 배열을 나란히 적는 편이 읽기 쉽다. X 매크로의 값은 목록이 길고, 목록에서 파생되는 코드가 여러 벌이며, 항목이 계속 늘어날 때 나온다. 짧은 목록에 쓰면 얻는 것(중복 제거)보다 잃는 것(펼쳐진 코드가 디버거·오류 메시지에서 보이지 않음, 처음 보는 사람이 읽지 못함)이 크다.
실제 사례. 실무에서 만나는 X 매크로
이 기법은 이름만 낯설 뿐 곳곳에 있다. 리눅스 커널의 시스템 호출 표와 오류 코드 정의, 여러 임베디드 SDK의 레지스터·핀 정의, 게임 엔진의 자산 종류 목록, 그리고 상태 기계의 상태 목록이 대표적이다. 공통점은 같다 — 한 목록이 열거형·문자열·검사 코드·직렬화 코드로 동시에 쓰이는 자리다. 도구가 없는 언어에서 중복을 없애려는 압력이 만들어 낸 발명품이고, 그래서 C의 성격을 잘 보여 주는 기법이기도 하다.흔한 오해. “매크로는 함수의 빠른 버전이다”
옛 시절에는 반쯤 사실이었다 — 함수 호출 비용을 아끼려고 짧은 함수를 매크로로 적곤 했다. 그러나 오늘의 컴파일러는 작은 함수를 알아서 인라인으로 펼치므로(14장의 편집자), 속도를 위해 매크로를 쓸 이유는 거의 사라졌다. 남은 것은 대가뿐이다 — 타입 검사 없음, 인자 다중 평가, 디버거에서 안 보임, 오류 메시지가 펼쳐진 코드 기준으로 나와 읽기 어려움. 현대적 지침은 분명하다: 값 계산은 함수(필요하면static inline), 상수는 enum이나 C23의 constexpr, 매크로는 함수로 안 되는 일 — 조건부 컴파일, 토큰 조립, 소스 위치 포착 — 에만.57.7 번역 단계 — 표준이 못박은 여덟 걸음
전처리가 “먼저” 일어난다는 것은 알았는데, 정확히 무엇이 어떤 순서로 일어나는가. 표준은 이것을 번역 단계(translation phases)라는 이름으로 여덟 걸음으로 못박아 두었다. 실제 컴파일러가 이 단계를 물리적으로 나눠 수행할 필요는 없지만, 마치 이 순서로 한 것처럼 동작해야 한다(14장의 “겉보기만 같으면”이 여기에도 적용된다).
| 단계 | 하는 일 |
|---|---|
| 1 | 소스 문자를 읽어 내부 문자 집합으로 옮긴다(9장의 인코딩 세계). 옛날에는 삼중자 치환도 여기였다 — C23이 제거했다 |
| 2 | 줄 끝의 역슬래시로 이어진 줄들을 한 줄로 합친다 |
| 3 | 주석을 공백 하나로 바꾸고, 소스를 토큰으로 쪼갠다 |
| 4 | 전처리 지시를 실행한다 — #include(재귀적으로 1~4단계 반복), #define 치환, 조건부 컴파일 |
| 5 | 문자·문자열 리터럴의 문자를 실행 문자 집합으로 변환한다 |
| 6 | 이웃한 문자열 리터럴들을 하나로 잇는다("가" "나" → "가나") |
| 7 | 토큰을 C 문법으로 해석하고 번역한다 — 여기가 “컴파일”이다 |
| 8 | 필요한 정의들을 이어 실행 이미지를 만든다 — 링크 |
표 57.7
이 표가 여러 수수께끼를 한꺼번에 푼다. 주석이 3단계에서 공백이 되므로 a/*/b는 ab가 아니라 a b다. #include가 4단계에서 재귀적으로 처리되므로 헤더 안의 헤더가 자연스럽게 펼쳐진다. 문자열 이어 붙이기가 6단계에 있으므로 긴 문자열을 여러 줄로 나눠 적는 관용구가 성립하고, 매크로가 만든 문자열 조각(# 연산자의 결과)도 옆 리터럴과 붙는다. 그리고 문법 해석이 7단계라는 사실이 이 장의 핵심을 다시 말해 준다 — 전처리기가 일할 때 C 문법은 아직 존재하지 않는다.
문. 그러면 매크로 안에서 문법 오류를 내면 어디서 잡히는가?
답. 7단계에서, 펼쳐진 뒤의 코드를 기준으로 잡힌다 — 그래서 오류 메시지가 내가 적은 줄과 동떨어져 보이는 것이다. 진단의 정석은 16장에서 배운 -E(전처리만 하기)다: 문제의 파일을 전처리해 실제로 펼쳐진 모습을 눈으로 보면, 수수께끼 같던 오류가 대개 즉시 풀린다. 현대 컴파일러는 “매크로 NAME에서 펼쳐짐”이라는 추적 정보를 함께 보여 주어 이 일을 거들기도 한다.
소스가 프로그램이 되는 전 과정을 이제 층별로 알게 됐다. 다음 장은 printf가 인자를 몇 개든 받는 비밀 — 가변 인자 함수다.