Introduction

C에는 함수 오버로딩이 없습니다. 정수와 실수에 같은 연산을 적용하려면 abs_int, abs_double처럼 타입마다 별도의 함수를 만들어야 합니다. C11 표준은 이 문제에 _Generic 선택 표현식Generic Selection Expression이라는 해법을 내놓았습니다. 이번 글에서는 _Generic의 문법과 동작 원리를 살펴보고, 실전에서 타입에 따라 분기하는 매크로를 작성하는 방법을 다룹니다.

C에 제네릭이 필요한 이유

C++에는 함수 오버로딩과 템플릿이 있고, 러스트에는 제네릭과 트레이트가 있다. 하지만 C에는 같은 이름으로 서로 다른 타입을 처리하는 언어 수준의 메커니즘이 없다.

int    abs_int(int x)       { return x < 0 ? -x : x; }
double abs_double(double x) { return x < 0 ? -x : x; }
long   abs_long(long x)     { return x < 0 ? -x : x; }

로직은 동일한데 타입만 다르다. 호출하는 쪽은 인자의 타입을 직접 확인해서 abs_intabs_double 중 올바른 함수를 골라야 한다. C11 이전에는 이 문제를 우회하기 위해 주로 두 가지 방법이 쓰였다.

void *와 수동 캐스팅

void swap(void *a, void *b, size_t size) {
    char tmp[size];
    memcpy(tmp, a, size);
    memcpy(a, b, size);
    memcpy(b, tmp, size);
}

void *는 타입 정보를 지우기 때문에, 호출하는 쪽에서 size를 직접 넘겨야 하고 컴파일러는 타입 오류를 잡아주지 못한다.

GCC 확장: __builtin_types_compatible_p

#define abs_generic(x) \
    (__builtin_types_compatible_p(typeof(x), int)    ? abs_int(x)    : \
     __builtin_types_compatible_p(typeof(x), double) ? abs_double(x) : \
     abs_long(x))

GCC 전용 내장 함수1로 타입 비교가 가능하지만, 이식성이 없다. 이 코드는 MSVC나 다른 컴파일러에서 컴파일되지 않는다. 그래서 C11 표준에서는 이식 가능한 타입 기반 분기 메커니즘으로 _Generic 선택 표현식을 도입했다.

_Generic 선택 표현식

문법

_Generic의 문법은 switch 문과 유사하다. 제어 표현식의 타입을 기준으로 분기한다.

_Generic(제어-표현식,
    타입1: 표현식1,
    타입2: 표현식2,
    ...
    default: 기본-표현식
)

switch에서 을 기준으로 case를 선택하듯, _Generic타입을 기준으로 연관 표현식association을 선택한다. default는 어떤 타입에도 매칭되지 않았을 때의 폴백이다.

기본 예제

앞서 만든 세 개의 abs 함수를 하나의 매크로로 통합할 수 있다.

#define abs_generic(x) _Generic((x), \
    int:    abs_int,                  \
    double: abs_double,               \
    long:   abs_long                  \
)(x)

호출하는 쪽은 타입을 신경 쓸 필요가 없다.

int    a = abs_generic(-42);    // abs_int 선택
double b = abs_generic(-3.14);  // abs_double 선택
long   c = abs_generic(-100L);  // abs_long 선택

매크로가 전개되면 컴파일러는 _Generic 표현식에서 인자의 타입에 맞는 함수 이름을 골라낸다. 예를 들어 abs_generic(-3.14)는 전처리 후 abs_double(-3.14)와 동등한 코드가 된다.

핵심 규칙

_Generic을 사용할 때 반드시 기억해야 할 규칙이 몇 가지 있다.

제어 표현식은 평가되지 않는다.

_Generic의 괄호 안에 들어가는 제어 표현식은 오직 타입 정보를 추출하기 위해서만 존재한다. sizeof의 피연산자처럼 실제로 실행되지 않는다2.

int x = 0;
// x++는 실행되지 않는다. x의 타입(int)만 사용된다.
_Generic(x++, int: 1, double: 2);  // 결과: 1, x는 여전히 0

타입 매칭은 정확해야 한다.

_Generic은 암시적 타입 변환을 수행하지 않는다. float 인자는 double 연관과 매칭되지 않는다.

#define type_name(x) _Generic((x), \
    int:    "int",                  \
    float:  "float",                \
    double: "double",               \
    default: "unknown"              \
)

type_name(1.0f);  // "float" — double이 아님
type_name(1.0);   // "double"
type_name('A');    // "int" — C에서 문자 리터럴은 int

단, 배열은 포인터로, 함수는 함수 포인터로 디케이decay3된 타입으로 매칭된다. _Generic("hello", char *: 1)에서 문자열 리터럴 "hello"의 타입은 char [6]이지만 char *로 디케이되어 매칭에 성공한다.

중복 타입은 허용되지 않는다.

같은 타입이 두 번 이상 나타나면 컴파일 오류가 발생한다. default도 최대 하나만 허용된다.

