Procedural Programming with C · Intermediate · Week 22

Unions, Enumerations, and Type Aliases

Three small features that finish C's type system. One of them — the enumeration — buys you a genuine static check the language otherwise never offers. Another — the union — is the only place C lets you interpret the same bytes two ways, which is powerful and requires discipline.

By the end of this week you can
  • Define enumerations and use the compiler warning they unlock.
  • Use typedef where it helps and recognize where it hides too much.
  • Explain what a union is, and why a bare union is almost always wrong.
  • Build a tagged union and access it safely.
  • Explain structure padding, compute member offsets, and reorder members to save space.

1Enumerations

typedef enum {
    STATE_IDLE,        /* 0 */
    STATE_RUNNING,     /* 1 */
    STATE_PAUSED,      /* 2 */
    STATE_STOPPED      /* 3 */
} State;

State s = STATE_IDLE;

An enumeration defines a set of named integer constants. Values start at 0 and increase by one unless you say otherwise:

typedef enum {
    HTTP_OK        = 200,
    HTTP_NOT_FOUND = 404,
    HTTP_ERROR     = 500
} HttpStatus;

typedef enum {
    FLAG_READ  = 1 << 0,    /* 1 */
    FLAG_WRITE = 1 << 1,    /* 2 */
    FLAG_EXEC  = 1 << 2     /* 4 */
} Permission;

The second form gives named bit flags, which week 25 combines with the bitwise operators.

The reason to use them

Enumerations are not merely tidier than #define. They unlock a compile-time check that C offers nowhere else:

const char *describe(State s)
{
    switch (s) {
    case STATE_IDLE:    return "idle";
    case STATE_RUNNING: return "running";
    case STATE_PAUSED:  return "paused";
    /* STATE_STOPPED not handled, and no default */
    }
    return "unknown";
}
warning: enumeration value 'STATE_STOPPED' not handled in switch [-Wswitch]

Add a new state to the enumeration a year later and the compiler points at every switch that needs updating. This is genuine static verification, and it is lost the moment you write a default label — so on a switch over an enumeration, omit default deliberately and handle the out-of-range case after the switch.

What an enum is, underneath. Its constants have type int, and the enumeration type itself is some implementation-chosen integer type large enough to hold them. An enum variable can legally hold a value outside the listed set — (State)99 compiles — so the switch warning is a help, not a guarantee. C23 lets you fix the underlying type: enum State : unsigned char { … }.

2typedef

typedef introduces an alias for an existing type. It creates no new type — it is a naming device, and the compiler treats the alias and the original as identical.

typedef unsigned long  Timestamp;
typedef struct Point   Point;
typedef int          (*Comparator)(const void *, const void *);

Where it clearly helps

  • Structures, to avoid writing struct at every use.
  • Function pointers, where the raw declaration is genuinely hard to read — week 30 relies on this.
  • Portability, as a single place to change a width. This is exactly what <stdint.h> does with int32_t.
  • Opaque handles, where the caller must not see the definition — week 33.

Where it hurts

typedef int *IntPtr;
IntPtr a, b;              /* both pointers — surprising, but fine */
const IntPtr p = q;       /* a CONST POINTER, not a pointer to const */

Hiding a * behind an alias makes const apply to the pointer rather than the target, which is rarely what the reader expects. The standard library avoids pointer typedefs for exactly this reason, and so should you — with the deliberate exception of opaque handles, where hiding is the point.

3Unions

A union's members all occupy the same storage. Its size is that of its largest member, and only one member holds a meaningful value at any moment.

union Value {
    int    as_int;
    double as_double;
    char   as_text[16];
};

union Value v;
v.as_int = 42;
printf("%d\n", v.as_int);      /* fine */
printf("%f\n", v.as_double);   /* garbage: you did not store a double */
intdoublechar[8] struct: 20 bytes, side by side int double char[8] union: 8 bytes, all overlaid — one valid at a time

Reading a member you did not write gives an unspecified value, and is how type punning is done.

