Why this matters
Sometimes a value can be one of several different things: a token is a number or a name or an operator; a JSON value is null, a boolean, a number, a string, an array or an object; a network message has one of several payload formats. A struct holding all the alternatives side by side wastes memory on the ones not in use. A union overlays them in the same bytes, and a tagged union (a struct with an enum saying which alternative is active plus a union holding it) is the standard C way to represent "one of these". Interpreters (CPython's objects, Lua's values), compilers' syntax trees and the X11 and SDL event systems are all built this way. Unions are also a tool for looking at the raw bytes of a value.
A union shares one block of memory
A union is declared like a struct, but all members start at offset 0:
#include <stdio.h>
#include <stdint.h>
union number {
int32_t i;
float f;
unsigned char bytes[4];
};
int main(void)
{
union number n;
printf("sizeof(union number) = %zu\n", sizeof n);
n.i = 1;
printf("as int: %d\n", n.i);
printf("bytes: %02x %02x %02x %02x\n", n.bytes[0], n.bytes[1], n.bytes[2], n.bytes[3]);
n.f = 1.0f;
printf("as float: %f\n", n.f);
printf("as int: %d (0x%08x)\n", n.i, (unsigned)n.i);
return 0;
}
sizeof(union number) = 4
as int: 1
bytes: 01 00 00 00
as float: 1.000000
as int: 1065353216 (0x3f800000)
struct (members side by side) union (members overlap)
+------+------+-------------+ +---------------------------+
| i | f | bytes[4] | | i (4 bytes) |
+------+------+-------------+ | f (same 4 bytes) |
0 4 8 12 | bytes[0..3] (same 4) |
+---------------------------+
0 4
Consequences:
- Size: a union is as big as its largest member, rounded up to its strictest alignment.
- One at a time: writing one member overwrites the others. The union holds one value; which one is up to you to remember.
- Reading a different member than the one last written reinterprets the same bytes as another type. Here writing
1.0fand reading the int shows0x3f800000, the IEEE 754 bit pattern of 1.0 (sign 0, exponent 127, fraction 0), which you met in the floating point lesson. The bytes01 00 00 00show that x86-64 stores the low byte of an integer first, called little-endian; the bits module covers byte order.
Type punning
Reading a member other than the last one written is called type punning. C (unlike C++) explicitly allows it through a union: the bytes are reinterpreted as the new type. It is the standard way to inspect the bits of a float:
uint32_t float_bits(float f)
{
union { float f; uint32_t u; } pun;
pun.f = f;
return pun.u;
}
What you must not do is the pointer version, *(uint32_t *)&f. It breaks the strict aliasing rule (an object may only be accessed through its own type, a character type, or a few other exceptions), which is undefined behavior; optimizing compilers really do miscompile it. The union or memcpy(&u, &f, sizeof u) are the safe ways. The result of punning still depends on the machine (byte order, float format), so it is for low-level code, not portable data exchange.
Tagged unions
A bare union does not know which member is valid, so on its own it is rarely useful. Pair it with an enum, the tag (also called the discriminant), in a struct:
#include <stdio.h>
#include <string.h>
typedef enum { TOK_NUMBER, TOK_IDENT, TOK_OP } TokenKind;
typedef struct {
TokenKind kind;
union {
double number; /* TOK_NUMBER */
char ident[16]; /* TOK_IDENT */
char op; /* TOK_OP */
} as;
} Token;
void print_token(const Token *t)
{
switch (t->kind) {
case TOK_NUMBER: printf("NUMBER %g\n", t->as.number); break;
case TOK_IDENT: printf("IDENT %s\n", t->as.ident); break;
case TOK_OP: printf("OP %c\n", t->as.op); break;
}
}
int main(void)
{
Token toks[3];
toks[0].kind = TOK_IDENT;
strcpy(toks[0].as.ident, "width");
toks[1] = (Token){ .kind = TOK_OP, .as.op = '*' };
toks[2] = (Token){ .kind = TOK_NUMBER, .as.number = 2.5 };
for (int i = 0; i < 3; i++)
print_token(&toks[i]);
printf("sizeof(Token) = %zu\n", sizeof(Token));
return 0;
}
IDENT width
OP *
NUMBER 2.5
sizeof(Token) = 24
The layout: 4 bytes of tag, 4 bytes of padding (the union contains a double, so it must be 8-aligned), then the 16-byte union. A struct with all three members separately would be 8 + 16 + 1 + padding = 32 bytes, and with more alternatives the savings grow.
Token (24 bytes)
+--------+--------+---------------------------------------+
| kind | pad | as: number (8) / ident (16) / op (1) |
+--------+--------+---------------------------------------+
0 4 8 24
The rules that make tagged unions safe:
- Set the tag and the member together, ideally in one place (a constructor function or a compound literal with designated initializers, like
.kind = TOK_OP, .as.op = '*'). - Switch on the tag before reading, and read only the member it names.
- No
default:in those switches, so adding a new kind makes-Wswitchpoint at every switch that needs a new case.
This is the C version of what other languages build in: Rust's enum, Swift's enums with associated values, TypeScript's discriminated unions, Haskell's algebraic data types. Those languages check rule 2 for you; in C it is discipline.
Anonymous unions
C11 lets you leave the union unnamed inside a struct, so its members are accessed directly:
typedef struct {
TokenKind kind;
union {
double number;
char ident[16];
char op;
}; /* no name */
} Token2;
/* t.number instead of t.as.number */
Both styles are common. The named version (as, u or data are popular names) makes it obvious at every use that you are reading a union member.
Unions holding pointers
When an alternative owns heap memory (a string of any length, an array of child values), the destroy function must switch on the tag to know what to free:
void value_free(Value *v)
{
switch (v->kind) {
case V_TEXT: free(v->as.heap_text); break;
case V_INT:
case V_REAL: break; /* nothing owned */
}
}
Freeing through the wrong member (treating a number's bits as a pointer) is an invalid free and an abort. This is where the tag discipline matters most.
Common mistakes
- Reading the wrong member after forgetting to check the tag: meaningless values, or crashes with pointer members.
- Updating the member but not the tag (or the other way round). Write small functions such as
Value make_int(long i)so both always change together. - Pointer-cast punning
*(uint32_t *)&finstead of a union ormemcpy. gcc may warndereferencing type-punned pointer will break strict-aliasing rulesat higher optimization levels; the behavior is undefined regardless. - Expecting a union to be the sum of its members. It holds one value. Writing
n.fdestroyedn.i. - Initializing the wrong member:
union number n = { 1.5 };initializes the first member (theint32_t, so it becomes 1). Use a designated initializer,{ .f = 1.5f }.
Recap
- A union's members overlap at offset 0; its size is the largest member, rounded up for alignment; it holds one value at a time.
- Reading a different member than the one written reinterprets the bytes (type punning). It is allowed in C through unions or
memcpy, but not through casted pointers. - A tagged union is a struct holding an enum tag and a union. Set both together, switch on the tag before reading, and let
-Wswitchfind missing cases. - Destroy functions for tagged unions switch on the tag to free owned members.
Going further: extend the Value exercise with a V_LIST kind holding a pointer to a heap array of Values and a length. Write value_free recursively, and check it with valgrind. That is, in miniature, how a JSON library represents a document.