C / STRUCTS, UNIONS AND ENUMS
Tagged unions as a safer variant pattern
Pair a union with an enum tag in one struct so every payload read is guarded by the tag, and let -Wswitch catch forgotten variants.
What you will learn
- Wrap an enum tag and a union in one struct so the two can never be stored apart
- Set tag and payload together in one make_* function that returns the struct by value
- Switch on the tag with no default arm so -Wswitch reports every missing variant
- Write accessors that check the tag and refuse the read when it does not match
Understanding Tagged unions as a safer variant pattern
A union gives you storage shaped for several different payloads, but the bytes carry no record of which member was written last, and C will hand back whichever member you name. The tagged union pattern closes that hole by putting an enum beside the union inside the same struct, so the fact "this one holds a double" becomes part of the value and is copied, assigned and returned together with the payload it describes. Nothing in the language enforces the link: the tag is an ordinary member, and the guarantee comes entirely from the rule that no read of the union happens without first consulting it.
Think of the struct as a labelled box whose label lists the finitely many shapes the contents may take. Two habits keep the label honest: writes go through one small make_* function per variant, which sets the tag and its payload on adjacent lines, and reads go through a switch on the tag or an accessor that tests it first. Once those functions are the only way in and out, a tag that disagrees with the payload takes deliberate effort instead of a moment's inattention, and there is exactly one place to review.
Using an enum rather than an int for the tag buys compiler help. When you switch over an enum and leave out a default arm, GCC and Clang under -Wall report every switch that fails to handle a newly added enumerator, so growing the variant set produces a compile-time list of the sites to fix. The cost is that adding default: silences that warning permanently, which is why the usual layout validates the tag once where data enters the program from a file, socket or cast, and keeps the interior switches default-free. Below, as_double has no default so it stays under -Wswitch, while print_number keeps one because it is the function that renders values whose tag may be untrusted.
<stdio.h>
typedef enum { NUM_INT, NUM_DOUBLE, NUM_RATIO } NumKind;
typedef struct {
NumKind kind; /* which member of as is live */
union {
long i;
double d;
struct { long num, den; } r;
} as;
} Number;
static Number make_int(long v) { Number n; n.kind = NUM_INT; n.as.i = v; return n; }
static Number make_double(double v) { Number n; n.kind = NUM_DOUBLE; n.as.d = v; return n; }
static Number make_ratio(long a, long b) { Number n; n.kind = NUM_RATIO; n.as.r.num = a; n.as.r.den = b; return n; }
/* No default arm on purpose: -Wswitch names this function if NumKind grows. */
static double as_double(const Number *n)
{
switch (n->kind) {
case NUM_INT: return (double)n->as.i;
case NUM_DOUBLE: return n->as.d;
case NUM_RATIO: return (double)n->as.r.num / (double)n->as.r.den;
}
return 0.0; /* reachable only with a tag that is not a valid NumKind */
}
static void print_number(const Number *n)
{
switch (n->kind) {
case NUM_INT: printf("int %ld", n->as.i); break;
case NUM_DOUBLE: printf("double %g", n->as.d); break;
case NUM_RATIO: printf("ratio %ld/%ld", n->as.r.num, n->as.r.den); break;
default: printf("<corrupt kind %d>", (int)n->kind); break;
}
}
int main(void)
{
Number vals[3];
vals[0] = make_int(7);
vals[1] = make_double(0.125);
vals[2] = make_ratio(3, 4);
double sum = 0.0;
for (int k = 0; k < 3; k++) {
print_number(&vals[k]);
printf(" -> %.3f\n", as_double(&vals[k]));
sum += as_double(&vals[k]);
}
printf("sum = %.3f\n", sum);
Number junk;
junk.kind = (NumKind)42; /* e.g. a tag byte read back from a damaged file */
print_number(&junk);
putchar('\n');
return 0;
}
A union forgets which member is live, so storing an enum tag next to it in the same struct makes that fact part of the value itself.
Worked examples
Checked accessor instead of a bare read
An accessor that tests the tag before touching the union, so a wrong-variant read is refused rather than reinterpreted.
<stdio.h>
typedef enum { CELL_EMPTY, CELL_TEXT, CELL_MONEY } CellKind;
typedef struct {
CellKind kind;
union {
const char *text;
long cents;
} as;
} Cell;
/* Writes *out and returns 1 only when the tag says the payload really is money. */
static int cell_money(const Cell *c, long *out)
{
if (c->kind != CELL_MONEY)
return 0;
*out = c->as.cents;
return 1;
}
int main(void)
{
Cell cells[3];
cells[0].kind = CELL_MONEY; cells[0].as.cents = 1999;
cells[1].kind = CELL_TEXT; cells[1].as.text = "n/a";
cells[2].kind = CELL_EMPTY; /* tag only, as is never written */
long total = 0;
for (int i = 0; i < 3; i++) {
long cents = 0;
if (cell_money(&cells[i], ¢s)) {
total += cents;
printf("cell %d: %ld.%02ld\n", i, cents / 100, cents % 100);
} else {
printf("cell %d: skipped (kind %d)\n", i, (int)cells[i].kind);
}
}
printf("total cents: %ld\n", total);
return 0;
}
Example explained
Line 1cell_money returns before it ever names c->as, so a wrong tag cannot reinterpret the const char * in text as a long.
Line 2cells[2] has a tag but no payload written at all; the tag check is what keeps that indeterminate storage from being read.
Line 3The success flag is separate from the value, so a genuine 0 cents would not be confused with "not money".
Line 4Printing (int)cells[i].kind shows the enumerator values 1 and 0, confirming the tag is just an integer member you maintain.
A tagged union as a function's return type
Returning success and several failure shapes from one function, with the tag and its payload copied out together.
<stdio.h>
typedef enum { R_OK, R_BAD_DIGIT, R_EMPTY } ResultKind;
typedef struct {
ResultKind kind;
union {
long value;
struct { int pos; char c; } bad;
} as;
} Result;
static Result parse_uint(const char *s)
{
Result r;
if (s[0] == '\0') { r.kind = R_EMPTY; return r; }
long v = 0;
for (int i = 0; s[i]; i++) {
if (s[i] < '0' || s[i] > '9') {
r.kind = R_BAD_DIGIT;
r.as.bad.pos = i;
r.as.bad.c = s[i];
return r;
}
v = v * 10 + (s[i] - '0');
}
r.kind = R_OK;
r.as.value = v;
return r;
}
static void report(const char *in, Result r)
{
switch (r.kind) {
case R_OK: printf("[%s] -> %ld\n", in, r.as.value); break;
case R_BAD_DIGIT: printf("[%s] -> bad char '%c' at %d\n", in, r.as.bad.c, r.as.bad.pos); break;
case R_EMPTY: printf("[%s] -> empty input\n", in); break;
}
}
int main(void)
{
const char *tests[] = { "4096", "12x4", "" };
for (int i = 0; i < 3; i++)
report(tests[i], parse_uint(tests[i]));
return 0;
}
Example explained
Line 1Every branch of parse_uint assigns the tag right next to its payload, and the whole struct is returned by value, so the two cannot drift apart in transit.
Line 2R_EMPTY writes no payload at all: a variant is allowed to be tag-only, and no reader will look at as for that tag.
Line 3r.as.bad groups two fields into a single variant, which a union of bare scalars could not express.
Line 4report switches with no default arm, so -Wswitch will name this function the day a fourth ResultKind is added.
Important notes
A tagged union is as large as its biggest payload plus the tag plus padding, so one fat variant inflates every value; if the shapes differ wildly in size, store a pointer in the large one.
-Wswitch goes quiet as soon as a switch has a default arm; compile with -Wswitch-enum if you want missing enumerators reported even in switches that keep a default.
Common mistakes
Keeping the tag outside the struct, in a parallel array or a nearby variable: after a sort, a copy or a realloc the two fall out of step and every reader then trusts a tag that describes some other value's bytes.
Overwriting a payload without updating the tag, as in v.as.d = 2.5; on a value whose kind is still NUM_INT: the next dispatch takes the int arm and prints the double's bit pattern as a long, with no error anywhere.
Adding default: break; to every switch "for safety": that silences -Wswitch, so when a fourth variant appears the compiler says nothing and the new case quietly falls into the do-nothing arm.
Try it yourself
Change, predict, then run
Add a NUM_COMPLEX variant carrying two doubles (re and im) to Number, then compile with -Wall and use the warnings to find every switch that needs a new case: make as_double return the real part and have print_number show the value in the form 1.5+2i.
Open the C workspaceCheck your understanding
A helper receives Number *n whose kind is NUM_INT and does n->as.d = 0.5; before returning, without touching n->kind. What happens the next time some code switches on n->kind?
- The compiler rejects the assignment, because kind says the value holds a long
- kind becomes NUM_DOUBLE on its own, since writing a union member makes that member the active one
- The switch takes the NUM_INT arm and reads the bytes of 0.5 as a long
- The switch takes the NUM_DOUBLE arm, because the union remembers which member was written last
Show answer
kind is an ordinary struct member that only your code maintains; the language never links it to the union, so the stale NUM_INT tag still steers the dispatch and that arm reinterprets the double's bit pattern as a long. Option 1 is tempting because the write really does make d the active member as far as the language rules go, but that fact is not recorded anywhere your switch can consult, and certainly not copied into kind.