back

가변 인자 매크로의 모든 것 - 보충

6년 전 작성

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


C 표준에 들어갈 가능성이 큰 __VA_OPT__ 를 실험적으로 구현해보면서 , ## __VA_ARGS__ 확장(쉼표 생략 확장, comma omission extension)과 관련해 놓쳤던 중요한 사실 하나를 깨달았습니다.

바로 매크로 재정의(macro redefinition)와 관련된 문제입니다. 표준은 한 매크로가 여러 헤더 파일에 정의될 가능성을 고려해 특정 조건을 만족하면(같은 내용이면) 재정의하는 것을 허용해주고 있습니다. 예를 들면,

#define paste(x, y) x ## y
#define paste(x, y) x ## y

는 당연히 허용되며,

#define paste(x, y) x ## y
#define paste(x, y) x   ##   y

와 같은 형태도 허용됩니다. 간단히 설명하면, 동일한 토큰으로 구성되어 있고 같은 위치에서 공백(whitespace)으로 분리되어 있으면 재정의를 허용합니다. 예를 들어, 아래처럼 같은 의미이지만 동일한 토큰이 아니거나

#define paste(x, y) x ## y
#define paste(a, b) a ## b

동일한 위치가 분리되어 있지 않으면

#define paste(x, y) x ## y
#define paste(x, y) x##y

재정의가 허용되지 않습니다.

이는 표준 라이브러리 등을 구현할 때 둘 이상의 헤더 파일에서 정의되어야 하는 NULL 같은 매크로와 관련된 고민을 덜어주면서, 동시에 매크로 재정의를 검사하는 코드를 너무 복잡하지 않게 만들어주는 규칙입니다.

NULL<stdlib.h><stdio.h> 를 포함해 여러 표준 헤더에 정의되어 있습니다. 만약 매크로 재정의가 허용되지 않으면 사용자가 어떤 헤더를 어떤 순서로 추가할지 알 수 없기 때문에

#ifndef NULL
#define NULL 0
#endif

처럼 매크로 정의 부분을 모두 재정의 검사로 감싸주어야 합니다. 하지만, 만약 모든 NULL 정의가 동일한 내용이라면 별도의 검사 없이 매크로 정의를 여러 헤더에서 해주더라도 문제가 되지 않습니다.

