back

가변 인자 매크로의 모든 것 - 현재 #3

6년 전 작성

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

수정: 마지막 글(가변 인자 매크로의 모든 것 - 보충)이 추가되었습니다.


글이 길어지니 잠시 복습해 보겠습니다.

#define debug(msg, ...) printf("%s:%d:" msg "\n", __FILE__, __LINE__, __VA_ARGS__)

C99 부터 지원되는 가변 인자 매크로 함수(variadic function-like macro)에서 한 가지 문제점은 실 함수와 달리 가변 인자를 완전히 생략할 수 없다는 점입니다. 때문에 아래와 같은 형태로 매크로를 호출 할 수 없고

debug("message only");

가변 인자 부분에 최소한 하나 이상의 인자가 있어야 한다는 요구를 만족하기 위해

debug("message only",);

로 빈 인자를 제공하면 확장 결과가

printf("%s:%d:" "message only" "\n",)

가 되어 여분의 쉼표 때문에 컴파일 오류가 됩니다.

그리고 이전 글에서는 이 문제를 우회할 수 있는 조금은 지저분한 트릭을 소개해 드렸습니다.

이번 글에서는 같은 문제를 해결한 컴파일러 확장을 살펴보려 합니다. gcc 는 오래 전부터 확장을 통해 이 문제에 대한 해결책을 지원해왔고, 다른 오픈 소스 컴파일러(대표적으로 clang 이 있습니다)도 gcc 를 따라 같은 형태로 확장을 지원하고 있습니다. 문제 해결을 위해 가변 인자 부분에 아무 것도 주어지지 않는 상황을 허용해 주어야 하기 때문에 이들 컴파일러는 아래와 같은 형태로 확장을 제공합니다.

우선, 아래 코드처럼 가변 인자에 인자가 전혀 제공되지 않는 경우를 허용해 줍니다.

#define foo(p1, ...)
foo(a1)    // 표준에서는 오류

그리고 무엇보다 중요한 것은 매크로의 유일한 매개변수가 ... 인 경우, 인자가 주어지지 않으면 빈 인자 1개가 아닌 인자가 0개인 경우로 인식해 주어야 합니다.

#define foo(...)
foo()    // 표준에서는 빈 인자 1개, 확장에서는 0개

그리고 문제가 되는 여분의 쉼표를 제거하기 위해 요상한 문법을 도입합니다 - 토큰 연결에 사용하는 ## 를 다른 목적으로 끌어다 쓰기 시작합니다. 매크로 정의 리스트에서 ## 앞에 쉼표가, 뒤에 __VA_ARGS__ 가 나오는 경우(즉, , ## __VA_ARGS__ 형태인 경우) 토큰 연결이 아닌 , 를 옵션으로 만들기 위한 목적으로 인식합니다.

그렇게 나온 확장의 최종 모습은 아래와 같습니다.

#define debug(msg, ...) printf("%s:%d:" msg "\n", __FILE__, __LINE__, ## __VA_ARGS__)

이제 가변 인자 부분에 인자가 주어지는 경우 ## 는 아무 일도 하지 않고 사라져서 원래대로 동작하고, 인자가 주어지지 않는 경우 ## 가 이를 인식하여 자기 앞에 있는 쉼표를 제거해 줍니다.