선택되지 않은 분기도 문법적으로 유효해야 한다

모든 연관 표현식은 컴파일이 가능해야 한다. 선택되지 않은 분기라도 문법 오류가 있으면 컴파일에 실패한다. 다만 의미론적 제약(예: 타입 불일치)은 선택된 분기에만 적용된다4.

실전 패턴

타입별 포맷 문자열

printf의 포맷 지정자를 타입에 따라 자동으로 선택하는 매크로를 작성할 수 있다.

#include <stdio.h>

#define fmt(x) _Generic((x), \
    int:           "%d",      \
    unsigned int:  "%u",      \
    long:          "%ld",     \
    double:        "%f",      \
    char *:        "%s",      \
    default:       "%p"       \
)

#define print_val(x) printf(fmt(x), (x))
int n = 42;
double pi = 3.14;
char *msg = "hello";

print_val(n);    // printf("%d", n)
print_val(pi);   // printf("%f", pi)
print_val(msg);  // printf("%s", msg)

타입 안전한 max 매크로

전통적인 #define MAX(a, b) ((a) > (b) ? (a) : (b))는 인자를 두 번 평가하는 문제가 있다. _Genericinline 함수를 조합하면 타입 안전성과 단일 평가를 모두 확보할 수 있다.

static inline int    max_int(int a, int b)       { return a > b ? a : b; }
static inline double max_dbl(double a, double b) { return a > b ? a : b; }
static inline long   max_lng(long a, long b)     { return a > b ? a : b; }

#define max(a, b) _Generic((a), \
    int:    max_int,             \
    double: max_dbl,             \
    long:   max_lng              \
)(a, b)

이 방식은 ab가 각각 한 번만 평가되며, 타입이 맞지 않으면 컴파일 오류로 잡힌다.

다단 _Generic 체이닝

두 인자의 타입 조합에 따라 분기해야 할 때는 _Generic을 중첩할 수 있다.

#define add(a, b) _Generic((a),            \
    int: _Generic((b),                      \
        int:    add_ii,                     \
        double: add_id                      \
    ),                                      \
    double: _Generic((b),                   \
        int:    add_di,                     \
        double: add_dd                      \
    )                                       \
)(a, b)

외부 _Generica의 타입으로 분기하고, 내부 _Genericb의 타입으로 다시 분기한다. 타입 조합이 nn개일 때 연관의 수는 n2n^2로 늘어나므로, 조합이 많아지면 코드 생성 매크로를 별도로 작성하는 편이 낫다.

_Generic으로 _Static_assert 흉내내기

_Genericdefault 분기를 의도적으로 생략하면, 지원하지 않는 타입이 들어왔을 때 컴파일 오류를 유발할 수 있다. 이를 활용해 타입 제약을 표현할 수 있다.

#define ensure_integral(x) _Generic((x), \
    short: (x),                           \
    int:   (x),                           \
    long:  (x)                            \
    /* default 없음 — 정수가 아니면 컴파일 오류 */  \
)

동작 원리: 컴파일 타임 선택

_Generic컴파일 타임에 완전히 해소된다. 런타임에는 분기 비용이 전혀 없다.

컴파일러가 _Generic 표현식을 만나면 다음과 같은 절차를 거친다.

구체적으로 살펴보면 이렇다.

  1. 제어 표현식의 타입을 결정한다. 이때 좌변값 변환lvalue conversion5이 적용된다. 배열은 포인터로, 함수는 함수 포인터로 디케이되며, 한정자qualifier(const, volatile)는 제거된다. const int x의 제어 표현식 타입은 const int가 아니라 int이다.
  2. 결정된 타입을 연관 리스트의 각 타입과 호환성(ISO C에서 정의하는 compatible type6)을 기준으로 비교한다. 정확히 호환되는 타입이 있으면 해당 표현식이 선택된다. 없으면 default가 선택되고, default마저 없으면 컴파일 오류가 발생한다.
  3. 선택된 표현식이 _Generic 전체를 대체한다. 나머지 연관 표현식은 버려진다.

이 과정이 C++ 템플릿의 인스턴스화나 Rust의 단형화Monomorphization와 본질적으로 다른 점은, _Generic이 새로운 코드를 생성하지 않는다는 것이다. 이미 존재하는 함수 중 하나를 선택할 뿐이다. 코드 생성은 전처리기 매크로(#define)가 담당하고, 타입 기반 선택은 _Generic이 담당하는 분업 구조다.

tgmath.h: 표준 라이브러리의 _Generic 활용

C99는 <tgmath.h>7라는 타입 제네릭 수학 헤더를 도입했다. sin(x)를 호출하면 xfloat이면 sinf, double이면 sin, long double이면 sinl이 선택된다. C99 당시에는 이를 구현할 표준적인 방법이 없어서 컴파일러 내장 기능에 의존했다.

C11에서 _Generic이 도입되면서, tgmath.h를 순수 표준 C만으로 구현할 수 있게 되었다. 아래는 핵심 메커니즘을 보여주기 위해 간략화한 의사 구현이다.

#include <math.h>

/* 주의: 실제 구현에서는 매크로 이름이 함수 이름과 충돌하므로
   __tg_sin 같은 내부 이름을 사용한다. 아래는 원리를 보여주기 위한 예시다. */
#define tg_sin(x) _Generic((x), \
    float:       sinf,           \
    double:      sin,            \
    long double: sinl            \
)(x)

