16. Function-like macros

함수형 매크로는 C 표준 라이브러리에서도 쓰는데, tgmath.h가 있다. 함수형 매크로는 코드를 번잡하게 만들기 때문에 쓰기 위해서는 강력한 제한이 필요하다. 가장 좋은 것은 필요할 때만 쓰는 것이다.

Takeaway 3.16.0.1. 가능하면 inline 함수를 함수형 매크로에 비해 선호하라.

함수형 매크로는 다음과 같은 상황에서 나쁜 부가효과를 낳는다.

unsigned count(void) {
    static counter = 0;
    ++counter;
    return counter;
}

#define square_macro(X) (X * X) // Bad: don't use this.

unsigned a = count();
unsigned b = square_macro(count());

위에서 square_macro(count())는 count() * count()로 대체되는데, 이 경우 count()가 두 번 실행되어 카운터가 2회 증가한다. 대안은 다음과 같은 inline 함수를 쓰는 것이다.

inline unsigned square_unsigned(unsigned x) { // Good
    return x * x;
}
unsigned c = count();
unsigned d = square_unsigned(count());

그러나 함수형 매크로가 함수보다 더 나을 때가 있다.

  • 특정 타입 매핑 강제 및 인자 체킹
  • 실행 추적
  • 가변 인자에 대한 인터페이스 제공
  • 타입 제네릭 인터페이스 제공
  • 함수의 기본값 인자 제공

그 외에도 C에서는 _Generic이라는 제네릭 매크로 인터페이스와 가변 인자 함수를 제공한다. 가변 인자 함수는 새로운 코드에서는 쓰여서는 안 되는 특성이지만, 오래된 코드를 알아보기 위해서는 짚고 넘어가야 한다. 매크로 프로그래밍은 코드를 매우 지저분하게 만든다는 점을 유념할 필요가 있다.

Takeaway 3.16.0.2. 함수형 매크로는 복잡한 작업에 대한 간단한 인터페이스를 제공한다.

16.1. How function-like macros work.

C는 위의 특성들을 제공하기 위해 텍스트 치환이라는 메커니즘을 택했다. 매크로들은 컴파일의 초기 단계인 전처리에서 텍스트 치환된다. 이 때의 룰은 C 표준에서 정의하고 있으며 같은 플랫폼에서의 모든 컴파일러는 동일한 텍스트 치환 결과를 제공해야 한다.

#define MINSIZE(X, Y) (sizeof(X) < sizeof(Y) ? sizeof(X) : sizeof(Y))
#define BYTECOPY(T, S) memcpy(&(T), &(S), MINSIZE(T, S))

함수형 매크로에는 조건이 있다: 각 인자에 대한 평가는 정확히 한 번만 이루어져야 하고, 모든 인자는 괄호로 보호되어야 하고, 예상치 못한 제어 흐름 변화와 같은 부가 효과는 없어야 한다. 매크로의 인자는 반드시 식별자여야 한다. 이 때 치환 텍스트 내에 사용할 수 있는 식별자를 제한하는 룰이 별도로 존재한다.

컴파일러가 ()가 뒤에 붙은 함수형 매크로를 인식했을 때, 컴파일러는 이를 매크로 호출로 인식하고 다음 룰 하에 텍스트 치환을 수행한다.

(1) 매크로의 정의부는 무한 재귀를 막기 위해 일시적으로 중지된다.

(2) () 안쪽의 텍스트는 ,에 의해 인자 리스트로 스캔된다. (과 )의 짝이 맞아야 하며 ,의 갯수도 맞아야 한다.

(3) 각 매크로 인자는 그 인자에 해당하는 매크로가 있을 경우 그에 맞춰 전개된다.

(4) 매크로의 치환 텍스트 내 인자는 전개된 매크로 인자로 치환된다.

(5) 치환 텍스트의 복사본이 만들어지고, 매개변수가 등장하는 모든 부분에 대해서 해당 매개변수는 그 정의부로 치환된다.

(6) 치환 텍스트 결과물은 또 다시 가능한 경우 매크로 치환된다.

(7) 최종 치환 텍스트 결과물이 소스 코드에 삽입된다.

(8) 매크로 정의부가 재작동된다.

이 때 BYTECOPY(A, B)는 다음과 같아진다.

memcpy(&(A), &(B), (sizeof((A)) < sizeof((B)) ? sizeof((A)) : sizeof((B))))

매크로의 식별자는 함수형 매크로이든 아니든 별개의 이름공간에 존재한다. 이것의 이유는 간단하다.

