요약
C에서도 공용체 하나로 제네릭 자료구조에 컴파일 시점 타입 검사를 붙일 수 있습니다.
개발자 대니얼 후퍼가 2025년에 쓴 글입니다. 다른 곳에서 본 적 없는 기법이라며, 연결 리스트를 예로 들어 단계별로 설명했습니다.
왜 중요한가
- 템플릿이 없는 C에서도 함수 하나로 여러 타입을 다루면서 타입 검사까지 받을 수 있습니다.
- 타입마다 헤더를 따로 펼치는 기존 방식과 달리 코드가 중복되지 않아 실행 파일이 커지지 않습니다.
- 맵, 배열, 이진 트리처럼 어떤 자료구조에도 같은 방식을 쓸 수 있다고 했습니다.
핵심 내용
List(type)매크로는 실제 노드 포인터와 타입 정보만 담는payload포인터를 공용체(union)로 묶습니다.- 삽입 매크로는 삼항 연산자로 넣는 값과
payload의 타입을 맞춰 보게 해서, 타입이 다르면 컴파일 오류를 냅니다. - 값을 돌려주는 함수는
typeof로void *결과를 원래 타입으로 바꿔 줍니다. - 유연 배열 멤버(구조체 끝에 크기 없이 두는 배열)로 데이터를 노드 안에 담아 할당을 한 번으로 줄입니다.
- 같은 매크로로 만든 타입끼리도 다른 타입으로 보는 컴파일러가 많아, 함수 인자로 넘기려면
typedef가 필요합니다.
HN 반응
- 매크로로 C를 고치지 말고 C++를 쓰라는 주장에, C++ 런타임과 표준 라이브러리가 더 골치 아프다는 반론이 이어졌습니다.
std::map같은 표준 컨테이너가 느리고 할당이 눈에 안 보인다는 비판과, 평균적인 용도에 맞춘 설계이고 문서에 다 나와 있다는 옹호가 맞섰습니다.
다른 언어에서는 벌써 오래전에 해결한 일을 C에서 매크로 꼼수로 다시 만들어 내는 사람들이 이해가 안 돼요. 왜 C++를 쓰지 않죠? 거의 어디서나 쓸 수 있고, 기존 C 코드베이스에 도입하기도 꽤 간단하잖아요. 물론 C++에도 나름의 단점은 있지만, C++가 이미 제공하는 언어 기능과 표준 라이브러리 컨테이너를 놔두고 C에서 매크로로 난장판을 만드는 게 더 낫다는 건가요?
예전에 C++를 많이 써 봤는데, C++의 난장판이 훨씬 심하다고 생각합니다. 저는 C 코드베이스에 C++를 도입하는 프로젝트에도 기여하고 있는데, 솔직히 그러지 않았으면 좋았겠다 싶습니다.
그리고 제가 좋아하는 C 기능 중에는 코드베이스에 C++를 들이려면 없애야 하는 것들이 있어서, 늘 간단하게 끝나는 일도 아닙니다.
C++에 패턴 매칭이 추가되고 여러 프로젝트에서 실제로 잘 쓰인다면, C 생태계도 패턴 매칭 같은 걸 넣는 걸 고려할까요? C++에서 들어오는 것에서 영감을 받든, 그렇지 않든요.
C 전문가이신 uecker 님께 질문도 하나 드릴게요. 복합 리터럴(compound literal) 기능은 수명 규칙이 복잡하다거나, 그 밖에 의미 규칙이 복잡하다고 보시나요?
C++의 뭐가 난장판이라는 거죠? 어두운 구석이 좀 있고 C보다 복잡한 건 맞지만, 그 복잡함 덕분에 표현력과 타입 안전성을 얻잖아요. 그리고 쓸모 있어 보이는 C++ 기능만 골라 쓰면 되고요.
그런 프로젝트들에서 문제가 되는 게 뭔가요? C만 써 오던 개발자들이 C++에 익숙하지 않다는 것 말고요.
몇 가지만 들어 볼게요.
C++에서 객체의 얕은 복사(shallow copy)는 어떻게 하나요? C에서는요? C에서는 어떤 struct든 memcpy 한 번이면 유효한 얕은 복사본이 나온다는 걸 아는 것만으로도 엄청난 일입니다.
C++에서 안정적인 ABI는 어떻게 만드나요? C에서는요? C에서는 인터페이스만 바꾸지 않으면 내보낸 함수들이 대체로 그냥 잘 동작하고 안정적이라는 걸 아는 게 역시 큰 장점이고, 덕분에 다른 언어 바인딩도 아주 쉽게 만들 수 있습니다.
간접적으로 무언가를 저장하는 클래스는 일반적으로 얕은 복사가 불가능합니다. 소유권 의미론을 깨니까요. C++에서는 그렇게 하지 않습니다. 복사에는 보통 연산자 = 를 쓰는데, POD 구조체라면 memcpy 로 최적화되고, 더 복잡한 타입이라면 실제 깊은 복사를 하느라 추가 작업을 할 수 있습니다.
복잡한 주제입니다. 함수 호출을 위한 기본 ABI는 C와 같습니다. 다만 타입 레이아웃이나 (컨테이너 같은) 라이브러리 타입의 내부 구현은 구현체마다, 버전마다 다를 수 있다는 점을 염두에 둬야 합니다.
C++에서도 못 할 이유가 없습니다. 외부 인터페이스는 C 스타일로 두고, 내부에서는 C++의 장점을 다 쓰면 됩니다.
네, 그래서 사람들이 C로 쓰는 거예요.
"C++에서는 그렇게 하지 않습니다"라니요. 저는 여기서 일을 어떻게 할지 제가 직접 정할 수 있어서 C가 좋거든요.
QED.
C에서도 꼭 그렇게 할 수 있는 건 아닙니다. 그 객체가 포인터 안정성에 의존하는 컨테이너 안에 들어 있는지에 달렸죠. 예를 들어 C에서 흔히 쓰는 침습형(intrusive) 연결 리스트는 그렇게 복사하면 깨집니다.
C에서 아무 struct에나 memcpy를 호출하는 게 바로 버그를 만드는 방법입니다. struct가 다른 포인터를 담고 있다면 그걸 누가 해제할 책임을 지나요? struct가 내부 포인터(struct의 하위 객체를 가리키는 포인터)를 갖고 있다면요? 불장난을 하고 있다는 걸 본인도 알잖습니까.
아, 그 오래된 "C++을 쓸 때는 프로그래머가 더 규율을 지키면 된다" 타령이군요.
네, C 프로그래머들은 그런 말을 한 번도 들어 본 적이 없겠죠...
C 프로그래머는 규율이 필요 없다는 듯이 말씀하시네요. 보세요, C 프로그래머도 언어에서 좋은 부분만 골라 써야 합니다. 초보자가 gets()를 골랐다고요? 아, 어리고 경험 없는 자의 비애죠. VLA를 골랐다면요? 리누스가 불을 뿜을 겁니다.
C++ 프로그래머와 C 프로그래머의 차이는, C++ 프로그래머는 자기 언어에 피해야 할 나쁜 부분이 있다는 걸 알고 있는 반면, C 프로그래머는 누군가 그걸 쓰기 전까지는 나쁜 부분을 의식하지 못하고 그 전까지는 언어를 찬양만 한다는 점입니다.
제 매크로 벡터 타입은 타입 안전합니다. 사실 그게 핵심이에요.
저는 C++가 더 표현력이 좋거나 더 타입 안전하다고 생각하지 않습니다.
C에서는 malloc()의 반환값을 특정 타입에 대입해도 경고만 나옵니다.
반면 C++에서는 하드 에러입니다.
이건 "더 타입 안전하다"에 해당하지 않나요?
컴파일러 버전이 어떻게 되나요? GCC 14와 clang 15부터는 포인터에서 int로의 암묵적 변환이 경고가 아니라 에러이고, 별도의 적합성 모드나 경고 모드를 지정하지 않아도 그렇습니다. 암묵적 정수 변환은 적어도 C99부터 제약 조건 위반이라 진단 메시지가 필수였던 걸로 압니다. 다만 경고가 에러로 바뀐 게 C23 문구 변경 때문인지, 아니면 이미 있던 (하지만 잘못된) 코드가 실패하는 것에 대한 부담이 줄어든 전반적인 분위기 변화 때문인지는 잘 모르겠습니다.
GCC 11이었어요. clang 15에서는 에러가 나는 걸 확인했고(14는 아니었고요), 제 요점은 특정 컴파일러와 상관없이 언어 명세 자체가 이걸 허용한다는 거였어요. 그리고 제가 이해한 원래 논쟁은 C와 C++ 자체(언어 명세)의 타입 안전성에 관한 것이었고요.
아니요, 저한테는 이게 C++가 멍청하다는 증거예요. malloc이 반환하는 메모리는 타입이 없습니다. void라는 타입이 존재하는 유일한 이유가 타입이 없다는 개념을 전달하기 위해서이고, 그래서 프로그래머가 직접 타입에 대입해야 하는 거고요. "바이트 영역이니 쓰기 전에 캐스팅하세요"라는 뜻이라면 그건 'char *'입니다. 그런데 C++는 이제 캐스트를 쓰라고 강제하니까, 결과적으로 오류를 숨기고 침묵시키라고 강요하는 셈이에요.
저는 타입을 어기면 에러가 나길 원하지만, void에 대해서는 원하지 않아요. 그게 void의 존재 의의니까요. C++는 양쪽의 단점만 골라 담는 데 성공했네요.
아니죠. malloc의 반환 타입은 포인터예요. malloc이 반환한 포인터가 가리키는 메모리에 타입이 없는 거지, 반환 타입이 void는 아니에요. void*, 즉 알 수 없는 대상을 가리키는 포인터죠.
저는 void에서 int 같은 것으로의 암묵적 캐스트는 대체로 괜찮다고 봐요. 하지만 이 코드 예시에서는 void*에서 int로 암묵적 캐스트가 일어나고 있어요. 포인터 값 자체를 숫자로 바꾸는데, 그 숫자가 포인터를 담기에 모자랄 수도 있죠. 이건 대부분 원하는 동작이 아니에요. 이 경우에는 명시적 캐스트를 요구하는 쪽이 훨씬 낫습니다.
한 가지 이유를 대자면, C++ 런타임(과 표준 라이브러리)은 그 자체로 또 하나의 복잡한 골칫덩어리라서 대부분의 사람은 건드리지 않아도 되면 건드리고 싶어 하지 않거든요. 그리고 대부분은 안 건드려도 되고요.
"물론 눈알이 뽑히는 데도 단점이 있긴 하지만, 그래도 C의 그 끔찍한 매크로 난장판을 읽는 것보단 낫지 않나요?" 이 질문에 대부분의 사람이 어떻게 답할지 알면 놀라실걸요.
또 시작이네요. C++ 표준 라이브러리에 나쁜 부분도 있고 좋은 부분도 있는 건 맞습니다. 하지만 타입 안전한 제네릭 자료구조를 만들려는 것뿐이라면 나쁜 부분을 건드릴 일은 거의 없습니다. 많은 코드베이스가 표준 라이브러리의 일부를 금지하고 있어요. 예를 들어 LLVM은 <iostream>을 include하는 걸 금지합니다. 표준 라이브러리의 특정 부분을 쓰지 못하게 막을 수 있습니다.
좋아요, <vector>를 빼면 좋은 부분이 뭐가 있죠? 다른 댓글들이 맞게 지적했듯이, <vector> 하나 쓰자고 코드베이스를 C에서 C++로 옮기는 건 그럴 가치가 없으니까요.
<map>은 표준 위원회가 C++ 프로그래머들에게 친 슬픈 농담이에요.
문자열이 있죠. C는 아직도 제대로 못 하는 거고, SDS 같은 것조차 표준 라이브러리에 들어 있지 않습니다.
<map>은 제 일을 충분히 잘 합니다. 모두가 매일 마이크로벤치마크에서 이겨야 하는 일을 하는 건 아니니까요.
제가 작업해 본 큰 C++ 프로젝트들 중 상당수는 표준 문자열이 이런저런 이유로 부족해서 자체 문자열 타입을 갖고 있었어요.
맞아요. 그리고 쓸 만한 간단한 문자열 타입은 *struct MyString { char buf; size_t len; }; 하나면 끝나요. 진짜 마법은 그걸 어떻게 쓰느냐, 어디서 할당하느냐, 할당과 포매팅과 로깅과 I/O를 어떻게 통합하느냐에 있죠. 낡고 복잡한 std::string이 해결해 주지 못하는 것들이 전부 거기 있어요.
인터페이스가 꽤 이상해요. C++17에서 일부 결함이 어느 정도 보완되기 전까지는 특히요.
느리고 API도 직관적이지 않은 제네릭 자료구조입니다. 리트코드나 CRUD에는 쓸 수 있어요. 그보다 까다로운 용도에는 끔찍하게 비대하고 형편없습니다. std::string도 마찬가지고요. string이나 map 같은 STL 데이터 타입이 보이는 순간, RAII, 암묵적 할당, 이상한 연산자 문법, 예기치 못한 변경(무효화) 같은 걸 다 감당해야 해요.
(카운터를 올리는 것 같은 무해한 일이 아니라, 파괴적 변경을 지원하거나 심지어 권장하는 것, 그러니까 이터레이터 무효화와 크래시를 허용하는 것도 제 생각에는 std::vector가 나쁜 이유예요. 이런 건 90년대와 2000년대 프로그래밍의 관용적 API이고, 2026년에는 피할 줄 알아야 하잖아요.)
C++ 컨테이너는 평균적인 요구를 기준으로 설계된 겁니다. 더 특수한 게 필요하면 얼마든지 다른 구현을 쓰면 되고요. 그리고 순수 C에서 매크로로 난리를 치는 것보다는 그게 낫습니다.
RAII의 뭐가 문제죠?
할당은 암묵적이지 않습니다. 할당이 어디서 일어나는지는 대개 문서에 분명히 나와 있습니다(문자열을 이어 붙이거나 복사할 때처럼요).
표준 라이브러리 컨테이너는 그렇지 않습니다. 변경하는 메서드에는 const 한정자가 붙지 않으니 어디서 변경이 일어날 수 있는지 분명합니다. 그리고 C++에서는 일반 사용자 코드에서 변경되면 안 되는 모든 것에 const를 붙이길 강하게 권장합니다.
C/C++만 써 온 지 오래된 사람으로서 말씀드리면, 2026년에 이 언어들의 가장 큰 셀링 포인트는 바로 제네릭이 아니라 특정한 무언가에 맞출 수 있다는 점입니다.
이론과 실전은 다르죠. 모든 걸 const 정확하게 만드는 건 실전에서는 너무 고통스럽고, 불가능한 경우도 많습니다. 실제로 쓸모 있는 일을 하는 데는 집중하지 못하는 샌님들이나 걸려드는 함정이에요. 제 과거의 저 자신에게도 가혹하게 말하는 겁니다. 반대로 묻겠습니다. 절대 바뀌지 않는 기존 데이터에 왜 할당 메커니즘까지 끼워 넣어야 하죠? 더 단순한 걸 하려는데 왜 보일러플레이트를 훨씬 더 써야 하는 거죠?
연관 컨테이너의 operator[]는 변경 연산이고 할당을 할 수 있습니다. 문서에도 나와 있고요. 그리고 const 인스턴스용 operator[] 오버로드는 없으니, 요소를 읽기만 해서 할당이 일어나게 할 수는 없습니다.
복사본을 만들려면 할당이 필요합니다. 이런 경우에 다른 동작을 기대하시나요?
혹시 (90년대의) 레거시 코드베이스를 다루고 계신 건가요? 저는 지난 10년쯤 여러 코드베이스에서 일했는데, 변경되면 안 되는 것을 const로 유지하는 게 전혀 문제가 되지 않았습니다. 코드가 전부 그런 방식으로 작성돼 있었어요.
이 질문은 제가 제대로 이해하지 못한 것 같습니다. 무엇이 절대 바뀌지 않는다는 거죠? 말씀하신 예시에서는 컨테이너를 변경하고 있고, 그 복사본을 만드는 것이고(그 복사본은 나중에 바뀔 수 있습니다).
아니요, 이런 게 암묵적이라서 읽기 어렵다는 말을 하는 거예요. 인덱싱 대입에서 할당이 일어나는 건 특히 그렇고요.
자료구조가 더 복잡해지고 서로 더 얽히기 시작하면, const가 대체 무슨 뜻이어야 하는지부터 불분명해져요(깊은 복사와 얕은 복사 문제와 비슷해요. 선을 어디에 긋죠? 객체는 어디에 있죠?).
그리고 const와 non-const 구분의 가장 큰 철학적 결함은, 엄격하게 구분하려면 (대체로, C++의 세부 사항까지 더 파고들지 않고 말하면) 변경 가능한 모든 것을 생성자 호출 안으로 옮겨야 한다는 거예요. 그건 아주 어색하고, 알고 있어야 하는 실질적인 트레이드오프입니다. 저는 오랫동안 이런 짓을 해 보고 나서야 이게 얼마나 시간 낭비이고 얼마나 많은 복잡성을 만드는지 깨달았어요.