07 · Modular Programming¶
Level 1, Module 9 showed the
mechanics: put declarations in a .h, definitions in a .c, compile both. This
module is about the design — how to decide what goes in a header, how to hide
implementation details behind static, what "linkage" actually means, and how
to build a module whose users cannot even see its internals.
A well-designed C module has one job, a small header describing what it does, and a source file nobody else needs to read. Get this right and a 50,000-line program stays workable; get it wrong and every change ripples through the whole build.
What belongs in a header¶
A header is a contract, not a dumping ground. It should contain only what callers need to compile against your module.
Put in the .h |
Keep in the .c |
|---|---|
| Public function prototypes | Function bodies |
typedefs and structs callers must construct |
Structs only you touch |
enums used in the public API |
Internal constants |
#defines that are part of the API |
Implementation-only macros |
| Header guard | Everything static |
Two rules that save real pain:
- Never define a variable or a non-inline function in a header. Every
.cthat includes it gets its own copy, and the linker reports "duplicate symbol". Declare withexternin the header, define once in a.c. - Include what you use, and only what you use. A header should include the
headers its own declarations need (e.g.
<stddef.h>if it mentionssize_t) — and nothing more.
static at file scope: the module's privacy keyword¶
Inside a function, static means "keep this variable between calls" — that's
the Level 1 meaning. At file scope it means something different and far more
important: this name is not visible outside this translation unit.
// stats.c
#include "stats.h"
#include <stdlib.h>
// PRIVATE: no other .c file can call or even see these.
static int compare_doubles(const void *a, const void *b) {
double x = *(const double *)a;
double y = *(const double *)b;
return (x > y) - (x < y);
}
static double sum_all(const double *v, size_t n) {
double total = 0.0;
for (size_t i = 0; i < n; i++) total += v[i];
return total;
}
// PUBLIC: declared in stats.h
double stats_mean(const double *v, size_t n) {
if (n == 0) return 0.0;
return sum_all(v, n) / (double)n;
}
Marking helpers static gives you three concrete wins:
- No name collisions. Another file can define its own
sum_alland the linker won't care. - Freedom to change. Nothing outside
stats.ccan depend on it, so you can rename or delete it safely. - Better optimization. The compiler knows every call site, so it can inline aggressively or drop the function entirely if unused.
The habit: make every function static by default, and remove static only
when you deliberately publish it in the header.
Linkage, in one table¶
"Linkage" is the rule that decides whether two declarations of the same name in different files refer to the same thing.
| Declaration (at file scope) | Linkage | Meaning |
|---|---|---|
int counter; |
external | one global, shared across the program |
static int counter; |
internal | private to this .c file |
extern int counter; |
external (reference) | "defined elsewhere — link me to it" |
static void helper(void) |
internal | private function |
void helper(void) |
external | callable from any file |
Sharing a global correctly¶
// config.h
#ifndef CONFIG_H
#define CONFIG_H
extern int g_verbose; // DECLARATION only -- no storage allocated
void config_set_verbose(int on);
#endif
// config.c
#include "config.h"
int g_verbose = 0; // THE definition -- exactly one, in one .c file
void config_set_verbose(int on) {
g_verbose = on;
}
Write int g_verbose = 0; in the header instead and every file that includes it
defines its own — a duplicate-symbol link error, or worse, silently different
copies. (Globals are best avoided anyway; prefer passing state explicitly.)
Opaque types: hiding the struct entirely¶
The strongest form of encapsulation in C. Callers get a pointer to a struct whose definition they never see, so they cannot touch its fields — and changing the layout doesn't force them to recompile.
counter.h — the public contract:
// counter.h
#ifndef COUNTER_H
#define COUNTER_H
// Incomplete type: callers know Counter exists, not what's inside it.
typedef struct Counter Counter;
Counter *counter_create(const char *label);
void counter_destroy(Counter *c);
void counter_increment(Counter *c);
int counter_value(const Counter *c);
const char *counter_label(const Counter *c);
#endif
counter.c — the private implementation:
// counter.c
#include "counter.h"
#include <stdio.h> // snprintf
#include <stdlib.h>
#define LABEL_MAX 32
// The full definition lives HERE and nowhere else.
struct Counter {
char label[LABEL_MAX];
int value;
int increments; // internal bookkeeping nobody outside knows about
};
Counter *counter_create(const char *label) {
Counter *c = malloc(sizeof *c);
if (c == NULL) return NULL;
snprintf(c->label, LABEL_MAX, "%s", label ? label : "unnamed");
c->value = 0;
c->increments = 0;
return c;
}
void counter_destroy(Counter *c) {
free(c); // free(NULL) is safe, so no check needed
}
void counter_increment(Counter *c) {
if (c == NULL) return;
c->value++;
c->increments++;
}
int counter_value(const Counter *c) {
return c ? c->value : 0;
}
const char *counter_label(const Counter *c) {
return c ? c->label : "";
}
main.c — the caller:
// main.c
#include <stdio.h>
#include "counter.h"
int main(void) {
Counter *hits = counter_create("page-hits");
if (hits == NULL) return 1;
for (int i = 0; i < 5; i++) counter_increment(hits);
printf("%s = %d\n", counter_label(hits), counter_value(hits));
// hits->value = 99; // COMPILE ERROR: incomplete type, no members visible
counter_destroy(hits);
return 0;
}
// Output:
// page-hits = 5
This create/destroy pair is the standard C object idiom — FILE * works exactly
this way, which is why you've never seen inside a FILE. The rules of the
pattern:
- The header exposes a pointer type and functions; never the struct body.
- Every
_createhas exactly one matching_destroy, and the header documents who calls it. - All functions take the object pointer first, and tolerate
NULLgracefully. - Prefix every public name with the module name (
counter_), since C has no namespaces.
Designing module boundaries¶
A few heuristics that hold up in real projects:
- One responsibility per module. If you can't name a module without "and", it's two modules.
- Depend on headers, not on files. If
a.cneeds something fromb.c, it should includeb.h— never declareb's functions itself, or the two declarations will silently drift apart. - Avoid circular includes. If
a.hincludesb.handb.hincludesa.h, the design is telling you the split is wrong. Forward-declare (typedef struct Foo Foo;) instead of including where you only need a pointer. - Keep headers cheap. A header that pulls in ten others makes every build slower and every change more disruptive.
Compiling and linking a multi-module program¶
# Compile each module to an object file independently
gcc -Wall -Wextra -c counter.c -o counter.o
gcc -Wall -Wextra -c stats.c -o stats.o
gcc -Wall -Wextra -c main.c -o main.o
# Link them into one executable
gcc counter.o stats.o main.o -o app
Change one .c and only that .o needs rebuilding. Change a .h and every
file that includes it must be rebuilt — which is precisely the dependency
tracking that Module 8 automates with make.
Three linker and compiler errors you will meet, and what they mean:
| Error | Cause |
|---|---|
undefined reference to 'foo' |
You declared foo and called it, but never compiled/linked the .c that defines it |
duplicate symbol '_foo' |
foo is defined in two .c files (or defined in a header) |
implicit declaration of function 'foo' |
You called foo without including its header |
How It Actually Works¶
static at file scope is a directive to the compiler about symbol
visibility in the object file it produces, not just a style convention.
A non-static function like void helper(void) gets an entry in the
object file's external symbol table — visible to the linker, which can
then resolve calls to helper from other .o files. Mark it static and
the compiler either omits it from the symbol table entirely or marks it
local (visible with nm -a counter.o, where non-static symbols show T
and static ones show t) — the linker literally cannot see it, which is
the actual mechanism behind "no name collisions": two static sum_all
functions in different .c files never even reach the linker's symbol
resolution step, because from the linker's point of view only one of them
exists per file, invisible to the other.
The opaque-type pattern (typedef struct Counter Counter; with no body in
the header) works because C only needs to know a struct's size at the
point where a variable of that type is declared or a member is accessed —
neither of which the header does. Counter *hits only needs to know that
Counter is some type worth having an 8-byte pointer to; the compiler
doesn't need Counter's size to store a pointer to it, only to allocate an
actual Counter object, which only counter.c ever does (via
malloc(sizeof *c) — computed by the compiler there, where the full
struct Counter definition is visible). This is exactly why
hits->value = 99; in main.c is a compile error rather than a runtime
one: the compiler in main.c's translation unit has literally never seen
struct Counter's member list, so ->value has no offset to resolve —
the error happens before any code is even generated, let alone run.
The undefined reference to 'foo' error surfaces the same
declaration/definition split from
Level 1, Module 9 one level up:
the compiler is satisfied by a prototype alone and emits a call instruction
to an unresolved symbol named foo; it's the linker, running after
every file is separately compiled, that actually needs foo's address to
patch that placeholder — and only fails at that final stage if no object
file anywhere in the link line actually defines it. This two-stage
separation (compiler checks usage, linker checks existence) is what
lets you compile main.c successfully today even though counter.c has
a bug that won't be caught until link time, or vice versa.
Exercise¶
Build a three-file Stack module using the opaque-type pattern. stack.h
declares typedef struct Stack Stack; plus stack_create(int capacity),
stack_destroy, stack_push (returning 1/0 for success as in
Module 6), stack_pop, stack_is_empty, and
stack_size. stack.c defines the struct with a heap-allocated int *data
array (see Module 2) and keeps at least one static
helper such as static int stack_is_full(const Stack *s). main.c pushes ten
values, pops them all, and prints them — proving they come out in reverse order.
Then try to write s->data[0] = 1; in main.c and confirm the compiler refuses.