제가 깨닫게 된 문제란 바로 이렇습니다. 가변 인자 부분과 연동하여 생략되는 토큰에 대한 정보를 매크로 정의(#define)를 인식하는 코드에서 생성해 둘 경우 토큰 분리에 대한 정보가 사라지기 때문에, 잘못된 매크로 재정의를 잡아내지 못하게 됩니다. 예를 들어, 현재 규칙에 따르면

#define foo(...) , ## __VA_ARGS__
#define foo(...) ,##__VA_ARGS__

와 같은 코드를 잘못된 재정의로 잡아 오류를 내주어야 하지만, -extension 옵션으로 확장 지원을 켜는 순간 아무런 오류 없이 받아 들이게 됩니다. 내부적으로 이미 저 부분이

  • 생략 가능한 ,
  • __VA_ARGS__

로 해석되어 저장되기 때문에 공백 문자가 토큰을 분리하는지 여부를 구분하지 못하는 것입니다.

애초에 , ## __VA_ARGS__ 확장 구현을 설계할 때 나중에 추가할 __VA_OPT__ 에 대한 고려만 할 뿐 잘못된 재정의를 잡을 생각은 못해 생긴 일이었습니다. 실제 쉼표 생략 확장을 구현할 때 적용했던 vaopt 플래그(flag)를 사용해 __VA_OPT__ 를 구현할 경우에도 기본 동작에는 아무 문제가 없지만 아래와 같은 매크로 재정의를 제대로 잡아내지 못하는 결과가 발생합니다.

#define foo(...) __VA_OPT__(foo) __VA_OPT__(bar)
#define foo(...) __VA_OPT__(foo bar)

매크로 정의를 인식하는 코드(mcr_define())에서 쉼표 생략이나 __VA_OPT__ 를 인식해서 vaopt 같은 플래그로 해석해 둘 경우 이후 단계에서 처리가 간결해지고 버그가 생길 가능성도 줄어듭니다. 하지만, 잘못된 매크로 재정의를 잡아내려면 매크로 정의를 인식하는 코드에서 토큰 및 토큰 분리와 관련된 정보를 유지하고, 매크로를 확장하는 코드(mcr_expand())로 쉼표 생략이나 __VA_OPT__ 처리를 옮겨두어야 합니다. 더불어 토큰마다 달려있던 vaopt 플래그는 그 역할이 사라지니 지워지겠죠 - 실제 확장하는 시점에 처리를 하니 플래그가 필요없습니다.

쉼표 생략 확장의 경우 매크로 정의를 인식하는 과정에서 따로 검사할 부분이 없는 덕에 매크로 정의 부분에 추가했던 코드를 전부 삭제하고 확장 부분에서 인식하도록 할 수도 있습니다. 하지만, 이전 글에서 소개했듯이, 현재 구현에서는 매크로 매개변수가 확장되는지 여부를 판단해 인자 확장을 준비해 두고 있습니다. 만약, 매크로 매개변수가 ## 의 피연산자로만 주어지면 해당 매개변수로 주어진 인자를 확장할 필요가 없기 때문에 인자 확장 과정을 생략하게 됩니다. 하지만, , ## __VA_ARGS__ 에서 ## 는 특별한 표기를 위해 차용했을 뿐 실제로는 토큰 결합의 역할을 하지 않기 때문에 __VA_ARGS__## 의 피연산자임에도 인자 확장을 준비해두어야 합니다 - 이래서 제가 ## 를 원래와 다른 의미로 사용하는 gcc 의 확장이 후지다고 생각할 수밖에 없습니다!

그렇다면 최소한 두 군데(인자 확장 여부를 결정하는 시점, 실제 매크로 확장을 수행하는 시점)에서 쉼표 생략 확장을 인식해야 하니, 차라리 기존 매크로 정의를 인식하는 코드에 작업했던 부분을 남겨 쉼표 생략은 매크로 정의 부분에서 인식하고(플래그를 일정 값으로 설정), 이후 인자 확장을 결정하는 부분이나 매크로를 확장하는 부분에서 그 정보(플래그)를 사용해 실제 작업을 진행하는 형태로 접근하였습니다.

그렇게 나온 결과물, ## __VA_ARGS__ 확장을 제대로 지원하면서 동시에 매크로 재정의 오인식 문제도 해결하게 됩니다.

__VA_OPT__ 구현은 , ## __VA_ARGS__ 확장 지원과는 조금 다른 부분이 있습니다. __VA_OPT__ 의 경우 (매크로 확장을 수행하는 과정이 아닌) 매크로 정의를 인식하는 과정에서 아래와 같은 코드를 잡아주어야 합니다.

#define foo(...) bar __VA_OPT__(##) __VA_ARGS__    // error

그리고 추가로 여닫는 괄호가 짝이 맞는지도 검사해야 하고, 중첩된 괄호에 대한 인식도 필요하기 때문에 불가피하게 매크로 정의 인식 과정에서 __VA_OPT__ 를 인식할 수 밖에 없습니다. 이 때문에 매크로 정의를 인식할 때 필요한 검사를 마치고 쉼표 생략 확장과 유사하게 플래그를 특정 값으로 설정해두고, 이후 매크로 확장 과정에서는 플래그를 통해 보다 쉽게 정상적인 __VA_OPT__ 를 인식해 처리하도록 할 수 있습니다. 이는 구현 형태 면에서도 쉼표 생략 확장과 나란한 모양이기 때문에 설계 상의 심미적인 만족도 가져오게 됩니다.

그럼에도 __VA_OPT__ 는 아직 주류 컴파일러들도 어처구니 없는 버그를 담고 있을 만큼 구현 초기 단계입니다. 더구나 대강의 동작에 대해서는 사람마다 동의가 이루어져 있으나 세세한 부분에 대해서는 구현 방식마다 다른 특성을 보일 수 있어, C++ 위원회가 아닌 C 위원회 쪽에서 보다 상세한 표준 가안이라도 나오면 구현을 진행할 예정입니다. 제 컴파일러는 gcc 나 clang 처럼 주류가 아니라 표준화 위원회에서 표준안을 마련할 때 영향력이 전무하니 몸을 사릴 수밖에 없겠네요 - 하물며 gcc 측의 제안도 원칙에 맞지 않으면 무시하는 곳인걸요!