개인적으로 이와 같은 형태의 확장이 그리 마음에 들지는 않습니다. 하나의 토큰(##)에 중의를 부여하는 것은 언어 학습에 생각보다 큰 지장을 초래합니다. 그 대표적인 예가 많은 분들이 자주 보아 왔지만 그 정체를 설명하려면 말문이 막히는 static 입니다.

static void bar(void);

void foo(void)
{
    static void bar(void);
    bar();
}

static void bar(void) {}

이 코드에서 foo() 안의 bar() 선언이 왜 오류(invalid storage class 'static' for function)가 되는지 설명할 수 있는 사람이 생각보다 많지 않습니다. 그리고 그 이유의 근본에는 static 이 하나 이상의 기능으로 사용된다는 사실이 깔려 있습니다.

## 는 이미 토큰 연결이라는 의미를 가지고 있는데 이를 매우 특수한 상황에 특수한 의미로 사용하면 해당 확장이 해결하고자 하는 문제에 익숙하지 않고서는 코드를 이해하기 어려울 수 있습니다. 당장 코드에서 만났을 때 검색을 뭐라 할지도 막막하지요.

오픈 소스 컴파일러가 아닌 MSVC 의 경우에는 (표준을 무시하기로 유명하므로) 보다 적극적인 방법으로 확장을 제공합니다. ## 를 사용하지 않아도 가변 인자 부분에 아무 것도 제공되지 않으면 그냥 앞에 있는 쉼표를 제거해 줍니다 - gcc 확장의 경우 ## 를 사용해야지만 쉼표를 삭제하는데 반해 MSVC 는 쉼표 삭제를 의도하지 않은 경우에도 무조건 삭제됩니다.

MSVC 가 표준을 무시한다는 식으로 이야기를 해놓으니 마치 gcc 의 확장은 표준과 잘 어울리는 것처럼 느껴지나 또 그렇지도 않습니다. 표준에는 "표준 확장(conforming extension)"이라는 개념이 있습니다. 이는 표준을 잘 따르는 프로그램의 의미를 바꾸지 않고 추가 기능을 제공하는 확장을 의미합니다. 예를 들어 보겠습니다.

int foo(int a = 10, int b)
{
...
}

흔히 기본 인자(default argument)라고 부르는 기능입니다. 만약, 어떤 컴파일러가 이 기능을 C 언어에 지원한다면 이는 표준 확장에 해당할까요?

표준을 따르는 코드는 매개변수를 선언할 때 = 10 과 같은 초기치 부분을 가질 수 없습니다. 즉, 표준에서 문법 오류(syntax error)나 정의되지 않은 행동(undefined behavior)으로 규정한 부분 안에서 추가적인 의미를 부여한 것이기 때문에 저 확장이 도입되었다고 표준을 준수하는 코드가 영향을 받을 일이 없습니다. 따라서 표준 확장에 해당합니다. 실험적으로는 확장에 의해 의미가 달라지는 표준을 준수하는 프로그램을 작성할 수 없다면 "표준 확장"이라고 부를 수 있습니다.

그럼, 이제 문제의 gcc 확장이 표준 확장인지 고민해 보겠습니다.

gcc 확장은 토큰 연결에 사용하는 ## 를 다른 의미로 사용하고 있습니다. 하지만, C 언어의 연산자(정확히는 토큰) 중에 쉼표(,)는 다른 문자와 결합하여 의미가 다른 연산자를 만들 수 없습니다 - 예를 들어, + 는 뒤에 + 를 하나 더 붙여 ++ 라는 새로운 연산자를 만들 수 있고, = 를 붙여 += 이라는 연산자도 만들 수 있으나 , 는 늘 혼자서만 사용됩니다. 이 때문에 , ## __VA_ARGS__ 는 표준 프로그램에서는 오류에 해당하는 구조로 보일 수 있습니다. 가변 인자 부분에 온 어떤 토큰과 앞의 , 를 연결해도 유효한 토큰이 나올 수 없어 보이기 때문입니다. 오류에 해당하는(정의되지 않은 행동에 해당하는) 코드에 추가로 의미를 부여한 것이기에 gcc 의 확장은 표준 확장일까요?

이 설명에서 한 가지 간과한 부분이 있습니다. 바로 빈 인자를 주는 경우입니다. 가변 인자로 빈 인자가 제공되면 __VA_ARGS__ 는 (표준에서는 placeholder 라고 부르는) 비어 있는 토큰으로 대체되고 앞의 , 와 뒤의 빈 토큰이 결합한 결과인 , 는 여전히 유효한 토큰으로 남습니다. 결국, ## 를 특수한 의미로 재사용한 gcc 확장은 표준 확장에 해당하지 않습니다.

그렇다면, gcc 확장에 따라 의미가 달라질 수 있는 표준을 준수하는 프로그램을 만들 수 있을까요? 아래 예가 있습니다.

#define has_comma(...) helper(__VA_ARGS__, 1)
#define helper(a, b, ...) 0 ## b

#define nonstd(...) has_comma(dummy, ## __VA_ARGS__)

#if nonstd()
non-conforming
#else
conforming
#endif

이 코드는 표준을 준수하는 적법한 프로그램이면서 gcc 확장이 제공될 때는 결과로 non-conforming 을, 표준에 따라 처리될 때는 conforming 을 결과로 냅니다.

gcc 도 자신들이 추가한 ## 를 사용한 가변 인자에 대한 확장이 비표준 확장임을 잘 인지하고 있습니다. 따라서, 특별한 옵션 없이 실행될 때는 확장을 켜지만, 표준 모드로 구동될 때는 확장을 꺼줍니다. 실제 실행 결과가 아래에 있습니다.

이미지

beluga 는 (다소 과격한) 비표준 확장을 지원할 때 별도 옵션을 받습니다. -extension 옵션을 주면 비표준 확장 중 구현된 것들을 켜달라는 의미가 되어 앞서 설명한 확장을 켜줍니다.

이미지

이렇게 트릭과 컴파일러 확장이 난무하는 문제를 놓고 표준화 위원회에서는 어떤 대응을 하고 있을까요? 다음 글에서는 이 트릭/확장의 미래에 대해 고민해 보도록 하겠습니다.