Takeaway 3.16.1.1. 매크로 치환은 프로그램을 구성하는 토큰에 대한 다른 표현이 주어지기 전 번역 초기 단계에 이루어진다.

다시 말해, 컴파일러가 매크로 치환을 수행하는 시점에서는 이것이 변수인지, 타입인지, 키워드인지 아무것도 모르는 상태에서 텍스트 치환을 한다.

매크로 치환에서 재귀는 금지되어 있으므로, 함수형 매크로가 똑같은 식별자를 쓰는 것도 가능하다. 다음은 맞는 C 코드이다.

inline char const* string_literal(char const str[static 1]) {
    return str;
}
#define string_literal(S) string_literal("" S "")

Takeaway 3.16.1.2.(macro retention) 함수형 매크로에 () 가 따라오지 않는다면, 해당 매크로는 전개되지 않는다.

위의 예제에서 함수의 정의부와 매크로의 평가는 등장하는 순서에 의존한다. 매크로가 함수 정의부보다 먼저 오면 다음과 같이 전개되어 에러가 난다.

inline char const* string_literal("" char const str[static 1] "") { // Error
    return str;
}

하지만 string_literal을 괄호로 감싸면 전개되지 않아 올바른 정의부로 남는다.

// header
#define string_literal(S) string_literal("" S "")
inline char const* string_literal(char const str[static 1]) {
    return str;
}
extern char const* (*func)(char const str[static 1]);
// one TU
char const* (string_literal)(char const str[static 1]);
// another TU
char const* (*func)(char const str[static 1]) = string_literal;

즉, 함수의 inline 정의부와 그를 인스턴스화하는 선언부 모두 식별자를 괄호로 보호하면 함수형 매크로에 의해 팽창되지 않는다는 뜻이다.

16.2. Argument checking.

C의 타입 시스템으로 잘 모델될 수 있는 고정 개수 인자의 함수라면, 함수형 매크로를 쓰면 안 되고 함수를 써야 한다. 안타깝게도, C의 타입 시스템이 모든 특이 케이스를 커버할 수 있지는 않다.

흥미로운 케이스는 printf 같이 위험한 함수에 넘겨주는 문자열 리터럴이다. C의 문자열 리터럴은 읽기 전용이지만 const로 보호되지도 않는다. C에서는 printf에 넘겨주는 문자열 str이 다음의 조건을 만족시키는지 묘사할 방법이 없다.

  • 문자에 대한 포인터인가?
  • null이 아닌가?
  • 변경 불가능한가?
  • 0으로 끝나는가?

이를 컴파일 타임에 체크할 수 있으면 좋겠지만 함수 인터페이스에서 그렇게 할 수 있는 방법이 없다.

매크로 string_literal은 string_literal이 문자열 리터럴에 대해서만 호출될 수 있음을 보장한다.

string_literal("hello"); // "" "hello" "" - "hello"
char word[25] = "hello";
string_literal(word); // "" word "" - error

더 좋은 예시로는 다음이 있을 것이다.

#if NDEBUG
#define TRACE_ON 0
#else
#define TRACE_ON 1
#endif

#define TRACE_PRINT(F, X) \
do { if (TRACE_ON) fprintf(stderr, "" F "\n", X); } while (false)

이는 fprintf에 전달되는 포인터 F가 임의의 문자 포인터가 아닌 문자열 리터럴임을 보장한다. fprintf 호출이 NDEBUG 대신 TRACE_ON이라는 변수에 대해 제어되도록 해 놓았는데, 이 경우 fprintf 호출에 대한 최적화와 인자의 문자열 리터럴 여부를 모두 체크할 수 있다.

매크로 인자가 특정 타입이 되도록 강제하는 법이 있다. +0을 붙여서 산술 타입임을 강제하고, +0.0F을 붙여서 숫자 타입임을 강제하는 것이다.

#define TRACE_VALUE0(HEAD, X) TRACE_PRINT(HEAD " %Lg", (X) + 0.0L)

매개변수 인자가 특정 타입 T에 대입 가능한지에 대해서는 복합 리터럴을 이용한다. 다음 코드는 위험하다:

#define TRACE_PTR0(HEAD, X) TRACE_PRINT(HEAD " %p", (void*)(X))

위의 코드 대신에 X가 void*로 암묵적 형변환이 가능한 경우에만 포인터로 변환해 출력하도록 하려면 복합 리터럴을 이용한다.

#define TRACE_PTR1(HEAD, X) TRACE_PRINT(HEAD " %p", ((void*){0} = (X)))

