3P by GN⁺ | ★ favorite | 댓글 2개
  • C에서도 매크로, void *, flexible array member, union을 조합하면 타입 안전한 제네릭 자료구조를 만들 수 있으며, 예시는 연결 리스트로 단계별 구현을 보여줌
  • 타입별 헤더를 여러 번 include하는 방식은 안전하지만, 매크로 생성 코드 때문에 정의 추적과 코드 완성이 어렵고 바이너리 크기·빌드 시간이 늘어날 수 있음
  • void * 기반 리스트는 범용성이 있지만 타입 오류를 막지 못하고, 노드와 데이터를 따로 할당하면 노드당 2회 할당과 캐시 미스가 생길 수 있음
  • flexible array member로 데이터를 노드 안에 저장하고 List(type)union으로 감싸면, 런타임 비용 없이 컴파일 타임 타입 정보를 붙일 수 있음
  • list_prepend 매크로는 삼항 연산자로 전달 값과 payload 타입을 맞춰 컴파일 오류를 유도하며, 반환 포인터 타입에는 __typeof__()를 활용할 수 있음

C 제네릭 구현의 출발점

  • 목표는 C에서 List(int), List(Foo)처럼 타입별 리스트를 선언하고, 잘못된 타입을 넣으면 컴파일되지 않게 만드는 것임
  • 예시에서는 List(Foo)Foo 값을 넣을 수 있지만, list_prepend(&foo_list, 7)처럼 다른 타입을 넣는 코드는 컴파일되지 않음
  • list_for(item, &foo_list) 내부의 itemFoo * 타입으로 다룰 수 있음

레벨 0: 제네릭 헤더 방식

  • 한 가지 방법은 자료구조를 헤더에 작성하고, 타입 매크로 T를 바꿔가며 #include를 여러 번 수행하는 것임
  • list.hT를 기반으로 FooListNode, Foo_list_prepend 같은 타입과 함수를 매크로로 생성함
  • 이 방식은 제네릭이고 타입 안전하지만 사용성이 거칠어짐
    • 타입과 함수가 매크로로 구성돼 정의 위치를 찾기 어려움
    • 코드 완성이 잘 동작하지 않을 수 있음
    • 동일한 함수 사본이 타입별로 생겨 바이너리 크기와 빌드 시간이 늘어남
    • list_prepend() 하나가 아니라 Foo_list_prepend(), int_list_prepend()처럼 타입 접두사가 붙은 함수를 써야 함
  • 타입별 코드 생성이 필요한 제네릭 함수에는 이 방식이 더 적합할 수 있음

레벨 1: void * 기반 리스트

  • ListNodevoid *data를 가지면 여러 타입의 데이터를 담을 수 있음
  • list_prepend(ListNode **head, void *data)는 데이터 포인터를 그대로 저장하므로 구현이 단순함
  • 문제는 이 구조가 타입 안전하지 않다는 점임
  • 노드와 데이터가 별도 할당되면 메모리와 성능 비용도 커짐
    • 노드 하나에 두 번 할당이 필요함
    • data 포인터 자체가 추가 메모리를 사용함
    • 리스트 순회 시 다음 노드 접근과 데이터 접근에서 각각 캐시 미스가 날 수 있음
  • 예시 코드는 익숙함 때문에 malloc을 사용하지만, 실제로는 Arena 사용을 권장하며 관련 자료로 영상을 참고할 수 있음

레벨 2: 노드 내부에 데이터 저장

  • void *data 대신 Flexible Array Member를 사용하면 데이터를 노드 내부에 둘 수 있음
  • struct ListNodeListNode *nextchar data[]를 가지며, 할당 시 sizeof(* node) + data_size만큼 한 번에 확보함
  • list_prepend는 전달받은 데이터와 크기를 받아 memcpynode->data에 복사함
  • 이 방식은 next와 실제 데이터가 메모리상 가까이 배치돼 void * 방식의 할당과 캐시 문제를 줄임
  • 대신 호출자가 data_size를 넘겨야 하는 부담이 생김
  • memcpy를 피하고 싶다면 list_alloc_front가 노드의 데이터 영역 포인터를 반환하게 하고, 호출자가 그 메모리를 직접 초기화할 수 있음
  • data 멤버의 정렬, 패딩, 크기 계산 문제는 별도 주제라 예시에서는 자세히 다루지 않음

