원문 캡처 · davidpriver.com
원문 캡처 · davidpriver.com

개인 프로젝트로 C2Y 컴파일러·인터프리터·REPL인 DrC를 만들고 있는 개발자가 C 전처리기에 디버깅과 가변 인자 처리를 돕는 기능을 추가했다. 표준 전처리기에서 복잡한 매크로 기법으로 해결해야 했던 작업을 간단히 하려는 확장이다.

C 컴파일러를 만들려면 먼저 C 전처리기를 작성해야 한다. 흔히 생각하는 것과 달리 C 전처리기는 단순한 텍스트 변환기가 아니라 C 토큰을 처리하는 단계이며, 표준은 여러 처리 단계를 거쳐 C 파서가 읽을 토큰을 만든다고 규정한다. 이 글은 전처리기 구현법 대신 DrC에 추가한 확장 기능 중 일부를 소개한다. 전체 목록은 DrC의 확장 문서에서 볼 수 있다.

매크로 디버깅과 인자 처리

__print(...)은 인자를 확장한 결과를 표준 오류(stderr)에 출력하는 내장 디버깅 매크로다. 전체 번역 단위를 출력하는 cpp와 달리 확인하려는 부분만 볼 수 있다.

text
$ printf "#define X 1\n__print(X X)" | drcpp
(stdin):2:1: 1 1

__where()는 매크로가 정의된 위치를 표준 오류에 출력한다. 복잡한 매크로가 어디에서 정의됐는지 찾을 때 쓸 수 있다.

text
$ printf "#define X 1\n\n\n\n__where(X)" | drcpp
(stdin):1:9: info: X defined

__map()은 목록의 각 인자에 매크로를 적용한다. 표준 C 전처리기는 매크로의 직접 재귀를 막기 때문에, 기존 방식으로는 반복 횟수만큼 매크로를 수동으로 늘어놓아야 한다. 다음 예시는 최대 8개 인자를 처리하도록 재귀를 흉내 낸다. 전처리기 매크로를 과도하게 활용하는 방식이라 DrC 제작자는 느릴 수 있다고 설명한다.

c
// map.c
#define CAT(a, b) CAT_(a, b)
#define CAT_(a, b) a##b
#define NARG(...) \
 NARG_(__VA_ARGS__ __VA_OPT__(,) 8,7,6,5,4,3,2,1,0)
#define NARG_(_1,_2,_3,_4,_5,_6,_7,_8,N,...) N
#define MAP_0(m)
#define MAP_1(m, x) m(x)
#define MAP_2(m, x, ...) m(x) MAP_1(m, __VA_ARGS__)
#define MAP_3(m, x, ...) m(x) MAP_2(m, __VA_ARGS__)
#define MAP_4(m, x, ...) m(x) MAP_3(m, __VA_ARGS__)
#define MAP_5(m, x, ...) m(x) MAP_4(m, __VA_ARGS__)
#define MAP_6(m, x, ...) m(x) MAP_5(m, __VA_ARGS__)
#define MAP_7(m, x, ...) m(x) MAP_6(m, __VA_ARGS__)
#define MAP_8(m, x, ...) m(x) MAP_7(m, __VA_ARGS__)
#define __map(m, ...) \
 CAT(MAP_, NARG(__VA_ARGS__))(m __VA_OPT__(,) __VA_ARGS__)
int x = 1;
int y = 2;
int z = 3;

#define PRINTINT(x) printf(#x " = %d\n", x);
int printf(const char*, ...);
int main(){
 __map(PRINTINT, 1, 2, 3, 4, x, y, z)
}

이 코드는 다음과 같이 확장된다. 공백은 읽기 쉽도록 넣었다.

c
// map.i
int x = 1;
int y = 2;
int z = 3;
int printf(const char*, ...);
int main(){
 printf("1" " = %d\n", 1); // 1 = 1
 printf("2" " = %d\n", 2); // 2 = 2
 printf("3" " = %d\n", 3); // 3 = 3
 printf("4" " = %d\n", 4); // 4 = 4
 printf("x" " = %d\n", x); // x = 1
 printf("y" " = %d\n", y); // y = 2
 printf("z" " = %d\n", z); // z = 3
}

DrC의 내장 __map(macro, ...)을 사용하면 같은 예제를 반복 매크로 없이 작성할 수 있다. 각 인자에 매크로를 한 번씩 적용하며, 처리할 항목 수에 고정된 제한이 없다고 DrC 제작자는 설명한다.

c
int x = 1;
int y = 2;
int z = 3;
#define PRINTINT(x) printf(#x " = %d\n", x);
int printf(const char*, ...);
int main(){
 __map(PRINTINT, 1, 2, 3, 4, x, y, z)
}

가변 인자 개수 확인

C23은 __VA_OPT__()으로 가변 인자 매크로 사용을 편리하게 했지만, 인자 개수에 따라 매크로를 바꾸거나 함수에 인자 개수와 포인터를 함께 전달하는 작업은 여전히 번거로울 수 있다. 기존 방식은 인자 개수를 세는 매크로를 직접 구성한다.

c
// sum.c
#define CAT(a, b) CAT_(a, b)
#define CAT_(a, b) a##b
#define NARG(...) \
 NARG_(__VA_ARGS__ __VA_OPT__(,) 8,7,6,5,4,3,2,1,0)
#define NARG_(_1,_2,_3,_4,_5,_6,_7,_8,N,...) N
int sum(size_t count, int* data){
 int result = 0;
 for(size_t i = 0; i < count; i++){
 result += data[i];
 }
 return result;
}
#define sum(...) sum(NARG(__VA_ARGS__), (int[]){__VA_ARGS__})
int printf(const char*, ...);
int main(){
 printf("%d\n", sum(1,2,3)); // 6
 printf("%d\n", sum(4,5)); // 9
}

DrC에는 전처리기가 이미 알고 있는 가변 인자 개수를 직접 반환하는 내장 매크로 __VA_COUNT__가 있다. 이를 쓰면 직접 개수를 세는 매크로를 작성하지 않아도 된다.

c
// sum-drc.c
int sum(size_t count, int* data){
 int result = 0;
 for(size_t i = 0; i < count; i++){
 result += data[i];
 }
 return result;
}
#define sum(...) sum(__VA_COUNT__, (int[]){__VA_ARGS__})
int printf(const char*, ...);
int main(){
 printf("%d\n", sum(1,2,3)); // 6
 printf("%d\n", sum(4,5)); // 9
}

DrC 제작자는 C 전처리기가 강력하지만, 기능을 제대로 활용하려면 읽기 어려운 매크로 기법이 필요하다고 본다. 이는 전처리기로 하려는 작업 자체의 문제가 아니라 기능이 충분히 발전하지 못한 데 따른 결과이며, 확장을 통해 전처리기를 더 읽기 쉽고 쓰기 편하게 만들 수 있다는 설명이다. DrC 제작자는 변경 가능한 전처리기 상태 같은 다른 확장도 다룰 계획이라고 덧붙였다.

관련 글: C의 가장 큰 실수를 바로잡기, 트리 워커에서 바이트코드로