49 Unions and representation
What to know first
Looking back
Chapter 14 said the old technique of “reading a float’s bits through uint32′s eye” violates strict aliasing, and that the correct methods are memcpy or a union. Then what exactly is a union, that it stands in that place?
A. A type whose several members share the same memory. If a struct lays members side by side (chapter 47), a union lays them overlapping — its size fits the largest member, and at any moment only one thing is really held. It is chapter 3′s perspective, “seeing the same bits through this eye and through that”, made into syntax.
The need for this chapter, and its context
By the end of this chapter
The questions this chapter answers
- When, then, is a union the standard thing to use?
- So how much does this need worrying about in practice?
49.1 What a union is — taking turns in one place#
A structure held “this and that” (chapter 47). A student has a name and a score. But some data is “this or that”. One token read by a calculator is a number, or an operator, or a parenthesis; one value in a configuration file is an integer, or a real, or a string. It cannot be two at once.
Put such data in a structure and every member gets its own place, so the places of the unused members always sit empty. A union makes the members share the same place. It takes only as much room as its largest member, and only one member lives there at a time. The standard defines the union in the same paragraph as the structure (the one read in chapter 47 — “an overlapping set of member objects”), and spells out its meaning like this.
“ The size of a union is sufficient to contain the largest of its members. The value of at most one of the members can be stored in a union object at any time. A pointer to a union object, suitably converted, points to each of its members …, and vice versa.1 ”
There are two main uses.
- A value that is one of several kinds — used together with a mark that records “which member is alive now”. The tagged union later in this chapter is that idiom.
- Seeing the same bits through another eye — write 4 bytes as a
float, read them as auint32_t, and see the bits. The demo in the section on the active member and type punning (punning.c) does that, and the decisive point is that C allows it for unions only.
In chapter 27′s families a union was a derived type but not an aggregate — because only one member is alive at a time. This chapter shows that reason in the flesh.
49.2 The union — laying things over one another#
The syntax is a twin of the struct’s. Change struct to union:
union bits32 {
uint32_t as_int;
float as_float;
};Member access is the same (u.as_int). The only difference is the layout of memory — the two members share the same four bytes, so write to as_float and read as_int and you see the same bits under a different interpretation. The C standard permits this “read through a member other than the one written” (type punning) for unions in particular — unlike chapter 14′s pointer-cast approach, it is inside the contract, which is the decisive difference (though the caution remains that the value read may not be a valid value of that type).
49.3 Representation with our own eyes — endianness and padding#
Chapter 3 booked the endianness check, saying “actually doing this check in C is the union chapter’s demonstration.” Now we make good on it. In this chapter a representation is the pattern of bytes and bits that a value occupies in memory. Here, instead of a union, we use the most portable method — the eye of bytes (unsigned char) learned in chapter 38, and memcpy.
examples-en/unions/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); /* look into the representation byte by byte */
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 byte */
int count; /* 4 bytes — alignment opens a gap in front of it */
};
printf("1 + 4 = 5, but sizeof = %zu\n", sizeof(struct padded));
return 0;
}
Output
value: 0x12345678
memory order: 78 56 34 12
this machine is little-endian
1 + 4 = 5, but sizeof = 8
The first part is exactly chapter 3′s picture. 0x12345678 sits in memory in the order 78 56 34 12 — meaning this book’s verification machine is little endian, and chapter 3′s diagram is confirmed in the flesh. (Run this example on a big-endian machine and 12 34 56 78 is printed and the verdict sentence changes — the code stays the same.)
The second part shows what a struct hides. A struct holding one char (1 byte) and one int (4 bytes) has size 8, not 5 — because chapter 4′s alignment rule inserted three bytes of padding after the char. The int member must start at a multiple of four for the machine to grab it in one handful (chapter 4). So one practical habit follows — lay the large members first and the gaps shrink. In code handling millions of structs this one layout decision governs memory and cache efficiency (chapter 12). The layout rules, and the ways to remove gaps or force alignment (pack, alignas), were treated in detail in chapter 48 — the purpose here is to confirm with our own eyes that the gap really exists.
In practice. The secret spilled by a gap — padding information leaks in kernels
A common misconception. “You can just write a struct to a file or send it over a network as it is”
Q. When, then, is a union the standard thing to use?
A. In two places. First, the looking into representation (type punning) just seen — low-level code inspecting floating-point bits or viewing a hardware register through several eyes. Second, and more common, the tagged union: putting into a struct both a union and a mark (a tag) saying “which member is valid now”, to represent alternative data such as “this value is an integer, or a real number, or a string.” It is the basic tool of interpreters’ value representations and configuration-file parsers — and chapter 4′s tagged pointer was the same idea at the bit level. Modern languages’ enumerations (Rust’s enum, Swift’s associated values) lifted this pattern to the level of the language.
49.4 The active member and type punning — the same bits through another eye#
The contract of a union comes down to one term: the active member — the one whose value was written last. So what happens if you read a member that is not active? The answer to that question is where C and C++ part.
examples-en/unions/punning.c
/* Three ways to view the same bits as another type — and each one's contract. */
#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, fraction 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);
/* (1) a union — allowed in C.
Reading a member other than the last one written reinterprets the
representation. */
union bits b = { .f = f };
show("through a union", b.u);
/* (2) memcpy — inside the contract everywhere; folds to one instruction */
uint32_t u;
memcpy(&u, &f, sizeof u);
show("through memcpy", u);
/* (3) a pointer cast — *only this one is outside the contract* (strict
aliasing, chapter 56). The value may look right, but the compiler is
entitled to reorder. The line below is shown, not used. */
puts(" through a pointer cast *(uint32_t *)&f — outside the contract, not run");
puts("\n[the other direction is the same]");
union bits c = { .u = 0x40490FDBu }; /* bits close to pi */
printf(" 0x40490FDB seen as a float is %.7f\n", (double)c.f);
float g;
memcpy(&g, &c.u, sizeof g);
printf(" and through memcpy %.7f\n", (double)g);
puts("\n[where C and C++ part]");
puts(" C : reading another union member is allowed (reinterpretation).");
puts(" C++ : reading a member that is not the active one is undefined.");
puts(" In a header shared by both languages, memcpy is the safe 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");
puts(" representations that must not be read.");
return 0;
}
Output
reading the bits of the float 1.5 as a 32-bit integer
through a union 0x3FC00000 (sign 0, exponent 127, fraction 0x400000)
through memcpy 0x3FC00000 (sign 0, exponent 127, fraction 0x400000)
through a pointer cast *(uint32_t *)&f — outside the contract, not run
[the other direction is the same]
0x40490FDB seen as a float is 3.1415927
and through memcpy 3.1415927
[where C and C++ part]
C : reading another union member is allowed (reinterpretation).
C++ : reading a member that is not the active one is undefined.
In a header shared by both languages, memcpy is the safe spelling.
[careful: not every bit pattern is a value]
0x7FC00000 → nan (NaN)
Integers and floats are usually harmless, but some types have trap
representations that must not be read.
In C, reading a member that is not active is allowed. The standard describes reading another member of a union as reinterpreting the stored representation as that member’s type. So putting in a float and reading a uint32_t to look at the bits, as in the demonstration, is inside the contract. The technique is called type punning.
In C++, the same read is undefined behaviour. That language’s rule is that a member which is not the active one may not be read. The same code therefore means different things in the two languages — which really does bite where a C header is included from C++.
| Method | In C | Note |
|---|---|---|
| Reading another union member | Inside the contract | Undefined behaviour in C++ |
Moving it with memcpy | Inside the contract | ★ Safe in both languages; folds to one instruction when optimised |
| Casting a pointer and reading | Outside the contract | A strict-aliasing violation (chapter 38) |
Table 49.1 — Ways to type-pun, and how C rules on each
The middle row is the answer. memcpy looks slow, but a small copy whose size is known at compile time folds into a single register move — as the union and the memcpy gave the same value in the demonstration, the machine code is usually the same too.
A common misconception. “*(uint32_t *)&f is the most direct and the fastest”
It is the most dangerous. This accesses an object of one type through another and so breaks the strict aliasing rule (chapter 38). The compiler is entitled to assume “the float that was written and the uint32_t that was read are different objects”, and to reorder the two operations on that assumption.
The result is the familiar pattern — it works at -O0 and is wrong at -O2. And if the address is not aligned, a strict machine dies on the spot.
Some codebases quiet the compiler with -fno-strict-aliasing (the Linux kernel does), but that is paying with a whole level of optimisation. In new code, use memcpy.
49.4.1 Bit patterns that are not values — non-value representations#
Not every bit pattern becomes a value. The reinterpreted bits may not be a valid representation of that type, and the standard has a name for such a thing: a non-value representation. The definition is one line (§3.24).
“ An object representation that does not represent a value of the object type. ”
The name is new. Through C17 the same idea was called a trap representation. You will still meet that name in older documents, in other people’s code and in search results, so both are worth knowing. Why it was changed is plain enough — the “trap” is not required. The standard defines a trap as “interrupting execution of the program such that no further operations are performed” (§3.25) and says that fetching a non-value representation permits an implementation to trap but does not require it (§3.25, footnote). A name containing “trap” reads as though the program must stop, so it was replaced by what the thing actually is: a representation that is not a value.
| What | Content | Basis |
|---|---|---|
| definition | an object representation that does not represent a value of the type | §3.24 |
| former name | trap representation — through C17 | C17 §6.2.6.1p5 |
| reading one | undefined behaviour, if read by an lvalue that is not of character type | §6.2.6.1p5 |
| writing one | undefined behaviour, if produced through an lvalue that is not of character type | §6.2.6.1p5 |
| trapping | permitted, not required — the implementation’s choice | §3.25 |
| where they arise | padding bits (a parity bit, say), uninitialised objects, type punning, out-of-range bool and enumerations | §6.2.6.2 NOTE 1 |
| where they do not | storing a value shall not generate one | §6.2.6.1p8 |
Table 49.2 — Non-value representations — where they come from, what touching them does
Three things are worth noticing.
First, access through character types is the exception. Both the reading one and writing one rows carry the proviso “by an lvalue that is not of character type”. Turned around, that means looking through unsigned char is always legal, which is the basis on which memcpy and byte-by-byte inspection are safe (chapter 38). And unsigned char itself has no non-value representations: the standard pins that type to pure binary notation (§6.2.6.1p3), so every combination of bits is a value.
Second, a structure or union as a whole is never a non-value representation. The standard writes this out (§6.2.6.1p6) — even where the bytes belonging to some member are a non-value representation for that member, the representation of the whole object is not one. So copying or assigning a union wholesale is safe; the danger lies in which member you read it back through.
Third, holding one is not the same as using one. A footnote puts it exactly: an automatic variable may be initialised to a non-value representation without undefined behaviour, but its value cannot be used until a proper value is stored in it (footnote 43). That is the basis on which an uninitialised variable can be called harmless “as long as you do not read it” (chapter 24).
Q. So how much does this need worrying about in practice?
A. Punning between integers and floating point is mostly harmless — on today’s machines the unsigned integer types have no non-value representations (they have no padding bits), and any bit pattern is a value in IEEE 754 (a number, an infinity or a NaN). The end of the demonstration shows that NaN.
The places to be careful are elsewhere: pointers punned to integers and back (chapter 38′s provenance), a bool holding bits that are neither 0 nor 1, and an enumeration holding a value that is not on its list. The first two reproduce on this book’s verifying machine; the last follows from the standard fixing an enumeration’s range through its underlying type.
★ In one line: look all you like as bytes (character types); read as a value only where it is guaranteed to be a value of that type.
49.4.2 The size and alignment of a union, and the common initial sequence#
| What | The rule |
|---|---|
| Size | Enough for the largest member; alignment may make it larger |
| Alignment | The maximum of the members’ alignments |
| Address | Every member starts at the same address — the union’s own |
| Initialisation | One initializer initialises the first member; designated initializers choose |
Table 49.3 — The size and alignment rules of a union
One more rule matters when writing tagged unions. If several structs share a common initial sequence — leading members matching in type, one for one — then wherever the declaration of a union holding those structs as members is visible, that common part may be read through any member.
union shape {
struct { int kind; double r; } circle; /* both start with int kind */
struct { int kind; double w, h; } rect;
};
/* s.circle.kind and s.rect.kind name the same place */That rule is what makes the “look at the kind first, then branch” pattern legal. The conditions are fussy, though — the complete declaration must be visible and the types of the common part must match exactly. In practice it is commoner, and safer, to keep the tag outside the union: a struct holding a kind beside it.
49.5 Bit fields — cutting up one word#
Write a colon and a number after a struct member and it becomes a bit field — you specify directly how many bits that member occupies. Overlay a union on that and you have both “the eye that sees it whole as one word” and “the eye that sees it divided into fields” at once.
examples-en/unions/bitfield.c
#include <stdio.h>
#include <stdint.h>
/* A shape common in practice: one device register word divided into fields,
with the same memory also seen whole as a 32-bit integer. */
union control_reg {
uint32_t raw; /* the eye that reads and writes it whole */
struct {
uint32_t enable : 1; /* one bit */
uint32_t mode : 3; /* three bits */
uint32_t priority : 4;
uint32_t reserved : 8;
uint32_t counter : 16;
} f; /* the eye that sees fields */
};
/* the practical pattern of a union inside a struct: a tag plus the content */
enum msg_kind { MSG_INT, MSG_TEXT, MSG_POINT };
struct message {
enum msg_kind kind; /* the tag telling which eye to look through */
unsigned flags : 4; /* a few small states */
unsigned urgent : 1;
union { /* an anonymous 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);
/* write it whole and read it as fields — the same memory through two eyes */
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;
}
Output
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
The first part is the typical pattern for handling a hardware register. Write to a field as in r.f.mode = 5 and the value goes into the bit positions without library help, and reading r.raw shows the result as one word. The reverse — writing r.raw whole and reading the fields — works too; the output’s third line is the check.
Convenient though it looks, the price in portability is large, because much is not fixed by the standard.
- The order the bits are laid in — whether they fill from the low end or the high end is implementation-defined. So the same declaration may produce different layouts on different compilers.
- Fields crossing a boundary — whether a field straddling a storage unit is allowed is also up to the implementation. So is how much padding is inserted.
- Sign — whether a plain
int x : 1;is signed is implementation-defined — under GCC and Clang it is a signed one-bit field, so its values are 0 and −1. If that was not the intention,unsignedmust be stated. - Its address cannot be taken —
&cannot be applied to a bit field. Nor can bit fields be made into an array. - It is not atomic — if two threads touch two fields sitting in the same word, that is a data race, undefined behaviour — adjacent bit fields are one memory location in C. Unlike chapter 13′s false sharing, it does not merely slow things down; values come out wrong.
A common misconception. “Bit fields can represent a file or network format directly”
That is why bit fields are not recommended today. The reason to know the syntax nonetheless is clear — you still meet them in the register definitions of embedded SDKs, in the flag bundles of old codebases, and in kernel data structures. Be able to read them, but think twice before writing new ones is the practical instinct.
49.5.1 What the implementation decides about bit fields#
Bit fields look convenient, but they are among the least portable features of the language. Collect what the standard leaves to the implementation and the reason is plain.
| What | The implementation decides |
|---|---|
| The order bits are placed in | From the low end of the storage unit or the high end |
| The boundary of a storage unit | Whether a field may straddle one, or is pushed to the next |
The signedness of an int bit field | signed or unsigned — int x : 1; may hold −1 |
| The permitted types | Beyond bool, signed int, unsigned int and bit-precise integers (_BitInt) it is implementation-defined |
| Padding and alignment | Padding may appear between units |
Table 49.4 — What the implementation decides about bit fields
One syntactic restriction goes with them — the address of a bit field cannot be taken. No &, and no pointer to it.
So the conclusion is firm. Do not parse a protocol or a file format with bit fields. There you read bytes and pull the pieces out with shifts and masks — the same reason and the same prescription as chapter 48′s serialisation. Bit fields are for saving memory inside one program only.
49.6 The practical pattern of mixing structs and unions#
The latter part of the bit-field example (bitfield.c) is a different story. It is a tagged union — a struct holding a tag and a union together — the pattern named in the exchange above.
struct message {
enum msg_kind kind; /* the tag telling which eye to look with */
unsigned flags : 4; /* small states — bit fields earn their place here */
unsigned urgent : 1;
union { /* an anonymous union (C11) */
int number;
char text[16];
struct { int x, y; } point;
};
};Three things are layered here. The tag, the state flags saved by bit fields, and an anonymous union (C11). With no name, members can be used one step more directly, as m->number, which makes a tagged union far more readable.
There is only one discipline and it is everything — read only the member the tag says. If kind is MSG_TEXT and you read number, it becomes the “looking through another eye” of this chapter’s first section and gives a meaningless value. So code handling such data is almost always made to pass through a single switch on the tag, like the example’s show. Gather the access in one place and the place to keep the discipline is one place too.
That sizeof(struct message) came out as 24 bytes is worth reading as well — a 4-byte tag plus the 4-byte word holding the bit fields plus the 16-byte union. No padding went in, but the word holding the bit fields keeps bits nobody uses. Representation always takes a little more than what was declared.
49.6.1 One real specimen — two-byte Johab Hangul#
Cutting one word into bit fields is not only a textbook affair. When Hangul was first being put into computers, splitting one word into fields for the three jamo became an actual standard — Johab (조합형, “the combining form”).
examples-en/unions/johab.c
/* Two-byte Johab Hangul — a real case of one word split into initial,
medial and final jamo. (Hangul appears here because the subject is an
encoding of Hangul.) */
#include <stdio.h>
#include <stdint.h>
#include <string.h>
/* Names of the jamo. Codes 0 and 1 are fill/unused, so they stay empty. */
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",
};
/* (1) Extract with shifts and masks — the way the standard pins down */
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: flag=%u cho=%2u(%-2s) jung=%2u(%-3s) jong=%2u(%s)\n",
(unsigned)(w >> 15), cho, CHO[cho] ? CHO[cho] : "?",
jung, JUNG[jung] ? JUNG[jung] : "?",
jong, JONG[jong] ? JONG[jong] : "?");
}
/* (2) View it with bit fields in a union — handy, but the layout is
implementation-defined */
union johab {
uint16_t raw;
struct {
uint16_t jong : 5; /* this order is not promised by the standard */
uint16_t jung : 5;
uint16_t cho : 5;
uint16_t mark : 1;
} f;
};
int main(void)
{
/* The Johab codes of 가 and 한, taken from the real table. */
const uint16_t GA = 0x8861; /* 1 00010 00011 00001 */
const uint16_t HAN = 0xD065; /* 1 10100 00011 00101 */
printf("GA(가) = 0x%04X, HAN(한) = 0x%04X\n\n", GA, HAN);
puts("GA (가):"); split_by_shift(GA);
puts("HAN (한):"); split_by_shift(HAN);
union johab u = { .raw = GA };
printf("\nthrough bit fields: mark=%u cho=%u jung=%u jong=%u\n",
u.f.mark, u.f.cho, u.f.jung, u.f.jong);
puts(" (On this compiler it matched the shift/mask result, because this");
puts(" implementation fills bits from the low end. That is not a promise.)");
/* (3) The second byte collides with ASCII — Johab's famous trap */
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' — searching bytes for 'a'\n",
bytes[1], bytes[1]);
printf(" lands inside a character: strchr result = %s\n",
strchr((char *)bytes, 'a') ? "found (false hit)" : "not found");
return 0;
}
Output
GA(가) = 0x8861, HAN(한) = 0xD065
GA (가):
shift/mask: flag=1 cho= 2(G ) jung= 3(A ) jong= 1((none))
HAN (한):
shift/mask: flag=1 cho=20(H ) jung= 3(A ) jong= 5(N)
through bit fields: mark=1 cho=2 jung=3 jong=1
(On this compiler it matched the shift/mask result, because this
implementation fills bits from the low end. That is not a promise.)
the two bytes of 가: 88 61
the second byte 0x61 is ASCII 'a' — searching bytes for 'a'
lands inside a character: strchr result = found (false hit)
The design is exactly what this chapter has taught. Sixteen bits are divided into four: the leading bit marks “this is Hangul”, and the remaining fifteen are cut into three fields of five bits each — initial, medial and final jamo.
| bits | 15 | 14–10 / 9–5 / 4–0 | meaning |
|---|---|---|---|
| field | flag | initial / medial / final | five bits each |
가 = 0x8861 | 1 | 2 / 3 / 1 | ㄱ + ㅏ + (none) |
한 = 0xD065 | 1 | 20 / 3 / 5 | ㅎ + ㅏ + ㄴ |
Table 49.5 — The bit layout of Johab Hangul
The numbers given to the jamo follow a rule. Initials run 2–20 from ㄱ to ㅎ (0 and 1 are fill and reserved), and finals start with 1 for “none” and run on to 29. Only the medials leave 8–9, 16–17 and 24–25 empty — the trace of laying the vowels out in blocks of eight with the last two of each block left empty. The gaps are visible in the example’s tables.
What it bought was clear: combining jamo let it write all 11,172 modern Hangul syllables. The rival of the time, the precomposed standard (KS C 5601-1987), listed only the 2,350 syllables in common use, which famously left ordinary names and words unwritable. Johab chose to generate syllables by rule rather than enlarge a table.
In practice. Three lessons Johab left behind
First, the second byte collides with ASCII. 가 is 88 61, and the trailing byte 0x61 is plain 'a'. Code searching bytes for 'a' therefore lands in the middle of a character — the last line of the example shows the false hit happening. Chapter 8′s “a byte is not a character” turns into a bug right here.
Second, the C standard does not fix the layout of bit fields. The example’s union happened to agree with the shift/mask result on this compiler, but only because this implementation fills bits from the low end. The rule of the bit-field section stands: handle external formats with shifts and masks.
Third, there is a place where rule beat table. Unicode took the same idea further and tidier. The code of a Hangul syllable is computed: 0xAC00 + (initial * 21 + medial) * 28 + final — multiplication instead of bit slicing, but the same thought that syllables are made by combining jamo. Johab itself faded (Windows 95 adopted a unified precomposed code and left it behind), yet its idea lives on inside today’s standard.
49.7 On to the next chapter#
Having seen representation with our own eyes through the unions of this chapter, we have two jobs left for that representation. The next chapter turns values in memory into bytes agreed with the outside, serialisation (chapter 50), and the one after asks how to lay the same data out inside (chapter 51). Part VIII closes with those two chapters.
Notes
- ISO/IEC 9899 (C23) 6.7.3.2, paragraph 18. ↩