레벨 3: union으로 타입 정보 붙이기

  • 핵심 기법은 List(type)union으로 정의하고, 실제 리스트 헤드와 타입 정보용 포인터를 함께 두는 것임
#define List(type) union { \
    ListNode *head; \
    type *payload; \
}
  • payload는 런타임에 쓰이지 않고 컴파일 타임 타입 정보를 제공함
  • union을 사용하므로 payload가 별도 메모리를 소비하지 않음
  • List(Foo) foo_list, List(int) int_list처럼 타입별 리스트를 만들 수 있음

삼항 연산자로 타입 검사하기

  • list_prepend 매크로는 내부 함수 _list_prepend를 호출하면서 삼항 연산자로 item(list)->payload의 타입을 맞춤
#define list_prepend(list, item) \
    _list_prepend(&((list)->head), \
                  (1 ? (item) : (list)->payload), \
                  sizeof(*(list)->payload))
  • 삼항 연산자의 두 후보 타입이 맞지 않으면 컴파일러가 타입 불일치 오류를 냄
  • 예를 들어 List(Foo)Bar *를 넘기면 Clang은 Foo *Bar *의 포인터 타입 불일치를 오류로 표시함
  • 같은 매크로가 sizeof(*(list)->payload)로 저장 타입의 크기도 자동 전달함
  • 실제 작업은 _list_prepend(ListNode **head, void *data, size_t data_size) 같은제네릭 내부 함수가 맡음

반환 타입에는 __typeof__() 사용

  • 제네릭 함수가 내부 데이터 포인터를 반환해야 할 때는 __typeof__()void * 반환값을 payload 타입으로 캐스팅할 수 있음
#define list_alloc_front(list) \
    (__typeof__((list)->payload))_list_alloc_front(&(list)->head, sizeof(*(list)->payload))
  • __typeof__()는 Clang, GCC, MSVC 19.39 이상에서 지원됨
  • __typeof__()는 C23에서 표준에 포함되기 전까지 선택적 확장이었음
  • MSVC 19.39 이전처럼 __typeof__()가 없는 컴파일러에서는 삼항 연산자 기반 타입 검사를 사용할 수 있음
  • 타입 안전한 반환도 payload를 통한 할당 방식으로 가능하지만, 세부 구현은 생략됨

예전 방식과 정의상 주의점

  • 이전 방식은 _list_prepend__typeof__((list)->payload)를 포함한 함수 포인터 타입으로 캐스팅해 호출하는 구조였음
  • 타입 캐스팅된 함수 포인터 호출은 기술적으로 정의되지 않은 동작이지만, 현대 컴파일러와 현대 플랫폼에서는 실제로 문제가 없다고 다룸
  • 현재 방식은 함수 포인터 캐스팅 대신 삼항 연산자 타입 일치로 오류를 유도함

List(Foo)를 인자로 넘길 때의 문제

  • C 컴파일러는 동일한 구조를 가진 두 List(Foo) 정의를 같은 타입으로 보지 않을 수 있음
List(Foo) a;
List(Foo) b = a; // error
  • 함수 인자로 void my_function(List(Foo) list)를 정의하고 my_function(a)를 호출해도 호환되지 않는 타입 오류가 날 수 있음
  • 해결책은 typedef로 타입 이름을 붙이는 것임
typedef List(Foo) ListFoo;

ListFoo a;
ListFoo b = a; // ok

void my_function(ListFoo list);
my_function(a); // ok
  • 지역 변수에서는 List(Foo) local_foo_list 형태를 계속 사용할 수 있음
  • GCC 15와 2025년 말 Clang에서는 규칙 변경으로 같은 태그 이름을 가진 구조적으로 동일한 타입이 같은 타입으로 취급될 예정임

리스트 밖의 자료구조에도 적용 가능

  • 같은 기법은 리스트뿐 아니라 맵, 배열, 이진 트리 같은 여러 자료구조에 적용할 수 있음
  • 여러 관련 타입이 필요한 자료구조에도 확장 가능함
  • 예를 들어 해시 맵은 내부 구조와 키 타입, 값 타입을 union 안에 함께 둘 수 있음
#define Map(key_type, value_type) union { \
    MapInternal map; \
    key_type *key; \
    value_type *value; \
}
  • stb_ds.h도 타입 안전한 제네릭 자료구조의 예지만, 배열과 맵이 C 배열을 사용하기 때문에 일부 타입 오류가 값 전달 시점이 아니라 배열 대입 시점에 잡히는 구조임

