04 · Structs & Unions Advanced¶
Level 1, Module 7 covered structs as record types:
group some fields, give them a name, pass them around. This module goes under
the hood — how the compiler lays a struct out in memory, why sizeof is often
bigger than you expect, and the three sibling constructs (union, bit-fields,
and the tagged-union idiom) that let a struct mean different things at
different times.
You need this the moment you start reading binary formats, writing network code, or building linked data structures — all of which show up in the rest of this level.
Pointers to structs and the -> operator¶
Once structs get big, you pass pointers instead of copies. p->field is
shorthand for (*p).field:
#include <stdio.h>
#include <string.h>
typedef struct {
char name[32];
int quantity;
double price;
} Item;
// const Item * = "I will read this and not modify it"
void print_item(const Item *it) {
printf("%-10s qty=%-4d $%.2f\n", it->name, it->quantity, it->price);
}
// Non-const pointer: this one modifies the caller's struct.
void restock(Item *it, int amount) {
it->quantity += amount;
}
int main(void) {
Item widget = {"widget", 5, 9.99};
print_item(&widget);
restock(&widget, 10);
print_item(&widget);
// (*p).field and p->field are exactly equivalent
Item *p = &widget;
printf("%d == %d\n", (*p).quantity, p->quantity);
return 0;
}
// Output:
// widget qty=5 $9.99
// widget qty=15 $9.99
// 15 == 15
Passing by pointer copies 8 bytes instead of 48, and it's the only way for the function to modify the caller's struct — the same pass-by-value rule from Level 1, Module 4 applies to structs.
Padding and alignment: why sizeof surprises you¶
The CPU wants each field at an address that's a multiple of its size. The compiler inserts invisible padding bytes to make that happen:
#include <stdio.h>
#include <stddef.h>
struct Bad { char a; int b; char c; }; // char, pad, int, char, pad
struct Good { int b; char a; char c; }; // int, char, char, pad
int main(void) {
printf("sizeof(struct Bad) = %zu\n", sizeof(struct Bad));
printf("sizeof(struct Good) = %zu\n", sizeof(struct Good));
printf("Bad: a@%zu b@%zu c@%zu\n",
offsetof(struct Bad, a),
offsetof(struct Bad, b),
offsetof(struct Bad, c));
printf("Good: b@%zu a@%zu c@%zu\n",
offsetof(struct Good, b),
offsetof(struct Good, a),
offsetof(struct Good, c));
return 0;
}
// Output on a typical 64-bit system:
// sizeof(struct Bad) = 12
// sizeof(struct Good) = 8
// Bad: a@0 b@4 c@8
// Good: b@0 a@4 c@5
Same three fields, same data, 50% more memory — purely because of declaration
order. struct Bad puts 3 padding bytes after a so b lands on a 4-byte
boundary, then pads the tail so an array of these structs keeps every element
aligned.
Practical rules:
- Declare wide members first (
double,long, pointers), narrow ones last. This usually minimizes padding for free. offsetof(from<stddef.h>) tells you where a member actually sits.- Never assume a struct's byte layout is portable. Padding differs between compilers and architectures, which is exactly why writing a raw struct to a file is risky — see Module 5.
- Padding bytes have indeterminate values, so comparing two structs with
memcmpcan report "different" for structs whose fields are all equal. Compare field by field.
Self-referential structs¶
A struct cannot contain itself (infinite size), but it can contain a pointer to itself. This is the foundation of every linked structure:
#include <stdio.h>
#include <stdlib.h>
typedef struct Node { // tag "Node" is required to refer to itself
int value;
struct Node *next; // note: "struct Node", the typedef isn't ready yet
} Node;
int main(void) {
Node *head = NULL;
// Build 3 -> 2 -> 1 by pushing onto the front
for (int i = 1; i <= 3; i++) {
Node *n = malloc(sizeof *n);
if (n == NULL) return 1;
n->value = i;
n->next = head;
head = n;
}
for (Node *cur = head; cur != NULL; cur = cur->next) {
printf("%d -> ", cur->value);
}
printf("NULL\n");
// Free the list: save "next" BEFORE freeing the node
Node *cur = head;
while (cur != NULL) {
Node *next = cur->next;
free(cur);
cur = next;
}
return 0;
}
// Output:
// 3 -> 2 -> 1 -> NULL
The struct tag (struct Node) is mandatory here: inside the braces the
typedef name doesn't exist yet. And notice the free loop saves cur->next
first — reading cur->next after free(cur) is a use-after-free, the bug from
Module 2.
You'll build a full linked list in the Level 2 project.
Unions: one block of memory, several interpretations¶
A union gives every member the same starting address. Its size is that of
its largest member, and only one member is meaningful at a time:
#include <stdio.h>
union Value {
int i;
float f;
char bytes[4];
};
int main(void) {
union Value v;
printf("sizeof(union Value) = %zu\n", sizeof v);
v.i = 1234567;
printf("as int: %d\n", v.i);
v.f = 3.14f; // OVERWRITES the same bytes
printf("as float: %.2f\n", v.f);
printf("as int: %d <-- meaningless now\n", v.i);
return 0;
}
// Output:
// sizeof(union Value) = 4
// as int: 1234567
// as float: 3.14
// as int: 1078523331 <-- meaningless now
Writing one member and reading another is "type punning". C explicitly allows it through a union (unlike C++), and it's how you'd inspect the raw bytes of a float — but the result depends on endianness and float representation, so treat it as machine-specific.
Tagged unions: the safe way to use them¶
A bare union has no idea which member is live. The fix is to pair it with an enum that records the current type:
#include <stdio.h>
typedef enum { VAL_INT, VAL_DOUBLE, VAL_STRING } ValueType;
typedef struct {
ValueType type; // the "tag" -- says which member is valid
union {
int i;
double d;
const char *s;
} as; // a union member named "as": v->as.i, v->as.d
} Variant;
void print_variant(const Variant *v) {
switch (v->type) {
case VAL_INT: printf("int: %d\n", v->as.i); break;
case VAL_DOUBLE: printf("double: %.2f\n", v->as.d); break;
case VAL_STRING: printf("string: %s\n", v->as.s); break;
}
}
int main(void) {
Variant vals[] = {
{ VAL_INT, { .i = 42 } },
{ VAL_DOUBLE, { .d = 2.5 } },
{ VAL_STRING, { .s = "hello" } }
};
for (int i = 0; i < 3; i++) print_variant(&vals[i]);
printf("sizeof(Variant) = %zu\n", sizeof(Variant));
return 0;
}
// Output:
// int: 42
// double: 2.50
// string: hello
// sizeof(Variant) = 16
This is how interpreters store dynamically-typed values, how libpng and
similar libraries model "one of several chunk types", and how you'd represent a
JSON value in C. Reading the wrong member is now a bug you can catch rather
than silent garbage.
Note the { .i = 42 } syntax — designated initializers, added in C99. They
also work for structs and are far more readable than positional ones:
Bit-fields: packing flags into single bits¶
When memory really matters — embedded work, protocol headers — you can declare members that occupy a specified number of bits:
#include <stdio.h>
typedef struct {
unsigned int is_active : 1; // 1 bit: 0 or 1
unsigned int is_visible : 1;
unsigned int priority : 3; // 3 bits: 0..7
unsigned int reserved : 27;
} Flags;
int main(void) {
Flags f = { .is_active = 1, .is_visible = 0, .priority = 5 };
printf("active=%u visible=%u priority=%u\n",
f.is_active, f.is_visible, f.priority);
printf("sizeof(Flags) = %zu\n", sizeof f);
f.priority = 9; // 9 needs 4 bits -- silently truncated to 3 bits
printf("priority after setting 9: %u\n", f.priority);
return 0;
}
// Output:
// active=1 visible=0 priority=5
// sizeof(Flags) = 4
// priority after setting 9: 1
Bit-fields are convenient but not portable in layout: the standard leaves bit ordering, straddling, and padding up to the implementation. Use them for your own in-memory flags; use explicit shifts and masks (covered in Level 3, Module 4) when the exact bit positions must match a file format or wire protocol.
Quick reference¶
| Construct | Size | Members live at once | Typical use |
|---|---|---|---|
struct |
sum of members + padding | all | records, nodes, objects |
union |
size of largest member | exactly one | type punning, memory reuse |
| tagged union | enum + union | one, tracked by the tag | variants, interpreters, parsers |
| bit-field | packed into words | all | flags, tight embedded state |
enum |
an int in practice |
— | named constants, the tag itself |
How It Actually Works¶
A union's "share one address" behavior isn't a language-level trick — the
compiler allocates one region of memory exactly sizeof(largest member)
bytes wide, and every member's offset is computed as 0, full stop. v.i
and v.f are literally the same four bytes reinterpreted through different
type lenses at compile time: writing v.f = 3.14f runs the IEEE-754
encoder and stores the resulting bit pattern into those same four bytes;
reading v.i immediately afterward reinterprets that identical bit pattern
as two's-complement, producing a value with no numeric relationship to
3.14 — the "meaningless" 1078523331 you saw is exactly the 32-bit IEEE-754
encoding of 3.14f read back as a signed integer. This is the mechanical
basis of type punning: the union simply refuses to remember which member
you last wrote, because at the hardware level there is only one memory
location and one set of bits — "type" is metadata the compiler drops the
instant it finishes generating code.
Self-referential structs work despite "a struct can't contain itself"
because struct Node *next is a pointer — a fixed 8-byte address-sized
field — not an embedded Node. The compiler can compute sizeof(Node)
immediately (it just needs to know a pointer's size, not the size of
whatever it eventually points to), which is why the forward reference
struct Node *next; compiles fine inside struct Node's own definition
while an embedded struct Node next; would require infinite regress and is
correctly rejected. Building the list with malloc for each node means
each Node lives at its own independently-allocated heap address, with
next fields chaining those addresses together — the list's structure
exists entirely as pointer values, not as any contiguous memory layout the
way an array is.
Bit-fields push the padding logic one level deeper: instead of allocating
whole bytes per member, the compiler packs several fields into the bits of
one underlying storage unit (commonly a 32-bit unsigned int), tracking a
bit-offset within that unit for each field and generating shift-and-mask
instructions to read or write each one. f.priority = 9 truncating to 1
happens because the compiler generates a mask that keeps only the low 3
bits of whatever value you assign (9 is 1001 in binary; masked to 3
bits it becomes 001, i.e. 1) — there's no bounds check, just an AND
operation baked in at compile time, the same silent-truncation behavior as
integer overflow but happening at a sub-byte granularity.
Exercise¶
Model a small shape system. Define typedef enum { CIRCLE, RECTANGLE, TRIANGLE
} ShapeKind; and a tagged union Shape holding the tag plus a union of
{ double radius; }, { double w, h; }, and { double base, height; }. Write
double area(const Shape *s) that switches on the tag, and
void describe(const Shape *s) that prints the kind and area. Build an array of
five shapes using designated initializers, print each, and print the total area.
Then add a printf of sizeof(Shape) and explain to yourself why it is what it
is, using the padding rules above.