6. Composite and User-Defined Types
Scalars and arrays only take you so far: real programs model things — a point, a packet, a customer record — that consist of several values of different types travelling together. This chapter covers the constructs C gives you for that: struct for grouping, union for overlapping, enum for named constants, and typedef for naming the result. It closes with memory layout, because in C the difference between a 12-byte struct and a 24-byte one is often just the order you wrote the members in.
<a id="61-structures"></a>
6.1 Structures
A structure groups several values into one named object. This section covers how to declare and initialize one, how to reach its members through values and pointers, how structures nest and form arrays, and what happens when you pass one to a function.
Defining and Initializing structs
Theory
Suppose you are tracking employees. With scalars alone you get parallel arrays: names[i], ages[i], salaries[i]. Nothing ties index i in one array to index i in another; sort one and the rest are silently wrong. What you want is a single object holding all three fields, so they move together.
That is a struct: a type whose objects contain several named members, laid out in memory in declaration order. Unlike an array, the members may have different types, and you access them by name rather than by index.
The declaration introduces a new type:
struct point { /* 'point' is the struct TAG */
int x; /* members, in declaration order */
int y;
}; /* the semicolon is mandatory */
Note that struct point — both words — is the type name. C keeps struct tags in a separate namespace from ordinary identifiers, which is why you may have a variable named point and a struct point in the same scope, and why the struct keyword must appear at every use. Most code eliminates the repetition with a typedef (Chapter 6.3).
Several declaration forms exist:
struct point { int x, y; }; /* type only */
struct point p1, p2; /* variables of that type */
struct { int x, y; } anon; /* anonymous: no tag, one use */
struct point3 { int x, y, z; } origin; /* type and a variable at once */
Initialization mirrors arrays. Positional initializers fill members in order, and any member you omit is zero-initialized:
struct point a = {10, 20}; /* x=10, y=20 */
struct point b = {10}; /* x=10, y=0 */
struct point c = {0}; /* both zero */
struct point d; /* INDETERMINATE if automatic */
C99's designated initializers (covered in detail later in this section) let you name members, which is both clearer and immune to member reordering.
Three properties distinguish structs from arrays, and all three matter:
Structs are copied on assignment. p2 = p1; copies every member — a genuine value copy, unlike arrays, which cannot be assigned at all. The copy is shallow: a pointer member is duplicated, not what it points to.
Structs can be passed and returned by value. Again unlike arrays.
Structs cannot be compared with ==. There is no member-wise comparison operator, and memcmp is not a valid substitute because padding bytes (Chapter 6.3) hold indeterminate values. Write a comparison function.
Finally, a struct may be declared without a definition — struct node; — creating an incomplete type. You can form pointers to it but not objects, which is what makes self-referential structures and opaque handles possible.
Examples
#include <stdio.h>
#include <string.h>
struct point { int x, y; };
struct employee {
char name[32];
int age;
double salary;
};
/* Structs cannot be compared with ==; write the comparison yourself. */
int point_equal(struct point a, struct point b)
{
return a.x == b.x && a.y == b.y;
}
int main(void)
{
struct point a = {10, 20}; /* positional */
struct point b = {10}; /* y is ZERO, not garbage */
struct point c = {0}; /* both zero */
printf("a = (%d, %d)\n", a.x, a.y);
printf("b = (%d, %d) <- omitted member is zero\n", b.x, b.y);
printf("c = (%d, %d)\n", c.x, c.y);
/* Assignment copies EVERY member -- arrays cannot do this. */
struct point copy = a;
copy.x = 99;
printf("a.x=%d copy.x=%d (independent objects)\n", a.x, copy.x);
/* if (a == copy) ... ERROR: no == for structs */
printf("point_equal(a, c) = %d\n", point_equal(a, c));
/* A struct containing an array: the array IS copied along with it. */
struct employee e1 = {"Ada Lovelace", 36, 95000.0};
struct employee e2 = e1; /* the whole 48+ bytes */
strcpy(e2.name, "Grace Hopper");
printf("e1: %-14s age %d $%.0f\n", e1.name, e1.age, e1.salary);
printf("e2: %-14s age %d $%.0f\n", e2.name, e2.age, e2.salary);
/* Incomplete type: pointers are fine, objects are not. */
struct node; /* declared, not defined */
struct node *maybe = NULL; /* legal */
/* struct node actual; ERROR: incomplete type */
(void)maybe;
return 0;
}
struct employee { char name[32]; int age; double salary; };
offset 0 32 36 40 48
+------------------------+-------+---------+---------+
| name[32] | age | padding | salary |
+------------------------+-------+---------+---------+
^^^^^^^
4 bytes inserted so 'salary'
lands on an 8-byte boundary
(see Chapter 6.3)
Members are laid out in DECLARATION ORDER, with padding as needed.
The struct's own size is a multiple of its strictest member alignment.
Key Takeaways
- A
structgroups differently typed members under one name, laid out in declaration order. struct tagis the full type name; tags live in their own namespace, which is why the keyword repeats until you add atypedef.- Any member left out of an initializer is zero-initialized; an uninitialized automatic struct holds indeterminate values.
- Structs are copied on assignment and can be passed and returned by value — arrays can do none of that.
- Structs cannot be compared with
==, andmemcmpis unreliable because of padding; write an explicit comparison. - A declared-but-undefined struct is an incomplete type: pointers to it are legal, objects are not.
🧪 Practice
- Define
struct book { char title[64]; int year; double price; };, create one with a partial initializer, and print every member.- Assign one struct to another, modify the copy, and prove the original is unchanged. Then add a
char *member and show that the copy shares the pointee.- Try
if (s1 == s2)on two structs, read the error, then writeint book_equal(const struct book *a, const struct book *b).- Interview-style: "Why can't you compare two structs with
==when you can assign them?" Hint: assignment copies bytes you chose; comparison would have to interpret bytes nobody wrote.
Member Access and Arrow Operator
Theory
Two operators reach a struct's members, and which one you use depends on whether you hold the struct or its address:
.(the dot, or member operator) applies to a struct value:p.x.->(the arrow) applies to a pointer to a struct:ptr->x.
The arrow is pure convenience. ptr->x is defined as exactly (*ptr).x — dereference the pointer, then select the member. The parentheses in that expansion are not optional: . binds tighter than unary *, so *ptr.x parses as *(ptr.x), which tries to select a member from a pointer and fails to compile. The arrow exists so that nobody has to write (*ptr).x thousands of times.
Because pointers to structs are everywhere in C — linked structures, function parameters that avoid copying, opaque handles — -> is one of the most-typed operators in the language.
Both operators sit in the highest precedence group along with [] and function calls, and they associate left to right, so chains read naturally:
a.b.c /* nested struct values */
p->next->next /* two pointer hops */
arr[i].field /* index first, then select */
p->arr[3].x /* dereference, index, select */
Two precedence interactions are worth memorizing because they cause real bugs:
++p->count /* ++(p->count) -- increments the MEMBER */
(++p)->count /* increments the POINTER, then selects */
*p->name /* *(p->name) -- dereferences the member */
Since -> binds tighter than * and than prefix ++, the member selection happens first in the unparenthesized forms.
One more rule: the member name is not an expression. You cannot write s.(compute_name()) or index into members by number. Selecting a member is resolved entirely at compile time, which is why C has no reflection and why generic access to fields requires offsetof (Chapter 6.3) or macros.
Examples
#include <stdio.h>
#include <string.h>
struct address { char city[32]; int zip; };
struct person {
char name[32];
int age;
struct address addr; /* a nested struct VALUE */
struct person *next; /* a pointer to the same type */
};
/* Taking a pointer avoids copying and is the usual C convention. */
void print_person(const struct person *p)
{
printf("%s (%d) of %s %d\n",
p->name, p->age, /* arrow: p is a pointer */
p->addr.city, /* arrow then dot: chained */
p->addr.zip);
}
int main(void)
{
struct person alice = {"Alice", 30, {"Berlin", 10115}, NULL};
struct person bob = {"Bob", 25, {"Munich", 80331}, &alice};
/* Dot on a value, arrow on a pointer. */
printf("value : %s, %s\n", alice.name, alice.addr.city);
struct person *p = &bob;
printf("pointer: %s, %s\n", p->name, p->addr.city);
/* The arrow is exactly this: */
printf("equivalent: %s == %s\n", p->name, (*p).name);
/* printf("%s", *p.name); ERROR: parses as *(p.name) */
/* Chained hops through pointers. */
print_person(p);
print_person(p->next); /* bob -> alice */
printf("chain: %s\n", bob.next->addr.city);
/* Precedence: what gets incremented? */
struct person *cursor = &bob;
printf("cursor->age before : %d\n", cursor->age);
++cursor->age; /* ++(cursor->age): the MEMBER */
printf("cursor->age after : %d\n", cursor->age);
struct person list[2] = {alice, bob};
struct person *it = list;
printf("(++it)->name = %s <- the POINTER moved\n", (++it)->name);
/* Array of structs: [] binds before . */
printf("list[0].name = %s\n", list[0].name);
return 0;
}
struct person bob; struct person *p = &bob;
bob p
+-------------+ +---------+
| name "Bob" | <---------------- | &bob |
| age 25 | +---------+
| addr .city |
| .zip | bob.name -> dot: bob is a VALUE
| next ------+---> alice
+-------------+ p->name -> arrow: p is a POINTER
(*p).name -> identical, spelled out
p->next->age -> chained hops
Precedence: -> . [] () bind tighter than * ++ --
++p->count == ++(p->count)
*p->name == *(p->name)
Key Takeaways
- Use
.on a struct value and->on a pointer to a struct;p->xis defined as(*p).x. - The parentheses in
(*p).xare required because.binds tighter than unary*. .,->,[], and()share the highest precedence and associate left to right, sop->a.b[i].creads left to right.++p->countincrements the member, not the pointer; write(++p)->countif you meant the pointer.- Member names are resolved at compile time — there is no runtime field lookup in C.
🧪 Practice
- Create a struct value and a pointer to it, then print the same member three ways:
s.m,p->m, and(*p).m.- Write
*p.nameand record the compiler error, then explain the parse that caused it.- Build a three-node linked list of structs on the stack and print a member reached through
a.next->next->field.- Interview-style: "What is the difference between
++p->x,(++p)->x, andp++->x?" Hint: apply the precedence table first, then say which object each expression modifies and what value it yields.
Nested Structures
Theory
A struct member may itself be a struct. This is composition: building complex types out of simpler ones, the same way you build functions out of functions. A struct rectangle contains two struct points; a struct employee contains a struct address; a struct packet contains a struct header.
The nested struct is stored inline, as part of the outer struct's own storage — not as a pointer to it. sizeof(struct rectangle) includes both points in full. This gives you one contiguous object with no indirection, which is good for locality and means a single assignment copies the whole tree of values.
Access chains with . and -> as far as the nesting goes:
rect.top_left.x /* value, value */
prect->top_left.x /* pointer, then value */
p->addr.city /* pointer, then value */
Initialization nests with braces that mirror the structure:
struct rectangle r = { {0, 0}, {10, 5} };
The inner braces are optional (C allows flattening, {0, 0, 10, 5}) but omitting them is a false economy — compilers warn under -Wmissing-braces, and the flattened form breaks silently if a member is added.
C11 added anonymous structs and unions: a nested struct or union with no member name, whose members are accessed as if they belonged to the outer struct. This is genuinely useful for grouping without adding a level of naming:
struct value {
int type;
union { /* anonymous: no member name */
int i;
double d;
}; /* accessed as v.i and v.d, not v.u.i */
};
The alternative to nesting by value is nesting by pointer — struct address *addr. That is the right choice when the inner object is large, optional, shared, or of a type whose definition you would rather not expose (Chapter 6.3's opaque types). The cost is an allocation, an indirection, and a lifetime to manage.
Note the one thing a struct cannot contain: itself, by value. struct node { struct node next; }; is infinitely large and rejected. A pointer to the same type is fine, because a pointer has a known size even when the type is still incomplete. That is exactly how linked lists and trees are built.
Examples
#include <stdio.h>
struct point { int x, y; };
struct rectangle {
struct point top_left; /* stored INLINE, not as a pointer */
struct point bottom_right;
};
/* C11 anonymous union: members are reached directly through the outer struct. */
struct value {
enum { V_INT, V_DOUBLE } type;
union { /* no member name */
int i;
double d;
};
};
/* Self-reference must go through a POINTER. */
struct node {
int value;
struct node *next; /* legal: pointer to an incomplete type */
/* struct node bad; ERROR: infinitely sized */
};
int area(const struct rectangle *r)
{
int w = r->bottom_right.x - r->top_left.x; /* arrow, then dot */
int h = r->bottom_right.y - r->top_left.y;
return w * h;
}
int main(void)
{
/* Nested braces mirror the nesting. */
struct rectangle r = { {0, 0}, {10, 5} };
printf("top_left = (%d, %d)\n", r.top_left.x, r.top_left.y);
printf("area = %d\n", area(&r));
printf("sizeof rect = %zu (both points stored inline)\n", sizeof r);
/* One assignment copies the whole nested tree. */
struct rectangle copy = r;
copy.top_left.x = 99;
printf("r.top_left.x=%d copy.top_left.x=%d\n", r.top_left.x, copy.top_left.x);
/* Anonymous union: v.i, not v.u.i */
struct value v = { .type = V_DOUBLE };
v.d = 3.5;
printf("anonymous : type=%d d=%.1f\n", v.type, v.d);
/* Self-referential struct: the basis of every linked structure. */
struct node c = {3, NULL}, b = {2, &c}, a = {1, &b};
for (struct node *n = &a; n; n = n->next) printf("%d ", n->value);
putchar('\n');
return 0;
}
Nesting by VALUE (inline):
struct rectangle
+-----------------------------+
| top_left | bottom_right|
| +-----+-----+ | +-----+-----+
| | x | y | | | x | y |
| +-----+-----+ | +-----+-----+
+-----------------------------+
one contiguous object, sizeof = 16, no indirection
Nesting by POINTER:
struct person struct address
+----------+ +------------+
| name | | city |
| addr ----+------------> | zip |
+----------+ +------------+
separate allocation, separate lifetime, one dereference per access
Key Takeaways
- A struct member may be a struct; it is stored inline, so the outer object contains the inner one in full.
- Access chains with
.and->according to whether each step holds a value or a pointer. - Use nested braces in initializers to mirror the structure; flattening compiles but breaks silently when members change.
- C11 anonymous structs and unions expose their members directly in the enclosing struct, avoiding an extra name.
- A struct cannot contain itself by value, but a pointer to its own type is legal and is how linked structures are built.
🧪 Practice
- Define
struct dateand embed it instruct event; initialize with nested braces and print each field through the chain.- Write a function taking
const struct rectangle *that returns the perimeter, using both->and.in the same expression.- Convert an inline nested member into a pointer member and list every change required at the call sites, including allocation and freeing.
- Interview-style: "Why can a struct contain a pointer to its own type but not an instance of it?" Hint: ask what the compiler must know to compute
sizeofin each case.
Arrays of Structures
Theory
Combining the previous two chapters' primitives gives you the workhorse of C data handling: an array of structs. Each element is a complete record, stored contiguously, and indexing gives you one record at a time.
struct employee staff[100];
staff[3].salary = 50000;
The layout is exactly what you would expect: sizeof(struct employee) bytes per element, back to back, with each element internally padded as its type requires. Because the element size is a compile-time constant, staff[i] is one multiply-add, just like any array.
This organization is called array of structs (AoS), and the alternative is struct of arrays (SoA), where each field gets its own array:
struct staff_soa {
char names[100][32];
int ages[100];
double salaries[100];
};
The two have identical information content and very different performance profiles. When you process whole records — read employee 42, print all fields — AoS wins, because the record is in one or two cache lines. When you process one field across many records — sum every salary — SoA wins, because the salaries are packed contiguously with no other fields wasting cache space. Numerical and graphics code often uses SoA for exactly this reason (it also vectorizes better); ordinary application code uses AoS because it is simpler and matches how you think about records.
| Array of structs | Struct of arrays | |
|---|---|---|
| Layout | [rec0][rec1][rec2] | [f0 f0 f0][f1 f1 f1] |
| Whole-record access | One cache line | One miss per field |
| Single-field scans | Wastes cache on other fields | Fully sequential |
| SIMD friendliness | Poor | Good |
| Code clarity | High | Lower |
| Adding a record | One element | Parallel updates |
Two practical notes for AoS.
Initialization uses nested braces, one inner brace per element, and designated initializers work at both levels.
Sorting with qsort works directly: pass sizeof(struct employee) as the element size, and the comparator casts const void * back to const struct employee * (Chapter 5.2). Note that qsort moves whole records — for large structs, sorting an array of pointers to them is cheaper, since only pointers move.
Finally, remember that an array of structs decays to a pointer to its first element like any array (Chapter 5.1), and sizeof arr / sizeof arr[0] remains the way to count elements where the array is in scope.
Examples
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct employee {
char name[16];
int age;
double salary;
};
/* qsort comparator: cast back to the real element type. */
static int by_salary_desc(const void *a, const void *b)
{
const struct employee *x = a, *y = b;
if (x->salary < y->salary) return 1; /* descending */
if (x->salary > y->salary) return -1;
return 0;
}
static int by_name(const void *a, const void *b)
{
const struct employee *x = a, *y = b;
return strcmp(x->name, y->name);
}
int main(void)
{
/* Nested braces: one inner brace per element. */
struct employee staff[] = {
{"Alice", 30, 95000.0},
{"Bob", 25, 72000.0},
{"Charlie", 41, 88000.0},
};
size_t n = sizeof staff / sizeof staff[0];
printf("element size = %zu, %zu elements, %zu bytes total\n",
sizeof staff[0], n, sizeof staff);
/* Indexing selects a whole record; [] binds before . */
for (size_t i = 0; i < n; i++)
printf(" %-8s %3d $%.0f\n", staff[i].name, staff[i].age, staff[i].salary);
/* Pointer walk over records: p++ advances by sizeof(struct employee). */
puts("via pointer walk:");
for (const struct employee *p = staff; p != staff + n; p++)
printf(" %-8s at offset %td\n", p->name, (p - staff));
/* qsort moves WHOLE records (48 bytes each here). */
qsort(staff, n, sizeof staff[0], by_salary_desc);
printf("by salary : %s, %s, %s\n", staff[0].name, staff[1].name, staff[2].name);
qsort(staff, n, sizeof staff[0], by_name);
printf("by name : %s, %s, %s\n", staff[0].name, staff[1].name, staff[2].name);
/* Cheaper for large records: sort an array of POINTERS instead. */
struct employee *refs[3];
for (size_t i = 0; i < n; i++) refs[i] = &staff[i];
/* Only 8-byte pointers move; the records stay put. */
printf("refs[0] -> %s\n", refs[0]->name);
return 0;
}
Array of structs (AoS): records are contiguous
staff[0] staff[1] staff[2]
+----------------+ +----------------+ +----------------+
|name|age|salary | |name|age|salary | |name|age|salary |
+----------------+ +----------------+ +----------------+
|<-- 32 bytes -->|
"print employee 1" -> one cache line, everything present
"sum all salaries" -> loads name and age too, wasting cache
Struct of arrays (SoA): fields are contiguous
names[] : [n0][n1][n2]...
ages[] : [a0][a1][a2]...
salaries : [s0][s1][s2]... <- summing walks this array alone
Key Takeaways
- An array of structs stores whole records contiguously;
arr[i]costs the same indexed arithmetic as any array. - Initialize with one inner brace per element; designated initializers work at the element and member level.
- AoS suits whole-record access; SoA suits scanning one field across many records and vectorizes better.
qsortsorts an array of structs directly withsizeof(struct T)as the element size, moving entire records.- For large records, sort an array of pointers instead — only pointer-sized values move.
🧪 Practice
- Build an array of five
struct point, fill it in a loop, and print each element with its index.- Sort an array of structs by two different fields using two
qsortcomparators.- Rewrite a three-field AoS as SoA, then time summing one field over a million elements in both layouts.
- Interview-style: "When would you choose struct-of-arrays over array-of-structs?" Hint: describe the access pattern first, then say what the cache loads in each case.
Passing and Returning Structures
Theory
Unlike arrays, structs are first-class values: you can pass one to a function and return one from a function, and in both directions the whole struct is copied. This is a genuine convenience — no aliasing, no lifetime questions — but it has a cost proportional to the struct's size.
What actually happens at the machine level depends on the ABI. On x86-64 System V, small structs (up to 16 bytes) travel in registers, making them as cheap as passing two scalars. Larger ones are copied to the stack, and a returned struct is written through a hidden pointer the caller supplies. So a 16-byte struct point3 costs nothing extra, while a 4 KB struct costs a 4 KB memcpy per call.
The practical guidance:
| Struct size | Pass as | Reason |
|---|---|---|
| Small (a few words) | Value | Register-passed, no aliasing, clearer |
| Large | const struct T * | Avoids the copy |
| Must be modified | struct T * | Copies do not propagate back |
Passing by value is not just a performance decision — it is a semantic one. A by-value parameter is the callee's own copy, so the caller's object cannot be modified. That is often exactly what you want, and it makes the function easier to reason about. Passing const struct T * gets you the same read-only guarantee without the copy, at the price of an indirection.
Two hazards deserve emphasis.
The copy is shallow. If the struct contains a pointer, both copies point to the same memory. Freeing through one leaves the other dangling; this is the classic double-free in C data structures. If a struct owns heap memory, either pass pointers to it or write an explicit deep-copy function.
Returning a struct is safe; returning a pointer to a local struct is not. return s; copies the value out. return &s; returns the address of a dead frame (Chapter 4.1).
Returning a struct is the cleanest way for a function to produce multiple values, and C99's compound literals make constructing one at the call site pleasant:
return (struct point){ .x = 3, .y = 4 }; /* an unnamed struct value */
A compound literal is a real object with a type; inside a function it has automatic storage duration, so do not return a pointer to one.
Examples
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct point { int x, y; }; /* small: pass by value */
struct big { char data[4096]; int id; }; /* large: pass by pointer */
struct owner { char *name; }; /* contains a POINTER: copies alias */
/* By value: the callee gets its own copy; the caller is untouched. */
struct point moved(struct point p, int dx, int dy)
{
p.x += dx; p.y += dy; /* modifying the copy */
return p; /* the whole struct is copied out */
}
/* By pointer with const: no copy, no modification. */
long checksum(const struct big *b)
{
long sum = b->id;
for (size_t i = 0; i < sizeof b->data; i++) sum += b->data[i];
return sum;
}
/* By pointer without const: the caller's object IS modified. */
void bump_id(struct big *b) { b->id++; }
/* Compound literal: build a struct value inline (C99). */
struct point origin(void) { return (struct point){ .x = 0, .y = 0 }; }
/* Deep copy, needed because the shallow copy would alias 'name'. */
int owner_copy(struct owner *dst, const struct owner *src)
{
size_t n = strlen(src->name) + 1;
dst->name = malloc(n);
if (!dst->name) return -1;
memcpy(dst->name, src->name, n);
return 0;
}
int main(void)
{
struct point p = {1, 2};
struct point q = moved(p, 10, 10);
printf("p = (%d,%d) unchanged; q = (%d,%d)\n", p.x, p.y, q.x, q.y);
printf("origin = (%d,%d)\n", origin().x, origin().y);
/* Compound literals also work as arguments. */
struct point r = moved((struct point){5, 5}, 1, 1);
printf("r = (%d,%d)\n", r.x, r.y);
struct big *b = malloc(sizeof *b);
if (!b) return EXIT_FAILURE;
memset(b, 0, sizeof *b);
b->id = 7;
printf("checksum = %ld\n", checksum(b)); /* no 4 KB copy */
bump_id(b);
printf("id after bump = %d\n", b->id); /* 8: modified in place */
free(b);
/* Shallow copy hazard. */
struct owner a = { malloc(8) };
if (!a.name) return EXIT_FAILURE;
strcpy(a.name, "shared");
struct owner shallow = a; /* both point at the SAME buffer */
printf("aliased: %s / %s (same address: %s)\n",
a.name, shallow.name, a.name == shallow.name ? "yes" : "no");
struct owner deep;
if (owner_copy(&deep, &a) == 0) {
printf("deep copy independent: %s\n", deep.name);
free(deep.name);
}
free(a.name); /* freeing once is correct;
shallow.name now dangles */
return 0;
}
Pass by value (small struct) Pass by pointer (large struct)
caller callee caller callee
+-------+ +-------+ +-----------+ +--------+
| p.x=1 | --> | p.x=1 | (copy) | 4 KB data | <-- | b (ptr)|
| p.y=2 | --> | p.y=2 | +-----------+ +--------+
+-------+ +-------+ one address copied, no data moved
caller's p cannot change caller's object CAN change
unless the parameter is const
Shallow copy of a struct holding a pointer:
a.name ------+
+---> [ "shared" ] ONE buffer, TWO owners
shallow.name-+ free() through either leaves the
other dangling
Key Takeaways
- Structs are passed and returned by value, copying every member; small structs travel in registers, large ones are memcpy'd.
- Pass small structs by value for clarity and safety; pass large ones as
const struct T *to avoid the copy. - To let a function modify the caller's struct, pass a non-const pointer — a by-value copy never propagates back.
- Struct copies are shallow: pointer members alias the same memory, so owning structs need an explicit deep copy.
- Returning a struct by value is safe; returning a pointer to a local struct or compound literal is not.
🧪 Practice
- Write a function taking
struct pointby value that modifies it, and confirm the caller's copy is unchanged. Then change the parameter to a pointer.- Time one million calls passing a 4 KB struct by value versus by
constpointer.- Write a struct containing a
char *, copy it by assignment, free through one copy, and observe what happens under AddressSanitizer when using the other.- Interview-style: "Is it ever wrong to pass a struct by value?" Hint: consider size, whether the struct owns resources, and what the ABI does with the return value.
Designated Initializers
Theory
Positional initializers work, but they scale badly. Given
struct config c = {1, 0, 8080, 30, 0, 1};
nobody can tell what those values mean, and inserting a member at the front silently shifts every value to the wrong field. C99's designated initializers fix both problems by naming the member:
struct config c = { .port = 8080, .timeout = 30, .verbose = 1 };
The rules are simple and generous:
- Any member you do not name is zero-initialized. This is the same rule as positional initializers, and it means you can specify only what differs from zero.
- Order does not matter.
{ .y = 2, .x = 1 }is fine. - You may mix designated and positional forms. After a designator, initialization continues from that member, which is legal but confusing — prefer one style.
- A later initializer for the same member wins, which is occasionally useful for "defaults, then overrides" patterns within one initializer.
- They work for arrays too, with
[index] = value, and they nest arbitrarily.
The benefits are substantial for real code. Configuration structs become self-documenting. Adding or reordering members does not silently corrupt existing initializers. Sparse tables — dispatch tables indexed by opcode, lookup tables indexed by character — become readable and gap-tolerant, with unlisted entries reliably NULL or 0.
One caveat about the zero-fill guarantee: it applies to members, not to padding bytes. Any struct with padding may have indeterminate bytes between members even after = {0}, which is why memcmp on structs is unreliable (Chapter 6.3). If you need every byte zeroed — for hashing, for writing to disk, for a security-sensitive wipe — use memset.
Two related C99/C23 conveniences travel with this feature. Compound literals use the same syntax to build an unnamed object anywhere an expression is allowed: (struct point){ .x = 1 }. And C23's = {} finally provides an official "zero everything" initializer without naming a first member.
Note that C++ supports only a restricted form of designated initializers (C++20, requiring declaration order and no gaps), so shared headers should not rely on C's more permissive rules.
Examples
#include <stdio.h>
#include <string.h>
struct config {
int verbose;
int daemonize;
int port;
int timeout;
int max_clients;
const char *logfile;
};
/* A settings struct is far clearer with designators. */
static const struct config defaults = {
.port = 8080,
.timeout = 30,
.max_clients = 64,
.logfile = "/var/log/app.log",
/* verbose and daemonize are ZERO by the omission rule */
};
/* Sparse dispatch table: unlisted entries are guaranteed NULL. */
typedef void (*handler)(void);
static void on_get(void) { puts("GET"); }
static void on_post(void) { puts("POST"); }
enum { M_GET, M_POST, M_PUT, M_DELETE, M_COUNT };
static handler handlers[M_COUNT] = {
[M_GET] = on_get,
[M_POST] = on_post,
/* M_PUT and M_DELETE are NULL -- explicit and safe to test */
};
/* Designators nest, in arrays and structs alike. */
struct point { int x, y; };
static struct point corners[4] = {
[0] = { .x = 0, .y = 0 },
[2] = { .x = 10, .y = 10 },
/* [1] and [3] are {0, 0} */
};
int main(void)
{
/* Order is irrelevant; omitted members are zero. */
struct config c = { .timeout = 5, .verbose = 1 };
printf("verbose=%d daemonize=%d port=%d timeout=%d logfile=%s\n",
c.verbose, c.daemonize, c.port, c.timeout,
c.logfile ? c.logfile : "(null)");
/* Defaults, then override -- copy the struct, then set fields. */
struct config custom = defaults;
custom.port = 9090;
printf("custom : port=%d timeout=%d max=%d\n",
custom.port, custom.timeout, custom.max_clients);
/* A later designator for the same member wins. */
struct point p = { .x = 1, .y = 2, .x = 99 };
printf("p = (%d, %d) <- the last .x wins\n", p.x, p.y);
/* Sparse table: test the gaps before calling. */
for (int m = 0; m < M_COUNT; m++) {
if (handlers[m]) handlers[m]();
else printf("method %d: unimplemented\n", m);
}
for (int i = 0; i < 4; i++)
printf("corner %d = (%d, %d)\n", i, corners[i].x, corners[i].y);
/* Zeroing MEMBERS is not the same as zeroing BYTES. */
struct config z = {0}; /* every member zero; padding indeterminate */
struct config m;
memset(&m, 0, sizeof m); /* every BYTE zero -- use this for hashing/IO */
printf("z.port=%d m.port=%d\n", z.port, m.port);
return 0;
}
| Style | Example | Survives member reordering? | Readable? |
|---|---|---|---|
| Positional | {1, 0, 8080} | No | No |
| Designated | { .port = 8080 } | Yes | Yes |
| Mixed | { 1, .port = 8080 } | Partly | No |
= {0} / C23 = {} | zero everything | Yes | Yes |
Key Takeaways
- Designated initializers name the member (
.field = value), making initialization order-independent and self-documenting. - Unnamed members are zero-initialized, so you only specify what differs from zero.
- They work for array elements (
[3] = ...) and nest, which makes sparse tables safe — gaps are reliablyNULL/0. - Adding or reordering struct members cannot silently corrupt a designated initializer, unlike a positional one.
- Zero-initialization applies to members, not padding; use
memsetwhen every byte must be zero.
🧪 Practice
- Define a six-member config struct and initialize only two members with designators; print all six and confirm the rest are zero.
- Build a dispatch table indexed by an enum with two of five entries filled, and write a loop that safely handles the gaps.
- Take a positional initializer, add a new member at the front of the struct, and observe how the values shift. Then convert it to designators and repeat.
- Interview-style: "Does
struct s x = {0};guarantee every byte ofxis zero?" Hint: distinguish members from the bytes between them, and say when the difference is observable.
<a id="62-unions-and-enumerations"></a>
6.2 Unions and Enumerations
Structures put members side by side; unions stack them on top of each other, and enumerations give names to integer constants. This section covers both, the tagged-union discipline that makes unions safe, and the bit fields that let a struct describe individual bits.
union Declaration and Semantics
Theory
A struct allocates space for all its members. A union allocates space for the largest one, and every member starts at the same address. The members overlap; writing one overwrites the others.
The syntax is identical to a struct, only the keyword changes:
union number {
int i; /* 4 bytes */
float f; /* 4 bytes */
double d; /* 8 bytes */
}; /* sizeof(union number) is 8 (plus alignment) */
Think of it as a room that can be furnished as an office or a bedroom, but not both at once — the floor space is shared. A struct, by contrast, is a house with a separate room for each purpose.
The defining rule: only one member is "active" at a time — the one most recently written. Reading a member other than the active one is a separate topic (type punning, next but one) with real caveats. The language does not track which member is active; that bookkeeping is entirely yours, which is why unions almost always appear inside a struct alongside a tag (the next topic).
Why would you want overlapping storage? Three legitimate reasons:
Memory efficiency for mutually exclusive data. A token in a parser is either an integer, a float, or an identifier — never more than one. A struct with all three wastes space in every token; a union does not.
Variant records. A value in a dynamic language interpreter, a message in a protocol, a node in an AST — anything with "one of N shapes".
Low-level reinterpretation. Examining the bytes of a float, or splitting a 32-bit word into fields. This works in practice and is explicitly blessed by C99's footnote on type punning, but it comes with caveats worth understanding.
Mechanics to know:
sizeof(union)is at least the size of the largest member, rounded up to satisfy the union's alignment (the strictest of its members').- Initialization with
= { value }initializes the first member. Designated initializers let you pick:union number n = { .f = 1.5f };. - Unions can be assigned, passed, and returned by value like structs, copying the whole storage.
- A union can contain structs, arrays, and other unions; C11 anonymous unions inside a struct expose their members directly.
Examples
#include <stdio.h>
#include <string.h>
union number {
int i;
float f;
double d;
char bytes[8];
};
/* A union of structs: two views of the same 4 bytes. */
union rgba {
unsigned int packed;
struct { unsigned char r, g, b, a; } channel;
};
int main(void)
{
union number n;
printf("sizeof(union number) = %zu (the LARGEST member, not the sum)\n",
sizeof n);
printf("sizeof(int)=%zu float=%zu double=%zu\n",
sizeof n.i, sizeof n.f, sizeof n.d);
/* Every member shares one address. */
printf("&n.i = %p\n&n.f = %p\n&n.d = %p <- all identical\n",
(void *)&n.i, (void *)&n.f, (void *)&n.d);
/* Writing one member overwrites the others. */
n.i = 42;
printf("after n.i = 42 : n.i = %d\n", n.i);
n.d = 3.14; /* i and f are now meaningless */
printf("after n.d = 3.14 : n.d = %g, n.i = %d (garbage: not active)\n",
n.d, n.i);
/* Initialization: first member by default, or pick one with a designator. */
union number a = {7}; /* initializes .i */
union number b = { .f = 1.5f }; /* initializes .f */
printf("a.i = %d, b.f = %.1f\n", a.i, b.f);
/* Unions copy by value, like structs. */
union number copy = b;
printf("copy.f = %.1f\n", copy.f);
/* Two views of the same four bytes. */
union rgba color;
color.channel.r = 0x12;
color.channel.g = 0x34;
color.channel.b = 0x56;
color.channel.a = 0x78;
printf("packed = 0x%08x (byte order depends on endianness)\n", color.packed);
printf("sizeof(union rgba) = %zu\n", sizeof color);
return 0;
}
struct three { int i; float f; double d; }; -- side by side
offset 0 4 8 16
+------+------+------------+
| i | f | d | sizeof = 16
+------+------+------------+
union number { int i; float f; double d; }; -- stacked
offset 0 8
+--------------------------+
| d | sizeof = 8
+------+-------------------+
| i |
+------+
| f |
+------+
^
all three members START HERE
Writing n.d overwrites whatever n.i and n.f held.
Key Takeaways
- A union's members all start at the same address and share storage; its size is that of the largest member, rounded to its alignment.
- Only the most recently written member is active; the language does not track which one, so you must.
- Unions save space for mutually exclusive data and express variant records; they also enable low-level byte reinterpretation.
= {value}initializes the first member; use a designated initializer to choose another.- Unions assign, pass, and return by value like structs, copying the whole shared storage.
🧪 Practice
- Declare a union of
char,int, anddouble, print itssizeof, and print the address of each member to confirm they coincide.- Write one member, then read another, and explain why the result is not meaningful without further rules.
- Define a union with a 4-byte integer and a 4-element
unsigned chararray, set the integer to0x01020304, and print the bytes. Explain the order.- Interview-style: "What is the difference between a struct and a union, and when would you pick a union?" Hint: contrast total size and simultaneity, then give a case where the data is genuinely one-of-N.
Tagged Unions
Theory
A bare union has a fatal flaw: it does not know which member is valid. Read the wrong one and you get nonsense with no diagnostic. The fix is a discipline, not a language feature — pair the union with a tag that records which member is active, and wrap both in a struct:
struct value {
enum { V_INT, V_FLOAT, V_STRING } type; /* the tag */
union {
int i;
double f;
char *s;
} as; /* the payload */
};
This is called a tagged union, a discriminated union, or a variant record. It is one of the most important patterns in C, and it is how interpreters represent values, how compilers represent AST nodes, how protocol parsers represent messages, and how any "this is one of several shapes" problem gets solved.
The rules that make it work:
Every write to the union updates the tag. Do this in one place — a constructor function per variant — so the invariant cannot drift.
Every read switches on the tag. Never access a member without checking. A switch over an enum with no default lets -Wswitch tell you when a new variant is added and a case is missing, which turns a class of bugs into compile errors.
Ownership travels with the variant. If one arm holds a char * that was allocated, the destructor must check the tag before freeing. This is where tagged unions most often leak.
The space saving is real: with three members of 4, 8, and 8 bytes, a struct-of-all is 24 bytes while the tagged union is 16. More importantly, the tag makes the intent explicit and checkable.
Compare the alternatives you might reach for:
| Approach | Space | Type safety | Extensibility |
|---|---|---|---|
| Struct with all fields | Largest (wastes) | None (any field readable) | Easy, but grows |
| Bare union | Smallest | None | Unsafe |
| Tagged union | Small | Good, with switch warnings | Add an enum value and a case |
void * plus type code | Smallest struct | Worst (casts everywhere) | Requires allocation |
C11's anonymous unions remove the as. prefix if you prefer v.i over v.as.i — a matter of taste, though the explicit name documents that the members are alternatives.
Examples
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef enum { V_INT, V_DOUBLE, V_STRING } ValueType;
typedef struct {
ValueType type; /* the TAG: which member is live */
union {
long i;
double d;
char *s; /* OWNED: must be freed */
} as;
} Value;
/* Constructors: the only places that set the tag, so it cannot drift. */
static Value make_int(long v) { return (Value){ .type = V_INT, .as.i = v }; }
static Value make_double(double v){ return (Value){ .type = V_DOUBLE, .as.d = v }; }
static Value make_string(const char *v)
{
Value out = { .type = V_STRING, .as.s = NULL };
size_t n = strlen(v) + 1;
out.as.s = malloc(n);
if (out.as.s) memcpy(out.as.s, v, n);
return out;
}
/* Every read switches on the tag. No default: -Wswitch flags a missing case
when a new variant is added to ValueType. */
static void value_print(const Value *v)
{
switch (v->type) {
case V_INT: printf("int %ld\n", v->as.i); break;
case V_DOUBLE: printf("double %g\n", v->as.d); break;
case V_STRING: printf("string \"%s\"\n", v->as.s ? v->as.s : ""); break;
}
}
/* Ownership follows the tag: only the string variant owns memory. */
static void value_free(Value *v)
{
if (v->type == V_STRING) {
free(v->as.s);
v->as.s = NULL;
}
v->type = V_INT; /* leave it in a valid state */
v->as.i = 0;
}
static double value_as_number(const Value *v)
{
switch (v->type) {
case V_INT: return (double)v->as.i;
case V_DOUBLE: return v->as.d;
case V_STRING: return v->as.s ? atof(v->as.s) : 0.0;
}
return 0.0;
}
int main(void)
{
Value values[] = { make_int(42), make_double(3.5), make_string("2.75") };
size_t n = sizeof values / sizeof values[0];
printf("sizeof(Value) = %zu (tag + largest member + padding)\n", sizeof(Value));
for (size_t i = 0; i < n; i++) {
value_print(&values[i]);
printf(" as number: %g\n", value_as_number(&values[i]));
}
for (size_t i = 0; i < n; i++) value_free(&values[i]);
return 0;
}
Tagged union layout
+--------+---------------------------+
| type | union { i | d | s } |
+--------+---------------------------+
4 bytes 8 bytes (largest member)
(+4 padding before the union, for 8-byte alignment)
type = V_DOUBLE -> read .d (reading .s here would be a bug)
type = V_STRING -> read .s, and free it in the destructor
The discipline, in three rules:
write ---> always set the tag in the same statement (a constructor)
read ---> always switch on the tag, no default, let -Wswitch help
free ---> check the tag; only some variants own memory
Key Takeaways
- A tagged union pairs an enum tag with a union payload inside a struct, recording which member is active.
- Set the tag in constructor functions so the union and tag can never disagree.
- Read only through a
switchon the tag, and omitdefaultso-Wswitchreports missing cases when a variant is added. - Ownership is per-variant: the destructor must check the tag before freeing pointer members.
- This is the standard C pattern for interpreter values, AST nodes, and protocol messages.
🧪 Practice
- Build a
Shapetagged union with circle, rectangle, and triangle variants, and write anareafunction that switches on the tag.- Add a fourth variant to the enum without adding a
case, compile with-Wall, and read the warning.- Extend the
Valueexample with aV_ARRAYvariant that owns a heap array, and update the destructor correctly.- Interview-style: "How would you implement a dynamically typed value in C?" Hint: describe the two fields, then say what enforces the correspondence between them.
Type Punning Caveats
Theory
Type punning means accessing the same bytes as two different types — reading a float's bits as an int, or a byte buffer as a struct. It is unavoidable in serialization, hashing, graphics, and driver code, and it is one of the easiest places in C to write something that works today and breaks under -O2 next year.
The trouble comes from strict aliasing. The standard says an object may only be accessed through an lvalue of its own type (or a compatible one, or a character type). This is not pedantry — it lets the optimizer assume that an int * and a float * never refer to the same memory, so it can keep values in registers across stores. Violate the rule and the compiler is free to reorder or elide your accesses; the result is code that works at -O0 and fails at -O2.
There are three approaches, and only two are reliable.
1. Cast pointers (the bad way).
float f = 1.5f;
int i = *(int *)&f; /* strict-aliasing violation: undefined behavior */
GCC warns with -Wstrict-aliasing. Some code disables the optimization with -fno-strict-aliasing (the Linux kernel does), but you cannot rely on that in portable code.
2. Use a union (the pragmatic way). C99 added a footnote, promoted to normative text in C11, explicitly allowing you to read a union member other than the last one written; the bytes are reinterpreted in the new member's type. GCC and Clang document and support this. It is the idiomatic C solution:
union { float f; uint32_t u; } pun = { .f = 1.5f };
uint32_t bits = pun.u; /* well defined in C (not in C++) */
3. Use memcpy (the portable way). Copying bytes into a properly typed object is always well defined, in both C and C++, and modern compilers optimize a fixed-size memcpy into a single register move — there is no runtime cost:
uint32_t bits;
memcpy(&bits, &f, sizeof bits); /* always correct, always fast */
C23 adds memcpy-based helpers and <stdbit.h>; when in doubt, memcpy is the answer.
Three further hazards apply to all approaches:
Endianness. The byte order of a multi-byte value is platform-specific. Punning to a byte array on x86 (little-endian) gives you the reverse order of a network protocol's big-endian layout. Use htonl/ntohl or explicit shifts for anything crossing a machine boundary.
Padding. Reinterpreting a struct as bytes exposes padding, whose values are indeterminate. Never hash, compare, or transmit a struct's raw bytes without memseting it first — and preferably serialize field by field.
Trap representations and alignment. Not every bit pattern is valid for every type, and reading through a misaligned pointer is undefined behavior (and a crash on strict-alignment hardware such as some ARM configurations).
The rule of thumb: use memcpy for punning, a union when it reads better, and never a bare pointer cast.
Examples
#include <stdio.h>
#include <string.h>
#include <stdint.h>
/* The union approach: well defined in C, and the idiomatic form. */
static uint32_t float_bits_union(float f)
{
union { float f; uint32_t u; } pun;
pun.f = f;
return pun.u; /* reading the other member is allowed in C */
}
/* The memcpy approach: portable to C++ too, and optimized to a register move. */
static uint32_t float_bits_memcpy(float f)
{
uint32_t u;
memcpy(&u, &f, sizeof u); /* always well defined */
return u;
}
/* The pointer cast: DO NOT DO THIS -- strict aliasing violation. */
/* static uint32_t float_bits_bad(float f) { return *(uint32_t *)&f; } */
int main(void)
{
float f = 1.5f;
printf("union : 0x%08x\n", float_bits_union(f));
printf("memcpy : 0x%08x <- identical, and portable\n", float_bits_memcpy(f));
/* IEEE 754 single precision: sign | exponent(8) | mantissa(23) */
uint32_t bits = float_bits_memcpy(f);
printf("sign=%u exponent=%u mantissa=0x%06x\n",
bits >> 31, (bits >> 23) & 0xFF, bits & 0x7FFFFF);
/* Endianness: the same value, different byte order per platform. */
uint32_t value = 0x01020304;
unsigned char bytes[4];
memcpy(bytes, &value, sizeof bytes);
printf("0x01020304 in memory: %02x %02x %02x %02x (%s-endian)\n",
bytes[0], bytes[1], bytes[2], bytes[3],
bytes[0] == 0x04 ? "little" : "big");
/* Portable byte extraction: shifts do NOT depend on endianness. */
printf("portable bytes : %02x %02x %02x %02x\n",
(value >> 24) & 0xFF, (value >> 16) & 0xFF,
(value >> 8) & 0xFF, value & 0xFF);
/* Padding hazard: raw struct bytes include indeterminate padding. */
struct padded { char c; int i; }; /* 3 bytes of padding after c */
struct padded a, b;
memset(&a, 0, sizeof a); /* zero EVERY byte, padding included */
memset(&b, 0, sizeof b);
a.c = b.c = 'x';
a.i = b.i = 42;
printf("memcmp after memset : %d (0 = equal, reliable ONLY because\n"
" both were memset first)\n",
memcmp(&a, &b, sizeof a));
return 0;
}
| Technique | Well defined in C? | In C++? | Optimized well? | Verdict |
|---|---|---|---|---|
*(int *)&f | No (strict aliasing) | No | n/a | Never |
| Union member read | Yes (C99 footnote, C11 text) | No (UB) | Yes | Fine in C |
memcpy | Yes | Yes | Yes (single move) | Preferred |
unsigned char * view | Yes | Yes | Yes | For byte inspection |
Why strict aliasing exists
void f(int *a, float *b) {
*a = 1;
*b = 2.0f; compiler assumes these CANNOT overlap,
return *a; so it may return 1 without reloading
}
If you pass the same address as both, the assumption is wrong and
the result is whatever the optimizer decided. -O0 hides it; -O2 does not.
Key Takeaways
- Type punning through a bare pointer cast violates strict aliasing and is undefined behavior that typically breaks only under optimization.
- Reading a union member other than the one last written is explicitly allowed in C (but not in C++).
memcpyinto a correctly typed object is always well defined and compiles to the same instructions — make it your default.- Byte order is platform-specific; use shifts or
htonl/ntohlfor anything crossing machines. - Raw struct bytes include indeterminate padding;
memsetthe whole object before hashing, comparing, or transmitting it, and prefer field-by-field serialization.
🧪 Practice
- Extract the sign, exponent, and mantissa of a
floatusing both a union andmemcpy, and confirm both give the same bits.- Detect your machine's endianness by writing
0x01020304and inspecting the bytes; then write an endianness-independent byte extractor with shifts.- Compare two structs with
memcmpwithoutmemseting them first, fill them identically, and see whether the result is reliably zero. Explain.- Interview-style: "Why might
*(int*)&my_floatwork at-O0and fail at-O2?" Hint: name the assumption the optimizer is allowed to make about two differently typed pointers.
enum Constants and Underlying Type
Theory
Magic numbers make code unreadable and bugs invisible. if (state == 3) tells you nothing; if (state == STATE_CLOSED) tells you everything. C offers two ways to name a constant — #define and enum — and for related sets of integers the enum is almost always better.
enum color { RED, GREEN, BLUE }; /* RED=0, GREEN=1, BLUE=2 */
The rules:
- Values start at 0 and increment unless you assign one explicitly.
- You may assign any constant expression, and subsequent members continue from there:
enum { A = 5, B, C }gives 5, 6, 7. - Values need not be unique.
enum { OK = 0, SUCCESS = 0 }is legal. - The constants are ordinary identifiers, visible in the enclosing scope — not namespaced under the enum tag. That is why C code prefixes them (
COLOR_RED,STATE_CLOSED) to avoid collisions. - The constants have type
intin C17 and earlier, regardless of the enum's own type.
The enumerated type is a different matter. Up to C17, its underlying type is implementation-defined — the compiler picks a type that can hold all the values, which may be int, unsigned int, or something smaller. So sizeof(enum color) is not portable, and you should not rely on an enum's size in a struct that maps to a binary format. C23 fixes this: enums now have a fixed underlying type by default (int, or a larger type if needed), and you may specify one explicitly with enum flags : unsigned char { ... }.
Why prefer enums over #define?
#define | enum | |
|---|---|---|
| Visible to the debugger | No (preprocessor removes it) | Yes, as a symbol |
| Type checking | None (plain text) | It is a type |
| Scoped | No (whole file onward) | Follows normal scope rules |
| Auto-numbering | Manual | Automatic |
switch exhaustiveness warnings | No | Yes (-Wswitch) |
| Works for non-integers | Yes (strings, expressions) | No (integers only) |
That -Wswitch row is the strongest practical argument. Switching on an enum without a default makes the compiler list any missing enumerator — so adding a state to a state machine produces a compile-time list of every place that must handle it. Adding a default: break; throws that away, which is why exhaustive enum switches deliberately omit it.
Two idioms worth knowing. A trailing COUNT member sizes arrays automatically:
enum { M_GET, M_POST, M_PUT, M_COUNT }; /* M_COUNT == 3 */
handler table[M_COUNT];
And enums are not restricted to sequential values — powers of two make combinable flags, though the combination itself is an int, not a member of the enum.
Finally, note that C enums are not type-safe in the way C++ or Rust enums are: any integer can be assigned to an enum variable, and arithmetic on enum values silently yields int. The enum documents intent and enables warnings; it does not enforce a closed set.
Examples
#include <stdio.h>
/* Auto-numbered from 0; the trailing COUNT sizes tables automatically. */
enum weekday { MON, TUE, WED, THU, FRI, SAT, SUN, DAY_COUNT };
/* Explicit values, then continuing from the last one. */
enum status { ST_OK = 0, ST_WARN = 10, ST_ERROR, ST_FATAL }; /* 0, 10, 11, 12 */
/* Powers of two: combinable flags. */
enum perm { P_READ = 1 << 0, P_WRITE = 1 << 1, P_EXEC = 1 << 2 };
static const char *day_name[DAY_COUNT] = {
"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"
};
/* No default: -Wswitch reports any enumerator this fails to handle. */
static const char *describe(enum status s)
{
switch (s) {
case ST_OK: return "ok";
case ST_WARN: return "warning";
case ST_ERROR: return "error";
case ST_FATAL: return "fatal";
}
return "unknown"; /* reached only for out-of-range integers */
}
int main(void)
{
printf("MON=%d SUN=%d DAY_COUNT=%d\n", MON, SUN, DAY_COUNT);
printf("ST_WARN=%d ST_ERROR=%d ST_FATAL=%d <- continue from 10\n",
ST_WARN, ST_ERROR, ST_FATAL);
for (enum weekday d = MON; d < DAY_COUNT; d++)
printf(" %d = %s\n", d, day_name[d]);
printf("status: %s / %s\n", describe(ST_OK), describe(ST_FATAL));
/* Flags: the OR of two enumerators is an int, not an enum member. */
int mode = P_READ | P_WRITE;
printf("mode=%d readable=%s writable=%s executable=%s\n", mode,
(mode & P_READ) ? "yes" : "no",
(mode & P_WRITE) ? "yes" : "no",
(mode & P_EXEC) ? "yes" : "no");
/* The underlying type is implementation-defined before C23. */
printf("sizeof(enum weekday) = %zu (do not rely on this in binary formats)\n",
sizeof(enum weekday));
/* C enums are not a closed set: any int may be assigned. */
enum status bogus = (enum status)99;
printf("bogus = %d -> %s\n", bogus, describe(bogus));
return 0;
}
enum weekday { MON, TUE, WED, THU, FRI, SAT, SUN, DAY_COUNT };
0 1 2 3 4 5 6 7
^
not a real day: it is the COUNT,
and it resizes itself when you
add a day above it
Why omitting 'default' is a feature:
switch (s) { add ST_TIMEOUT to the enum
case ST_OK: ... ---------------------------> warning: enumeration
case ST_WARN: ... value 'ST_TIMEOUT' not
case ST_ERROR: ... handled in switch
case ST_FATAL: ...
} With 'default:' present, the compiler stays silent
and the new state falls into the wrong branch.
Key Takeaways
- Enumerators start at 0 and increment; explicit values are allowed and later members continue from them.
- Enum constants have type
int(pre-C23) and live in the ordinary identifier scope, so prefix them to avoid collisions. - The enum type's underlying type is implementation-defined before C23; do not rely on
sizeofin binary layouts. - Prefer enums to
#definefor related integers: they are debuggable, scoped, auto-numbered, and enable-Wswitchexhaustiveness checks. - Omit
defaultin enum switches so new enumerators produce compile-time warnings; add a trailingCOUNTmember to size tables. - C enums are not a closed set — any integer can be stored in one, so validate values from outside the program.
🧪 Practice
- Define an enum for the months with explicit values 1-12 and print each with its number.
- Write a
switchover a four-value enum with nodefault, add a fifth enumerator, and read the compiler warning.- Build a flags enum with four powers of two, combine three of them, and write a function that prints which are set.
- Interview-style: "When would you use
#defineinstead of anenum?" Hint: think about non-integer constants, and about values that must be usable in#ifpreprocessor conditions.
Bit Fields
Theory
Sometimes an int is a hundred times more space than a value needs. A boolean flag needs one bit; a 3-bit priority field needs three. Bit fields let a struct declare members measured in bits rather than bytes:
struct flags {
unsigned int is_active : 1; /* 1 bit */
unsigned int priority : 3; /* 3 bits, values 0-7 */
unsigned int error_code : 4; /* 4 bits, values 0-15 */
}; /* fits in one byte of an int */
The syntax is a member declaration followed by : and a width in bits. The compiler packs consecutive bit fields into an underlying storage unit and generates the shifting and masking for you, so you read and write them like ordinary members.
Two legitimate uses: saving memory when you have millions of records with small fields, and describing hardware or protocol layouts where the specification is written in bits.
That second use is where people get burned, because bit field layout is largely implementation-defined:
- Whether fields are allocated from the low end or high end of the storage unit is up to the compiler (in practice it follows endianness).
- Whether a field may straddle a storage-unit boundary is implementation-defined.
- The alignment and size of the underlying unit is implementation-defined.
- A plain
intbit field may be signed or unsigned — so a 1-bitintfield can hold only 0 and -1. Always writeunsigned int(orsigned intwhen you mean it).
The consequence: a struct of bit fields is not a portable description of a hardware register or a network packet. It will work on the compiler you tested and may silently reorder elsewhere. For portable bit manipulation, use explicit masks and shifts on a fixed-width integer (Chapter 12.1) — more verbose, but exactly specified.
Further restrictions:
- You cannot take the address of a bit field (
&f.priorityis an error), since it may not start on a byte boundary. They also cannot be used withsizeofindividually. - Only integer types are allowed:
int,unsigned int,_Bool, and (implementation-defined) other integer types. - A width of 0 with no name forces the next field to start at the next storage unit — an alignment tool.
- An unnamed field of nonzero width inserts padding bits.
Finally, bit fields are not always a win. Reading one costs a load, a shift, and a mask; writing costs a read-modify-write. In a hot loop, three unsigned char members may be faster than three bit fields, and in multithreaded code, adjacent bit fields in the same unit are a data race unless separately synchronized (C11 defines the memory model here — distinct bit fields in the same unit are not independently updatable without a race).
Examples
#include <stdio.h>
#include <stdint.h>
/* Space saving: 8 bits of information in 4 bytes instead of 12. */
struct packed_flags {
unsigned int is_active : 1; /* ALWAYS write 'unsigned' explicitly */
unsigned int priority : 3; /* 0-7 */
unsigned int error_code : 4; /* 0-15 */
};
struct unpacked_flags {
unsigned int is_active;
unsigned int priority;
unsigned int error_code;
};
/* Using width 0 to force the next field into a new storage unit. */
struct aligned_fields {
unsigned int a : 4;
unsigned int : 0; /* pad to the next unit boundary */
unsigned int b : 4;
};
/* The signedness trap. */
struct trap {
int flag : 1; /* may be SIGNED: holds 0 and -1 */
unsigned int ok : 1; /* holds 0 and 1 */
};
int main(void)
{
printf("sizeof packed = %zu\n", sizeof(struct packed_flags));
printf("sizeof unpacked = %zu\n", sizeof(struct unpacked_flags));
printf("sizeof aligned = %zu\n", sizeof(struct aligned_fields));
struct packed_flags f = { .is_active = 1, .priority = 5, .error_code = 12 };
printf("active=%u priority=%u error=%u\n", f.is_active, f.priority, f.error_code);
/* Overflow wraps silently: 3 bits cannot hold 9. */
f.priority = 9; /* 9 = 0b1001, truncated to 0b001 */
printf("priority after assigning 9 = %u (truncated to 3 bits)\n", f.priority);
/* The signedness trap in action. */
struct trap t = {0};
t.flag = 1;
t.ok = 1;
printf("int flag:1 holds %d, unsigned ok:1 holds %u\n", t.flag, t.ok);
/* &f.priority; ERROR: cannot take the address of a bit field */
/* The PORTABLE alternative: explicit masks and shifts. */
uint16_t reg = 0;
reg |= (uint16_t)(1u << 0); /* bit 0: active */
reg |= (uint16_t)((5u & 0x7u) << 1); /* bits 1-3: priority */
reg |= (uint16_t)((12u & 0xFu) << 4); /* bits 4-7: error code */
printf("register = 0x%04x -> active=%u priority=%u error=%u\n", reg,
(unsigned)( reg & 1u),
(unsigned)((reg >> 1) & 0x7u),
(unsigned)((reg >> 4) & 0xFu));
return 0;
}
struct packed_flags -- one plausible layout (NOT guaranteed)
bit: 31 8 7 4 3 1 0
+---------------------------+------+----+-+
| unused |error |pri |a|
+---------------------------+------+----+-+
4 bits 3 1
Another compiler may allocate from the other end:
bit: 0 1 3 4 7 8 31
+-+----+------+---------------------------+
|a| pri|error | unused |
+-+----+------+---------------------------+
Both are conforming. This is why bit fields do NOT portably
describe hardware registers or wire formats.
Key Takeaways
- Bit fields declare members in bits (
unsigned int flag : 1), letting one storage unit hold several small values. - Always write
unsigned int— a plainintbit field may be signed, so a 1-bit field would hold 0 and -1. - Bit ordering, straddling, and unit size are implementation-defined, so bit fields are not portable descriptions of registers or wire formats.
- You cannot take the address of a bit field; a zero-width unnamed field forces alignment to the next unit.
- Assigning a value too large for the width truncates silently.
- For portable bit manipulation, use explicit masks and shifts on fixed-width integers, and remember bit fields cost read-modify-write and are not independently thread-safe.
🧪 Practice
- Define a struct with 1-, 3-, and 4-bit fields, print its
sizeof, and compare against the same fields as fullunsigned ints.- Assign a value larger than a field's width and observe the truncation; compute the expected result by hand first.
- Rewrite a three-field bit-field struct as a single
uint16_twith mask/shift accessor functions, and verify both produce the same values.- Interview-style: "Would you use bit fields to describe a network packet header?" Hint: list what the standard leaves to the implementation, and what that means when the sender and receiver use different compilers.
<a id="63-typedefs-and-layout"></a>
6.3 Typedefs and Layout
Naming a type and knowing how it sits in memory are the two remaining pieces. This section covers typedef and the opaque handles it enables, then the alignment rules that decide a struct's real size — and the tools for measuring, controlling, and occasionally overriding them.
typedef for Readability
Theory
typedef creates an alias for an existing type. It does not create a new type: after typedef int Metres;, a Metres is an int, assignable to and from any other int with no warning. C has no way to declare a distinct integer type — the alias is documentation with autocomplete, not type safety.
Its real value is eliminating noise and taming complex declarations. Three patterns dominate:
Struct aliases. Without a typedef, every use repeats the keyword: struct point p;. With one, Point p;. The idiomatic form combines the definition and the typedef, keeping the tag so the type can still be used self-referentially and forward-declared:
typedef struct node {
int value;
struct node *next; /* the TAG is needed here: 'Node' does not
exist yet at this point */
} Node;
Function pointers. This is where typedef stops being cosmetic. void (*handler)(int, char *) in a parameter list is hard to read; Handler h is not (Chapter 5.3).
Platform abstraction. typedef uint64_t Timestamp; lets you change the underlying type in one place. The standard library does this everywhere — size_t, time_t, FILE, ptrdiff_t are all typedefs whose real types vary by platform.
The syntax follows declaration syntax exactly: write the declaration you would use for a variable, put typedef in front, and the name being declared becomes the type name. typedef int (*Fn)(void); declares Fn where the variable Fn would have been.
Three cautions:
Hiding pointers is usually a mistake. typedef struct node *NodePtr; means readers cannot see that NodePtr p is a pointer, so they miss that it can be null, must be freed, and aliases. The convention in most C codebases is to hide struct-ness but not pointer-ness. The exception is genuinely opaque handles (next topic), where hiding is the point.
Names ending in _t are reserved. POSIX reserves the _t suffix for its own types, so mytype_t risks a collision with a future standard type. Use CamelCase or a project prefix instead.
A typedef does not create a new type for overload or warning purposes. Assigning Metres to Seconds compiles silently. If you need distinct types, wrap the value in a one-member struct — the compiler will then reject cross-assignment.
Examples
#include <stdio.h>
#include <stdlib.h>
/* 1. Struct alias, keeping the tag for self-reference. */
typedef struct node {
int value;
struct node *next; /* 'Node' is not defined yet -- use the tag */
} Node;
/* 2. Function pointer alias: the strongest case for typedef. */
typedef int (*BinaryOp)(int, int);
static int add(int a, int b) { return a + b; }
static int mul(int a, int b) { return a * b; }
/* Compare the two signatures: */
static int apply_raw(int (*op)(int, int), int a, int b) { return op(a, b); }
static int apply(BinaryOp op, int a, int b) { return op(a, b); }
/* 3. Platform abstraction: change the underlying type in one place. */
typedef unsigned long Timestamp;
/* 4. Distinct types via a wrapper struct -- the compiler CAN check these. */
typedef struct { double v; } Metres;
typedef struct { double v; } Seconds;
/* A plain alias gives no protection: */
typedef double RawMetres;
typedef double RawSeconds;
int main(void)
{
/* Struct alias in use: no 'struct' keyword needed. */
Node c = {3, NULL}, b = {2, &c}, a = {1, &b};
for (Node *n = &a; n; n = n->next) printf("%d ", n->value);
putchar('\n');
BinaryOp op = add;
printf("apply(add, 3, 4) = %d\n", apply(op, 3, 4));
printf("apply_raw(mul, 3, 4) = %d\n", apply_raw(mul, 3, 4));
Timestamp t = 1725792000UL;
printf("timestamp = %lu\n", t);
/* A typedef alias is NOT a new type: this compiles with no warning. */
RawMetres m = 5.0;
RawSeconds s = m; /* silently accepted: both are double */
printf("raw alias allows nonsense: %.1f seconds from %.1f metres\n", s, m);
/* The struct wrapper makes the same mistake a compile error. */
Metres distance = { 5.0 };
Seconds duration = { 2.0 };
/* duration = distance; ERROR: incompatible struct types */
printf("checked: %.1f m over %.1f s = %.1f m/s\n",
distance.v, duration.v, distance.v / duration.v);
return 0;
}
| Use | Without typedef | With typedef |
|---|---|---|
| Struct variable | struct node n; | Node n; |
| Function pointer parameter | int (*op)(int, int) | BinaryOp op |
| Array of function pointers | int (*t[4])(int, int) | BinaryOp t[4] |
| Pointer to array | int (*p)[10] | typedef int Row[10]; Row *p; |
typedef follows declaration syntax exactly:
int x; -> typedef int Metres;
int (*f)(void); -> typedef int (*Fn)(void);
char *s[10]; -> typedef char *StringTable[10];
^ ^
the variable name the new TYPE name
goes exactly where goes in the same place
Key Takeaways
typedefcreates an alias, not a new type; the compiler will not catch mixing two aliases of the same underlying type.- Use it to drop the
structkeyword, to name function pointer types, and to abstract platform-dependent types. - Keep the struct tag when defining a self-referential type, since the alias does not exist yet inside its own definition.
- Avoid hiding pointers behind typedefs except for genuinely opaque handles — readers need to know what can be null and what must be freed.
- Do not name your types with a
_tsuffix; that namespace is reserved by POSIX. - For real type distinction, wrap the value in a single-member struct.
🧪 Practice
- Define a struct with and without a typedef and compare the declarations at ten use sites.
- Write a function taking a function pointer parameter, first spelled out and then via a typedef, and compare readability.
- Create two typedefs of
doubleand assign one to the other; then convert them to wrapper structs and observe the error.- Interview-style: "Is
typedef struct { ... } Foo;ever a problem?" Hint: consider what happens when the struct needs a member pointing to its own type.
Opaque Types
Theory
An opaque type is one whose definition callers cannot see. They receive a pointer, pass it back to your functions, and never look inside. This is C's strongest form of encapsulation, and it is built from one language feature: the incomplete type.
/* counter.h -- the public header */
typedef struct Counter Counter; /* declared, never defined here */
Counter *counter_create(void);
void counter_destroy(Counter *c);
void counter_increment(Counter *c);
long counter_value(const Counter *c);
Because struct Counter is never defined in the header, callers can hold a Counter * but cannot declare a Counter, cannot access a member, and cannot compute sizeof. The full definition lives in counter.c, where the implementation can change freely.
What that buys you, concretely:
True information hiding. No caller can depend on a field, because none can see one. Compare this with a struct in a header, where nothing stops someone reaching in.
ABI stability. Adding, removing, or reordering members changes the struct's size and layout. If callers know the size — because they declared one on the stack or allocated one — every caller must be recompiled. With an opaque handle, only your .c file knows the size, so you can change the layout and ship a new shared library without breaking existing binaries. This is why virtually every C library exposes handles: FILE *, sqlite3 *, pthread_mutex_t (partially), SSL_CTX *.
Enforced invariants. All construction goes through your create function, so an object cannot exist in a half-initialized state.
The costs are real and worth stating:
- Every object must be heap-allocated, because callers cannot know the size to reserve stack space. That is one allocation per object and a
destroycall to match. - Every access is a function call, not a direct field read. Compilers cannot inline across translation units without LTO.
- More code: a getter and setter for anything callers legitimately need.
Design conventions that make opaque APIs pleasant: name every function with the module prefix; return NULL from the constructor on failure; make the destructor tolerate NULL; take const Handle * for read-only operations; and document ownership in the header, since the type gives no hints.
A middle ground exists when callers must stack-allocate for performance: expose a struct whose members are documented as private (often with an underscore prefix or a char opaque[N] blob sized generously). This trades encapsulation for allocation control, and it is how pthread_mutex_t works.
Examples
/* ===================== stack.h : the entire public contract ============ */
#ifndef MYPROJ_STACK_H
#define MYPROJ_STACK_H
#include <stddef.h>
typedef struct Stack Stack; /* opaque: no definition here */
Stack *stack_create(size_t capacity); /* NULL on failure; caller owns it */
void stack_destroy(Stack *s); /* safe on NULL */
int stack_push(Stack *s, int value); /* 0 on success, -1 if full */
int stack_pop(Stack *s, int *out); /* 0 on success, -1 if empty */
size_t stack_size(const Stack *s); /* const: read-only operation */
#endif
/* ===================== stack.c : everything private ==================== */
#include <stdlib.h>
#include "stack.h"
struct Stack { /* the real layout, invisible to callers */
int *data;
size_t len;
size_t cap;
size_t push_count; /* added later: NO caller needs recompiling */
};
Stack *stack_create(size_t capacity)
{
if (capacity == 0) return NULL;
Stack *s = malloc(sizeof *s); /* only THIS file knows the size */
if (!s) return NULL;
s->data = malloc(capacity * sizeof *s->data);
if (!s->data) { free(s); return NULL; }
s->len = s->push_count = 0;
s->cap = capacity;
return s;
}
void stack_destroy(Stack *s)
{
if (!s) return; /* tolerate NULL, like free() */
free(s->data);
free(s);
}
int stack_push(Stack *s, int value)
{
if (s->len == s->cap) return -1;
s->data[s->len++] = value;
s->push_count++;
return 0;
}
int stack_pop(Stack *s, int *out)
{
if (s->len == 0) return -1;
*out = s->data[--s->len];
return 0;
}
size_t stack_size(const Stack *s) { return s->len; }
/* ===================== main.c : sees only the contract ================= */
#include <stdio.h>
#include "stack.h"
int main(void)
{
Stack *s = stack_create(4);
if (!s) return 1;
/* Stack local; ERROR: incomplete type -- size unknown */
/* s->len = 99; ERROR: dereferencing an incomplete type */
/* sizeof(Stack); ERROR: same reason */
for (int i = 1; i <= 5; i++)
printf("push %d -> %s\n", i, stack_push(s, i) == 0 ? "ok" : "full");
printf("size = %zu\n", stack_size(s));
int v;
while (stack_pop(s, &v) == 0) printf("pop %d\n", v);
stack_destroy(s);
stack_destroy(NULL); /* explicitly safe */
return 0;
}
What each translation unit knows
main.c stack.c
+----------------------+ +--------------------------+
| typedef struct Stack | | struct Stack { |
| Stack; | | int *data; |
| | | size_t len, cap; |
| Stack *s; (8 bytes)| | size_t push_count; |
| -- cannot deref | | }; |
| -- cannot sizeof | | -- free to change layout |
+----------------------+ +--------------------------+
Adding push_count changed sizeof(struct Stack).
main.o did not need recompiling, because it never knew the size.
| Transparent struct in header | Opaque handle | |
|---|---|---|
| Caller can stack-allocate | Yes | No (heap only) |
| Caller can read fields | Yes | Only via functions |
| Changing layout requires | Recompiling all callers | Rebuilding one .c |
| Access cost | Direct, inlinable | Function call |
| Best for | Small value types (Point) | Resources, evolving types |
Key Takeaways
- An opaque type is an incomplete type in the header (
typedef struct T T;) with its definition confined to one.cfile. - Callers can hold and pass the pointer but cannot dereference it, declare an object, or take its
sizeof. - This gives real encapsulation and ABI stability: layout changes need no caller recompilation.
- The costs are mandatory heap allocation, a function call per access, and accessor boilerplate.
- Return
NULLon constructor failure, make the destructorNULL-tolerant, useconst Handle *for reads, and document ownership.
🧪 Practice
- Build the three-file
Stackexample, then add a field tostruct Stackand confirm onlystack.crecompiles.- Try to declare
Stack s;inmain.cand explain the "incomplete type" error in your own words.- Convert a transparent struct module you have written into an opaque one, listing every capability callers lose.
- Interview-style: "Why does
FILEexist as an opaque type rather than a struct you can inspect?" Hint: consider what would happen to every compiled program if a libc update added a field.
Struct Padding and Alignment
Theory
You might expect struct { char c; int i; } to occupy 5 bytes. It almost always occupies 8. The reason is alignment, and understanding it turns struct layout from a mystery into something you can compute.
CPUs do not read memory one byte at a time; they read aligned words. A 4-byte int at an address divisible by 4 is fetched in one memory access. At a misaligned address it may take two accesses plus recombination — or, on strict-alignment architectures (some ARM configurations, SPARC), it faults. So each type has an alignment requirement, normally equal to its size for scalars: char 1, short 2, int 4, double 8, pointers 8 on 64-bit.
C guarantees that every member of a struct is properly aligned. To keep that promise while laying members out in declaration order, the compiler inserts padding bytes:
struct bad {
char c; /* offset 0 */
/* offsets 1-3: PADDING so i lands on a multiple of 4 */
int i; /* offset 4 */
char d; /* offset 8 */
/* offsets 9-11: TRAILING padding */
}; /* sizeof = 12, not 6 */
Two rules produce that result:
- Each member starts at an offset that is a multiple of its own alignment.
- The struct's total size is a multiple of its strictest member's alignment, so that arrays of the struct keep every element aligned. That is where trailing padding comes from.
The practical consequence is that member order changes struct size. Sorting members from largest to smallest alignment usually minimizes padding:
struct good {
int i; /* offset 0 */
char c; /* offset 4 */
char d; /* offset 5 */
/* 2 bytes trailing padding */
}; /* sizeof = 8, one third smaller */
For an array of a million records, that is 4 MB saved and noticeably better cache behavior — one of the few "free" optimizations in C.
Two hazards follow from padding:
Padding bytes hold indeterminate values. They are not zeroed by = {0} (which zeroes members). This is why memcmp on two structs is unreliable, why hashing raw struct bytes is a bug, and why writing a struct to a file or socket can leak stack contents — a real class of kernel security issue. memset the whole object first, or serialize field by field.
Padding is not portable. Sizes and alignments differ between 32-bit and 64-bit, and between ABIs. Never assume a struct's layout matches a file format or wire protocol.
To inspect layout, use offsetof (next topic), _Alignof, or your compiler: gcc -Wpadded warns wherever padding is inserted, and pahole visualizes struct holes.
Examples
#include <stdio.h>
#include <stddef.h>
#include <string.h>
struct bad { char c; int i; char d; }; /* poor order */
struct good { int i; char c; char d; }; /* good order */
struct mixed {
char a; /* 1 byte */
double b; /* 8 bytes, needs 8-byte alignment */
short c; /* 2 bytes */
int d; /* 4 bytes */
};
int main(void)
{
printf("alignments: char=%zu short=%zu int=%zu double=%zu ptr=%zu\n",
_Alignof(char), _Alignof(short), _Alignof(int),
_Alignof(double), _Alignof(void *));
printf("\nstruct bad : sizeof=%zu\n", sizeof(struct bad));
printf(" c at %zu, i at %zu, d at %zu\n",
offsetof(struct bad, c), offsetof(struct bad, i),
offsetof(struct bad, d));
printf("struct good : sizeof=%zu <- same members, 33%% smaller\n",
sizeof(struct good));
printf(" i at %zu, c at %zu, d at %zu\n",
offsetof(struct good, i), offsetof(struct good, c),
offsetof(struct good, d));
printf("\nstruct mixed: sizeof=%zu\n", sizeof(struct mixed));
printf(" a at %zu, b at %zu, c at %zu, d at %zu\n",
offsetof(struct mixed, a), offsetof(struct mixed, b),
offsetof(struct mixed, c), offsetof(struct mixed, d));
/* Padding bytes are INDETERMINATE -- memcmp is unreliable. */
struct bad x, y;
memset(&x, 0xAA, sizeof x); /* simulate whatever was on the stack */
memset(&y, 0x55, sizeof y);
x.c = y.c = 'a'; x.i = y.i = 1; x.d = y.d = 'b';
printf("\nmembers equal, memcmp = %d <- nonzero: the PADDING differs\n",
memcmp(&x, &y, sizeof x));
memset(&x, 0, sizeof x); memset(&y, 0, sizeof y); /* zero every byte */
x.c = y.c = 'a'; x.i = y.i = 1; x.d = y.d = 'b';
printf("after memset, memcmp = %d <- now reliable\n", memcmp(&x, &y, sizeof x));
return 0;
}
struct bad { char c; int i; char d; }; sizeof = 12
offset: 0 1 2 3 4 5 6 7 8 9 10 11
+----+----+----+----+-------------------+----+----+----+----+
| c | ## | ## | ## | i | d | ## | ## | ## |
+----+----+----+----+-------------------+----+----+----+----+
padding (3) trailing padding (3)
struct good { int i; char c; char d; }; sizeof = 8
offset: 0 1 2 3 4 5 6 7
+-------------------+----+----+----+----+
| i | c | d | ## | ## |
+-------------------+----+----+----+----+
trailing (2)
Rule 1: every member at a multiple of its own alignment
Rule 2: total size a multiple of the strictest alignment
(so arr[1] starts aligned too)
Key Takeaways
- Each type has an alignment requirement; the compiler inserts padding so every member lands on a valid offset.
- A struct's size is rounded up to a multiple of its strictest member alignment, which produces trailing padding.
- Declaration order determines size — ordering members from largest to smallest alignment usually minimizes padding.
- Padding bytes are indeterminate:
= {0}zeroes members, not padding, somemcmp, hashing, and raw I/O on structs are unsafe withoutmemset. - Layout is platform- and ABI-specific; never assume a struct matches a file or wire format.
- Inspect layout with
offsetof,_Alignof,gcc -Wpadded, orpahole.
🧪 Practice
- Declare
struct { char a; double b; char c; }, print itssizeofand every member offset, then reorder to shrink it and report the saving.- Write two structs with identical members, fill them identically without
memset, and compare withmemcmp. Explain any nonzero result.- Compile a struct-heavy file with
-Wpaddedand interpret three of the warnings.- Interview-style: "How would you reduce the memory footprint of an array of ten million records?" Hint: start with member ordering, then consider field widths and whether every field belongs in the hot record.
offsetof
Theory
offsetof(type, member), from <stddef.h>, gives the byte offset of a member from the start of its struct, as a size_t. It is a compile-time constant, so it can size arrays and appear in static initializers.
size_t off = offsetof(struct employee, salary); /* e.g. 40 */
Why would you need it? Three recurring situations:
Inspecting layout. Printing offsets is the fastest way to understand where padding went (previous topic).
Generic, data-driven access. A serializer, a config parser, or an ORM-like table can describe a struct as a list of {name, offset, type} records, then read or write fields by computing (char *)object + offset. This is how C code achieves reflection-like behavior without reflection.
The container_of idiom. Given a pointer to a member, recover a pointer to the struct that contains it. This is what makes intrusive data structures work — where the list node is embedded inside the object rather than pointing to it:
#define container_of(ptr, type, member) \
((type *)((char *)(ptr) - offsetof(type, member)))
The Linux kernel is built on this. A struct list_head is embedded in every object that wants to be listed; the generic list code manipulates only list_heads, and container_of recovers the enclosing object. The benefit over a void *-based list is that no separate node allocation is needed and the type is recovered exactly.
Implementation notes worth knowing: offsetof is a macro, traditionally defined as ((size_t)&((type *)0)->member) — forming a pointer to a member of a null pointer, which is technically undefined behavior. Modern compilers implement it with a builtin (__builtin_offsetof), so use the macro from <stddef.h> rather than rolling your own.
Restrictions: offsetof requires a complete struct or union type and a non-bit-field member, since bit fields may not start on a byte boundary.
For the arithmetic itself, always cast to char * before adding an offset — pointer arithmetic is scaled by the pointee type (Chapter 5.2), and char is the only type whose scale is one byte.
Examples
#include <stdio.h>
#include <stddef.h>
#include <string.h>
struct employee {
char name[16];
int age;
double salary;
int id;
};
/* Recover the containing struct from a pointer to one of its members. */
#define container_of(ptr, type, member) \
((type *)((char *)(ptr) - offsetof(type, member)))
/* A data-driven field table: generic access without reflection. */
typedef enum { F_INT, F_DOUBLE, F_STRING } FieldType;
struct field {
const char *name;
size_t offset;
FieldType type;
};
static const struct field employee_fields[] = {
{ "name", offsetof(struct employee, name), F_STRING },
{ "age", offsetof(struct employee, age), F_INT },
{ "salary", offsetof(struct employee, salary), F_DOUBLE },
{ "id", offsetof(struct employee, id), F_INT },
};
/* Print any employee field by name, driven entirely by the table. */
static void print_field(const struct employee *e, const char *name)
{
for (size_t i = 0; i < sizeof employee_fields / sizeof employee_fields[0]; i++) {
const struct field *f = &employee_fields[i];
if (strcmp(f->name, name) != 0) continue;
/* Cast to char* so the arithmetic is in BYTES. */
const void *p = (const char *)e + f->offset;
switch (f->type) {
case F_INT: printf("%s = %d\n", name, *(const int *)p); break;
case F_DOUBLE: printf("%s = %.2f\n", name, *(const double *)p); break;
case F_STRING: printf("%s = %s\n", name, (const char *)p); break;
}
return;
}
printf("%s: no such field\n", name);
}
/* Intrusive list: the node lives INSIDE the object. */
struct list_head { struct list_head *next; };
struct task {
int id;
struct list_head link; /* embedded, not a pointer */
char label[8];
};
int main(void)
{
printf("layout of struct employee (sizeof=%zu):\n", sizeof(struct employee));
printf(" name at %2zu\n", offsetof(struct employee, name));
printf(" age at %2zu\n", offsetof(struct employee, age));
printf(" salary at %2zu\n", offsetof(struct employee, salary));
printf(" id at %2zu\n", offsetof(struct employee, id));
struct employee e = { "Ada", 36, 95000.0, 7 };
puts("\ngeneric access by field name:");
print_field(&e, "name");
print_field(&e, "salary");
print_field(&e, "id");
print_field(&e, "bonus");
/* container_of: from an embedded member back to the whole object. */
struct task t = { .id = 42, .label = "build" };
struct list_head *node = &t.link; /* generic code sees only this */
struct task *recovered = container_of(node, struct task, link);
printf("\ncontainer_of: id=%d label=%s (same object: %s)\n",
recovered->id, recovered->label,
recovered == &t ? "yes" : "no");
return 0;
}
container_of arithmetic
struct task
+------+----------------+---------+
| id | link | label |
+------+----------------+---------+
^ ^
| node (what the generic list code holds)
|
node - offsetof(struct task, link) = the task itself
offsetof(struct task, link) = 8
node = 0x1008 -> (char*)node - 8 = 0x1000 -> (struct task *)
Why intrusive lists matter:
pointer-based list: node -> [ptr] -> object 2 allocations, 2 hops
intrusive list: node IS inside object 1 allocation, 1 subtraction
Key Takeaways
offsetof(type, member)from<stddef.h>yields a compile-timesize_tbyte offset within a struct.- It requires a complete type and does not work on bit fields.
- Use it to inspect layout, to build data-driven field tables that read and write members generically, and for
container_of. container_ofrecovers the enclosing struct from a pointer to an embedded member — the foundation of intrusive lists in the Linux kernel.- Always cast to
char *before adding a byte offset, since pointer arithmetic is otherwise scaled by the pointee type.
🧪 Practice
offsetoffor every member of a four-member struct and reconcile the numbers with itssizeof.- Implement the
container_ofmacro and use it to recover a struct from a pointer to its second member.- Build a field table for a struct and write a function that sets any field by name given a
void *value.- Interview-style: "How does the Linux kernel's linked list work without knowing the element type?" Hint: the node is embedded in the object, and one macro does the arithmetic that recovers it.
_Alignas and _Alignof
Theory
Before C11, alignment was something the compiler did to you. C11 made it controllable with two operators, declared in <stdalign.h>:
_Alignof(type)yields the alignment requirement of a type, as a compile-timesize_t. It answers "what address multiple must this start on?"_Alignas(x)on a declaration raises an object's alignment to at leastx, which may be a constant or a type.
<stdalign.h> provides alignof and alignas as macros for the underscore-free spelling; in C23 both are keywords and the header is unnecessary.
Why would you ever need more alignment than the compiler gives?
SIMD instructions. SSE requires 16-byte alignment, AVX 32-byte, AVX-512 64-byte. A misaligned load either faults or takes the slow path.
Cache-line alignment. On a 64-byte cache-line machine, two variables written by two threads that share a cache line cause false sharing: each write invalidates the other core's copy, and throughput collapses even though the variables are logically independent. Aligning each to 64 bytes and padding to a full line fixes it — a standard technique in concurrent data structures.
Hardware buffers. DMA controllers and memory-mapped devices frequently require specific alignment.
The rules:
_Alignasmay only increase alignment, never decrease it._Alignas(1) int x;is an error.- The argument must be a valid alignment (a power of two) that the implementation supports.
- It applies to objects and struct members, but not to function parameters, bit fields, or
registervariables. - Applying it to a struct member increases the struct's own alignment and size accordingly.
For heap allocations, malloc returns memory aligned for any fundamental type (typically 16 bytes), which is not enough for AVX-512 or cache-line alignment. C11 added aligned_alloc(alignment, size), where the size must be a multiple of the alignment; POSIX offers posix_memalign. Memory from either is freed with plain free.
max_align_t (in <stddef.h>) names the strictest fundamental alignment, which is what malloc guarantees.
Examples
#include <stdio.h>
#include <stdlib.h>
#include <stdalign.h> /* alignas / alignof macros */
#include <stddef.h>
#include <stdint.h> /* uintptr_t, for the modulo checks below */
/* Cache-line alignment to prevent false sharing between threads. */
#define CACHE_LINE 64
struct counters {
alignas(CACHE_LINE) long a; /* each counter gets its own cache line */
alignas(CACHE_LINE) long b;
};
/* Over-aligned type for SIMD. */
typedef struct {
alignas(16) float v[4]; /* SSE wants 16-byte alignment */
} vec4;
int main(void)
{
printf("alignof: char=%zu short=%zu int=%zu double=%zu\n",
alignof(char), alignof(short), alignof(int), alignof(double));
printf("alignof(void*)=%zu alignof(max_align_t)=%zu (what malloc guarantees)\n",
alignof(void *), alignof(max_align_t));
/* Raising alignment on a single object. */
alignas(64) int aligned_var = 5;
int normal_var = 5;
printf("\naligned_var at %p (%% 64 = %zu)\n",
(void *)&aligned_var, (size_t)((uintptr_t)&aligned_var % 64));
printf("normal_var at %p (%% 64 = %zu)\n",
(void *)&normal_var, (size_t)((uintptr_t)&normal_var % 64));
/* Alignment on members inflates the struct. */
printf("\nsizeof(struct counters) = %zu (two 64-byte lines)\n",
sizeof(struct counters));
printf("alignof(struct counters) = %zu\n", alignof(struct counters));
printf("offsetof b = %zu\n", offsetof(struct counters, b));
printf("\nsizeof(vec4)=%zu alignof(vec4)=%zu\n", sizeof(vec4), alignof(vec4));
/* Heap: malloc guarantees only max_align_t; use aligned_alloc for more. */
void *plain = malloc(256);
void *over = aligned_alloc(64, 256); /* size MUST be a multiple of 64 */
if (!plain || !over) { free(plain); free(over); return EXIT_FAILURE; }
printf("\nmalloc -> %p (%% 64 = %zu)\n",
plain, (size_t)((uintptr_t)plain % 64));
printf("aligned_alloc -> %p (%% 64 = %zu)\n",
over, (size_t)((uintptr_t)over % 64));
free(plain);
free(over); /* plain free() is correct */
/* alignas(1) int x; ERROR: alignment can only be RAISED */
return 0;
}
False sharing, and what alignas fixes
Without alignment: two counters in ONE cache line
cache line (64 bytes)
+--------+--------+---------------------------------+
| a | b | unused |
+--------+--------+---------------------------------+
thread 1 writes a -> invalidates the whole line on core 2
thread 2 writes b -> invalidates the whole line on core 1
result: constant cache-coherency traffic, throughput collapses
With alignas(64): separate lines
line 0 line 1
+--------+--------------+ +--------+--------------+
| a | padding | | b | padding |
+--------+--------------+ +--------+--------------+
independent writes, no coherency traffic
Key Takeaways
_Alignof/alignofqueries a type's alignment;_Alignas/alignasraises an object's or member's alignment.- Alignment can only be increased, and only to a supported power of two; it does not apply to parameters or bit fields.
- Over-alignment matters for SIMD loads, DMA buffers, and avoiding false sharing between threads on separate cache lines.
mallocguarantees onlymax_align_talignment; usealigned_alloc(size must be a multiple of the alignment) orposix_memalignfor more, and free both withfree.alignason a member raises the whole struct's alignment and size.
🧪 Practice
alignoffor six built-in types and for a struct containing them, and explain the struct's value.- Declare a variable with
alignas(64)and verify its address is a multiple of 64; then tryalignas(1)and read the error.- Allocate a buffer with
aligned_alloc(64, 640)and confirm the alignment; then measure a memory-bandwidth loop against a plainmallocbuffer.- Interview-style: "What is false sharing and how would you prevent it?" Hint: two threads, two variables, one cache line — and what the coherency protocol does on each write.
Packing Trade-offs
Theory
Padding wastes space, and sometimes you want it gone: a struct that must match a file header byte for byte, or an array of ten million records where 30 percent padding is real memory. Compilers offer packing to remove padding entirely.
There is no standard syntax; the two common spellings are:
#pragma pack(push, 1) /* MSVC and GCC/Clang */
struct header { char c; int i; };
#pragma pack(pop)
struct __attribute__((packed)) header { char c; int i; }; /* GCC/Clang */
Both produce a struct with no padding: sizeof becomes 5 instead of 8, and i starts at offset 1.
Now the costs, which are larger than they look.
Misaligned access is slow, and sometimes fatal. On x86 the CPU handles unaligned loads with a performance penalty (often small for scalars, significant when it straddles a cache line). On strict-alignment architectures — some ARM configurations, MIPS, SPARC — an unaligned load raises a fault. The compiler knows the field is misaligned and emits byte-by-byte assembly to compensate, which is correct but slower still.
Taking the address of a packed member is undefined behavior. &packed.i has type int * but does not satisfy int's alignment, so dereferencing it — or passing it to a function expecting an aligned int * — is UB. GCC warns with -Waddress-of-packed-member, and this is the most common real bug packing introduces.
Packing does not make layout portable. It removes padding but does nothing about endianness, type sizes (long is 4 bytes on Windows and 8 on Linux), bit field ordering, or signedness of char. A packed struct still fails to describe a wire format portably.
The recommended alternative for serialization is explicit marshalling: read and write field by field with fixed-width types and memcpy, converting byte order as needed. It is more code, but it is correct everywhere, it is greppable, and it makes the format explicit rather than implicit in a struct declaration.
| Goal | Packing | Better approach |
|---|---|---|
| Match a file/wire format | Not portable | Explicit memcpy marshalling with fixed-width types and byte-order conversion |
| Save memory in a large array | Works, costs speed | Reorder members first; shrink field widths; consider SoA |
| Match a hardware register | Fragile | volatile fixed-width integers with masks and shifts |
| Interop with a specific compiled ABI | Legitimate use | Packing, with static assertions on size and offsets |
When you do pack, defend it: add static_assert(sizeof(struct h) == 5, "layout changed") and static_assert(offsetof(...) == n, ...) so a compiler or platform change fails the build instead of corrupting data.
Examples
#include <stdio.h>
#include <stddef.h>
#include <string.h>
#include <stdint.h>
#include <assert.h>
struct normal { char c; int i; short s; };
/* GCC/Clang attribute form. */
struct __attribute__((packed)) packed_attr { char c; int i; short s; };
/* Pragma form (also MSVC). */
#pragma pack(push, 1)
struct packed_pragma { char c; int i; short s; };
#pragma pack(pop)
/* Guard the assumption so a platform change breaks the BUILD, not the data. */
static_assert(sizeof(struct packed_attr) == 7, "unexpected packed layout");
static_assert(offsetof(struct packed_attr, i) == 1, "unexpected offset");
/* The portable alternative: explicit marshalling, no layout assumptions. */
struct message { uint8_t type; uint32_t length; uint16_t flags; };
static size_t message_encode(const struct message *m, uint8_t *out)
{
size_t k = 0;
out[k++] = m->type;
/* Big-endian on the wire, regardless of host byte order. */
out[k++] = (uint8_t)(m->length >> 24);
out[k++] = (uint8_t)(m->length >> 16);
out[k++] = (uint8_t)(m->length >> 8);
out[k++] = (uint8_t)(m->length );
out[k++] = (uint8_t)(m->flags >> 8);
out[k++] = (uint8_t)(m->flags );
return k; /* exactly 7 bytes, everywhere */
}
static void message_decode(const uint8_t *in, struct message *m)
{
m->type = in[0];
m->length = ((uint32_t)in[1] << 24) | ((uint32_t)in[2] << 16) |
((uint32_t)in[3] << 8) | (uint32_t)in[4];
m->flags = (uint16_t)(((uint16_t)in[5] << 8) | in[6]);
}
int main(void)
{
printf("normal : sizeof=%zu c@%zu i@%zu s@%zu\n",
sizeof(struct normal), offsetof(struct normal, c),
offsetof(struct normal, i), offsetof(struct normal, s));
printf("packed_attr : sizeof=%zu c@%zu i@%zu s@%zu\n",
sizeof(struct packed_attr), offsetof(struct packed_attr, c),
offsetof(struct packed_attr, i), offsetof(struct packed_attr, s));
printf("packed_pragma : sizeof=%zu\n", sizeof(struct packed_pragma));
struct packed_attr p = { 'A', 0x11223344, 0x5566 };
printf("packed value : c=%c i=0x%08x s=0x%04x\n", p.c, p.i, p.s);
/* int *bad = &p.i; UNDEFINED: the address is not int-aligned.
GCC reports it with -Waddress-of-packed-member.
The safe way to get the value out: */
int copy;
memcpy(©, &p.i, sizeof copy);
printf("via memcpy : 0x%08x\n", copy);
/* Explicit marshalling: identical bytes on every platform. */
struct message m = { .type = 3, .length = 0x0000ABCD, .flags = 0x0102 };
uint8_t wire[7];
size_t n = message_encode(&m, wire);
printf("wire (%zu bytes):", n);
for (size_t i = 0; i < n; i++) printf(" %02x", wire[i]);
putchar('\n');
struct message back;
message_decode(wire, &back);
printf("decoded : type=%u length=0x%08x flags=0x%04x\n",
back.type, back.length, back.flags);
return 0;
}
struct normal { char c; int i; short s; }; sizeof = 12
0 1 2 3 4 5 6 7 8 9 10 11
+----+----+----+----+-------------------+---------+----+----+
| c | ## | ## | ## | i | s | ## | ## |
+----+----+----+----+-------------------+---------+----+----+
every member aligned; loads are single instructions
packed: sizeof = 7
0 1 2 3 4 5 6
+----+-------------------+---------+
| c | i | s |
+----+-------------------+---------+
^
i starts at offset 1: NOT 4-byte aligned
-> slower loads on x86, a FAULT on strict-alignment CPUs
-> &p.i is an unaligned int* : undefined behavior
Key Takeaways
#pragma pack(1)and__attribute__((packed))remove padding, shrinking the struct at the cost of misaligned members.- Misaligned access is slower on x86 and can fault on ARM, MIPS, and SPARC; the compiler compensates with slower code.
- Taking the address of a packed member is undefined behavior — use
memcpyto extract the value, and heed-Waddress-of-packed-member. - Packing does not make layout portable: endianness, type sizes, and bit field ordering still vary.
- Prefer explicit field-by-field marshalling with fixed-width types for wire and file formats.
- If you must pack, guard
sizeofandoffsetofwithstatic_assertso a platform change fails the build.
🧪 Practice
- Declare the same three members as a normal and a packed struct, print both sizes and all offsets, and compute the saving.
- Take the address of a packed member, compile with
-Wall -Wextra, and read the warning. Then rewrite the access withmemcpy.- Write
encode/decodefunctions for a four-field message using fixed-width types and big-endian byte order, and round-trip a value.- Interview-style: "A colleague proposes
#pragma pack(1)on a struct that is written directly to a network socket. What do you say?" Hint: name what packing fixes and the three things it leaves broken across platforms.