Reading a member other than the one last written is unspecified behavior in C — implementations commonly define it as reinterpreting the bytes, which makes unions the standard way to inspect a value's representation. That is legitimate and useful, and is exactly what week 4's float-byte dump did by another route.

What is not legitimate is forgetting which member is live. A bare union carries no record of that, so it is almost always wrong on its own.

4The tagged union

Pair the union with an enumeration recording which member is valid, and wrap both in a structure. This is the disciplined form, and it is how variant types are built in C.

typedef enum { VAL_INT, VAL_DOUBLE, VAL_TEXT } ValueKind;

typedef struct {
    ValueKind kind;
    union {
        int    as_int;
        double as_double;
        char   as_text[24];
    } data;
} Value;

Every access goes through the tag:

void print_value(const Value *v)
{
    switch (v->kind) {
    case VAL_INT:    printf("%d\n",  v->data.as_int);    break;
    case VAL_DOUBLE: printf("%g\n",  v->data.as_double); break;
    case VAL_TEXT:   printf("%s\n",  v->data.as_text);   break;
    }
}

And because the switch covers an enumeration with no default, adding a fourth kind produces a warning here and at every other site that inspects a Value. The tag is what makes the union safe, and the enum is what makes the tag checkable.

C11 allows the union to be anonymous, which removes the .data step:

typedef struct {
    ValueKind kind;
    union {                     /* no name */
        int    as_int;
        double as_double;
    };
} Value;

v.as_int = 42;                  /* accessed as if it were a direct member */

5Padding, alignment, and offsetof

Week 21 ended with two structures whose members were all equal comparing unequal under memcmp. Here is why.

Every type has an alignment requirement: an int must usually sit at an address divisible by 4, a double by 8. The compiler inserts unnamed padding bytes between members to satisfy this, and after the last member so that arrays of the structure stay aligned.

struct Wasteful {          /* 24 bytes */
    char   a;              /* offset 0  */
                           /* 7 bytes padding */
    double b;              /* offset 8  */
    char   c;              /* offset 16 */
                           /* 7 bytes padding */
};

struct Tight {             /* 16 bytes */
    double b;              /* offset 0  */
    char   a;              /* offset 8  */
    char   c;              /* offset 9  */
                           /* 6 bytes padding */
};

Same three members, one third less space. The rule of thumb: declare members from largest to smallest. On a single structure this is irrelevant; on an array of a million records it is megabytes, and week 34 shows it is also measurably faster because more records fit in a cache line.

offsetof, from <stddef.h>, reports where a member actually sits:

#include <stddef.h>
printf("%zu\n", offsetof(struct Wasteful, b));   /* 8 */

The padding bytes have unspecified contents — they are never initialized, not even by = {0} in every implementation. That is what defeated memcmp.

6Worked example: a variant type and a space audit

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stddef.h>
#include <stdbool.h>

/* ---------- enumeration with a checkable switch ---------- */

typedef enum {
    TOKEN_NUMBER,
    TOKEN_WORD,
    TOKEN_SYMBOL
} TokenKind;

/* No default label: adding a TokenKind makes the compiler
   point at this function. That is the whole reason for the enum. */
static const char *kind_name(TokenKind k)
{
    switch (k) {
    case TOKEN_NUMBER: return "number";
    case TOKEN_WORD:   return "word";
    case TOKEN_SYMBOL: return "symbol";
    }
    return "unknown";          /* reached only for an out-of-range value */
}

/* ---------- tagged union ---------- */

typedef struct {
    TokenKind kind;
    union {
        double as_number;
        char   as_word[24];
        char   as_symbol;
    } data;
} Token;

static Token token_number(double v)
{
    Token t = { .kind = TOKEN_NUMBER };
    t.data.as_number = v;
    return t;
}

static Token token_word(const char *w)
{
    Token t = { .kind = TOKEN_WORD };
    snprintf(t.data.as_word, sizeof t.data.as_word, "%s", w);
    return t;
}