이렇게 하면 최근의 컴파일러들은 복합 리터럴 임시 객체를 만들지는 않고 타입 체킹만을 수행한다.

16.3. Accessing the calling context.

C에서는 호출 컨텍스트에 접근할 수 있는 여러 특수 매크로가 존재한다. __LINE__은 소스의 실제 라인 번호로 치환된다. 마찬가지로, __DATE__, __TIME__, __FILE__도 날짜, 시각, 파일 명으로 치환된다. __func__는 함수명이다.

#define TRACE_PRINT4(F, X) \
do {                       \
    if (TRACE_ON)          \
        fprintf(stderr, "%s: %lu: " F "\n", __func__, __LINE__ + 0UL, X); \
} while (false)

__LINE__에는 문제가 있다.

Takeaway 3.16.3.1. __LINE__의 라인 번호는 int 범위를 벗어날 수도 있다.

Takeaway 3.16.3.2. __LINE__은 자체만으로 위험하다.

위의 예에서는 +0UL로 강제로 unsigned long으로 형변환시켜 이를 해결했다.

C에서는 매크로 연산자 #를 제공하는데, 매크로 치환 텍스트 안에서 매크로 인자 식별자 앞에 #가 붙으면 이는 문자열화된다.

#define STRINGFY(X) #X
#define STRGY(X) STRINGFY(X)

#define TRACE_PRINT5(F, X) \
do { \
    if (TRACE_ON) \
        fprintf(stderr, "%s: " STRGY(__LINE__) ":(" #X "): " F "\n", __func__, X); \
} while (false)

Takeaway 3.16.3.3. #를 통해 매크로 인자를 문자열화하면 이 매크로 인자는 전개되지 않는다.

16.4. Default arguments.

C에서 함수의 매개변수 기본값을 만드는 방법이 있다.

#define ZERO_DEFAULT3(...) ZERO_DEFAULT3_0(__VA_ARGS__, 0, 0, )
#define ZERO_DEFAULT3_0(FUNC, _0, _1, _2, ...) FUNC(_0, _1, _2)

#define strtoul(...) ZERO_DEFAULT3(strtoul, __VA_ARGS__)

이렇게 하면 strtoul의 2번째, 3번째 인자는 붙이지 않아도 0이 된다.

16.5. Variable-length argument lists.

가변 인자 리스트 함수는 타입 세이프하지 않으므로 위험하다. 새 코드에서는 쓰여서는 안 된다. 대신에 가변 인자 매크로에 대해서 알아보자.

16.5.1. Variadic macros.

가변 인자 매크로는 가변 인자를 __VA_ARGS__로 받는다. 이 리스트는 빈 리스트일 수 없으므로 빈 경우에는 따로 만들어줘야 한다.

#define TRACE_PRINT6(F, ...) \
do { \
    if (TRACE_ON) \
        fprintf(stderr, "%s: " STRGY(__LINE__) ": " F "\n", __func__, __VA_ARGS__); \
} while (false)

#define TRACE_FIRST(...) TRACE_FIRST0(__VA_ARGS__, 0)
#define TRACE_FIRST0(_0, ...) _0

#define TRACE_LAST(...) TRACE_LAST0(__VA_ARGS__, 0)
#define TRACE_LAST0(_0, ...) __VA_ARGS__

#define TRACE_PRINT8(...) TRACE_PRINT6(TRACE_FIRST(__VA_ARGS__) "%.0d", TRACE_LAST(__VA_ARGS__)) 

__VA_ARGS__ 부분은 따로 문자열화될 수 있다.

#define TRACE_PRINT9(F, ...) TRACE_PRINT6("(" #__VA_ARGS__ ") " F, __VA_ARGS__)

가변 인자 매크로의 단점은 F에 포맷 한정자를 넘겨줄 때 타입에 맞춰야 한다는 점이다. 다음과 같이 이를 해결할 수 있다.

inline void trace_values(FILE* s,
                  char const func[static 1],
                  char const line[static 1],
                  char const expr[static 1],
                  char const head[static 1],
                  size_t len, long double const arr[len]) {
  fprintf(s, "%s:%s:(%s) %s %Lg", func, line,
          trace_skip(expr), head, arr[0]);
  for (size_t i = 1; i < len-1; ++i)
    fprintf(s, ", %Lg", arr[i]);
  fputc('\n', s);
}

#define ALEN(...) ALEN0(__VA_ARGS__,                    \
  0x1E, 0x1F, 0x1D, 0x1C, 0x1B, 0x1A, 0x19, 0x18,       \
  0x17, 0x16, 0x15, 0x14, 0x13, 0x12, 0x11, 0x10,       \
  0x0E, 0x0F, 0x0D, 0x0C, 0x0B, 0x0A, 0x09, 0x08,       \
  0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01, 0x00)

