Frontend Labs
⋯

8. Structs, unions, enums, typedef, and the preprocessor

8.1 Structs: memory layout you can reason about

#include <stdio.h>
#include <stddef.h>

typedef struct {
    char  kind;     // 1 byte
    int   value;    // 4 bytes
    short flags;    // 2 bytes
} Item;             // sizeof is NOT 1+4+2=7 — see below

int main(void) {
    printf("sizeof(Item) = %zu\n", sizeof(Item));               // likely 12, not 7
    printf("offsetof value = %zu\n", offsetof(Item, value));    // likely 4, not 1
    return 0;
}

The compiler inserts padding so each field lands on an address that's a multiple of its own alignment requirement (an int usually needs 4-byte alignment). Reordering fields largest-to-smallest often shrinks a struct:

typedef struct {
    int   value;   // 4 bytes, 4-byte aligned
    short flags;   // 2 bytes
    char  kind;    // 1 byte
    // 1 byte padding to round the whole struct to a multiple of 4
} ItemPacked;       // sizeof == 8, not 12 — same three fields, better layout

Real-world example. This is exactly the optimization database and game engines do to arrays of millions of structs: reordering fields from worst-case to best-case padding can shrink memory footprint by 30%+ and improve cache-line utilization (Section 42 goes deep on this).

8.2 Unions: one block of memory, many interpretations

#include <stdio.h>
#include <stdint.h>

typedef union {
    uint32_t as_int;
    uint8_t  as_bytes[4];
} Word;

int main(void) {
    Word w;
    w.as_int = 0x01020304;
    // On a little-endian machine (x86, Apple Silicon in LE mode):
    printf("%02x %02x %02x %02x\n",
           w.as_bytes[0], w.as_bytes[1], w.as_bytes[2], w.as_bytes[3]);
    // prints 04 03 02 01 — this IS how you detect endianness in real code
    return 0;
}

This union-based byte inspection is legal, portable behavior-wise (type punning through a union is explicitly allowed in C, unlike C++), and is exactly how real network code and file-format parsers implement portable byte-order detection and manual serialization.

8.3 Tagged unions: C's answer to "sum types" / enums-with-data

typedef enum { SHAPE_CIRCLE, SHAPE_RECT } ShapeKind;

typedef struct {
    ShapeKind kind;
    union {
        struct { double radius; } circle;
        struct { double w, h; } rect;
    };  // C11 anonymous union — no member name needed to reach circle/rect
} Shape;

double area(const Shape *s) {
    switch (s->kind) {
        case SHAPE_CIRCLE: return 3.14159265 * s->circle.radius * s->circle.radius;
        case SHAPE_RECT:   return s->rect.w * s->rect.h;
    }
    return 0.0;
}

This is the pattern Part VI shows powering cJSON's cJSON struct and countless AST/value representations in real parsers and interpreters — languages with real sum types (Rust enum, Swift enum, Haskell ADTs) compile down to exactly this layout.

8.4 The preprocessor: macros are text substitution, not functions

#define SQUARE(x) ((x) * (x))     // parenthesize EVERYTHING — always

int main(void) {
    int a = 5;
    int b = SQUARE(a + 1);        // expands to ((a + 1) * (a + 1)) = 36, correct
    // Without the inner parens: ((a + 1 * a + 1)) = wrong answer
    (void)b;

    int i = 2;
    int bad = SQUARE(i++);        // ((i++) * (i++)) — undefined behavior: i modified twice
                                   // between sequence points. NEVER pass side effects to macros.
    (void)bad;
    return 0;
}

Prefer static inline functions over macros wherever you don't need text-substitution tricks — they're type-checked, debuggable, and don't double-evaluate arguments:

static inline int square(int x) { return x * x; }  // safe, and the compiler will still inline it

🔎 Checkpoint: structs, unions, and macros

1. Predict sizeof, then reorder to shrink it:

#include <stdio.h>
struct A { char a; double b; char c; };
struct B { double b; char a; char c; };   // same three fields, different order

int main(void) {
    printf("%zu %zu\n", sizeof(struct A), sizeof(struct B));
    return 0;
}
Answer

sizeof(struct A) is 24: a (1 byte) needs 7 bytes of padding before b can start on an 8-byte boundary, then b (8 bytes), then c (1 byte) needs 7 more bytes of padding so the whole struct's size is a multiple of its largest member's alignment (8). sizeof(struct B) is 16: putting the 8-byte double first means a and c (1 byte each) can share the remaining space with only 6 bytes of padding at the very end. Same three fields, 8 bytes saved per struct just from reordering — exactly §8.1's "largest to smallest" rule, and exactly the optimization §42.2 scales up to millions of structs.

2. Spot the bug — what does this actually print, and why is it probably not what you'd guess?

#include <stdio.h>
#define MIN(a,b) a < b ? a : b

int main(void) {
    int x = MIN(1,2) + 3;
    printf("%d\n", x);
    return 0;
}
Answer

It prints 1, not 4. MIN(1,2) + 3 expands, by pure text substitution (§8.4), to 1 < 2 ? 1 : 2 + 3. The ternary operator ?: binds looser than +, so this parses as (1 < 2) ? 1 : (2 + 3) — the condition is true, so the whole expression is 1, and the + 3 silently attached itself to the wrong branch. The fix is the same lesson as §46.10: parenthesize the entire macro body, not just each parameter: #define MIN(a,b) ((a) < (b) ? (a) : (b)).

3. Write it yourself: using §8.3's tagged-union pattern, define a Value type that can hold an int, a float, or a const char *, tagged by a ValueKind enum, plus a print_value function that switches on the tag.

One correct answer
#include <stdio.h>

typedef enum { VAL_INT, VAL_FLOAT, VAL_STRING } ValueKind;

typedef struct {
    ValueKind kind;
    union {
        int intval;
        float floatval;
        const char *strval;
    };
} Value;

void print_value(const Value *v) {
    switch (v->kind) {
        case VAL_INT:    printf("int: %d\n", v->intval); break;
        case VAL_FLOAT:  printf("float: %f\n", v->floatval); break;
        case VAL_STRING: printf("string: %s\n", v->strval); break;
    }
}

int main(void) {
    Value a = { .kind = VAL_INT, .intval = 42 };
    Value b = { .kind = VAL_STRING, .strval = "hi" };
    print_value(&a);
    print_value(&b);
    return 0;
}

This is the exact shape cJSON uses for a JSON value (§36) and your own Section 16.3 JSON parser's JsonValue — the same three-line pattern (tag, anonymous union, switch) is how C represents "one of several types" everywhere it comes up.

4. Conceptual: what actually goes wrong if you delete the #ifndef POINT_H / #define POINT_H / #endif lines from a header that two different .c files both #include (possibly indirectly, through another header)?

Answer

The preprocessor is pure text substitution (§8.4) — without an include guard, if the same header gets #included twice into one translation unit, every declaration in it gets pasted in twice. For a function prototype that's harmless (redeclaring the same prototype is legal), but for a struct/typedef definition it's a hard compile error: "redefinition of struct Point." The include guard makes the second #include of the same file expand to nothing, because POINT_H is already defined the second time the preprocessor reaches the #ifndef line.


Generated from real-life-c-guide.md · 8 Oct 2026 16:36