The C Path — learn C, visually

🎩 The Preprocessor

Header organization: designing multi-file programs

⏱ 14 min · free interactive lesson · quizzes, visualizations & a real compiler

▶ Open the interactive lesson — free, no signup
Why you're learning this

A 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:

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 prototypesfunction bodies
struct/enum/union definitions, typedefsvariable definitions (int mu_calls = 0;)
extern variable declarationsstatic helpers private to the file
macros, inline function definitionsthe 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.

mathutil.h — the interface
#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 */
mathutil.c — the implementation
#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);
}
main.c — the client
#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:

terminal
$ 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:

engine.h — forward declaration instead of #include
#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! */

#endif

struct 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:

stack.h — opaque interface
#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
stack.c — the secret layout
#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.

▶ Practice this lesson interactively (with live gcc)