back

가변 인자 매크로의 모든 것 - 구현

2달 전 작성

이 글은 시리즈 글의 일부입니다.


지난 글에서 소개한 __VA_OPT__ 는 실제 컴파일러에 채용된 선례(existing practice)가 없어 추후 추가될 경우를 고려는 하되 beluga 에 구현은 하지 않았습니다.


C99 에 추가된 매크로 관련 기능은 크게 빈 인자 허용가변 인자 지원입니다. 표준은 "일반적인 확장(common extension)"이라는 이름으로 당장 표준에 포함하지는 않았으나 컴파일러 다수에서 공통으로 지원 중인 확장 기능을 나열하고 있습니다(일례로 프로그램에서 환경 변수를 받을 수 있는 main() 함수의 세 번째 매개변수 envp 가 있습니다). C90 의 "일반적인 확장" 목록에 매크로 함수의 빈 인자 지원이 포함되어 있어, beluga 는 전처리기가 sea-canary 라는 이름으로 분리되어 있던 시절부터 빈 인자를 지원하고 있었습니다 - 물론, C90 모드에서는 경고를 내줍니다.

빈 인자를 구현하는 방식은 생각보다 단순합니다. 우선, 매크로 확장에서 인자 확장을 구현하는 방법부터 간단히 살펴보겠습니다.

표준은 ### 가 적용되지 않는 이상 매크로 함수 인자를 완전히 확장하여 매개변수 이름을 치환하도록 요구하고 있습니다. 예를 보면,

#define foo() bar
#define func(p) p and p

func(foo())    // bar and bar

와 같은 코드에서 func() 를 확장하기 위해 매개변수 p 를 인자 내용으로 대체하는 과정에서, 인자인 foo() 를 완전히 확장하여 결과로 나온 bar 가 매개변수를 대체합니다. 전반적으로 매크로 확장 과정은 (매크로 재스캔을 위해) 반복적인(iterative) 형태로 구현되는데 반해, 이 인자 확장 부분은 문맥(context)을 따로 가져가야 해서 재귀적인(recursive) 형태로 구현됩니다.

만약, 매크로 호출을 인식하는 과정에서 매개변수와 인자 테이블을 아래와 같이 구성하면

매개변수 인자
p foo()

매개변수 p 를 만날 때마다 foo() 를 확장하는 작업을 반복해야 합니다. 그러면 아래처럼 인자를 인식할 때 아예 확장한 결과를 만들어두면 어떨까요?

매개변수 인자
p bar

이 경우에는 아래 같은 코드에서 문제가 됩니다.

#define foo() bar
#define func(p) #p and p    // "foo()" and bar

