구조체는 "이것들 전부를, 함께"라고 말합니다. union은 "이것들 중 정확히 하나를, 한 번에"라고 말합니다. 멤버들이 같은 주소에 겹쳐 놓이므로, 공용체는 가장 큰 멤버만큼만 크고 한 멤버에 쓰면 나머지가 파괴됩니다.
함정처럼 들리고, 확인 없이 쓰면 실제로 함정입니다. 하지만 동시에, 여러 종류 중 하나일 수 있는 값 - 숫자이거나 문자열인 토큰, 클릭이거나 키 입력인 메시지 - 을 전부에 대한 비용을 치르지 않고 표현하는 C의 방법이기도 합니다.
공용체 선언과 사용
문법은 구조체를 그대로 따라합니다. 키워드만 바뀝니다.
멤버는 구조체와 똑같이 .(또는 포인터를 통해 ->)으로 접근합니다. 다른 점은 가장 최근에 쓴 멤버만 의미 있는 값을 담는다는 것입니다. v.f = 3.5f 뒤에 v.i를 읽으면 3이 나오는 것이 아니라, 3.5f의 비트 패턴이 철자하는 정수가 무엇이든 그것이 나옵니다.
크기: 가장 큰 멤버가 이긴다
같은 멤버를 가진 구조체와 공용체의 메모리 배치를 비교해 봅시다.
보통의 기계에서 구조체는 24바이트(int에 4, double에 8, char에 1, 더하기 패딩)이고 공용체는 8바이트입니다. double의 크기를 정렬에 맞춰 올린 값이죠. 출력되는 세 주소는 동일하며, 그 출력 한 줄에 공용체의 이야기 전부가 담겨 있습니다.
%p에 void * 캐스팅을 하는 점을 보세요. printf는 %p에 정확히 그것을 기대하며, 다른 포인터 타입을 넘기는 것은 보통 잘 되는 것처럼 보이더라도 미정의 동작입니다. 형식 지정자를 보세요.
공용체 초기화
지정자 없는 중괄호 초기화자는 첫 번째 멤버를 초기화합니다.
써야 할 것은 지정 형태입니다. {42}는 멤버 순서에 조용히 의존하므로, 나중에 선언 순서를 바꾸면 어느 멤버가 초기화되는지가 바뀝니다. 코드는 달라 보이는 것이 하나도 없기에 정말 고약한 버그입니다.
진짜 문제: 어느 멤버가 살아 있는가
공용체는 여러분이 마지막에 어느 멤버를 썼는지 기록하지 않습니다. 그저 바이트일 뿐이고, 그 지식은 여러분 머릿속에 살며, 지식이 사라지는 곳이 바로 거기입니다.
출력되는 숫자는 크고 이상합니다. 1.0f의 비트 패턴을 int로 읽은 것이죠. 아무것도 죽지 않았고 경고도 없었으며, 프로그램은 조용히 틀렸습니다. 공용체는 약속한 바를 정확히 했습니다. 실수는 어느 멤버가 살아 있는지 잊은 우리 쪽에 있었습니다.
해법: 태그 달린 공용체
표준적인 해법은 살아 있는 멤버를 기록하는 enum과 공용체를 짝지어 둘 다 구조체로 감싸는 것입니다. 그 조합을 태그 달린 공용체(또는 판별 공용체)라고 하며, 애플리케이션 코드의 사실상 모든 공용체를 이렇게 써야 합니다.
이제 모든 읽기가 kind에 대한 switch를 거치므로, 모든 쓰기가 태그도 함께 설정하는 한 쓴 적 없는 멤버를 읽는 일이 불가능해집니다. 쓰기를 작은 생성자 함수(value_from_int, value_from_string)로 감싸는 것이 그것을 잊을 수 없게 만드는 일반적인 방법입니다.
메모리 절약은 실제입니다. 여기서 각 Value는 셋 모두를 담는 구조체의 4 + 4 + 24 대신, 24바이트의 값과 태그만큼 듭니다. 그런 것이 십만 개라면 중요한 차이입니다.
-Wall로 컴파일하면 두 번째 안전망이 생깁니다. 나중에 enum에 VAL_BOOL을 추가하고 case를 잊으면 GCC가 처리되지 않은 열거 값에 대해 경고합니다.
익명 공용체
C11은 구조체 안에 이름 없는 공용체 멤버를 허용하며, 그 멤버들을 바깥 구조체의 이름 공간으로 끌어올립니다.
공용체 자체에 이름이 없으므로 s->as.circle.r이 아니라 s->circle.r이라고 씁니다. 읽기가 짧아지는 대신 공용체가 관여한다는 사실 자체를 감추게 되는데, 태그가 바로 옆에 있다면 괜찮습니다.
공용체가 실제로 값을 하는 곳
되풀이되는 쓰임 넷입니다.
- 변종 값. 인터프리터, JSON과 설정 파서, 메시지 큐 모두 타입이 실행 중에 결정되는 값을 나릅니다. 태그 달린 공용체가 정석적인 표현입니다.
- 메모리가 빠듯한 레코드. 구조체에 서로 배타적인 필드가 여럿 있고 그런 것이 수백만 개라면, 겹쳐 놓는 것이 곧바로 절약입니다.
- 프로토콜과 하드웨어 배치. 헤더 바이트에 따라 내용이 달라지는 패킷은 태그 달린 공용체에 자연스럽게 대응되고, 장치 레지스터도 그렇습니다.
- 바이트 들여다보기. 값을
unsigned char[]배열과 겹쳐 놓으면 개별 바이트를 볼 수 있습니다. 예를 들어 엔디안을 판별할 때입니다.
이것이 타입 퍼닝입니다. 일부러 바이트를 다른 타입으로 다시 읽는 것이죠. 이렇게 공용체를 통해 읽는 것은 C에서 구현 정의 결과로 명시적으로 허용됩니다(무관한 타입 사이에 포인터를 캐스팅하는 것과 달리, 앨리어싱 규칙을 깨지 않습니다). 그리고 unsigned char로 바이트를 들여다보는 것은 언제나 안전합니다. int를 float로 재해석하는 것은 다른 이야기입니다. 결과가 전적으로 플랫폼의 표현 방식에 달려 있으므로, 이식성 있는 코드에서는 빼 두세요.
흔한 실수
- 쓰지 않은 멤버를 읽기. 대표적인 위험입니다. 태그를 쓰세요.
- 공용체가 변환해 준다고 가정하기. 그러지 않습니다.
u.i = 3; float f = u.f;는 비트를 재해석하고,int i = 3; float f = i;는 변환합니다. 형변환을 보세요. - 공용체에 포인터를 넣고 놓쳐 버리기. 한 갈래가 직접 할당한
char *를 담고 있는데 공용체를 다른 멤버로 덮어쓰면 누수입니다. 그 버퍼를 가리키는 것이 아무것도 남지 않습니다. 갈래를 바꾸기 전에 해제하세요. - 컴파일러가 검사해 주리라 기대하기. 하지 않습니다. 공용체는 크기와 정렬 말고는 언어가 도움을 전혀 주지 않는 몇 안 되는 C 기능 중 하나입니다. 태그가 유일한 난간입니다.
자주 묻는 질문
C에서 공용체란 무엇인가요?
공용체는 모든 멤버가 같은 메모리를 공유하는 타입입니다. 한 멤버에 쓰면 다른 멤버들이 덮어써지므로, 공용체는 한 번에 정확히 하나의 멤버만 담습니다. 구조체처럼 선언하되 union 키워드를 씁니다. union Value { int i; float f; };처럼요.
C에서 공용체와 구조체의 차이는 무엇인가요?
구조체는 모든 멤버에게 각자의 저장 공간을 주고 전부를 한꺼번에 담으므로, 크기가 최소한 멤버들의 합입니다. 공용체는 모든 멤버를 같은 주소에 겹쳐 놓고 한 번에 하나만 담으므로, 크기가 가장 큰 멤버의 크기입니다.
C에서 공용체의 크기는 얼마인가요?
가장 큰 멤버가 들어갈 만큼이고, 정렬을 위해 올림합니다. int(4바이트)와 double(8바이트)의 공용체는 12바이트가 아니라 8바이트입니다. 자기 플랫폼에서 확인하려면 sizeof를 쓰세요.
쓰지 않은 공용체 멤버를 읽으면 어떻게 되나요?
같은 바이트를 다른 타입으로 재해석하게 됩니다. u.i = 1을 쓴 뒤 u.f를 읽는 것은 변환이 아닙니다. 정수의 비트 패턴을 실수로 읽어 의미 없는 숫자를 줍니다. 표준 C에서 이는 잘해야 미지정이며, 그래서 태그 달린 공용체 패턴이 존재합니다. 어느 멤버가 살아 있는지 말해 주는 태그를 곁에 두세요.