static Token token_symbol(char c)
{
    Token t = { .kind = TOKEN_SYMBOL };
    t.data.as_symbol = c;
    return t;
}

static void token_print(const Token *t)
{
    printf("  %-7s ", kind_name(t->kind));
    switch (t->kind) {
    case TOKEN_NUMBER: printf("%g\n",  t->data.as_number); break;
    case TOKEN_WORD:   printf("\"%s\"\n", t->data.as_word); break;
    case TOKEN_SYMBOL: printf("'%c'\n", t->data.as_symbol); break;
    }
}

/* ---------- named bit flags ---------- */

typedef enum {
    PERM_READ  = 1 << 0,
    PERM_WRITE = 1 << 1,
    PERM_EXEC  = 1 << 2
} Permission;

static void print_permissions(unsigned flags)
{
    printf("  %c%c%c  (0x%02X)\n",
           (flags & PERM_READ)  ? 'r' : '-',
           (flags & PERM_WRITE) ? 'w' : '-',
           (flags & PERM_EXEC)  ? 'x' : '-',
           flags);
}

/* ---------- padding ---------- */

struct Wasteful { char a; double b; char c; };
struct Tight    { double b; char a; char c; };

/* ---------- type punning, the legitimate use of a bare union ---------- */

union FloatBits {
    float    value;
    uint32_t bits;
};

int main(void)
{
    puts("== tagged union ==");
    Token tokens[] = {
        token_number(3.14),
        token_word("hello"),
        token_symbol('+'),
        token_number(42)
    };
    const size_t n = sizeof tokens / sizeof tokens[0];
    for (size_t i = 0; i < n; i++) {
        token_print(&tokens[i]);
    }
    printf("  sizeof(Token) = %zu: a tag plus the largest member\n",
           sizeof(Token));

    puts("\n== what a bare union looks like when you lose track ==");
    union { int as_int; float as_float; } raw;
    raw.as_int = 1065353216;
    printf("  stored as int   : %d\n", raw.as_int);
    printf("  read as float   : %g   <-- same bytes, different meaning\n",
           (double)raw.as_float);
    puts("  nothing in the union records which member is live");

    puts("\n== bit flags from an enumeration ==");
    unsigned p = PERM_READ | PERM_WRITE;
    print_permissions(p);
    p |= PERM_EXEC;
    print_permissions(p);
    p &= ~(unsigned)PERM_WRITE;
    print_permissions(p);

    puts("\n== padding: same members, different order ==");
    printf("  sizeof(struct Wasteful) = %zu\n", sizeof(struct Wasteful));
    printf("    a at %zu, b at %zu, c at %zu\n",
           offsetof(struct Wasteful, a),
           offsetof(struct Wasteful, b),
           offsetof(struct Wasteful, c));
    printf("  sizeof(struct Tight)    = %zu\n", sizeof(struct Tight));
    printf("    b at %zu, a at %zu, c at %zu\n",
           offsetof(struct Tight, b),
           offsetof(struct Tight, a),
           offsetof(struct Tight, c));
    printf("  the members total %zu bytes; the rest is padding\n",
           sizeof(char) * 2 + sizeof(double));
    printf("  one million records: %zu MB versus %zu MB\n",
           sizeof(struct Wasteful) * 1000000 / 1048576,
           sizeof(struct Tight)    * 1000000 / 1048576);

    puts("\n== why memcmp fails on structures ==");
    struct Wasteful p1, p2;
    memset(&p1, 0x00, sizeof p1);
    memset(&p2, 0xFF, sizeof p2);
    p1.a = 'A'; p1.b = 1.5; p1.c = 'C';
    p2.a = 'A'; p2.b = 1.5; p2.c = 'C';
    printf("  all members equal : %s\n",
           (p1.a == p2.a && p1.b == p2.b && p1.c == p2.c) ? "yes" : "no");
    printf("  memcmp says equal : %s\n",
           memcmp(&p1, &p2, sizeof p1) == 0 ? "yes" : "no");
    puts("  the padding bytes differ, and memcmp compares them too");

    puts("\n== type punning through a union ==");
    union FloatBits fb;
    fb.value = 1.0f;
    printf("  1.0f has the bit pattern 0x%08X\n", fb.bits);
    fb.bits = 0x40490FDB;
    printf("  0x40490FDB read as a float is %g\n", (double)fb.value);

    return EXIT_SUCCESS;
}
gcc -std=c17 -Wall -Wextra -g -o variants variants.c
./variants