댓글과 토론

간단하게 Zig 쓰면 되는거 아닌가? 하는 의문점이 들긴 합니다

Hacker News 의견들
  • 레벨 2 코드의 uint64_t data[];정렬 요구사항uint64_t보다 큰 타입에는 틀리고, 더 작은 타입에는 낭비가 됨. 예를 들어 64비트 아키텍처의 ilp32 ABI가 그런 경우임
    레벨 3 코드는 int main() { List(Foo) foo_list = {NULL};가 되어야 함
    typeof가 없어서 우회하면 아무것도 반환할 수 없고, ==가 대칭이라 이 우회 방식은 const 관련 오류도 허용함
    payload도 안전하게 생략할 수 없음. 올바른 크기를 알려면 필요하기 때문임. List(int64_t)int32_t를 추가하려는 경우는 가능해야 하지만, 그 int32_tsizeof를 알 수 없음. 이 코드가 제대로 동작하려면 아직 빠진 부분이 꽤 많음
    현재 C의 제네릭에는 큰 한계가 두 가지 있음. 첫째, vtable에 위임하는 방식은 구조체가 매크로를 담을 수 없고 함수만 담을 수 있어서 기능이 제한됨. 둘째, 오버헤드를 피하려면 외부 vtable에 위임해야 하는데, 그러려면 vtable을 쓸 모든 타입을 전방 선언해야 함
    지금까지 찾은 최선은 typedef를 선언하는 전방 헤더에 static 함수를 선언만 해두고 정의하지 않는 방식이었음. 실제로 특정 타입의 헤더를 어떤 번역 단위에 포함하지 않았을 때 “undefined static” 경고가 나오는 단계가 GCC와 Clang에서 다름
    예를 들어 서로 다른 헤더에서 온 struct SizedBuffer {void *p; size_t len;};struct BoundedBuffer {void *begin; void *end;};, 그리고 각각의 const 버전을 모두 받는 함수를 생각해보면 됨

    • 외부 vtable에 위임하려면 vtable을 쓸 모든 타입을 전방 선언해야 한다는 문제 때문에, 예전에 참여했던 Apache Clownfish 프로젝트에서는 아예 이를 위한 컴파일러까지 만들었음
      처음에는 .h 파일을 파싱했지만, 결국 .cfh “Clownfish Header”라는 작은 헤더 언어를 만드는 쪽이 더 낫다고 봤음
      부모 클래스 Obj에 정의된 Clone 메서드의 CharBuf 버전을 호출하기 위해 이런 코드를 생성했음

      typedef cfish_CharBuf*
      (*CFISH_CharBuf_Clone_t)(cfish_CharBuf* self);

      extern uint32_t CFISH_CharBuf_Clone_OFFSET;

      static inline cfish_CharBuf*
      CFISH_CharBuf_Clone(cfish_CharBuf* self) {
      const CFISH_CharBuf_Clone_t method
      = (CFISH_CharBuf_Clone_t)cfish_obj_method(
      self,
      CFISH_CharBuf_Clone_OFFSET
      );
      return method(self);
      }

      사용은 이렇게 했음

      cfish_CharBuf *charbuf = cfish_CharBuf_new();
      cfish_CharBuf *clone = CFISH_CharBuf_Clone(charbuf);

      Clownfish의 목적은 여러 동적 언어 바인딩을 위한 최소 공통분모 객체 모델을 제공하는 것이었고, .cfh 파일은 바인딩 언어용 타입을 도출하는 데도 쓰였음. 그래도 지적한 문제를 피하려고 생성한 상용구 코드의 양은 정말 터무니없이 많았음
      그래서 거의 모두가 타입 안전성을 포기하고 호출 대상에 void* 캐스팅을 그냥 쓰는 편임
      https://github.com/apache/lucy-clownfish

    • C에서 int main()은 인자를 안 받는다는 뜻이 아니라, 알 수 없는 개수의 인자를 받는다는 뜻임. 인자를 받지 않는다는 뜻은 int main(void)라고 써야 함. C++을 쓰는 사람들이 자주 잊는 사실임

    • union연합적으로 확장될 수 있으면 좋겠음. 한 타입이 가능한 모든 타입을 한곳에 미리 선언하지 않아도, 다른 타입과 같은 union의 일부인 것처럼 스스로 선언할 수 있는 방식임

    • malloc(sizeof(*node) + data_size);도 패딩 때문에 문제가 될 수 있음. 계산된 크기가 너무 작아질 수 있음

  • 반대함
    글에서 말한 trick#0으로 C 방언 전체를 만든 적이 있음. 예를 들어 제네릭 이진 힙은 https://github.com/gritzko/librdx/blob/master/abc/HEAPx.h에 있음
    문법은 좀 무겁지만, 끝에 얻는 것이 평범하고 예측 가능하며 최적화하기 쉬운 일반 C 구조체라는 큰 장점이 있음. 컴파일러가 도넛처럼 잘 먹어치울 코드임
    다른 방식은 결국 void*와 런타임 메모리 크기 계산이 필요하고, 어차피 매크로도 정의해야 함

    • 작성자임. 이진 힙과 연결 리스트는 사용 사례가 다름. 이진 힙은 올바르게 저장하려면 넣는 데이터를 읽어야 하지만, 연결 리스트는 그럴 필요가 없음
      제네릭 이진 힙을 쓴다면 선택지를 다르게 저울질했을 수 있음. 각주에서도 이 점을 언급했음
    • 헤더 구현을 선호할 이유가 실제로 여러 가지 있음. 매크로 함수와 달리 헤더 코드는 디버거에서 따라 들어갈 수 있고, 디버거가 볼 수 있는 타입 정보도 더 좋아서 디버깅이 나음
      각 인스턴스가 단형화되기 때문에 컴파일러 최적화 여지도 더 많고, 가변 크기 때문에 런타임 비용을 내지 않아도 됨. 고정 크기라 제네릭 구조체를 스택에 둘 수도 있음
      작성자가 말한 문제 중 적어도 두 가지는 우회 가능함. 이름은 단순한 이름 맹글링 매크로로 Bar_func(args…)에서 func(Bar)(args…)로 바꿀 수 있음. 바이너리 팽창은 약한 심벌을 써서 링크 시 번역 단위 간에 공유되는 함수를 중복 제거하게 하면 일부 줄일 수 있음
      포인터 타입의 제네릭 컨테이너에는 다른 문제가 있지만, typedef나 타입 별칭으로 우회할 수 있음
      C에서는 intrusive 자료구조가 여전히 더 편하지만, 디버거에서 다루기는 고통스러움
  • 함수 타입 캐스팅은 항목 포인터 타입, 예를 들어 Foo*void*와 같은 표현을 갖는다고 가정하는데, C 표준은 이를 보장하지 않음. 표준 용어로는 두 타입이 “호환”되지 않음
    따라서 변환된 타입으로 함수를 호출하는 것은 정의되지 않은 동작임. 포인터 표현이 우연히 같더라도 컴파일러의 별칭 분석에도 영향을 줌. 관련해서 [0]도 참고할 만함
    서로 다른 인자 타입으로 함수를 캐스팅하는 것이 제네릭 호출의 타입 안전성 핵심처럼 보이는데, 이게 고칠 수 있는 문제인지 모르겠음
    https://news.ycombinator.com/item?id=44421185

    • 이건 각주에서 다뤘음. 캐스팅이 타입 안전성의 핵심은 아님. 글 전체를 읽어보면 됨
  • “제네릭이 있는 C”를 원한다면 이렇게까지 우회하지 말고 그냥 C++ 을 쓰면 되지 않나

    • 안전 규제와 다른 품질 보증에 묶인 레거시 프로젝트에서 일하기 때문임. 다음 릴리스는커녕 열 번째 릴리스에도 C++로 포팅한 해법을 단순히 내보낼 수 없음. 그래서 가능해질 때까지 어떻게든 굴러가게 만들어야 할 수 있음
      다만 새 프로젝트에는 C++을 쓰도록 표준과 기대치를 정할 수 있고, 실제로 그렇게 하며 특정 std를 목표로 하도록 정함
      Hacker News에서 이런 태도를 꽤 자주 보는데, “실력 키워라”에 가까운 느낌임. 여기에는 훨씬 더 많은 맥락이 필요하다고 봄
    • C가 쓰이는 많은 사용 사례에서는 C++로 전환하는 쪽이 오히려 더 많은 우회를 요구하기 때문임
    • 어떤 사람들은 C++을 뼛속까지 싫어해서 이런 종류의 작업이 계속 나오는 것임
      Microsoft가 Linux와 자유·오픈소스 소프트웨어에 새로 호감을 보인 뒤에도 “C++이 미래”라는 입장에서 물러난 것은 정말 실망스러웠음
      https://herbsutter.com/2012/05/03/reader-qa-what-about-vc-an...
      https://devblogs.microsoft.com/cppblog/c11-and-c17-standard-...
      요즘은 정부와 사이버 법규 때문에 Microsoft에서 C와 C++에 새 정책이 생겼으니 크게 중요하지는 않음
      https://azure.microsoft.com/en-us/blog/microsoft-azure-secur...
      https://blogs.windows.com/windowsexperience/2024/11/19/windo...
    • 진짜 답은 이쪽이 더 재미있기 때문임
    • C에서 몇 가지 우회만으로 같은 결과를 얻을 수 있다면 왜 C++을 쓰나
  • 멋진 트릭임. 이미 내 실험용 라이브러리에서도 쓰고 있음 https://github.com/uecker/noplate/blob/main/src/list.h

    • 이걸 알 만한 사람이라면 당신일 것 같은데, intrusive 자료구조에도 이 방식을 적용할 방법이 보이나?
      지금처럼 노드 안에 데이터를 넣는 대신 데이터 안에 노드 구조체를 넣고, 부수적으로 한 객체가 여러 컨테이너에 들어갈 수 있게 하는 방식임
  • “구조적으로 동일한 타입은 GCC 15와 2025년 후반의 Clang에서 규칙 변경 덕분에 같은 타입으로 간주된다”는 부분은 조심해야 함
    새 규칙에서 같은 타입으로 간주되는 것은 태그가 있는 union뿐이며, 구조가 같고 태그도 같아야 함
    List(T) 매크로는 서로 다른 T마다 다른 태그를 생성하도록 바뀌어야 함. 단순한 한 단어 타입은 ##로 쉽지만, char 포인터, 즉 문자열처럼 조금만 복잡해도 불가능함
    물론 List에 쓰기 전에 모든 타입을 typedef하도록 강제할 수는 있지만, 그러면 범용성이 크게 줄어듦

    typedef char *str;
    List(str) my_list_of_str;
    List(str) tokenize(str input) {...}

    • “태그가 있는 union만 같은 타입으로 간주된다”는 말이 이해되지 않음. 태그드 유니언은 그냥 설계 패턴 아닌가
  • “아무것도 하지 않고 타입만 보유하는 멤버”의 일반적인 용어는 type witness라고 봄. 그런데 생각보다 type witness 관련 문헌이 훨씬 적음

    • 실제 변수의 타입으로는 전혀 쓰이지 않는 타입 변수가 있을 때는 phantom type이라는 비슷한 용어가 있음
      주로 Haskell에서 봤고, Scala에서도 실제 타입 시스템에 없는 타입 계층을 흉내 내려고 써본 적이 있음
      어떤 면에서 이 union 트릭도 보조 타입이 실제로는 전혀 쓰이지 않으니 phantom type과 비슷함
  • Linux 커널에서 쓰는 방식도 있음. 타입별 구조체 안에 리스트 정보인 struct list_head임베드하는 방식임
    https://kernelnewbies.org/FAQ/LinkedLists

    • LIST_HEAD_INITINIT_LIST_HEAD라는 이름은 헷갈림
  • 이렇게 해야 한다면 차라리 C++ 템플릿을 직접 쓰겠음

  • D에서는 이렇게 하면 됨

    struct ListNode(T) {
    ListNode* next;
    T data;
    }

    T!int node;

    왜 C 전처리기로 고생해야 하나? 전처리기 매크로를 쓰는 건 마감 목공에 네일건 대신 망치를 쓰는 것과 같음. 네일건은 10배 빠르고, 매번 못을 정확히 박으며, 작업물에 반달 모양 찍힘도 남기지 않음

    • 이 글은 C에 관한 것임. 어떤 프로젝트에서는 C를 반드시 써야 함
    • 망치만 쓰는 게 아니라 펀치를 같이 쓰면 됨. 몰딩 못을 망치로 1/8인치 정도 남기고 박은 다음, 펀치로 끝까지 넣으면 됨