#define ALEN0(_00, _01, _02, _03, _04, _05, _06, _07,           \
              _08, _09, _0A, _0B, _0C, _0D, _0F, _0E,           \
              _10, _11, _12, _13, _14, _15, _16, _17,           \
              _18, _19, _1A, _1B, _1C, _1D, _1F, _1E, ...) _1E

#define TRACE_VALUES(...)                       \
TRACE_VALUES0(ALEN(__VA_ARGS__),                \
              #__VA_ARGS__,                     \
              __VA_ARGS__,                      \
              0                                 \
              )

#define TRACE_VALUES0(NARGS, EXPR, HEAD, ...)                   \
do {                                                            \
  if (TRACE_ON) {                                               \
    if (NARGS > 1)                                              \
      trace_values(stderr, __func__, STRGY(__LINE__),           \
                   "" EXPR "", "" HEAD "", NARGS,               \
                   (long double const[NARGS]){ __VA_ARGS__ });  \
    else                                                        \
      fprintf(stderr, "%s:" STRGY(__LINE__) ": %s\n",           \
              __func__, HEAD);                                  \
  }                                                             \
 } while (false)

16.5.2. A detour: variadic functions.

가변 인자 함수는 함수 정의부에 …을 붙여서 만들어진다. 예를 들어:

int printf(char const format[static 1], ...);

이에는 문제가 있는데, 호출부에서 printf(“%d”, 0)을 호출할 때 함수 쪽에서 받는 0이 어떤 타입인지를 모른다는 점이다. 따라서 C에는 다음 규칙이 있다.

Takeaway 3.16.5.1. 가변 인자로 인자가 넘겨졌을 때, 모든 산술 타입은 산술 연산을 할 수 있는 타입으로 변환된다. 예외는 double로 변환되는 float이다.

하지만 어떤 타입을 받아야 할지에 대한 체크를 해 주지는 않는다.

Takeaway 3.16.5.2. 가변 인자 함수는 가변 인자 리스트 내의 인자 각각의 타입에 대한 올바른 정보를 받을 수 있어야 한다.

printf 함수는 이를 포맷 한정자로 극복한다. 예를 들어, 아래의 코드에서 zChar는 int로 승격된 뒤 printf에 넘겨져 다시 unsigned char로 재표현된다.

unsigned char zChar = 0;
printf("%hhu", zChar);

이 메커니즘은 복잡하고, 에러에 취약하다.

printf("%d: %s\n", 65536, " a small number"); // not portable
printf("%p: %s\n", NULL, " print of NULL"); // not portable

위의 코드는 65536이 int에 맞지 않을 수 있으므로 포터블하지 않다. 아래 코드는 최악이다. NULL은 (void*) 0일 수도 있고 0일 수도 있는데 만약 컴파일러가 NULL을 0으로 택하고 int의 크기는 32비트, 포인터 크기가 64비트인 플랫폼이라면 위 코드는 프로그램이 죽는다.

Takeaway 3.16.5.3. 가변 인자 함수는 인자가 특정 타입으로 강제되지 않는 이상 포터블하지 않다.

이는 가변 인자 매크로에서 가변 인자를 배열의 초기화자로 삼아서 모든 원소가 알맞는 타입으로 변환될 수 있게 할 수 있는 것과는 다르다.

Takeaway 3.16.5.4. 새 코드에서 가변 인자 함수를 쓰지 말라.

그럴 가치가 없다. 굳이 쓰고 싶다면 stdarg.h의 va_list 관련 다음 인터페이스를 이용한다.

void va_start(va_list ap, parmN);
void va_end(va_list ap);
type va_arg(va_list ap, type);
void va_copy(va_list dest, va_list src);

이는 다음과 같이 쓸 수 있다.

#ifdef __GNUC__
__attribute__((format(printf, 1, 2)))
#endif
int printf_debug(const char *format, ...) {
  int ret = 0;
  if (iodebug) {
    va_list va;
    va_start(va, format);
    ret = vfprintf(iodebug, format, va);
    va_end(va);
  }
  return ret;
}

va_list로 가변 인자를 받아 C 라이브러리 함수 vfprintf로 이를 출력하고 va_end로 이를 끝냈다.

n개의 double 값을 받아 합하는 함수는 다음과 같이 짤 수 있다.

