🎩 The Preprocessor
Header organization: designing multi-file programs
▶ Open the interactive lesson — free, no signupA modern app is thousands of files written by people who never read each other's code — this lesson shows how that's even possible. You'll split your own programs into small files that cooperate through clean "contracts", and discover that FILE *, which you've used since your very first printf, is exactly the hide-the-insides trick you're about to pull off yourself.
Real programs aren't one file. They're dozens of .c files compiled separately and then linked — stitched together into one program — and headers are the contracts that let them cooperate without seeing each other's internals. This lesson turns everything from this part into a real plan for organizing whole programs.
Declaration vs definition, one more time
The whole system rests on one distinction:
- A declaration announces that something exists:
double mean(const double *xs, size_t n);orextern int mu_calls;. Repeat it in ten files — no problem. - A definition actually creates it: a function body, or
int mu_calls = 0;. There must be exactly one across the whole program, or the linker rejects the build.
Since a header gets pasted into every file that includes it, the rule writes itself: headers may contain only things that are safe to duplicate.
| goes in the .h (interface) | goes in the .c (implementation) |
|---|---|
| function prototypes | function bodies |
struct/enum/union definitions, typedefs | variable definitions (int mu_calls = 0;) |
extern variable declarations | static helpers private to the file |
macros, inline function definitions | the header's own #include "self.h" (see below) |
🧠 Checkpoint: Which line, placed in a header included by 5 files, causes a LINKER error?
extern int count;int count = 0;struct cfg { int n; };(with include guard)void reset(void);
Show answer
int count = 0; — That is a definition — pasted into 5 translation units it creates 5 objects named count, and the linker reports multiple definition. Declarations (extern, prototypes) and guarded type definitions are safe to repeat.
A complete mini-project
Three files: an interface, its implementation, and a client.
#ifndef MATHUTIL_H
#define MATHUTIL_H
#include <stddef.h> /* the header uses size_t, so IT includes this */
/* prototypes: promises kept by mathutil.c */
double mean(const double *xs, size_t n);
double clamp(double x, double lo, double hi);
/* declared here, defined exactly once in mathutil.c */
extern int mu_calls;
#endif /* MATHUTIL_H */#include "mathutil.h" /* self-include: compiler checks our promises */
int mu_calls = 0; /* THE definition of mu_calls */
/* static = private to this file; no other .c can call it */
static double clamp2(double x, double lo, double hi) {
return x < lo ? lo : (x > hi ? hi : x);
}
double mean(const double *xs, size_t n) {
mu_calls++;
double sum = 0.0;
for (size_t i = 0; i < n; i++)
sum += xs[i];
return n ? sum / (double)n : 0.0;
}
double clamp(double x, double lo, double hi) {
mu_calls++;
return clamp2(x, lo, hi);
}#include <stdio.h>
#include "mathutil.h" /* only the contract, never the internals */
int main(void) {
double temps[] = {21.5, 40.2, 19.8, 22.1};
double avg = mean(temps, 4);
printf("mean: %.2f\n", avg);
printf("clamped: %.2f\n", clamp(avg, 0.0, 25.0));
printf("calls: %d\n", mu_calls);
return 0;
}Notes on the details that separate pros from beginners:
mathutil.cincludes its own header first — so the compiler cross-checks the prototypes against the definitions. Mismatch the signature and you get a compile error now instead of linker weirdness or UB later.- The header includes
<stddef.h>because its prototypes usesize_t. Headers must include what they use — never rely on the includer having pulled in the right things first. mu_callsis declaredexternin the header (a promise), defined once inmathutil.c. Drop theexternand every includer would define its own copy — a linker error.clamp2's helper status: markedstatic, it's invisible outsidemathutil.c— the C way of saying "private".
$ gcc -c mathutil.c # → mathutil.o $ gcc -c main.c # → main.o (mathutil.c not needed!) $ gcc main.o mathutil.o -o app $ ./app mean: 25.90 clamped: 25.00 calls: 2
Each .c file compiles independently — that's what makes big projects buildable in parallel and rebuildable incrementally (only recompile what changed — the job of make, coming in Part 8):
▶ This spot has an interactive flow widget — open the interactive lesson to play with it.
🧠 Checkpoint: Why does mathutil.c include its own header?
- Otherwise the linker cannot find it
- So the compiler verifies the definitions match the published prototypes
- To make mu_calls global
- Headers must be included somewhere or gcc warns
Show answer
So the compiler verifies the definitions match the published prototypes — With the header in view, defining mean with a wrong signature is an immediate compile error. Skip the self-include and the mismatch survives until link time — or worse, until runtime UB with a wrong calling convention.
Forward declarations: cutting the include web
Headers including headers including headers makes builds slow and dependencies tangled. Often you don't need a type's full definition — a pointer to it is enough:
#ifndef ENGINE_H
#define ENGINE_H
struct logger; /* forward declaration: incomplete type */
struct engine *engine_start(struct logger *log);
/* pointers to incomplete types are fine — no logger.h needed! */
#endifstruct logger; is a forward declaration: it names an incomplete type. You can declare pointers to it and pass them around; you just can't dereference it, take its sizeof, or copy it by value. Since engine.h only handles struct logger *, it doesn't need — and shouldn't pay for — logger.h.
The opaque pointer pattern
Take that idea to its logical end and you get C's flagship encapsulation technique. The header declares that a type exists, but its layout lives only in the .c file:
#ifndef STACK_H
#define STACK_H
#include <stdbool.h>
typedef struct Stack Stack; /* exists... but layout is secret */
Stack *stack_new(void);
void stack_push(Stack *s, int value);
int stack_pop(Stack *s);
bool stack_empty(const Stack *s);
void stack_free(Stack *s);
#endif#include "stack.h"
#include <stdlib.h>
struct Stack { /* only THIS file knows the fields */
int data[64];
int top;
};
Stack *stack_new(void) {
Stack *s = malloc(sizeof *s);
if (s) s->top = 0;
return s;
}
void stack_push(Stack *s, int value) { s->data[s->top++] = value; }
int stack_pop(Stack *s) { return s->data[--s->top]; }
bool stack_empty(const Stack *s) { return s->top == 0; }
void stack_free(Stack *s) { free(s); }Client code can hold a Stack * and call the functions, but s->top won't even compile — the compiler doesn't know the struct has a top. The implementation can be completely rewritten (array today, linked list tomorrow) without touching or even recompiling client code, as long as the function signatures hold. This is exactly how FILE * works: you've been using an opaque pointer since your first printf.
🧠 Checkpoint: Client code holds a Stack *s from the opaque header. What happens on s->top?
- Works — pointers can always be dereferenced
- Linker error: top not found
- Compile error: the struct type is incomplete in this file
- Runtime crash
Show answer
Compile error: the struct type is incomplete in this file — The client only saw "typedef struct Stack Stack;" — an incomplete type. Member access needs the full definition, which lives solely in stack.c. That is encapsulation, enforced by the compiler. FILE * from stdio.h works the same way.
Header checklist: ① include guard (or #pragma once) · ② includes only what its own declarations need · ③ no function bodies (except static inline) · ④ no variable definitions, extern declarations only · ⑤ hide struct layouts that clients shouldn't touch.
Try it
▶ This spot has an interactive editor widget — open the interactive lesson to play with it.
🎩 The preprocessor is fully yours — next, Part 5 steps into the modern era: static_assert, alignment control, generics, atomics, and the shiniest corners of C23.