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.