C 전처리기를 확장하는 재미

Fun Extending the C Preprocessor

davidpriver.com ▲ 53 댓글 33 david2ndaccount

요약

C 매크로가 난해한 것은 전처리기 기능이 모자라서라며, 한 개발자가 내장 기능을 직접 더했습니다.

C2Y(C23 다음 C 표준의 가칭) 컴파일러이자 인터프리터인 DrC를 개인 프로젝트로 만드는 데이비드 프리버의 글입니다. 그는 전처리기 메타프로그래밍이 흑마법처럼 읽히는 것은 전처리기의 장치가 덜 발달한 탓이라고 보고, 자기 전처리기에 넣은 확장 기능을 소개했습니다.

왜 중요한가

  • 매크로 꼼수로 흉내 내던 반복과 개수 세기를 전처리기의 내장 기능으로 바꾸자는 제안입니다.
  • 내장 기능은 인자를 거듭 다시 훑지 않아 빠르고, 처리할 수 있는 인자 개수에도 제한이 없습니다.

핵심 내용

  • __map(매크로, ...)은 가변 인자 하나하나에 매크로를 적용하며, 한 번 훑는 것으로 끝납니다.
  • 표준 전처리기는 매크로의 직접 재귀를 막아서, 그동안은 CAT·NARG와 번호 붙은 MAP_n 매크로를 엮어 써야 했습니다.
  • 이 우회법은 읽기 어렵고 느리며, 예제 기준으로 인자 8개까지만 처리합니다.
  • __VA_COUNT__는 전처리기가 이미 아는 가변 인자 개수를 바로 돌려줍니다.
  • __print(...)는 인자를 펼친 결과를, __where(...)는 매크로를 정의한 위치를 표준 오류로 출력해 디버깅을 돕습니다.

HN 반응

  • 기존 매크로 기법만으로 MAP을 만들 수 있다는 지적에, 반복 횟수 한계로 대규모 정의에는 못 쓰니 내장돼야 한다는 반론이 나왔습니다.
  • 복잡한 전처리는 실수이니 C++나 Zig를 쓰라는 주장과, 제네릭 자료구조에는 얇은 매크로 층이 C++보다 낫다는 반론이 맞섰습니다.

댓글

