Computing History · Lessons from Failures
Header Files as an Interface Contract and the ODR Landmine
What the ISO C and C++ translation-unit models formalize, how the one-definition rule fails silently when headers disagree, and why the language committee took decades to admit that separate compilation is not composition.
A C header file is not part of the language. It is a convention layered on top of a preprocessor that performs textual inclusion and a linker that matches symbol names. Everything an engineer expects from a header, that it declares the interface, that it is included before use, that a mismatch produces an error, is a social contract that the toolchain enforces only partially and often after the point where the mistake is irreversible. The documented failure record of the 1970s and 1980s is largely a record of teams discovering which parts of that contract were real.
Textual inclusion is the whole mechanism
The C preprocessor’s #include directive is specified as replacing the line with the contents of a named file. Nothing about that replacement is semantic. The result is one token sequence passed to the compiler proper, and the compiler never learns that the tokens came from more than one file. A translation unit is that entire sequence after preprocessing, and the standard’s model recognizes exactly one translation unit at a time.
This has three consequences that are easy to state and hard to internalize. First, a declaration is visible only if the file that needs it textually included it, so the correctness of every .c file depends on a set of include directives that no single file controls. Second, redundant inclusion is expensive in wall time but harmless to meaning, which is why include guards and later #pragma once exist as error suppression rather than as semantics. Third, and most important, two translation units that include different versions of the same header will each compile successfully, because each compiler invocation sees a consistent world. The disagreement exists only between them, and the compiler that could have reported it has already exited.
C’s rules make some of this visible. A function prototype with a mismatched parameter list is a constraint violation if both declarations appear in one translation unit. Across two, it is not: the call site passes an int where the callee expects a pointer, a compiler that has only seen the call site emits the call, and the resulting behaviour is undefined by the standard with a specific and usually silent consequence on the machine. The standard does specify that a program with differing declarations of the same external identifier has undefined behaviour, which is a formal way of saying the toolchain is not required to notice.
What the linker can and cannot check
Traditional linkers operate on symbol names. nm on an object file shows a table of undefined and defined symbols, and the linker’s job is to resolve the latter for the former. In plain C, the symbol for a function is its identifier with a platform-dependent prefix, so a mismatch in parameter types is invisible. A mismatch in name is caught, which is why renaming a function without updating callers produces an obvious failure while changing its parameter types usually does not.
C++ adds name mangling, and the mangling scheme encodes enough of the type to make many of these errors link errors. The Itanium C++ ABI’s mangling, used by the dominant toolchains on Linux and macOS, encodes namespaces, function name, parameter types, cv-qualification, and, for template instantiations, the template arguments themselves. Change an argument from int to long and the mangled symbol changes, so the caller’s undefined symbol does not match the library’s defined one and the link fails. This is a genuine engineering advance and it is also where the confusion starts: mangling catches changes to the parts of the type that the mangling encodes, and nothing else.
Return type is not encoded. A function whose return type changes from pointer to integer keeps its symbol, and the caller reads the wrong width of register. Default arguments are erased at the call site, so a change to a default is not a symbol change and produces no diagnostic in either direction. Struct layouts are not encoded at all; a struct that gains a member changes every offset in it while the accessor’s symbol name stays identical. The ABI encodes names, and names are a lossy projection of types.
The one-definition rule and the lie it permits
C++ needs a rule for entities that appear in many translation units because it wants templates, inline functions and class definitions to live in headers. The rule it chose, in the standard’s own words, is that a single definition of an odr-used function, class type, or template is required across all translation units of a program, with a set of conditions under which multiple definitions are tolerated. The tolerated case requires every token to be identical and every name lookup to resolve to the same entity.
The permission is what makes header-only libraries possible. The obligation is what makes them fragile, because the checking is, once again, assigned to a part of the toolchain that has no complete view. Within one translation unit, a class with two differing definitions is an error. Across translation units, it is undefined behaviour, and the compiler for each unit is fully compliant when it generates code against the definition it saw. The language therefore makes the mismatch it cannot see the one it treats most leniently.
Four divergences follow from this and each has a characteristic failure signature.
Size disagreement produces silent memory corruption. Translation unit A allocates the object with a layout of 24 bytes; translation unit B, compiled against a header with an extra field, writes a member at offset 24. The allocator provided the smaller block. Nothing reports the overflow until it corrupts an adjacent allocation, and the resulting crash is far from the code that caused it.
Layout without size disagreement produces reads of the wrong field. Reordering members, or changing a bool that was compiled by two different compilers with different padding rules, moves fields without changing the total size. Values are read correctly as bytes and incorrectly as members.
Inline function divergence produces a link-time decision between two definitions whose tokens differ, typically because a macro expanded differently or a conditional compilation branch differed. The linker keeps one, and every call in the program then executes the definition from one of the two translation units regardless of which the caller was compiled against.
ODR-used template instantiation divergence is the same problem with a larger blast radius, because the instantiation is emitted into each unit that uses it and merged by the linker.
The macro that gets in the way
The preprocessor makes divergence structurally likely rather than merely possible. A header guarded with #ifdef DEBUG produces different class layouts in debug and release builds. A header that includes a platform header conditionally produces different structures per platform, which is intended, and different structures across a single build when the conditional is not actually platform-dependent. A macro that shadows a library name changes what a header’s tokens mean at the point of inclusion, and the standard’s ODR conditions do not require the compiler to check that macro state was equal.
This is the historical root of the build-system requirement to keep include paths and preprocessor definitions globally consistent, a requirement that is never stated as a language rule because it cannot be one. The language has one translation unit at a time; the constraint is on the set of translation units, and the only tool with a view of that set is the build system, which does not parse anything.
Why separate compilation survived anyway
The alternative exists and is used by a minority of languages: compile against a structured module interface, where the compiler reads the declarations from a checked, versioned representation and can reject a mismatch at the point of use. Modula-2 had definition modules and implementation modules. Ada had package specifications and package bodies with strong name-based rules. Both trade the C convention’s flexibility for a guarantee that a translation unit’s assumptions match the interfaces it uses.
The trade is real in both directions. A textual header can describe anything: a function, a struct, a macro, a language construct that does not exist yet. A compiled module interface must enumerate what the language recognizes, and every extension needs committee work. The C tradition also made separate compilation genuinely separable, because the compiler needs only the text in front of it and no global database, so a build can be distributed across processes with no coordination and a header can be generated by a script that knows nothing about the compiler.
C++ eventually added modules as a language feature in C++20, which moves definitions into a compiled interface unit and makes the declaration set a first-class object. Adoption has been slow and uneven, partly because the ecosystem’s headers cannot be moved wholesale and partly because build systems have to understand module dependencies, which is a different dependency graph from the one they already tracked. The historical lesson is visible in the delay: the standard took on the problem only after the workaround had been normative for so long that a large fraction of the C++ code people depend on is header-only by design, and a header-only library is precisely the case that ODR divergence makes fragile.
Symbol visibility as the same problem at a coarser grain
The linkage model has a second axis. By default, an externally linked symbol in a shared library is exported, which means the dynamic linker will resolve other libraries’ references to it. Two shared libraries that both define a function with the same unqualified name will interfere: the resolution depends on load order and gives one of them to both callers. This was the reason for the widespread use of symbol prefixes in C libraries, and for the C++ transition from unnamed namespaces and static to explicit visibility attributes, because a modern compiler’s default of exporting everything in a shared object reproduces the C name-collision failure at the library level.
Linkers support version scripts and visibility annotations to restrict the exported set precisely because the namespace of a dynamically linked process is global and flat. Each restriction is a manual reimposition of a rule the language does not have. The pattern is the same as with the ODR: the standard describes a single program, the loader describes a process, and the gap between them is closed by convention.
For the surrounding history of how those conventions hardened into specifications, De Jure Standard Versus De Facto Implementation traces the general shape. The specific failure mode here is narrower: a check that runs after the information needed for the check has been discarded cannot be reported, only endured.
What the record leaves as transferable caution
Three things about the header model keep recurring across the documented failures. The first is that the point of detection determines the cost of the error, and the header model moves detection as far from the error as it can. The second is that a convention enforced by a human or a build script is not enforced, because the transcript of every build shows it succeeding. The third is that the tolerated case in a rule tends to become the dominant case, so what the ODR permits as an optimization for inline functions became the entire model for template-heavy libraries.
The same asymmetry appears at run time whenever a library’s interface assumes something the layer below may violate; Threads, Scheduler Activations and the Failed Promise of User-Level Scheduling documents the case where a thread library’s assumption about blocking calls could not survive an unobservable kernel decision. An interface whose checking happens at link time or later will be used as though it were checked at compile time, and nothing in the language will disabuse anyone of that belief.
References
- ISO/IEC 9899:2018 — Programming languages — C — ISO/IEC
- ISO/IEC 14882:2020 — Programming languages — C++ — ISO/IEC
- Itanium C++ ABI — Itanium C++ ABI project