49 공용체와 표현
먼저 알아야 할 것
돌아보기
14장에서 “float의 비트를 uint32의 눈으로 읽는” 옛 기법이 엄격한 앨리어싱 위반이며, 옳은 방법은 memcpy나 공용체라고 했다. 그러면 공용체란 정확히 무엇이길래 그 자리에 서는가?
답. 여러 멤버가 같은 기억을 공유하는 타입이다. 구조체가 멤버들을 나란히 놓는다면(47장), 공용체는 멤버들을 겹쳐 놓는다 — 크기는 가장 큰 멤버에 맞고, 어느 순간에든 실제로 담긴 것은 하나다. “같은 비트를 이 눈으로도 저 눈으로도 본다”는 3장의 관점이 문법이 된 것이다.
이 장의 필요성과 맥락
이 장이 끝나면
이 장에서 답할 질문
- 공용체는 그러면 언제 쓰는 것이 정석인가?
- 그러면 실무에서 이것을 얼마나 걱정해야 하는가?
(27장의 갈래에서 공용체는 파생 타입이되 집합체는 아니었다 — 한 번에 하나만 살아 있기 때문이라는 그 이유를 이 장에서 실물로 본다.)
49.1 공용체 — 겹쳐 놓기#
문법은 구조체와 쌍둥이다. struct를 union으로 바꾸면 된다:
union bits32 {
uint32_t as_int;
float as_float;
};멤버 접근도 같다(u.as_int). 다른 것은 기억의 배치뿐이다 — 두 멤버가 같은 4바이트를 공유하므로, as_float에 쓰고 as_int로 읽으면 같은 비트를 다른 해석으로 보게 된다. C 표준은 이 “쓴 멤버와 다른 멤버로 읽기”(타입 퍼닝)를 공용체에 한해 허용한다 — 14장의 포인터 캐스트 방식과 달리 계약 안이라는 점이 결정적 차이다(다만 읽은 값이 그 타입의 유효한 값이 아닐 수 있다는 주의는 남는다).
49.2 표현을 눈으로 — 엔디안과 패딩#
3장에서 “이 확인을 C로 실제로 해 보이는 것이 이 장의 시연”이라 예약해 두었다. 이제 갚는다. 여기서는 공용체 대신 가장 이식성 좋은 방법 — 38장에서 배운 바이트의 눈(unsigned char)과 memcpy — 을 쓴다.
examples/ch48/endian.c
#include <stdio.h>
#include <stdint.h>
#include <string.h>
int main(void)
{
uint32_t value = 0x12345678u;
unsigned char bytes[4];
memcpy(bytes, &value, sizeof value); /* 표현을 바이트로 들여다본다 */
printf("value: 0x%08X\n", value);
printf("memory order: %02X %02X %02X %02X\n",
bytes[0], bytes[1], bytes[2], bytes[3]);
printf("this machine is %s-endian\n",
bytes[0] == 0x78 ? "little" : "big");
struct padded {
char tag; /* 1바이트 */
int count; /* 4바이트 — 정렬 때문에 앞에 빈틈이 생긴다 */
};
printf("1 + 4 = 5, but sizeof = %zu\n", sizeof(struct padded));
return 0;
}
실행 결과
value: 0x12345678
memory order: 78 56 34 12
this machine is little-endian
1 + 4 = 5, but sizeof = 8
첫 부분이 3장의 그림 그대로다. 0x12345678이 메모리에 78 56 34 12 순서로 놓였다 — 이 책의 검증 기계는 리틀 엔디안이라는 뜻이고, 3장의 도해가 실물로 확인된 것이다. (빅 엔디안 기계에서 이 예제를 돌리면 12 34 56 78이 찍히고 판정 문장도 바뀐다 — 코드는 그대로다.)
둘째 부분은 구조체의 숨은 사정이다. char 하나(1바이트)와 int 하나(4바이트)를 담은 구조체의 크기가 5가 아니라 8이다 — 4장의 정렬 규칙 때문에 char 뒤에 3바이트의 패딩(padding, 빈틈)이 끼워진 것이다. int 멤버가 4의 배수 자리에서 시작해야 기계가 한 움큼에 집을 수 있기 때문이다(4장). 그래서 실무 관행 하나가 나온다 — 큰 멤버부터 배치하면 빈틈이 줄어든다. 구조체 수백만 개를 다루는 코드에서는 이 배치 하나가 메모리와 캐시 효율(12장)을 좌우한다. 배치 규칙과, 빈틈을 없애거나 정렬을 강제하는 방법(pack·alignas)은 48장에서 자세히 다뤘다 — 여기서는 그 빈틈이 실제로 있다는 사실을 눈으로 확인하는 것이 목적이다.
실제 사례. 빈틈이 흘린 비밀 — 커널의 패딩 정보 유출
흔한 오해. “구조체를 그대로 파일에 쓰거나 네트워크로 보내면 된다”
문. 공용체는 그러면 언제 쓰는 것이 정석인가?
답. 두 가지 자리다. 첫째, 방금 본 표현 들여다보기(타입 퍼닝) — 부동 소수점 비트를 검사하거나 하드웨어 레지스터를 여러 눈으로 보는 저수준 코드다. 둘째, 더 흔한 쓰임인 태그된 공용체(tagged union): 구조체 안에 “지금 어느 멤버가 유효한가”를 알리는 표시(태그)와 공용체를 함께 넣어, “이 값은 정수이거나, 실수이거나, 문자열이다” 같은 선택형 데이터를 표현하는 것이다. 인터프리터의 값 표현, 설정 파일 파서 같은 곳의 기본 도구이고 — 4장에서 본 태그 포인터가 비트 차원의 같은 발상이었다. 현대 언어들의 열거형(Rust의 enum, Swift의 associated value)이 이 패턴을 언어 차원으로 끌어올린 것이다.
49.3 활성 멤버와 타입 펀닝 — 같은 비트를 다른 눈으로#
공용체의 계약을 한 낱말로 적으면 활성 멤버(active member)다 — 마지막으로 값을 넣은 멤버가 지금 살아 있는 멤버다. 그러면 살아 있지 않은 멤버를 읽으면 어떻게 되는가? 이 물음의 답이 C와 C++에서 갈린다.
examples/ch48/punning.c
/* 같은 비트를 다른 타입으로 보는 세 가지 방법 — 그리고 각각의 계약. */
#include <stdint.h>
#include <stdio.h>
#include <string.h>
union bits { float f; uint32_t u; };
static void show(const char *how, uint32_t u)
{
printf(" %-28s 0x%08X (sign %u, exponent %3u, significand 0x%06X)\n",
how, u, u >> 31, (u >> 23) & 0xFFu, u & 0x7FFFFFu);
}
int main(void)
{
float f = 1.5f;
printf("reading the bits of the float %.1f as a 32-bit integer\n\n", (double)f);
/* ① 공용체 — C 에서는 허용된다.
마지막에 쓴 멤버가 아닌 멤버를 읽으면 '표현을 다시 해석'한다. */
union bits b = { .f = f };
show("through a union", b.u);
/* ② memcpy — 어디서나 계약 안이다. 최적화하면 명령 하나로 접힌다. */
uint32_t u;
memcpy(&u, &f, sizeof u);
show("through memcpy", u);
/* ③ 포인터 캐스트 — *이것만 계약 밖이다*(엄격한 앨리어싱, 37장).
값이 같아 보여도 컴파일러가 순서를 바꿀 권리를 갖는다.
아래 줄은 보이기 위해 적었을 뿐, 쓰면 안 되는 형태다. */
puts(" through a pointer cast *(uint32_t *)&f - outside the contract, not shown");
puts("\n[the other direction is the same]");
union bits c = { .u = 0x40490FDBu }; /* 원주율에 가까운 비트 */
printf(" 0x40490FDB seen as a float is %.7f\n", (double)c.f);
float g;
memcpy(&g, &c.u, sizeof g);
printf(" moved with memcpy it is also %.7f\n", (double)g);
puts("\n[where C and C++ part ways]");
puts(" C : reading another member of a union is allowed (the representation is reinterpreted).");
puts(" C++ : reading a member that is not the 'active' one is undefined behaviour.");
puts(" in a header shared by both languages, memcpy is the safer spelling.");
puts("\n[careful: not every bit pattern is a value]");
union bits nan_bits = { .u = 0x7FC00000u };
printf(" 0x7FC00000 → %f (NaN)\n", (double)nan_bits.f);
puts(" integers and floats are usually harmless, but some types have 'trap representations'.");
return 0;
}
실행 결과
reading the bits of the float 1.5 as a 32-bit integer
through a union 0x3FC00000 (sign 0, exponent 127, significand 0x400000)
through memcpy 0x3FC00000 (sign 0, exponent 127, significand 0x400000)
through a pointer cast *(uint32_t *)&f - outside the contract, not shown
[the other direction is the same]
0x40490FDB seen as a float is 3.1415927
moved with memcpy it is also 3.1415927
[where C and C++ part ways]
C : reading another member of a union is allowed (the representation is reinterpreted).
C++ : reading a member that is not the 'active' one is undefined behaviour.
in a header shared by both languages, memcpy is the safer spelling.
[careful: not every bit pattern is a value]
0x7FC00000 → nan (NaN)
integers and floats are usually harmless, but some types have 'trap representations'.
C에서는 허용된다. 표준은 공용체의 다른 멤버를 읽는 것을 「저장된 표현을 그 멤버의 타입으로 다시 해석하는 것」으로 규정한다. 그래서 시연처럼 float를 넣고 uint32_t로 읽어 비트를 들여다보는 것이 계약 안이다. 이 기법을 타입 펀닝(type punning)이라 부른다.
C++에서는 정의되지 않은 동작이다. 활성 멤버가 아닌 멤버를 읽을 수 없다는 것이 그 언어의 규칙이다. 그래서 같은 코드가 두 언어에서 뜻이 다르다 — C로 짠 헤더를 C++에서 포함하는 자리에서 실제로 문제가 된다.
| 방법 | C | 비고 |
|---|---|---|
| 공용체로 다른 멤버 읽기 | 계약 안 | C++에서는 정의되지 않은 동작 |
memcpy로 옮기기 | 계약 안 | ★ 두 언어 모두 안전. 최적화하면 명령 하나로 접힌다 |
| 포인터를 캐스트해 읽기 | 계약 밖 | 엄격한 앨리어싱 위반(38장) |
표 49.1 — 타입 펀닝의 방법과 C 에서의 판정
가운데 줄이 정답이다. memcpy가 느릴 것 같지만, 크기가 컴파일 타임에 정해진 작은 복사는 컴파일러가 레지스터 이동 한 번으로 접는다 — 시연에서 공용체와 memcpy가 같은 값을 낸 것처럼, 만들어지는 기계어도 대개 같다.
흔한 오해. “*(uint32_t *)&f 가 가장 직접적이고 빠르다”
가장 위험하다. 이것은 어떤 타입의 객체를 다른 타입으로 접근하는 것이라 엄격한 앨리어싱 규칙(38장)을 어긴다. 컴파일러는 “float를 쓴 것과 uint32_t를 읽은 것은 서로 다른 객체”라고 가정할 권리가 있고, 그 가정 위에서 두 연산의 순서를 바꾼다.
결과는 익숙한 꼴이다 — -O0에서는 되고 -O2에서 틀린다. 그리고 정렬이 맞지 않는 주소라면 엄격한 기계에서 그 자리에서 죽는다.
-fno-strict-aliasing 으로 컴파일러를 달래는 코드베이스도 있지만(리눅스 커널이 그렇다), 그것은 전체 최적화를 한 단계 낮추는 대가다. 새 코드라면 memcpy를 쓴다.
49.3.1 값이 아닌 비트열 — 비값 표현#
모든 비트열이 값이 되는 것은 아니다. 다시 해석한 비트가 그 타입의 유효한 표현이 아닐 수 있고, 표준은 그런 것에 이름을 붙여 두었다 — 비값 표현(non-value representation)이다. 정의는 한 줄이다(§3.24).
“ 객체 표현 가운데 그 객체 타입의 값을 나타내지 않는 것. ”
이름이 바뀐 낱말이다. C17 까지 같은 개념의 이름은 트랩 표현(trap representation)이었다. 옛 문서·남의 코드·검색 결과에서는 지금도 그 이름으로 만나므로 둘 다 알아 두어야 한다. 왜 갈았는지도 분명하다 — 「트랩」이 필수가 아니기 때문이다. 표준은 트랩을 「프로그램의 실행을 그 자리에서 끊어 더 이상 아무 연산도 하지 않는 것」이라 정의하고(§3.25), 비값 표현을 꺼내는 일이 구현에게 트랩을 허용하되 요구하지는 않는다고 못박는다(§3.25 각주). 이름이 「트랩」이면 반드시 멈출 것처럼 읽히니, 뜻 그대로 「값이 아닌 표현」으로 고친 것이다.
| 무엇 | 내용 | 근거 |
|---|---|---|
| 정의 | 그 타입의 값을 나타내지 않는 객체 표현 | §3.24 |
| 옛 이름 | 트랩 표현(trap representation) — C17 까지 | C17 §6.2.6.1p5 |
| 읽으면 | 문자 타입이 아닌 좌값으로 읽으면 정의되지 않은 동작 | §6.2.6.1p5 |
| 써 넣으면 | 문자 타입이 아닌 좌값으로 그런 표현을 만들어도 정의되지 않은 동작 | §6.2.6.1p5 |
| 트랩은 | 해도 되고 안 해도 된다 — 구현의 재량 | §3.25 |
| 어디서 생기나 | 패딩 비트(예: 홀짝 검사 비트), 초기화하지 않은 객체, 타입 펀닝, 범위 밖의 bool·열거체 | §6.2.6.2 NOTE 1 |
| 생기지 않는 곳 | 값을 저장하는 일이 비값 표현을 만들어 내지는 않는다 | §6.2.6.1p8 |
표 49.2 — 비값 표현 — 어디서 생기고, 만지면 어떻게 되는가
세 가지를 눈여겨볼 만하다.
첫째, 문자 타입은 예외다. 위 표의 두 줄이 모두 「문자 타입이 아닌 좌값으로」라는 단서를 달고 있다. 뒤집어 말하면 unsigned char 로 들여다보는 것은 언제나 합법이라는 뜻이고, 이것이 memcpy 와 바이트 단위 검사가 안전한 근거다 (38장). 게다가 unsigned char 자체에는 비값 표현이 없다 — 표준이 이 타입을 순수 이진 표기로 못박아(§6.2.6.1p3) 모든 비트 조합이 값이 되기 때문이다.
둘째, 구조체·공용체 전체는 비값 표현이 되지 않는다. 표준이 따로 적어 둔 자리다(§6.2.6.1p6) — 어떤 멤버의 자리에 해당하는 바이트가 그 멤버에게는 비값 표현이더라도, 구조체 객체 전체의 표현은 그렇지 않다. 그래서 공용체를 통째로 복사하거나 대입하는 것은 안전하고, 위험은 어느 멤버로 읽는가에서 생긴다.
셋째, 담아 두는 것과 쓰는 것은 다르다. 표준은 각주에서 이렇게 적는다 — 자동 변수를 비값 표현으로 초기화하는 것 자체는 정의되지 않은 동작이 아니지만, 제대로 된 값을 넣기 전에는 그 변수의 값을 쓸 수 없다(각주 43). 초기화하지 않은 변수를 「아직 읽지만 않으면 괜찮다」고 말할 수 있는 근거가 여기다(24장).
문. 그러면 실무에서 이것을 얼마나 걱정해야 하는가?
답. 정수와 부동소수 사이의 펀닝은 대개 무해하다 — 오늘날의 기계에서 unsigned 정수 타입에는 비값 표현이 없고(패딩 비트가 없다), IEEE 754의 어떤 비트열도 값(수, 무한대, NaN)이 된다. 시연의 마지막이 그 NaN을 보인다.
조심할 자리는 다른 데 있다. 포인터를 정수로 펀닝했다가 되돌리는 것(38장의 프로버넌스), bool 에 0도 1도 아닌 비트를 넣는 것, 그리고 열거체에 목록에 없는 값을 넣는 것이다. 앞의 둘은 이 책의 검증 기계에서도 재현되는 자리이고, 마지막은 표준이 열거체의 값 범위를 밑바탕 타입으로 정하기 때문에 생긴다.
★ 한 줄로 줄이면 이렇다 — 바이트로 볼 때는 마음껏 보고(문자 타입), 값으로 읽을 때는 그 타입의 값인지가 보장되어야 한다.
49.3.2 공용체의 크기와 정렬, 그리고 공통 초기 시퀀스#
| 무엇 | 규칙 |
|---|---|
| 크기 | 가장 큰 멤버를 담을 만큼. 정렬 때문에 그보다 클 수 있다 |
| 정렬 | 멤버 정렬의 최댓값 |
| 주소 | 모든 멤버가 같은 주소에서 시작한다 — 공용체의 주소와도 같다 |
| 초기화 | 초기자를 하나 주면 첫 멤버가 초기화된다. 지정 초기화로 고를 수 있다 |
표 49.3 — 공용체의 크기와 정렬 규칙
한 가지 규칙이 더 있는데, 태그 붙은 공용체를 쓸 때 요긴하다. 여러 구조체가 공통 초기 시퀀스(common initial sequence) — 앞쪽 멤버들의 타입이 차례로 같은 것 — 를 가지면, 그 공용체가 보이는 자리에서는 어느 멤버를 통해서든 그 공통 부분을 읽을 수 있다.
union shape {
struct { int kind; double r; } circle; /* 앞이 int kind 로 같다 */
struct { int kind; double w, h; } rect;
};
/* s.circle.kind 로 읽어도, s.rect.kind 로 읽어도 같은 자리다 */이 규칙 덕분에 「먼저 종류를 보고 그다음에 갈라 읽는」 패턴이 계약 안에서 성립한다. 다만 조건이 까다롭다 — 완전한 선언이 보이는 곳이어야 하고, 공통 부분의 타입이 정확히 같아야 한다. 실무에서는 아예 종류 태그를 공용체 밖에 두는 편(구조체 안에 kind와 공용체를 나란히)이 더 흔하고 안전하다.
49.4 비트 필드 — 한 워드를 쪼개 쓰기#
구조체 멤버 뒤에 콜론과 수를 적으면 비트 필드(bit field)가 된다 — 그 멤버가 차지할 비트 수를 직접 지정하는 것이다. 여기에 공용체를 겹치면 “통째로 한 워드로 보는 눈”과 “필드로 나눠 보는 눈”을 동시에 갖게 된다.
examples/ch48/bitfield.c
#include <stdio.h>
#include <stdint.h>
/* 실무에서 흔한 모양: 장치 레지스터 한 워드를 필드로 나눠 보고,
같은 기억을 통째로 32비트 정수로도 본다. */
union control_reg {
uint32_t raw; /* 통째로 읽고 쓰는 눈 */
struct {
uint32_t enable : 1; /* 비트 1개 */
uint32_t mode : 3; /* 비트 3개 */
uint32_t priority : 4;
uint32_t reserved : 8;
uint32_t counter : 16;
} f; /* 필드로 보는 눈 */
};
/* 구조체 안에 공용체를 넣는 실무 패턴: 태그 + 내용 */
enum msg_kind { MSG_INT, MSG_TEXT, MSG_POINT };
struct message {
enum msg_kind kind; /* 어느 눈으로 볼지 알려 주는 태그 */
unsigned flags : 4; /* 작은 상태 몇 개 */
unsigned urgent : 1;
union { /* 이름 없는 공용체 (C11) */
int number;
char text[16];
struct { int x, y; } point;
};
};
static void show(const struct message *m)
{
printf("kind=%d flags=%u urgent=%u -> ", (int)m->kind, m->flags, m->urgent);
switch (m->kind) {
case MSG_INT: printf("number %d\n", m->number); break;
case MSG_TEXT: printf("text \"%s\"\n", m->text); break;
case MSG_POINT: printf("point (%d, %d)\n", m->point.x, m->point.y); break;
}
}
int main(void)
{
union control_reg r = { .raw = 0 };
r.f.enable = 1;
r.f.mode = 5;
r.f.priority = 9;
r.f.counter = 1000;
printf("raw = 0x%08x\n", r.raw);
printf("fields: enable=%u mode=%u priority=%u counter=%u\n",
r.f.enable, r.f.mode, r.f.priority, r.f.counter);
/* 통째로 쓰고 필드로 읽기 — 같은 기억을 두 눈으로 본다 */
r.raw = 0x000A0013u;
printf("after raw write: enable=%u mode=%u priority=%u counter=%u\n",
r.f.enable, r.f.mode, r.f.priority, r.f.counter);
printf("sizeof(union control_reg) = %zu\n", sizeof r);
struct message a = { .kind = MSG_INT, .flags = 3, .urgent = 1, .number = 42 };
struct message b = { .kind = MSG_TEXT, .flags = 0, .urgent = 0 };
for (int i = 0; i < 5; i++) b.text[i] = "hello"[i];
b.text[5] = '\0';
struct message c = { .kind = MSG_POINT, .flags = 8, .urgent = 0,
.point = { .x = 3, .y = -7 } };
show(&a); show(&b); show(&c);
printf("sizeof(struct message) = %zu\n", sizeof(struct message));
return 0;
}
실행 결과
raw = 0x03e8009b
fields: enable=1 mode=5 priority=9 counter=1000
after raw write: enable=1 mode=1 priority=1 counter=10
sizeof(union control_reg) = 4
kind=0 flags=3 urgent=1 -> number 42
kind=1 flags=0 urgent=0 -> text "hello"
kind=2 flags=8 urgent=0 -> point (3, -7)
sizeof(struct message) = 24
앞부분이 하드웨어 레지스터를 다루는 전형적인 패턴이다. r.f.mode = 5처럼 필드에 쓰면 라이브러리 도움 없이도 비트 자리에 값이 들어가고, r.raw로 읽으면 그 결과가 한 워드로 보인다. 반대로 r.raw에 통째로 쓰고 필드로 읽는 것도 된다 — 출력의 세 번째 줄이 그 확인이다.
편해 보이지만 이식성의 대가가 크다. 표준이 정해 주지 않는 것이 많기 때문이다.
- 비트가 놓이는 순서 — 낮은 자리부터 채울지 높은 자리부터 채울지는 구현 정의(implementation-defined)다. 그래서 같은 선언이 컴파일러마다 다른 배치를 낼 수 있다.
- 경계를 넘는 필드 — 저장 단위를 걸치는 필드를 허용할지도 구현이 정한다. 패딩이 얼마나 끼는지도 마찬가지다.
- 부호 — 그냥
int x : 1;은 부호 있는 1비트라 값이 0과 −1이 된다. 의도한 것이 아니라면unsigned를 명시해야 한다. - 주소를 얻을 수 없다 — 비트 필드에는
&를 쓸 수 없다. 배열로 만들 수도 없다. - 원자적이지 않다 — 같은 워드에 놓인 두 필드를 서로 다른 스레드가 건드리면 13장의 거짓 공유와 같은 부류의 사고가 난다.
흔한 오해. “비트 필드로 파일·네트워크 형식을 그대로 표현할 수 있다”
오늘날 비트 필드를 권하지 않는 이유가 이것이다. 그럼에도 이 문법을 알아야 하는 이유는 분명하다 — 임베디드 SDK의 레지스터 정의, 오래된 코드베이스의 플래그 묶음, 커널 자료구조에서 여전히 만나기 때문이다. 읽을 줄은 알되, 새로 쓸 때는 한 번 더 생각한다가 실무의 감각이다.
49.4.1 비트 필드에서 구현이 정하는 것들#
비트 필드는 편해 보이지만 이식성이 가장 약한 문법 중 하나다. 표준이 구현에 맡긴 자리를 모아 보면 이유가 분명하다.
| 무엇 | 구현이 정한다 |
|---|---|
| 비트가 놓이는 순서 | 저장 단위 안에서 낮은 쪽부터인지 높은 쪽부터인지 |
| 저장 단위의 경계 | 필드가 단위를 걸칠 수 있는지, 아니면 다음 단위로 미는지 |
int 비트 필드의 부호 | signed인지 unsigned인지 — int x : 1;이 −1을 담을 수도 있다 |
| 허용되는 타입 | _Bool·signed int·unsigned int 외에는 구현에 달렸다 |
| 패딩과 정렬 | 단위 사이에 패딩이 들어갈 수 있다 |
표 49.4 — 비트 필드에서 구현이 정하는 것
여기에 문법적 제약이 하나 더 있다 — 비트 필드의 주소를 얻을 수 없다. &를 붙일 수 없고, 포인터로 가리킬 수도 없다.
그래서 결론은 단호하다. 통신 규약이나 파일 형식을 비트 필드로 파싱하지 않는다. 그 자리에서는 바이트를 읽어 시프트와 마스크로 직접 꺼낸다 — 48장의 직렬화와 같은 이유이고, 같은 처방이다. 비트 필드는 한 프로그램 안에서 기억을 아끼는 용도로만 쓴다.
49.5 구조체와 공용체를 섞는 실무 패턴#
예제의 뒷부분은 다른 이야기다. 구조체 안에 태그와 공용체를 함께 넣은 태그된 공용체(tagged union)로, 앞의 문답에서 이름만 나온 그 패턴이다.
struct message {
enum msg_kind kind; /* 어느 눈으로 볼지 알려 주는 태그 */
unsigned flags : 4; /* 작은 상태들 — 비트 필드가 여기서 쓰인다 */
unsigned urgent : 1;
union { /* 이름 없는 공용체 (C11) */
int number;
char text[16];
struct { int x, y; } point;
};
};세 가지가 겹쳐 있다. 태그, 비트 필드로 아낀 상태 플래그, 그리고 이름 없는 공용체(anonymous union, C11)다. 이름이 없으면 멤버를 한 단계 덜 거쳐 m->number처럼 바로 쓸 수 있어서, 태그된 공용체가 훨씬 읽기 좋아 진다.
규율은 하나뿐이고, 그것이 전부다 — 태그가 말하는 멤버만 읽는다. kind가 MSG_TEXT인데 number를 읽으면 49장 첫 절에서 본 “다른 눈으로 보기”가 되어 의미 없는 값이 나온다. 그래서 이런 자료를 다루는 코드는 거의 언제나 예제의 show처럼 태그로 분기하는 switch 하나를 통과 하도록 만든다. 접근을 한 곳에 모으면 규율을 지킬 자리도 한 곳이 된다.
sizeof(struct message)가 24바이트로 나온 것도 읽어 둘 만하다 — 태그 4바이트 + 비트 필드가 든 워드 + 16바이트 공용체에, 정렬을 맞추는 패딩(앞 절)이 더해진 결과다. 표현은 언제나 이렇게 선언한 것보다 조금 더 차지한다.
49.5.1 실물 하나 — 2바이트 조합형 한글#
이 패턴이 교과서 속 이야기만은 아니다. 한글을 컴퓨터에 담던 시절, 한 워드를 셋으로 쪼개 쓰는 방식이 실제 표준이 된 적이 있다 — 조합형이다.
examples/ch48/johab.c
/* 2바이트 조합형 한글 — 한 워드를 초성·중성·종성으로 쪼갠 실제 사례.
(유니코드 설명이 목적이므로 이 예제에는 한글이 들어간다.) */
#include <stdio.h>
#include <stdint.h>
#include <string.h>
/* 조합형 낱자 이름표. 코드값 0·1 은 채움/미사용이라 비워 둔다. */
static const char *const CHO[32] = {
[2]="G", [3]="GG", [4]="N", [5]="D", [6]="DD", [7]="R", [8]="M",
[9]="B", [10]="BB", [11]="S", [12]="SS", [13]="NG", [14]="J",
[15]="JJ", [16]="C", [17]="K", [18]="T", [19]="P", [20]="H",
};
static const char *const JUNG[32] = {
[3]="A", [4]="AE", [5]="YA", [6]="YAE", [7]="EO",
[10]="E", [11]="YEO", [12]="YE",
[13]="O", [14]="WA", [15]="WAE",
[18]="OE", [19]="YO", [20]="U", [21]="WEO", [22]="WE", [23]="WI",
[26]="YU", [27]="EU", [28]="YI", [29]="I",
};
static const char *const JONG[32] = {
[1]="(none)", [2]="G", [5]="N", [9]="L", [17]="M", [19]="B",
[21]="S", [23]="NG", [29]="H",
};
/* ① 시프트와 마스크로 뽑는다 — 표준이 정확히 약속하는 길 */
static void split_by_shift(uint16_t w)
{
unsigned cho = (w >> 10) & 0x1f;
unsigned jung = (w >> 5) & 0x1f;
unsigned jong = w & 0x1f;
printf(" shift/mask: mark=%u lead=%2u(%-2s) vowel=%2u(%-3s) tail=%2u(%s)\n",
(unsigned)(w >> 15), cho, CHO[cho] ? CHO[cho] : "?",
jung, JUNG[jung] ? JUNG[jung] : "?",
jong, JONG[jong] ? JONG[jong] : "?");
}
/* ② 비트 필드 + 공용체로 본다 — 편하지만 배치는 구현이 정한다 */
union johab {
uint16_t raw;
struct {
uint16_t jong : 5; /* 이 순서가 표준의 약속이 아니다 */
uint16_t jung : 5;
uint16_t cho : 5;
uint16_t mark : 1;
} f;
};
int main(void)
{
/* "가" 와 "한" 의 조합형 코드 — 실제 표에서 가져온 값이다. */
const uint16_t GA = 0x8861; /* 1 00010 00011 00001 */
const uint16_t HAN = 0xD065; /* 1 10100 00011 00101 */
printf("가 = 0x%04X, 한 = 0x%04X\n\n", GA, HAN);
puts("가:"); split_by_shift(GA);
puts("한:"); split_by_shift(HAN);
union johab u = { .raw = GA };
printf("\n'가' seen through bit fields: mark=%u lead=%u vowel=%u tail=%u\n",
u.f.mark, u.f.cho, u.f.jung, u.f.jong);
puts(" (on this compiler it came out the same as the shift/mask version.");
puts(" That is because this implementation fills bits from the low end; the standard does not promise it.)");
/* ③ 둘째 바이트가 ASCII 와 겹친다 — 조합형의 유명한 함정 */
unsigned char bytes[3] = { (unsigned char)(GA >> 8),
(unsigned char)(GA & 0xff), 0 };
printf("\nthe two bytes of '가': %02X %02X\n", bytes[0], bytes[1]);
printf(" the second byte 0x%02X is ASCII '%c' - so searching for 'a' byte by byte\n",
bytes[1], bytes[1]);
printf(" lands inside a character: strchr result = %s\n",
strchr((char *)bytes, 'a') ? "found (false positive)" : "not found");
return 0;
}
실행 결과
가 = 0x8861, 한 = 0xD065
가:
shift/mask: mark=1 lead= 2(G ) vowel= 3(A ) tail= 1((none))
한:
shift/mask: mark=1 lead=20(H ) vowel= 3(A ) tail= 5(N)
'가' seen through bit fields: mark=1 lead=2 vowel=3 tail=1
(on this compiler it came out the same as the shift/mask version.
That is because this implementation fills bits from the low end; the standard does not promise it.)
the two bytes of '가': 88 61
the second byte 0x61 is ASCII 'a' - so searching for 'a' byte by byte
lands inside a character: strchr result = found (false positive)
설계는 이 장에서 배운 그대로다. 16비트를 넷으로 나눠, 맨 앞 한 비트는 “이것은 한글”이라는 표시로 쓰고 나머지 15비트를 5비트씩 셋으로 잘라 초성·중성·종성에 준다.
| 비트 | 15 | 14–10 / 9–5 / 4–0 | 뜻 |
|---|---|---|---|
| 자리 | 표시 | 초성 / 중성 / 종성 | 각 5비트 |
가 = 0x8861 | 1 | 2 / 3 / 1 | ㄱ + ㅏ + (없음) |
한 = 0xD065 | 1 | 20 / 3 / 5 | ㅎ + ㅏ + ㄴ |
표 49.5 — 조합형 한글의 비트 배치
낱자에 붙은 번호에는 규칙이 있다. 초성은 ㄱ부터 ㅎ까지 2–20으로 이어지고(0·1은 채움과 예약), 종성은 “없음”이 1이며 2부터 29까지 이어진다. 중성만 8·9, 16·17, 24·25가 비어 있는데 — 모음을 넉 자리씩 묶어 배치한 흔적이다. 예제의 이름표에서 그 빈자리가 보인다.
이 방식이 준 것은 분명했다. 낱자만 조합하면 현대 한글 11,172자를 모두 적을 수 있었다. 같은 시기의 완성형(KS C 5601-1987)은 자주 쓰는 2,350자만 표에 담았고, 그래서 “펲시”·“똠방각하” 같은 글자를 아예 적지 못하는 유명한 사고가 있었다. 조합형은 표를 늘리는 대신 규칙으로 만들어 내는 길을 택한 것이다.
실제 사례. 조합형이 남긴 세 가지 교훈
첫째, 둘째 바이트가 ASCII와 겹친다. 가는 88 61인데 뒤 바이트 0x61은 그대로 'a'다. 그래서 바이트 단위로 'a'를 찾는 코드가 글자 한복판을 짚는다 — 예제의 마지막 줄이 그 오검출을 실제로 보여 준다. 8장에서 본 “바이트와 글자는 다르다”가 여기서 사고로 나타난다.
둘째, 배치는 표준이 정해 주지 않는다. 예제의 공용체는 이 컴파일러에서 마침 시프트/마스크와 같은 값을 냈지만, 그것은 이 구현이 비트를 낮은 자리부터 채우기 때문이다. 앞 절의 규칙 그대로 — 외부 형식은 시프트와 마스크로 다룬다.
셋째, 규칙이 표를 이긴 자리가 있다. 유니코드는 조합형과 같은 발상을 더 단정하게 가져갔다. 한글 음절의 코드는 0xAC00 + (초성 * 21 + 중성) * 28 + 종성으로 계산된다 — 비트를 자르는 대신 곱셈으로 자리를 정할 뿐, “낱자를 조합해 글자를 만든다”는 생각은 같다. 조합형은 사라졌지만(윈도우 95가 완성형 계열의 통합 코드를 택하면서 자리를 잃었다) 그 발상은 오늘의 표준 속에 남았다.
49.6 제8부를 닫으며#
타입을 만드는 두 문법을 얻었다 — 나란히 놓는 구조체(47장)와 겹쳐 놓는 공용체(49장). 그리고 그 과정에서 표현의 현실(엔디안·패딩)을 눈으로 확인했다. 제2부의 배경지식이 문법으로 완전히 회수된 것이다.
다음 부는 정밀의 부다 — 7장에서 배운 근사의 수학이 C의 실수 타입으로 내려오고(52장), 34장에서 씨앗을 심은 계약의 관점이 오류 처리로 자라며 (53장), 이 책이 곳곳에서 예고해 온 “계약 밖”의 세계 — 정의되지 않은 동작 — 를 정면으로 만난다(54장).