#define tg_cos(x) _Generic((x), \
    float:       cosf,           \
    double:      cos,            \
    long double: cosl            \
)(x)

실제 glibc의 tgmath.h에서는 #define sin(x) 형태로 정의하되, double: 연관에서 내부 빌트인 이름을 사용하여 매크로의 재귀적 전개를 방지한다. 또한 복소수 타입(_Complex)까지 다루기 때문에 훨씬 복잡하지만, 핵심 메커니즘은 동일하다8.

한계

_Generic은 C가 타입 기반 분기를 표준적으로 수행할 수 있게 해 주지만, 그 범위는 명확히 제한되어 있다. _Generic은 기존 함수를 선택할 뿐, 새로운 함수를 만들어내지 않는다. C++ 템플릿이나 러스트의 제네릭처럼 타입 매개변수를 받아 코드를 생성하는 기능은 없다. 타입별 함수는 직접 작성해야 한다. 또한 _Genericstruct 태그를 타입으로 사용할 수 있지만, 구조체마다 연관을 추가해야 하므로 확장성이 떨어진다.

struct Vec2 { float x, y; };
struct Vec3 { float x, y, z; };

#define length(v) _Generic((v), \
    struct Vec2: length_vec2,    \
    struct Vec3: length_vec3     \
)(v)

새로운 구조체가 추가될 때마다 매크로를 수정해야 한다. 개방-폐쇄 원칙Open-Closed Principle을 만족시키기 어렵다.

_Generic 자체는 가변 인자를 지원하지 않는다. 가변 인자 매크로(__VA_ARGS__)와 결합하려면 복잡한 전처리기 트릭이 필요하다. 또한 _Generic이 포함된 매크로에서 오류가 발생하면, 컴파일러의 오류 메시지가 매크로 전개 후의 코드를 가리키기 때문에 원인을 파악하기 어려울 수 있다. 이럴 때는 gcc -E로 전처리 결과를 확인하는 습관이 도움된다.

C11 이후의 변화: C23

C23(ISO/IEC 9899:2024)은 _Generic에 두 가지 개선을 도입했다9.

  1. 제어 표현식 자리에 타입 이름을 직접 쓸 수 있게 되었다. C11에서는 반드시 표현식을 넣어야 했지만, C23에서는 _Generic(int, int: 1, double: 2)처럼 타입 자체로 분기할 수 있다. 이로써 (typeof(x)){0} 같은 우회 표현이 불필요해진다.
  2. 좌변값 변환 규칙이 명확해졌다. C11에서 모호했던 한정된 타입의 매칭 동작이 C23에서 공식적으로 명세되었다.

출처

Footnotes

  1. __builtin_types_compatible_p(type1, type2)는 GCC 내장 함수로, 두 타입이 호환 가능하면 1, 아니면 0을 반환한다. 한정자(const, volatile)는 무시된다.

  2. 비평가 컨텍스트unevaluated context란 표현식이 컴파일 타임에 타입 정보만 추출되고 실제로 실행되지 않는 맥락을 말한다. C의 sizeof, _Alignof, _Generic의 제어 표현식이 이에 해당한다.

  3. 디케이decay는 배열이 첫 번째 원소의 포인터로, 함수가 함수 포인터로 암묵적으로 변환되는 규칙이다. C11 §6.3.2.1 참조.

  4. C11 §6.5.1.1 ¶3에 따르면, 선택되지 않은 연관 표현식의 제약 위반은 진단을 요구하지 않는다. 단, 구문 오류는 여전히 진단 대상이다.

  5. 좌변값 변환은 좌변값이 값 컨텍스트에서 사용될 때 적용되는 일련의 변환이다. 배열→포인터 디케이, 함수→함수 포인터 디케이, 한정자 제거가 포함된다. C11 §6.3.2.1 참조.

  6. C에서 두 타입이 호환 가능하다는 것은 같은 표현과 정렬 요구 사항을 가진다는 것이다. 예를 들어 intsigned int는 호환 가능하지만, intlong은 크기가 같더라도 호환 가능하지 않다. C11 §6.2.7 참조.

  7. <tgmath.h>는 Type-Generic Math의 약자로, <math.h><complex.h>의 함수들을 타입에 따라 자동 선택하는 매크로를 제공한다.

  8. glibc의 tgmath.h_Generic과 함께 __MATH_TG 매크로를 사용하며, float, double, long double, 그리고 각각의 _Complex 변형까지 총 6개 타입에 대한 분기를 처리한다.

  9. C23의 _Generic 개선은 WG14 문서 N2508("Allow type-names in generic-selection")과 N3260에서 논의되었다.