매개변수 p 의 첫번째 사용이 문자열화 연산자(stringization operator # 의 영향을 받기 때문에, 주석으로 표시한 것처럼 인자 foo()bar 로 확장되지 않고 바로 문자열로 바뀌어야 합니다.

그러면 인자를 확장한 버전과 그렇지 않은 버전 두 개를 유지했다가 적절하게 사용하면 될까요? 하지만, 예에서 func() 의 정의가 아래와 같다면

#define func() #p

p 에 주어진 인자의 확장 버전은 사용되지도 않는데 만들어 두는 것이 낭비가 됩니다.

고로, 두 가지 전략을 생각해 볼 수 있습니다.

  • 확장되지 않은 버전만 유지하다가 확장이 필요하면 확장하고 이를 기억(캐시)해 두어 반복적으로 쓰일 때 그대로 사용함
  • 확장 버전과 비확장 버전이 필요한 경우를 구분하여 필요한 정보만 생성함

beluga 에서는 매크로 함수 확장을 수행하기 전에 필요시 인자 확장을 준비해두기 위해 두 번째 전략을 취하고 있습니다. 앞서 설명드린대로 인자 확장은 재귀적인 문맥에서 수행되기 때문에, 매크로 함수 확장을 이미 시작한 후에 인자 확장을 수행하면 문맥 내에 존재하는 정보를 관리하기가 골치 아픕니다. 예를 볼까요?

매크로 확장에서 이미 확장 중인 매크로가 확장의 결과로 나오는 경우 해당 매크로는 더 이상 확장되지 않습니다 - 말이 복잡하지만 예를 보면 쉽습니다.

#define gnu gnu is not unix

만약, 매크로 재귀 확장을 막는 규칙이 없다면 gnu 라고 사용하게 되면

gnu is not unix
gnu is not unix is not unix
gnu is not unix is not unix is not unix
...

처럼 영원히 확장될 것입니다. 하지만, gnu 를 확장하는 과정에서 gnu 를 만났기 때문에 해당 매크로 명칭은 따로 표시를 해두게 됩니다(painted blue 라고 표현합니다). 그리고 매크로 재스캔을 수행하는 과정에서 그렇게 표시된 매크로는 다시 확장되지 않습니다. 고로, 위의 gnu 매크로는

gnu is not unix

까지만 확장되고 멈추게 됩니다. 이 규칙은 표준이 의도적으로 애매하게 남겨둔 경우를 제외하고 간접적으로 재귀가 발생하는 경우에도 그대로 적용됩니다 - foobar 로 확장되고, 다시 barfoo 로 확장되면 확장이 멈춥니다.

이런 규칙을 적용하기 위해 매크로를 확장할 때 현재 확장이 진행 중인 매크로 이름을 따로 기록해 두어야 합니다. 위의 예에서는 gnu 를 확장 중일 때 gnu 를 기억해야 확장 결과로 gnu 가 나오면 표시(painted blue)를 해둘 수 있습니다. 단, 표준에 의하면 인자를 확장하는 시점은 그 이전이라고 합니다. 또 예를 보겠습니다.

#define test(p) to p
#define call test(me)

test(call)    // to to me

조금 복잡해지기 시작하는군요. 마지막 줄에서 test() 를 호출할 때 인자를 보니 call 이고 매개변수 p 자리에 넣기 위해 확장을 하니 test(me) 가 나옵니다. 현재 test 를 확장 중이니 인자는 test(me) 에서 확장을 멈춰야 할까요? 표준에 따르면 그렇지 않습니다. 인자 확장은 해당 매크로 확장의 문맥이 아니기 때문에 test(me) 는 계속 확장되어 me 까지 확장된 후에 매개변수 p 를 치환하게 됩니다.

만약, 마지막 줄의 매크로 호출인 test(call) 확장을 시작한 후에(painted blue 목록에 test 를 올린 후에) 매개변수 p 를 위한 인자 확장을 시작한다면 문맥 정보를 구분해 인자에서 test() 확장이 멈추는 일을 방지해야 합니다. 하지만, 애초 test() 확장 시작 전에 인자를 미리 준비해두면 아직 test() 에 대한 문맥 정보를 구성하기 전이므로 복잡한 부분을 덜어낼 수 있습니다.

이런 고민 끝에 저는 두 번째 전략을 택했고, 매크로 정의를 인식하는 과정에서 매개변수가 실제 사용되는지, 사용된다면 ### 와 사용되는지 등을 판단하여 미리 기록해 둡니다. 그리고 매크로가 호출되는 시점에 사전에 판단한 정보를 바탕으로 확장되지 않은 버전과 확장된 버전을 필요한대로 준비해 두고 매크로 확장을 시작합니다.

매크로 정의를 구성(바로 위 예에서는 to p 부분)하는 부분과 인자의 내용(확장되기 전의 call 과 확장된 후의 to me) 등은 모두 토큰의 리스트(linked list) 구조로 저장합니다. 그리고, 확장이 필요한 부분에 필요한 토큰 리스트를 복사하여(오류나 경고 메시지에서 토큰의 위치나 매크로 확장에 대한 정보를 보여주기 위해 원본 토큰을 그대로 쓰지 못합니다) 연결하는 방식으로 매크로 확장이 이루어집니다.

이제 빈 인자가 주어진다면 (빈 인자에 대한 경고를 출력하는 부분을 제외하면) 자연스럽게 비어 있는 리스트를 구성하면 됩니다. 그리고 토큰 연결(##)이나 문자열화(#) 과정에서 비어 있는 리스트를 다룰 수 있게 주의만 해준다면 큰 어려움 없이 빈 인자를 구현할 수 있습니다.


이제 (드디어) 가변 인자 지원에 대해 이야기해보겠습니다. 가변인자의 핵심 동작은 아래와 같습니다.

  • ... 를 인식
  • 호출 시점에 ... 부분에서는 여러 개의 인자를 받을 수 있도록 허용
  • 확장시 ... 부분에 주어진 인자를 __VA_ARGS__ 부분에 추가

복잡해 보일 수 있지만 실 구현은 생각보다 단순합니다. 표준화 위원회에는 실제 컴파일러를 구현하는 사람들이 다수 포함되어 있기 때문에 컴파일러를 구현하는 입장이 반영되어 표준 내용이 정의되는 경우가 많습니다. 가변 인자에 대해 C99 표준에 추가된 부분 중 하나를 보시죠.

6.10.3.1 Argument substitution

An identifier __VA_ARGS__ that occurs in the replacement list shall be treated as if it were a parameter, and the variable arguments shall form the preprocessing tokens used to replace it.

6.10.3.1 인자 치환

치환 리스트에 명칭 __VA_ARGS__ 는 마치 매개변수인 것처럼 다루며, __VA_ARGS__ 치환을 위해 가변 인자가 전처리 토큰을 형성합니다.

대놓고 힌트를 주고 있습니다. 즉, __VA_ARGS__ 가 마치 매개변수인 것처럼 다룬다고 되어 있으므로 진짜 매개변수처럼 만들어주면 됩니다. 즉, 매크로 정의를 인식할 때 매개변수에서 (정상적인) ... 를 만나면 가변 인자를 받는 매크로임을 표시해두고 ... 부분을 __VA_ARGS__ 로 바꾸어 줍니다. 즉, 매개변수 이름이 __VA_ARGS__ 가 되도록 합니다. 이제 가변 인자로 주어진 토큰 들만 잘 모아주어 __VA_ARGS__ 매개변수와 연결해주면 __VA_ARGS__ 를 사용한 부분에 적절히 치환되어 삽입될 것입니다.

if (t->id == LEX_ELLIPSIS) {
    // 생략
    SPELL(t, "__VA_ARGS__");
    t->id = LEX_ID;
    // 생략
}

실제 매크로 정의를 인식하는 코드(mcr.cmcr_define())를 보면, ...(LEX_ELLIPSIS) 를 만나면 토큰의 내용을 ... 에서 __VA_ARGS__ 로 갈아치우고, 토큰 코드(t->id)를 명칭(LEX_ID)으로 바꾸는 것을 확인할 수 있습니다.

그러다보니 아래와 같은 코드에서 발생하는 오류 메시지는 매개변수 __VA_ARGS__ 가 중복 정의되어 있다는 내용(duplicate macro parameter '__VA_ARGS__')이 됩니다.

#define test(__VA_ARGS__, ...)

그리고 재미있는 것은 gcc 도 동일한 내용의 오류를 낸다는 것입니다. 즉, 표준이 준 힌트를 적극적으로 사용하였습니다 - 단, clang 은 전처리기에 그리 공을 들이지 않는지 위 코드의 오류를 제대로 잡아주지 못합니다.

호출 시점에 가변 인자 부분에서 쉼표를 포함한 인자를 받도록 하는 것도 (조심해야 하는 엣지 케이스(edge case)가 있기는 하지만) 크게 어렵지는 않습니다. 이미 중첩된 괄호 안에서 쉼표가 인자를 분리하지 않도록 인식하는 코드가 구현되어 있습니다. 이 부분 조건을 가변 인자 매크로이고 마지막 인자를 인식 중이면 마치 중첩된 괄호 안에 있는 것처럼 쉼표를 인자의 내용으로 인식하도록 해주면 그만입니다.

코드를 보면, 원래 이렇던 조건문이

if (level > (t->id == ','))
    tl = lst_append(tl, lst_copy(t, 0, strg_line));

아래와 같이 수정됩니다.

if (level > (t->id == ',') || (p->f.vaarg && level == 1 && n == p->func.argno))
    tl = lst_append(tl, lst_copy(t, 0, strg_line));

(앞서 언급한 엣지 케이스 때문에 지금은 다른 모양입니다.)

처음엔 현재 인식한 토큰이 쉼표(,)이고 괄호가 중첩되어 있으면(level > 1) 쉼표를 그대로 인자 리스트로 포함시켰습니다. 바뀐 코드에서는 추가로, 가변 인자 매크로(p->f.vaarg)이고 토큰이 쉼표이며 괄호 안에 중첩되지 않았고(level == 1) 마지막 매개변수(...)를 위한 인자(n == p->func.argno)이면 쉼표로 인자 분리를 하지 않고 토큰 리스트에 넣고 있습니다.

위 코드는 만난 토큰이 , 일 때뿐 아니라 ) 일 때도 진입하는 코드입니다. 따라서, level > (t->id == ',') 라는 코드로 두 가지 경우(토큰이 닫는 괄호이고 아직 다른 괄호 안에 중첩된 경우, 토큰이 쉼표이고 괄호 안에 중첩된 경우)를 모두 다루고 있습니다. 그리고, 가변 인자 매크로 조건 검사에서 괄호 안에 중첩된 경우는 이미 앞에서 다루므로 level 검사에 등호를 쓸 수 있으며, level == 1 인 경우 앞의 조건에 의하면 현재 토큰은 쉼표일 수 밖에 없어 t->id 에 대한 검사는 불필요합니다.

그러면 매개변수 이름 __VA_ARGS__ 부분에 쉼표를 포함한 토큰 들이 인자로 배정되어 있고, 이제 원래 있던 코드에 그냥 치환을 맡겨 버리면 __VA_ARGS__ 가 나오는 부분에 인자로 주어진 토큰이 들어가게 됩니다.

물론, 매크로 중복 정의를 적절히 처리하기 위한 코드나 ... 가 잘못 나오는 경우 등에 대한 예외 처리도 필요하지만, 실제 가변 인자를 구현하기 위한 코드는 생각보다 매우 소량입니다.

사실, C99 가변 인자 구현 과정에서 문제는 엉뚱한 곳에서 발생합니다. 표준이 __VA_ARGS__ 라는 이름을 가변 인자가 아닌 문맥에서는 사용하지 말도록 강제하고 있습니다. 물론, 당연히 그래야겠지만, 하필 이 규정을 매우 일반화하여 Constraints 라는 항목으로 넣어 두었습니다.

6.10.3 Macro replacement

Contraints

The identifier __VA_ARGS__ shall occur only in the replacement-list of a function-like macro that uses the ellipsis notation in the parameters.

6.10.3 매크로 치환

제한

__VA_ARGS__ 라는 명칭은 매개변수에 ... 를 사용하는 매크로 함수의 치환 리스트 안에서만 나와야 한다.

Constraints 안에 들어간 규정은 프로그램이 위반할 경우 컴파일러는 오류나 경고 메시지를 내주어야 합니다. 물론, 이론적으로는 "니 코드 뭔가 이상해" 같은 메시지도 충분하긴 하지만, 실제 그렇게까지 비협조적인 컴파일러는 많지 않겠지요? 때문에 코드 전체에서 __VA_ARGS__ 가 나오면 가변 인자 매크로의 치환 리스트 부분인지를 가려서 오류 메시지를 출력해야 하는 문제가 발생합니다. 말은 쉬워 보이지만, 이는 전처리기가 프로그램을 읽을 때 명칭을 만날 때마다 __VA_ARGS__ 인지 검사해야 한다는 뜻이 됩니다. 결국, 전처리기 성능에 영향을 미칠 수 밖에 없는 상황이 됩니다.

이제 또 다시 두 가지 전략을 고민해 볼 수 있습니다 (네, 프로그래밍은 끊임 없이 가능한 전략을 나열하고 최적의 방안을 찾아내는 과정입니다!).

  • 토큰을 인식하는 초기 단계에서 __VA_ARGS__ 를 인식해 가변 인자 매크로 정의 문맥이 아니면 에러를 내줌
  • 토큰 인식 단계는 건드리지 않고 전처리기 내에서 __VA_ARGS__ 를 필요한 곳마다 검사하여 에러를 내줌

첫 번째 전략의 장점은 일단 성능에서 크게 손해를 보지 않을 수 있다는 것입니다. 어차피 개별 토큰을 인식하는 코드는 문자를 비교하며 분기하는 거대한 switch입니다. 단점은, 가변 인자 매크로를 정의하는 정상적인 문맥에서는 에러 메시지가 나와선 안되고, 또 그 외의 상황이어도

#if 0
__VA_ARGS__
#else
...
#endif

와 같은 코드에서는 에러를 내면 안 되기 때문에, 프로그램 구조상 상위의 정보(가변 인자 매크로를 정의하는 문맥인가, 조건부 컴파일에 의해 사라지는 문맥인가)를 전역 변수 등을 통해 하위 구조인 어휘 분석기(lexer)에 내려주는 매우 바람직하지 않은 형태가 됩니다.

두번째 전략은 성능 상으로는 처음보다는 불리합니다. 명칭을 처음 인식 단계 이외에 별도로 __VA_ARGS__ 와의 비교가 필요하기 때문입니다. 또한, 전처리기가 명칭을 만날 수 있는 상황을 빠짐 없이 확인하여야 하므로 꼼꼼할 필요가 있습니다. 그럼에도 구조상 상위 구조에서만 문제가 해결되기 때문에 beluga 에서는 두 번째 전략을 취합니다.

아주 아주 다행히도 전처리를 하는 과정에서 모든 명칭은 매크로 확장 여부를 검사합니다. 따라서, 매크로 확장을 검사하는 코드에서 __VA_ARGS__ 여부를 확인해주면 대부분의 경우 빼먹지 않고 검사가 가능합니다. 그럼에도 성능 저하를 어떻게든 줄여보고자 검사할 때 아래처럼 함수 호출로 간단히 비교하지 않고

if (strcmp(s, "__VA_ARGS__") == 0)

함수 호출 전에 문자 비교로 필터링을 하고 있습니다 (실 코드에서는 반복된 검사를 함수 호출이 아닌 매크로로 묶어 두었습니다).

if (s[0] == '_' && s[1] == '_' && s[2] == 'V' && strcmp(s+3, "A_ARGS__") == 0)

명칭 중에 밑줄 문자로 시작하는 명칭의 빈도가 그리 크지 않으므로 위 코드는 대부분 첫번째 비교(s[0] == '_')에서 분기될 가능성이 큽니다. 3번째 문자가 V 인지 확인하고 나서야 strcmp() 를 호출하므로 실제 __VA_ARGS__ 가 아니고서는 거의 strcmp() 가 호출되는 일은 없다고 볼 수 있을 것입니다.

물론, 컴파일러에 따라 strcmp() 호출을 인라인(inline)으로 처리하여 좋은 성능의 코드를 생성해주는 경우도 있습니다. 하지만, beluga 는 자체의 이식성(C90 을 지원하는 컴파일러는 모두 beluga 를 컴파일할 수 있습니다)도 중요하여 특정 컴파일러의 기능에 의존하는 코드를 피하고 있습니다.

또한, 전처리 과정에서 명칭이 나와도 매크로 확장의 영향을 받지 않는 상황이 있습니다. 대표적인 경우가 바로 일부 전처리 지시자 안입니다. 예를 들어,

#define foo bar
#error foo is not defined

와 같은 코드는 bar is not defined 가 아닌 foo is not defined 를 오류 메시지로 냅니다. #error 지시자 안에서는 매크로 확장이 일어나지 않기 때문입니다. 이와 같은 경우도 찾아 적절히 __VA_ARGS__ 검사를 해주면 표준이 요구하는 바를 만족시킬 수 있습니다. 표준이 어떤 상황에서 오류 메시지를 요구하는지 저는 철저히 제 해석을 따라 구현했지만, gcc 나 clang 은 또 생각하는 바가 다른 듯 합니다.

사실, 이 부분을 구현하면서 크게 중요하지 않은 에러 메시지를 내는 코드 규모가 가변 인자 자체 구현과 비슷한 느낌이라 정말 위원회가 이를 의도한 것인지 심히 의문스럽습니다만, beluga 보다 훨씬 덩치 큰 clang 도 얌전히 따르고 있으니 추후 DR(defect report) 을 제출하더라도 일단 따르고 보기로 했습니다. 아무리 생각해도 제 생각에는 (모든 __VA_ARGS__ 가 아니라) 매크로 정의 문맥에서만 에러를 내주면 충분할 것 같다는 생각입니다.


마지막으로 gcc 확장을 구현한 방법에 대해 소개드릴까 합니다. gcc 확장 구현은 크게 두 부분으로 나뉩니다.

  • 가변 인자 부분에 인자가 주어지지 않으면 빈 인자가 아닌 인자가 없는 것으로 인식
  • , ## __VA_ARGS__ 를 인식해 적절히 처리

gcc 확장만 구현하고 표준화 위원회에 제안된 __VA_OPT__ 는 구현하지 않았으나 추후 __VA_OPT__ 가 구현될 경우를 염두에 두고 설계하였습니다.

우선, 인자가 아예 주어지지 않은 경우와 빈 인자가 주어진 경우를 구분하는 것은 플래그(flag)를 도입해 정리하였습니다. 현재 구현에서 빈 인자를 리스트 자체는 할당되나 안에 노드(node)가 없는 구조가 아니라 아예 널 포인터(null pointer)로 표현하고 있습니다. 따라서, 빈 인자(널 포인터)와는 달리 인자가 없는 경우를 추가로 구분하려면 (가장 흔히 쓰는 방법으로) 인자가 없음을 표현하기 위한 정적 변수를 만들고 그 주소를 사용해야 합니다. 만약, 가변 인자 부분뿐 아니라 다른 매개변수에서도 둘을 구분해야 한다면 그렇게 별도의 표현을 도입하는 것이 낫지만, 가변 인자 매크로의 가변 인자 부분에서만 구분이 필요하므로 가급적 코드 수정을 줄이는 방향을 택했습니다.

구분을 위한 플래그가 설정되는 경우는 두 군데 뿐입니다. 하나는 고정 매개변수도 갖는 가변 인자 매크로에서 인자가 부족하게 주어진 경우와 가변 인자만 받는 매크로에서 인자가 부족하게 주어진 경우입니다.

, ## __VA_ARGS__ 를 인식하고 처리하기 위해 여러 방법이 사용될 수 있으나 추후 __VA_OPT__ 가 추가될 가능성을 고려해 토큰에 vaopt 라는 속성을 추가하였습니다 - 토큰 속성은 비트 필드(bit-field)로 표현하기 때문에 1비트 속성을 하나 더 추가한다고 사용하는 메모리가 늘지는 않습니다. 그렇게 쉼표 뿐 아니라 다른 토큰도 필요에 따라 생략 가능으로 표시할 수 있도록 만들어주고, (앞서 설명한 플래그 검사하여) 가변 인자에 해당하는 인자가 주어지지 않으면, vaopt 속성을 갖는 토큰은 매크로 확장 과정에서 삭제되도록 구현하였습니다. 코드 상으로는

if (t->f.vaopt && noarg)
    continue;

이렇게 간단한 코드가 됩니다.

그리고 남은 할 일은 가변 인자 매크로에서 , ## __VA_ARGS__ 를 인식해 쉼표에 해당하는 토큰의 vaopt 속성을 켜주는 것 뿐입니다. 물론, 이 과정에서 ## 는 실제로는 토큰 결합의 역할을 하지 않으므로 삭제해주는 것도 잊어서는 안 됩니다.

나중에 __VA_OPT__ 가 추가되면 __VA_OPT__ 를 인식하는 코드에서 생략될 토큰들의 vaopt 속성을 켜주면 다른 부분 수정 없이 자연스럽게 지원이 가능한 구조가 됩니다.


지금까지 가변 인자 매크로에 대해서 "모든 것"이라고 부를 만한 것들을 차례로 살펴보았습니다 - 혹시나 부족하거나 더 궁금한 부분이 있다면 답글로 알려주시면 추후 보충하도록 하겠습니다. 최대한 쉽게 설명한다고 하였지만 제가 솜씨가 없는 탓에 중간중간 예상보다 복잡한 이야기가 불친절하게 전달된 것은 아닐지 걱정하며 긴 글을 마무리 짓겠습니다.