The program needs <stdint.h> for uint32_t; add it to the includes. Week 37 covers those fixed-width types properly.

Provoke the warning that makes enums worthwhile

Add a fourth token kind and rebuild — without touching anything else:

typedef enum {
    TOKEN_NUMBER,
    TOKEN_WORD,
    TOKEN_SYMBOL,
    TOKEN_STRING          /* new */
} TokenKind;
variants.c:20:5: warning: enumeration value 'TOKEN_STRING' not handled
                 in switch [-Wswitch]
variants.c:58:5: warning: enumeration value 'TOKEN_STRING' not handled
                 in switch [-Wswitch]

Two warnings, pointing at exactly the two functions that must be updated. Now add default: return "?"; to kind_name and rebuild: one warning disappears — and with it, the guarantee. That is the trade-off, and on a switch over an enumeration it is usually the wrong side to take.

Measure the padding yourself

pahole ./variants          # if installed: prints every struct with its holes

Without pahole, the offsetof output above tells the same story: Wasteful puts b at offset 8 although a ended at offset 1, so seven bytes are unused. Reordering recovers eight bytes per record — eight megabytes across a million of them.

7Common mistakes

MistakeWhat happensFix
Adding default to a switch over an enumLoses the unhandled-value warningOmit it; handle out-of-range after the switch.
Reading a union member you did not writeUnspecified valueTrack the live member with a tag.
A bare union in an interfaceCallers cannot know what is validWrap it in a struct with an enum tag.
Assuming sizeof equals the sum of membersOff by the paddingUse sizeof and offsetof; never compute by hand.
Declaring members smallest-firstWasted space, worse cache useLargest to smallest.
typedef int *IntPtr; then const IntPtrA const pointer, not a pointer to constDo not hide * behind a typedef.
Writing a struct's bytes to a file directlyPadding and layout are not portableSerialize member by member — week 37.
Assuming an enum variable holds only listed valuesIt can hold any value of the underlying typeValidate input before switching on it.

8Check yourself

What does an enumeration give you that a set of #define constants does not?

A compile-time check. A switch over an enumeration with no default label warns about any enumerator you failed to handle, so adding a value later makes the compiler list every site that must be updated. Enumerations also respect scope and are visible to a debugger, neither of which is true of macros.

Why is adding a default label to a switch over an enum usually a mistake?

Because it makes every value "handled", which switches the -Wswitch warning off. You trade a static guarantee for a runtime fallback. Handle the genuinely out-of-range case after the switch instead, so the compiler still checks that every named enumerator has a case.

Why is a bare union almost always wrong in an interface?

Because nothing in it records which member currently holds a valid value, and reading the wrong one yields an unspecified result. The caller would have to track that separately and correctly. Pairing the union with an enum tag inside a structure makes the state explicit and lets the compiler check every access site.

Where does structure padding come from, and how do you avoid most of it?

From alignment requirements: each member must start at an address that is a multiple of its alignment, and the structure is padded at the end so arrays stay aligned. Declaring members from largest to smallest packs them with the fewest gaps — often reducing the size by a third, which matters for large arrays of records.

Why can two structures with identical member values fail a memcmp?

Because memcmp compares every byte including padding, whose contents are unspecified and typically hold whatever was previously in that memory. Two structures assigned the same member values can still differ in those gaps. Compare the members individually instead.

9Where this leads

The type system is now complete: scalars, arrays, pointers, structures, unions, enumerations, and aliases. Week 23 puts them to work on the outside world — reading arguments from the command line and validating them — and week 24 surveys the standard library that has been supplying printf and malloc all along.