Program Modularity
Source and header files, declarations, external and internal linkage, storage-class specifiers, include guards, macros, conditional compilation, and separate compilation.
A single source file eventually becomes difficult to navigate, compile, test, and share safely. C supports modular programs through translation units, headers, declarations, definitions, linkage, and separate compilation. Good module boundaries hide representation choices while exposing small, stable contracts.
Translation Units
A source file and everything included into it after preprocessing form a translation unit. Each translation unit is compiled independently into an object file; the linker later combines object files and libraries.
main.c ──preprocess/compile──► main.o ─┐
├──link──► application
stats.c ─preprocess/compile──► stats.o ┘
The compiler checks one translation unit at a time. A shared header ensures every user sees the same interface declarations.
Source Files
Source files normally contain definitions and private helpers:
/* stats.c */
#include "stats.h"
static double sum(const double values[], size_t count) {
double total = 0.0;
for (size_t i = 0; i < count; i++) {
total += values[i];
}
return total;
}
bool stats_mean(const double values[], size_t count, double *result) {
if (values == NULL || result == NULL || count == 0) {
return false;
}
*result = sum(values, count) / (double)count;
return true;
}
The module includes its own public header first. If the definition is incompatible with the declaration, this translation unit receives a diagnostic rather than allowing callers and implementation to drift silently.
File names should reflect responsibilities, not one function per file or one giant “utilities” category.
Header Files
Headers provide declarations and definitions intended to be included by more than one translation unit:
/* stats.h */
#ifndef STATS_H
#define STATS_H
#include <stdbool.h>
#include <stddef.h>
bool stats_mean(const double values[], size_t count, double *result);
#endif
A public header should be self-contained: after including it in an otherwise empty translation unit, all names required by its declarations are available. stats.h uses bool and size_t, so it includes the standard headers that declare them.
Headers commonly contain:
- function declarations;
- type definitions needed by callers;
- enumeration constants and carefully designed macros;
externdeclarations for rare public objects;- documentation of contracts.
Headers should not usually contain ordinary external function definitions or tentative object definitions because inclusion in several source files can create multiple-definition linker failures.
Function Declarations
Every call should see a prototype:
bool stats_mean(const double values[], size_t count, double *result);
The declaration communicates types but not every semantic requirement. Document constraints such as:
- whether
valuesmay be null whencountis zero; - whether inputs may overlap outputs;
- whether output remains unchanged on failure;
- ownership and lifetime of returned pointers;
- thread-safety or persistent-state behaviour when relevant.
Parameter names in public declarations make such contracts easier to discuss.
Definitions
A declaration introduces an entity; a definition supplies it. A function body is a definition:
int answer(void); /* declaration */
int answer(void) { return 42; } /* definition */
An object declaration with an initializer is a definition:
int global_limit = 100;
For externally linked entities used across a program, there should normally be exactly one definition and compatible declarations everywhere else. Violating the one-definition requirement can produce linker errors or undefined behaviour when incompatible declarations evade diagnosis across translation units.
External Linkage
Linkage determines whether the same name in different scopes or translation units can denote the same entity. File-scope functions and many file-scope objects have external linkage by default:
int record_count; /* tentative definition with external linkage */
void print_report(void); /* external linkage declaration */
An externally linked definition can be connected to declarations in other translation units by the linker.
Minimise exported names. Every external name expands the module’s public surface and creates potential collisions. Prefix public C names by module, as in stats_mean, stats_minimum, and stats_maximum, because C has no namespace construct for ordinary identifiers.
Internal Linkage
At file scope, static gives a function or object internal linkage:
static double sum(const double values[], size_t count);
static unsigned int diagnostic_level;
The names refer only within that translation unit and do not collide with identically spelled private names elsewhere. Mark every non-public file-scope helper static.
Internal linkage is encapsulation at the linker level. It does not prevent code within the same source file from modifying the object, so mutable file-private state still needs disciplined interfaces.
extern
extern can declare an entity defined elsewhere:
/* settings.h */
extern const unsigned int settings_version;
/* settings.c */
const unsigned int settings_version = 3U;
Include the header in the defining file to check compatibility.
Avoid public mutable objects:
extern int current_mode; /* every user can modify hidden invariants */
Prefer functions such as settings_current_mode() and settings_set_mode() that can validate changes and preserve representation freedom.
At block scope, extern can declare an externally linked entity, but placing shared declarations in headers is more consistent and auditable.
static
static has related but distinct effects by context:
- at file scope, it gives functions or objects internal linkage;
- on a block-scope object, it gives static storage duration while scope remains local;
- in an array parameter such as
int values[static 4], it states a minimum accessible element contract.
static unsigned long next_id(void) {
static unsigned long current;
current++;
return current;
}
The function name is file-private. The local current object persists between calls. These are two different uses of the same keyword.
Persistent static state makes a function history-dependent and can affect testing or concurrency. Use it because persistence is part of the design, not merely to avoid passing state.
Include Guards
A header may be included more than once through dependency chains. Include guards ensure its content is processed once per translation unit:
#ifndef PROJECT_STATS_H
#define PROJECT_STATS_H
/* declarations */
#endif
Choose a guard name unlikely to collide, commonly derived from project and path. Names beginning with underscores in reserved patterns must be avoided.
#pragma once is widely supported but not part of ISO C17. A project tied to supporting implementations may use it; include guards remain the portable mechanism.
Guards do not solve multiple external definitions placed in a header. They prevent repetition only within one translation unit, while the header may still be included once in each of many translation units.
Preprocessor Directives
The preprocessor handles directives before C translation proper. Important directives include:
#includefor header inclusion;#defineand#undeffor macros;#if,#ifdef,#ifndef,#elif,#else, and#endiffor conditional processing;#errorfor deliberate translation failure;#linefor source-location control, mainly generated code.
Directives operate on preprocessing tokens, not typed C values. Keep preprocessor logic small and local because debuggers and type checkers see mainly the expanded program.
Include Forms
#include <stdio.h>
#include "stats.h"
Angle brackets select implementation-defined system-header search. Quotes first use implementation rules intended for project headers, then commonly fall back to system paths. Build options should supply project include directories rather than relying on fragile relative climbs.
Object Macros
An object-like macro replaces a name with tokens:
#define APP_VERSION "2.1"
#define DEFAULT_CAPACITY 128U
Macros have no type, scope in the C-language sense, or storage. Prefer const objects or enumerations where a language-level value works. Macros remain useful for conditional configuration, string literals shared in declarations, and expressions requiring preprocessing.
Never put a trailing semicolon in a value macro:
#define LIMIT 100; /* wrong in most uses */
Macro names conventionally use uppercase to warn readers that token substitution rather than ordinary name lookup is involved.
Function Macros
A function-like macro can accept arguments:
#define SQUARE(value) ((value) * (value))
Parentheses protect grouping, but repeated evaluation remains:
int result = SQUARE(i++); /* modifies i twice without safe sequencing */
Use a function when possible:
static inline int square_int(int value) {
return value * value;
}
The function has typed arguments evaluated once. inline is an optimisation-related language feature with linkage subtleties; static inline in a header is a common pattern for small private-per-translation-unit helpers.
Statement Macros
When a macro must behave like one statement, the conventional wrapper is:
#define LOG_ERROR(message) \
do { \
fputs("error: ", stderr); \
fputs((message), stderr); \
fputc('\n', stderr); \
} while (0)
The caller supplies the semicolon, and the expansion behaves safely under if-else. Arguments can still be evaluated multiple times if repeated in the macro; design each macro’s evaluation contract explicitly.
The # operator stringizes a macro argument, and ## pastes tokens. These tools are valuable in generated declarations and tests but can make ordinary application logic opaque.
Conditional Compilation
Conditional directives include or omit source before compilation:
#if defined(ENABLE_TRACE)
#define TRACE(message) fprintf(stderr, "trace: %s\n", (message))
#else
#define TRACE(message) ((void)0)
#endif
Command-line definition:
cc -std=c17 -DENABLE_TRACE main.c -o app
Use conditional compilation for platform adaptation, optional instrumentation, feature availability, and include guards. Avoid creating many feature combinations that are never compiled or tested.
Check numeric macro values explicitly when zero has meaning:
#if FEATURE_LEVEL >= 2
/* ... */
#endif
#ifdef FEATURE_LEVEL only asks whether the name is defined, not whether its replacement value is nonzero.
Feature Contracts
Keep platform-specific code behind a narrow interface:
/* clock_port.h */
bool clock_monotonic_seconds(double *result);
Different source files can implement the interface for different systems. This avoids scattering #ifdef branches throughout business logic.
Separate Compilation
For main.c, stats.c, and stats.h:
/* main.c */
#include "stats.h"
#include <stdio.h>
#include <stdlib.h>
int main(void) {
double values[] = {3.0, 4.5, 5.0};
double mean;
if (!stats_mean(values, 3, &mean)) {
fputs("mean unavailable\n", stderr);
return EXIT_FAILURE;
}
printf("mean: %.2f\n", mean);
return EXIT_SUCCESS;
}
Compile independently, then link:
cc -std=c17 -Wall -Wextra -Wpedantic -c main.c
cc -std=c17 -Wall -Wextra -Wpedantic -c stats.c
cc main.o stats.o -o report
If only stats.c changes, an incremental build can recompile it and relink without recompiling main.c. A build system records these dependencies and commands.
Link Failures
Typical failures reveal module-contract problems:
- undefined reference: a required definition was not linked, the name differs, or linkage is internal;
- multiple definition: more than one external definition exists, often because a definition was placed in a header;
- no compile diagnostic but runtime corruption: declarations may be incompatible across translation units because a shared header was not used.
Compile and link with one consistent set of ABI-relevant options.
Dependency Design
Headers create compile-time dependencies. Keep public interfaces small:
- include what the header itself needs;
- do not include headers merely for the source implementation’s convenience;
- use forward declarations for tagged structures when callers only hold pointers;
- avoid cyclic module responsibilities;
- expose operations rather than writable representation.
An opaque type can hide members:
/* counter.h */
typedef struct Counter Counter;
Counter *counter_create(void);
void counter_destroy(Counter *counter);
bool counter_increment(Counter *counter);
unsigned long counter_value(const Counter *counter);
Only the implementation file defines struct Counter. Callers cannot allocate it by value or modify members, so the module controls invariants and can change representation without recompiling callers under an appropriate binary interface strategy.
Translation Build Trace
| Change | main.c | stats.c | link |
|---|---|---|---|
edit private helper in stats.c | unchanged object reusable | recompile | relink |
edit main.c only | recompile | unchanged object reusable | relink |
change declaration in stats.h | dependent source must recompile | recompile | relink |
add missing stats.o to link command | no compilation needed | no compilation needed | relink correctly |
Header changes affect every translation unit that includes them directly or indirectly, which is another reason to keep interfaces focused.
Complete Statistics Module
The earlier main.c needs a self-contained public header and one external definition. Together, these three files form a complete program.
/* stats.h */
#ifndef STATS_H
#define STATS_H
#include <stdbool.h>
#include <stddef.h>
bool stats_mean(const double values[], size_t count, double *result);
#endif
The header includes every standard declaration its own interface needs. A caller should not have to include <stdbool.h> or <stddef.h> in a lucky order first.
/* stats.c */
#include "stats.h"
bool stats_mean(const double values[], size_t count, double *result) {
if (values == NULL || count == 0 || result == NULL) {
return false;
}
double total = 0.0;
for (size_t i = 0; i < count; i++) {
total += values[i];
}
*result = total / (double)count;
return true;
}
The implementation includes its own header first. If the definition drifts from the public declaration, this translation unit is likely to diagnose the mismatch immediately. stats_mean is the only name with external linkage here; any implementation helper should be declared static.
/* main.c */
#include "stats.h"
#include <stdio.h>
#include <stdlib.h>
int main(void) {
double values[] = {3.0, 4.5, 5.0};
size_t count = sizeof values / sizeof values[0];
double mean;
if (!stats_mean(values, count, &mean)) {
fputs("mean unavailable\n", stderr);
return EXIT_FAILURE;
}
printf("mean: %.2f\n", mean);
return EXIT_SUCCESS;
}
Build and link with:
cc -std=c17 -Wall -Wextra -Wpedantic -Werror -c stats.c
cc -std=c17 -Wall -Wextra -Wpedantic -Werror -c main.c
cc stats.o main.o -o report
At compile time, both translation units see an identical declaration copied from stats.h. At link time, main.o contains an unresolved external reference to stats_mean, while stats.o supplies one compatible external definition.
main.c --preprocess/compile--> main.o --\
+--> linker --> report
stats.c --preprocess/compile-> stats.o --/
^
|
stats.h included independently in both translation units
The header is not itself linked. It influences each source file before compilation. Include guards prevent its declarations from appearing twice in one translation unit; they do not prevent two source files from creating duplicate external definitions.
Symbol Audit
Classify every file-scope name before exposing it:
| Name kind | Typical placement | Linkage intent |
|---|---|---|
| public function declaration | header | external |
| public function definition | one source file | external |
| private helper definition | implementation source | internal via static |
| public constant value | function or carefully designed header/API | avoid writable global state |
| private persistent object | implementation source | internal via static |
| type used by callers | header | declarations visible wherever needed |
Exporting a name expands the compatibility surface. Once clients call it or depend on its type, renaming or changing it becomes a coordinated change. Keep helpers private until a real external contract exists.
Prefixes such as stats_ create a human-readable namespace because C has no module namespace syntax. A generic external name like init can collide with another library at link time. Private static names do not collide across translation units even when spelled the same.
Header Test
A public header should compile when included first in an otherwise minimal translation unit:
#include "stats.h"
int main(void) {
return 0;
}
This catches hidden inclusion-order dependencies. It should also tolerate being included twice, directly or through another header. Guards make repeated declarations manageable, while the rule “include what you use” makes the interface’s prerequisites explicit.
Do not add broad standard headers merely to make every possible caller convenient. Each additional include increases preprocessing work and exposes more names and macros. Include what the header’s declarations require; keep implementation-only dependencies in the source file.
Configuration Matrix
Conditional compilation creates multiple programs from one source tree. Two Boolean features already create four configurations; two platforms can double that to eight. A branch that no routine build compiles will decay.
List configurations deliberately:
| Platform | Trace | Optional parser | Required check |
|---|---|---|---|
| A | off | off | compile and core tests |
| A | on | off | trace calls compile without changing results |
| A | off | on | parser tests |
| A | on | on | combined integration |
| B | relevant supported combinations | platform adapter tests |
Not every theoretical combination must be supported, but unsupported combinations should fail clearly during preprocessing rather than compile into an incoherent program. Keep conditions near adapter boundaries, and let ordinary C logic operate on a stable interface.
A macro that disappears when disabled must preserve argument-evaluation policy. If enabled TRACE("value=%d", expensive()) evaluates expensive() but disabled tracing does not, observable behaviour changes. Either document that trace arguments are evaluated only when enabled or compute required effects outside the macro. Logging should not secretly become program logic.
Dependency Cycles
Two modules that include each other’s complete representations become difficult to compile and change. Often the cycle reveals confused responsibility:
order.h includes customer.h
customer.h includes order.h
If each interface only stores a pointer to the other’s tagged type, forward declarations can break the include cycle:
typedef struct Customer Customer;
typedef struct Order Order;
The implementation files include complete definitions where member access is required. If both modules manipulate each other’s internals heavily, a third coordinating module or redesigned ownership boundary may be better than clever declarations.
Include guards prevent infinite textual inclusion, but they do not make incomplete types complete. An incomplete tagged type can be named behind a pointer; sizeof, member access, and by-value fields require its definition.
ABI Drift
Separate translation units trust shared declarations. If one source sees:
long calculate(int value);
while the definition was compiled as an incompatible type, the linker usually connects only the external name; it does not guarantee a type-level diagnostic across already compiled objects. Calls can then use incompatible argument or return conventions.
Including one authoritative header in both caller and implementation lets each compilation check against the same declaration. After public header changes, rebuild every dependent translation unit. Stale object files can preserve an old ABI even while current source looks consistent.
Binary compatibility extends beyond function prototypes to structure layout, enumeration choices, calling conventions, compiler options, and library runtime expectations. Source compatibility—“it recompiles”—is not the same as drop-in binary compatibility. A personal site course need not define a stable ABI, but code should understand why clean rebuilds solve mysterious mismatches.
Opaque Lifecycle
An opaque module should make its valid lifecycle obvious:
Counter *counter_create(void); returns owner or NULL
bool counter_increment(Counter *); borrows mutable valid object
unsigned long counter_value(const Counter *); borrows read-only object
void counter_destroy(Counter *); ends ownership; accepts NULL by policy
Because callers cannot see struct Counter, they cannot allocate it by value, copy it by assignment, or repair its invariant directly. The module can later change from one integer to a synchronized or persisted representation without changing source callers if the interface remains stable.
Opaque design is not automatically superior. It introduces allocation or separate storage management, prevents stack allocation unless a different handle design is used, and may complicate debugging. Use it when invariant control and representation independence justify the boundary.
Macro Expansion Audit
For every function-like macro, test four hazards on paper:
- Grouping: what happens when an argument is
a + b? - Context: does the whole expansion behave inside a larger expression?
- Evaluation count: what happens when an argument is
i++or a function call? - Statement shape: does it pair correctly with a surrounding
if-else?
The safe answer is often a typed static inline function. Macros remain necessary for conditional compilation, stringizing, token generation, and operations whose type-generic behaviour is deliberately designed. Their power comes from operating before types, which is also why compiler checking of the definition is limited until expansion contexts exist.
Build Graph
A build system models generated artifacts as a dependency graph:
stats.h -> main.o
stats.h -> stats.o
main.c -> main.o
stats.c -> stats.o
main.o + stats.o -> report
When stats.h changes, both object targets become stale. When stats.c changes, only stats.o and the final link become stale. Correct dependency discovery is part of correctness: an incremental build that misses a header edge can produce an executable from inconsistent source generations.
A clean build removes stale artifacts as a diagnostic, but routinely requiring clean builds can hide broken dependency declarations. Test incremental changes as well as clean builds.
Linkage Failures
- Putting external definitions in headers: every including translation unit creates another definition.
- Relying on transitive includes: include the header that declares each dependency directly.
- Failing to include a module’s own header in its source: declarations and definitions can drift.
- Exporting every helper: mark file-private functions and objects
static. - Using public mutable globals: invariants and change control disappear.
- Writing unsafe macros: parenthesize parameters and avoid multiple evaluation.
- Assuming include guards prevent cross-translation-unit definitions: they operate per translation unit.
- Testing only one conditional-build configuration: inactive branches decay unnoticed.
Module Reasoning
- Preprocessed source forms a translation unit, which compiles independently into an object file.
- Headers declare shared interfaces; source files define them and contain private implementation.
- External linkage connects names across translation units; internal linkage keeps file-scope names private.
externdeclares externally defined entities, whilestatichas context-dependent linkage or duration effects.- Include guards prevent repeated header contents within one translation unit.
- Macros perform untyped token substitution and require strict evaluation and grouping discipline.
- Conditional compilation should isolate configuration and platform variation behind narrow interfaces.
- Separate compilation improves build scale only when declarations, definitions, dependencies, and link inputs remain consistent.
Module Problems
Classify the Names
- Distinguish source file, header, translation unit, object file, and executable.
- Distinguish declaration, definition, scope, linkage, and storage duration.
- Why should a module implementation include its own header?
- What problem do include guards solve, and what problem do they not solve?
Follow the Declarations
- Given four source files and their includes, list which translation units must recompile after each header changes.
- Expand
SQUARE(a + b)with and without parentheses and compare grouping. - Trace how
staticchanges meaning at file scope, block scope, and in an array parameter.
Repair the Link
- Repair a project with a global object definition in a header included by three source files.
- Diagnose an undefined reference caused by marking a required public definition
static. - Replace a macro that evaluates
i++twice with a typed function. - Make a header self-contained when it uses
size_tandboolbut relies on inclusion order.
Split the Program
- Split a calculator into input, arithmetic, and presentation modules. Give every public name a module prefix and every private helper internal linkage.
- Create a small library with an opaque type, constructor, observer, mutator, and destructor. Keep the structure definition private.
- Add compile-time trace logging that disappears when disabled while preserving argument-evaluation policy.
Control the Dependency
- Review a large “utilities” module and propose cohesive module boundaries based on contracts and reasons to change.
- Define a build matrix for two optional features and two platforms. Identify every configuration that must compile and which behaviours require tests.