double sumIt(size_t n, ...) {
  double ret = 0.0;
  va_list va;
  va_start(va, n);
  for (size_t i = 0; i < n; ++i)
    ret += va_arg(va, double);
  va_end(va);
  return ret;
}

Takeaway 3.16.5.5. va_arg 메커니즘은 va_list의 길이를 알 수 없다.

Takeaway 3.16.5.6. 가변 인자 함수는 가변 인자 리스트의 길이를 나타내는 약속된 규칙이 필요하다.

16.6. Type-generic programming.

C11의 _Generic 키워드는 타입 제네릭한 인터페이스를 짤 수 있게 해 준다. 용법은 다음과 같다.

_Generic(controlling expression, type1: expression1, ..., typeN: expresionN)

가장 간단한 용례는 타입에 따른 함수 포인터간 선택을 하는 사용법이다.

#define fabs(X) _Generic((X), float: fabsf, long double: fabsl, default: fabs)(X)

변수를 2개 이상 받는 경우 다음과 같이 쓴다. A + B의 타입을 기반으로 결정하고 있다.

inline double min(double a, double b) {
  return a < b ? a : b;
}

inline long double minl(long double a, long double b) {
  return a < b ? a : b;
}

inline float minf(float a, float b) {
  return a < b ? a : b;
}

#define min(A, B)                               \
_Generic((A)+(B),                               \
         float: minf,                           \
         long double: minl,                     \
         default: min)((A), (B))

Takeaway 3.16.6.1. _Generic 표현식의 결과 타입은 제어 표현식의 타입에 의해 결정된다.

Takeaway 3.16.6.2. _Generic과 inline 함수를 혼용하는 것은 최적화 기회를 증가시킨다.

이 때 제어 표현식의 결과 타입을 정하는 규칙은 다음과 같다.

  • 타입 한정자는 소멸된다. (const, volatile 등)
  • 배열 타입은 기반 타입에 대한 포인터로 변환된다.
  • 함수 타입은 함수에 대한 포인터로 변환된다.

Takeaway 3.16.6.3. _Generic의 타입 표현식은 한정자 없는 타입이며 배열/함수가 아닌 타입이다.

Takeaway 3.16.6.4. _Generic의 타입 표현식끼리는 상호 암묵적 형변환이 불가능해야 한다.

Takeaway 3.16.6.5. _Generic의 타입 표현식은 가변 길이 배열에 대한 포인터가 될 수 없다.

_Generic에 대해서는 모든 타입에 대한 모든 선택이 올바른 표현식이어야 한다.

Takeaway 3.16.6.6. _Generic 내의 모든 표현식에 대한 모든 선택은 올바른 표현식으로 평가될 수 있어야 한다.

즉 다음과 같은 구현은 올바른 구현이다.

#define TRACE_FORMAT(F, X)                      \
_Generic((X)+0LL,                               \
         unsigned long long: "" F " %llu\n",    \
         long long: "" F " %lld\n",             \
         float: "" F " %.8f\n",                 \
         double: "" F " %.12f\n",               \
         long double: "" F " %.20Lf\n",         \
         default: "" F " %p\n")

#define TRACE_POINTER(X)                        \
_Generic((X)+0LL,                               \
         unsigned long long: 0,                 \
         long long: 0,                          \
         float: 0,                              \
         double: 0,                             \
         long double: 0,                        \
         default: (X))

#define TRACE_CONVERT(X)                                \
_Generic((X)+0LL,                                       \
         unsigned long long: (X)+0LL,                   \
         long long: (X)+0LL,                            \
         float: (X)+0LL,                                \
         double: (X)+0LL,                               \
         long double: (X)+0LL,                          \
         default: ((void*){ 0 } = TRACE_POINTER(X)))

요점 정리

  • 함수형 매크로는 인라인 함수에 비해 유연하다.
  • 함수형 매크로는 함수 인터페이스에 대해 컴파일 타임 인자 체크를 수행할 수 있고 기본값 인자를 넘길 수 있으며 호출 환경에 대한 정보를 제공할 수 있다.
  • 함수형 매크로는 가변 인자 리스트에 대한 타입 세이프한 인터페이스를 제공할 수 있게 해 준다.
  • _Generic과 결합하면 타입 제네릭한 인터페이스를 구현할 수 있다.

답글 남기기

아래 항목을 채우거나 오른쪽 아이콘 중 하나를 클릭하여 로그 인 하세요:

WordPress.com 로고

WordPress.com의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

Google photo

Google의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

Twitter 사진

Twitter의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

Facebook 사진

Facebook의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

%s에 연결하는 중