30개 표시 · 전체 33개
  1. raible HN

    저는 __VA_ARGS__를 개수/포인터로 바꿀 때 이런 걸 씁니다.

      // Create a stack-allocated argv from N args gcc, clang, tcc, mingw, msvc, etc
      // argc is always correct, for portability argv has a dummy element when argc==0
      // This avoids problematic 0-length compound literal arrays.
      #define _ARG_CV(type, ...)  _ARG_CV1(((type []){0, __VA_ARGS__}))
      #define _ARG_CV1(array)     _ALEN(array) - 1, array + (_ALEN(array) > 1)
      #define _ALEN(array)         sizeof array / sizeof *array
    

    그러면 이렇게만 쓰면 됩니다.

      #define sum(...) sum(_ARG_CV(int, __VA_ARGS__))
    
  2. fuhsnn HN

    WG14에 __VA_TAIL__ 제안[1]이 있었는데, 이게 있으면 map 같은 사용자 정의 확장을 꽤 간단하게 만들 수 있었습니다. 글쓴이의 map 예제라면 이렇게 됩니다.

    #define T(x, ...) printf(#x " = %d\n", x); VA_TAIL()

    T(1, 2, 3, 4, x, y, z)

    제안서 저자가 만든 ellipsis[2]와 제가 만든 slimcc[3]에서 직접 써 볼 수 있습니다. 구현이 C로 30줄 남짓일 만큼 간단해서, 채택될 기미는 안 보이는데도 그냥 넣어 두었습니다.

    [1] https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3307.htm

    [2] https://codeberg.org/gustedt/ellipsis

    [3] https://github.com/fuhsnn/slimcc

  3. teo_zero HN

    사소하지 않은 전처리가 필요해지는 순간 어김없이 다시 만들게 되는 구조가 몇 가지 있습니다.

    그중 하나가 2단계 CAT이고, 또 하나가 글에서 다룬 NARG입니다.

    __COUNTER__가 다음 표준인 C29에 들어갈 예정이니, __VA_COUNT__와 __CAT__도 기대해 볼 만하지 않을까요!

  4. 0c3ca83 HN

    사소하지 않은 전처리를 하는 건 실수입니다. 컴파일이 느려지고, 디버깅하기 어렵고, 불투명하고 깨지기 쉬운 코드가 되고, 복잡도가 엄청나게 커집니다. 메타프로그래밍을 원한다면 C 헤더 호환이 필요할 때는 C++, 필요 없다면 zig가 낫습니다.

    그게 아니라면 전처리 지시문은 #include 하나뿐이라고 치는 편이 좋습니다. constexpr이 있으니 #define조차 이제 구식입니다.

  5. teo_zero HN

    사소하지 않은 무엇이든 하면 컴파일이 느려지고, 디버깅이 어려워지고, 깨지기 쉬워지고, 복잡해집니다. 그게 실수일 때도 있고 아닐 때도 있습니다.

    누가 가끔 남용한다는 이유로 어떤 기능을 없애자는 건 근시안적입니다.

  6. 0c3ca83 HN

    쓰지 않으려고 굳이 없앨 필요는 없습니다. 매크로 마법은 형편없는 코드로 이어집니다.

  7. jkercher HN

    맞는 말이기도 하고 아니기도 합니다. 제네릭 동적 배열 같은 걸 만들려면 타입 소거(void*)로 할 수는 있지만, 그러면 코드에 타입 안전성이 사라집니다. 저는 그쪽이 더 깨지기 쉽다고 봅니다. 전처리기를 쓰면 다른 현대적인 언어처럼 동작하는 구조를 추가할 수 있습니다.

    참고로 clangd는 매크로를 제자리에서 풀어서 보여 줄 수 있어서 디버깅이 쉽습니다.

  8. 0c3ca83 HN

    제 경험상 그냥 realloc이나, OOM이 나면 abort하는 래퍼를 쓰는 편이 대개 낫습니다.

  9. uecker HN

    그리고 제네릭 자료구조라면 타입 소거 구현 위에 아주 얇은 매크로 계층만 얹는 방법도 있습니다.

  10. mrpippy HN

    컴파일이 느리고, 디버깅하기 어렵고, 불투명하고 깨지기 쉬운 코드에, 엄청난 복잡도

    대부분 C++를 그렇게들 표현하죠.

  11. 0c3ca83 HN

    맞습니다. 그 단점을 감수할 생각이 있다면, 그런 종류의 영리한 기법을 표현하기에는 C++가 더 낫습니다.

  12. uecker HN

    글쎄요, 잘 모르겠습니다. C에서 매크로를 최소화해야 한다는 데는 동의하지만, 어떤 일에는 작은 매크로 계층이 C++의 복잡도보다 여전히 낫고, 제 생각에는 뒤떨어지지도 않습니다.

  13. 0c3ca83 HN

    C++ 템플릿이 C 매크로보다 훨씬 깔끔하고, 디버깅하기 쉽고, 오류 메시지도 더 알아보기 쉽습니다. 그러면서 C++ 템플릿은 위의 모든 면에서 형편없기로 정평이 나 있고, 그럴 만도 합니다.

  14. uecker HN

    저기, 두 번째 문장에는 동의하는데 첫 번째 문장은 잘 모르겠어요. 제 경험상 전처리기 결과를 직접 볼 수 있다는 것만으로도 매크로가 템플릿보다 디버깅하기 쉬워요.

  15. Joker_vD HN

    전처리기 결과를 직접 확인할 수 있어서 매크로가 디버깅하기 더 쉽다

    본론과는 조금 벗어난 얘긴데요, 왜 그러려면 컴파일러를 '-E' 플래그로 따로 다시 호출해야 하죠? W() 매크로 안에서 세미콜론 대신 쉼표를 써 버린 이런 바보 같은 예제가 있다고 해 볼게요.

        #define W(a, b)  b, int a
        #define X(a, b)  W(b, a)
    
    int kekw(int x) {
            int z = X(23, k) = x;
            return z + k;
        }
    

    이걸 -E 없이 GCC에 넣으면 이렇게 나와요.

        <source>: In function 'int kekw(int)':
        <source>:1:21: error: expected unqualified-id before 'int'
            1 | #define W(a, b)  b, int a
              |                     ^~~
        <source>:2:18: note: in expansion of macro 'W'
            2 | #define X(a, b)  W(b, a)
              |                  ^
        <source>:5:13: note: in expansion of macro 'X'
            5 |     int z = X(23, k) = x;
              |             ^
        <source>:6:16: error: 'k' was not declared in this scope
            6 |     return z + k;
              |
    

    그러지 말고, 왜 있잖아요, 생성된 변수 선언이 뭐가 잘못됐는지 볼 수 있게 확장된 소스 텍스트를 보여 주면 안 되나요? 예를 들면 이런 식으로요.

        <source>: In function 'int kekw(int)':
        <source>:1:21: error: expected unqualified-id before 'int'
            1 | #define W(a, b)  b, int a
              |                     ^~~
        <source>:2:18: note: in expansion of macro 'W'
            2 | #define X(a, b)  W(b, a)
              |                  ^
        <source>:5:13: note: in expansion of macro 'X'
            5 |     int z = X(23, k) = x;
              |             ^
        <source>:5:-: note: the expanded source text is
              |     int z = 23, int k = x;
              |                 ^~~
        <source>:6:16: error: 'k' was not declared in this scope
            6 |     return z + k;
              |
    

    어떨까요? 확장된 텍스트 안에서 줄/열을 추적하는 게 엄청 까다롭다는 건 이해해요(전부 최상위 매크로 위치 하나로 뭉뚱그리는 것도 똑같이 까다롭긴 하지만요). 그래도요. 컴파일 못 하는 그 소스를 보여 달라고요, 거기서 세 단계나 거슬러 올라간 원형 말고요. 저는 머릿속에서 매크로를 펼칠 재주가 전혀 없다고요.

    그리고 애초에 저 첫 번째 오류 메시지는 대체 무슨 소린가요? 왜 "int" 앞에 unqualified-id가 와야 한다는 거죠? "int"를 unqualified-id 뒤에 둬도 어차피 불법인데요. 게다가 이 문맥에서 쉼표 뒤에 올 수 있는 게 unqualified-id만도 아니잖아요.

  16. david2ndaccount HN

    소스 텍스트가 C 프로그램으로 번역되는 동안에는 여러 단계를 거칩니다. 먼저 원시 바이트가 전처리 토큰이 되고, 전처리 토큰이 확장되어 또 다른 전처리 토큰이 되고, 그다음 전처리 토큰이 C 토큰으로 번역되어 C 파서에 들어갑니다. “확장된 텍스트”라는 것은 없고 확장된 토큰만 있습니다. 보통 요청이 있을 때마다 토큰을 만들어 내는 상태 기계로 구현하기 때문에 확장된 텍스트는 존재하지 않습니다. GCC가 그 오류 메시지를 보여 줄 때는 기록해 둔 소스 위치를 원본 소스 파일에서 찾아 보여 줍니다. 이건 쉽습니다. 소스 파일의 한 줄만 확장하려면 전처리기를 처음부터 다시 호출하거나, 이미 소비한 전처리 토큰을 저장해 두어야 합니다.

    물론 가능은 하지만 아직 아무도 구현하지 않았고, 둘 중 어느 쪽을 원할지도 분명하지 않습니다. 오류 메시지를 위해 전처리 토큰을 버퍼링해 두면 메모리 사용량이 크게 늘고, 오류 없이 성공하는 대다수 컴파일에서 평소 경로가 느려집니다. 그렇다고 오류 메시지마다 전처리기를 다시 호출해 한 줄을 보여 주면 오류 보고가 느려집니다.

  17. Joker_vD HN

    “확장된 텍스트”는 없고 확장된 토큰만 있습니다.

    "-E"를 지원하는 컴파일러라면 확장된 텍스트에 해당하는 무언가가 분명히 있어요. 공백 정보도 (어느 정도는) 갖고 있고요. "b,"의 b와 쉼표 사이에 공백을 넣으면 "-E"가 꼬박꼬박 그대로 보여 줍니다. 네, 공백 여러 개는 하나로 합쳐지지만, 있는지 없는지는 이미 추적하고 있다는 거죠.

    소스 파일의 한 줄만 확장하려면 전처리기를 처음부터 다시 호출하거나 이미 소비한 전처리 토큰을 저장해 두어야 합니다.

    네, 가장 최근의 최상위(중첩이 아닌) 매크로의 확장 전체를 들고 있으면 되죠. LZ 같은 바이트코드식 압축(말씀하신 그 상태 기계의 재생)이면 추가 메모리도 조금만 들고 꽤 잘 될 거라고 봐요. 아니, 그건 잊으세요. 매크로 안에서는 #define/#undef가 불가능하니까 확장 결과를 기억할 필요도 없어요. 현재 매크로 정의만 유지하면서 마지막으로 확장하려던 매크로가 뭐였는지만 기억하면 되고, 오류가 그 확장 안에서 났다면 다시 확장하면 그만이에요. 추가 메모리는 0이에요. 지금 방식의 오류 보고에 이미 그 정보가 저장돼 있으니까요. 현재 출력을 봐도 어떤 매크로를 어디서 확장했는지, 문제의 토큰까지 내려가는 전체 경로를 기억하고 있잖아요. 1:21에서 "in expansion of macro 'W'", 그건 2:18에서 "in expansion of macro 'X'", 그건 5:13이고요.

    그렇다고 오류 메시지마다 전처리기를 다시 호출해 한 줄을 보여 주면 오류 보고가 느려집니다.

    글쎄요, 프로그래머가 직접 "-E" 옵션을 붙여 다시 돌리게 하는 것보다는 전체적으로 빠르죠.

  18. david2ndaccount HN

    "-E"를 지원하는 컴파일러라면 확장된 텍스트에 해당하는 무언가가 분명히 있습니다. 공백 정보도 (어느 정도는) 갖고 있고요. "b,"의 b와 쉼표 사이에 공백을 넣으면 "-E"가 꼬박꼬박 그대로 보여 줍니다. 네, 공백 여러 개는 하나로 합쳐지지만, 있는지 없는지는 이미 추적하고 있다는 거죠.

    전처리기에서는 공백이 의미가 있으니 당연히 공백을 추적합니다. 공백은 7단계에서 전처리 토큰이 C 토큰으로 바뀔 때 비로소 버려집니다.

    말씀드렸듯이 불가능한 건 아닙니다. 패치를 작성해서 GCC가 받아 주는지 보세요.

  19. andai HN

    프로그래밍 언어가 프로그래밍 가능하지 않다는 게 늘 마음에 걸렸어요.

    뭐, Lisp는 그렇다는 얘기를 계속 듣고 있는데 제대로 공들여 본 적은 없어요. (일단 브라우저에서 돌아가는 Lisp가 필요하겠지만요!) 다시 한번 들여다봐야 할지도 모르겠네요...

    아니면 VM이 프로그래밍 가능했으면 좋겠다는 건지도 몰라요. "이런 종류의 연산이 일어나면 알려 줘"라거나 "함수가 호출되기 전에 이름을 전부 출력해 줘" 같은 걸요. 결국 임시로 넣는 출력문을 프로그램 곳곳에 복붙하게 되거든요.

  20. FuckButtons HN

    (일단 브라우저에서 돌아가는 Lisp가 필요하겠지만요!)

    넷스케이프 경영진이 브렌던 아이크가 계획했던 대로 스킴(Scheme)을 쓰게 놔뒀더라면 우리는 아주 다른 세상에 살고 있을 거예요.

  21. kazinator HN

    일단 블록 스코프 let이 처음부터 있었겠죠. 거의 20년 동안 함수 스코프 var로 버티는 일도 없었을 테고요.

  22. HexDecOctBin HN

    C 전처리기를 확장해서 (Snobol 같은 걸 생각하고 있습니다) 튜링 완전한 언어로 만들어서 Terra 같은 메타프로그래밍을 가능하게 해 보려는 시도가 있었나요?

    (metalang99나 Cello 같은 것을 말하는 게 아닙니다. 그런 건 현재 규격 전처리기의 튜링 타르핏(Turing tarpit)을 이용하는 것이고, 제가 말하는 건 전처리기 자체에 대한 세련된 추가입니다.)

  23. david2ndaccount HN

    이 시리즈에서 거기까지 가 볼 겁니다.

  24. 0c3ca83 HN

    C++도 전처리기로 시작했습니다.

  25. cowlevel HN

    하지만 C 전처리기가 아니라 완전히 다른 전처리기였어요.

  26. tambourine_man HN

    Objective-C도 그랬죠.

  27. camel-cdr HN

    C 전처리기는 사실상 튜링 완전합니다. C 자체나 여러분의 컴퓨터가 사실상 튜링 완전한 것과 같은 의미에서요. 엄밀하게는 아니지만요.

  28. pajko HN

    MAP을 쓰려고 전처리기를 확장할 필요는 없습니다: https://github.com/swansontec/map-macro

  29. Neywiny HN

    그런데 그 저장소에서도 전처리기에 부담이 된다고 하고, 코드를 보면 재귀 깊이가 제한되어 있고 그 한도는 파일에 적힌 만큼입니다. 임베디드 시스템에 레지스터 정의가 수천 개 있다면 그걸로는 안 됩니다.

  30. david2ndaccount HN

    글에서 map을 구현하는 방법을 보여 주긴 하지만, 이건 내장 기능이어야 합니다.

Hacker News에서 보기 ↗