C++ Part I · Module 24 · C++ Build and Project Architecture
Translation Units and the One-Definition Rule
Every chapter before this one treated a program as a single thing. It is not. A C++ program is compiled one translation unit at a time, each one an independent job that knows only the text the preprocessor handed it, and the pieces are joined afterwards by a linker that understands names and sizes and nothing else. Almost every build error that is not a syntax error comes from that seam. The rule governing it is the one-definition rule, and it is worth learning precisely rather than as folklore, because it produces two completely different failures. One of them is the friendliest error in C++: the linker finds two definitions of the same thing, names it, and stops. The other is the least friendly failure in the language: two translation units disagree about what a type looks like, every compilation succeeds, the link succeeds, no warning is issued at any level, and the program reads fields out of the wrong offsets. This chapter measures both, along with the exceptions the rule grants — inline, templates, class definitions — and the exception that most often gets used wrongly, which is a static variable in a header.
Intermediate19 min readC++Part IFundamentalsBuild
Part I · Module 24 · Chapter 24.1 · Translation Units and the One-Definition Rule
1. What a Translation Unit Is
A translation unit is one source file after the preprocessor has finished with it: every #include pasted in, every macro expanded, every #if resolved. That text is what the compiler actually sees, and it is the only thing it sees. The compiler does not know about your other source files, your project layout, or your build system.
src/bus.cpp + everything it includes -> one translation unit -> build/bus.o
src/main.cpp + everything it includes -> one translation unit -> build/main.oThree consequences follow directly, and they explain most of the module:
A header is not a unit of compilation. It is text that gets copied into however many translation units include it. Chapter 24.4 §1 measures one small header expanding to 87,964 lines in each consumer.
Each translation unit is compiled in ignorance of the others. If two of them disagree about anything, the compiler cannot know. §4 is the measurement of what that costs.
The linker joins object files by symbol name. It sees names and addresses; it does not see your types. Chapter 24.5 is about what those names contain, and §4 is about what they leave out.
You can look at a translation unit directly, which is worth doing once:
clang++ -std=c++20 -E src/bus.cpp | wc -l2. The Rule, Stated Precisely
The one-definition rule has three parts, and people usually remember only the first.
One. Every variable, function, class type, enumeration and template must have exactly one definition in the whole program — with the exceptions in part three.
Two. Every translation unit may contain at most one definition of any of those. Declarations are unlimited; definitions are not.
Three. Some entities may be defined in more than one translation unit, provided every definition is token-for-token identical and the names in them mean the same things. This applies to:
- class types
- enumerations
inlinefunctions andinlinevariables- templates, and their partial specialisations
constexprfunctions, which are implicitly inline
That third part is what makes headers possible at all. A struct Config in a header is defined in every translation unit that includes it, and that is legal precisely because the rule permits it — on the condition that the definitions are identical. §4 is what happens when that condition is broken, and the thing to notice is that the condition is on you, not on the compiler. Nothing checks it.
One more clause matters and is easy to miss: a violation of the identical-definition requirement is undefined behaviour, and no diagnostic is required. The standard explicitly does not ask the implementation to detect it. So when §4 shows zero warnings, that is not a toolchain weakness; it is the rule working as specified.
3. The Loud Failure: Two Definitions
// a1.cpp
int shared() { return 1; }
// b1.cpp
int shared() { return 2; }duplicate symbol 'shared()' in:
…/odr/a1.o
…/odr/b1.o
ld: 1 duplicate symbols
clang++: error: linker command failed with exit code 1 (use -v to see invocation)This is the best error message in the chapter. It names the entity, demangled, and it names both object files. The build stops. Nothing wrong reaches a program.
The realistic causes are worth listing because the error is so blunt that the cause is sometimes not obvious:
- A function defined in a header without
inline, included by two translation units. This is the common one, and §6 is the fix. - A non-
inline, non-constvariable at namespace scope in a header —int verbose = 0;— which is a definition, not a declaration. - The same
.cppcompiled into two targets that are then linked together. - A name you did not choose colliding with a library's, which is what namespaces exist to prevent.
The mirror-image error is the other loud one:
Undefined symbols for architecture arm64:
"configure(int)", referenced from:
_main in sigmain.o
ld: symbol(s) not found for architecture arm64
clang++: error: linker command failed with exit code 1 (use -v to see invocation)That means a declaration promised something no definition provided. Chapter 24.5 §3 measures the most interesting cause — a signature that drifted between the header and the source — and Chapter 24.2 §2 measures the template version of it.
4. The Silent Failure: Two Different Definitions
This is the section to remember. The same class, declared in two translation units, with one field inserted in the middle — exactly what happens when a header changes and only some objects are rebuilt:
// a3.cpp — TU A's view
struct Config { int cycles; int master; };
// b3.cpp — TU B's view (a field was inserted)
struct Config { int cycles; int resp; int master; };Both compile. The program links. It runs:
one buffer, two disagreeing declarations of Config:
A wrote cycles=412 master=2 (A thinks sizeof=8)
B reads cycles=412 resp=2 master=-286331154 (B thinks sizeof=12)
sizeInA()=8 sizeInB()=12 -> the TUs DISAGREE
link status: SUCCESS. diagnostics: NONE.Read the second line carefully. The value 2 that A wrote into master is read by B as resp. And B's master reads -286331154, which is 0xEEEEEEEE — the byte pattern the test filled the buffer with, because B is reading four bytes past where A stopped writing.
Every field after the inserted one is shifted. A struct where the inserted field is early and the real data is late produces a program in which most values are wrong, and a struct where it is late produces one in which most values are right — which is worse, because it looks like a subtle bug in the logic rather than a build problem.
And nothing reports it. Compiling the same two translation units under -Weverything — clang's "every warning that exists" switch — and linking with -Wl,-why_load, then grepping both outputs for any mention of odr, definition or layout, returns zero matches from the compiler and zero from the linker.
-Weverything has nothing to say about this. Neither does the linker. This is not an oversight — §2's third part explains it. Each translation unit was internally consistent and individually correct; the inconsistency exists only between them, and no single compiler invocation can see more than one.
5. Which Exceptions Actually Apply
The exceptions in §2's third part are not interchangeable, and choosing the wrong one produces §4's failure. Measured, on a header included by two translation units:
// vars.h
static int staticCounter = 0; // ONE PER TU
inline int inlineCounter = 0; // ONE PROGRAM-WIDE (C++17) TU A and TU B both include the header. Do they see one object or two?
staticCounter: &A == &B ? TWO objects
inlineCounter: &A == &B ? SAME object
start A: static=0 inline=0 B: static=0 inline=0
after bumpFromA A: static=1 inline=1 B: static=0 inline=1
after bumpFromB A: static=2 inline=2 B: static=0 inline=2The program compares the two translation units' addresses rather than printing them, because the absolute values are randomised by ASLR on every run and would not reproduce — the relationship is the stable fact.
inlineCounter is one object, and its count rises 0, 1, 2 across both translation units. That is one variable, shared, which is what you almost always want from a header.
staticCounter is two objects. static at namespace scope means internal linkage — the name is not visible outside its translation unit — so each translation unit gets its own. That alone is not an ODR violation; it is two distinct variables that happen to share a spelling. The third line is where it becomes one, and §6 is why.
The object file says the same thing. nm -m a4.o, with the section offsets elided because they move with any edit:
… (__DATA,__data) weak external _inlineCounter
… (__DATA,__bss) non-external __ZL13staticCounterweak external is how the toolchain implements the exception: every translation unit emits a definition, they are all marked weak, and the linker keeps one and discards the rest. non-external means the symbol is invisible to the linker, and the _ZL prefix in the mangled name encodes the internal linkage — Chapter 24.5 §1 explains that encoding.
So the table for a header is:
| in a header, write | to get | because |
|---|---|---|
inline function | one definition, shared | the ODR exception, weak symbols |
inline variable (C++17) | one object, shared | same mechanism |
constexpr function | one definition, shared | implicitly inline |
constexpr variable | one per TU, usually harmless | implicitly const, internal linkage |
a class or enum definition | one per TU, must be identical | the ODR exception, unchecked |
| a template | instantiated per TU, deduplicated | the ODR exception |
static variable | one per TU — rarely what you want | internal linkage |
| a plain function definition | a duplicate-symbol error | §3 |
| a plain variable definition | a duplicate-symbol error | §3 |
Chapter 24.2 is that table with a reason for each row.
6. The Bug That Combines Them
static in a header is not a violation by itself. Combined with an inline function that uses it, it produces a program that is hard to explain from the source:
// vars.h
static int staticCounter = 0;
inline int bumpS() { return ++staticCounter; } // inline fn + TU-local staticTwo translation units include that, and only one of them calls bumpS():
start: A=0 B=0
after bumpViaB(): A=1 B=0 <-- which one moved?
after bumpViaA(): A=2 B=0Calling B's path incremented A's counter. B's counter never moves at all. The symbol table explains it exactly — nm -m on the executable finds one bumpS and two staticCounter objects:
… T __Z5bumpSv
0000000100008004 (__DATA,__bss) non-external __ZL13staticCounter
0000000100008008 (__DATA,__bss) non-external __ZL13staticCounterThe two staticCounter entries are four bytes apart — two distinct objects in __bss — and there is exactly one __Z5bumpSv.
Two counters, one function. bumpS is inline, so both translation units emitted a weak definition and the linker kept one — and the one it kept was compiled against its own translation unit's staticCounter. Every call in the program, from either translation unit, now touches that one object. The other counter is live, addressable, printed by its own translation unit, and permanently zero.
This is a genuine ODR violation: the two bumpS definitions are token-identical but the name staticCounter inside them refers to different entities, which §2's third part forbids. It is undefined behaviour, no diagnostic is required, and none is issued. The fix is to make the variable inline rather than static, so that there is one of it to refer to.
Three verifications were needed to be sure of that explanation rather than merely plausible about it: the runtime counts, the symbol counts in the executable, and a directed test that exercises only B's path. The first alone would have supported several wrong stories.
7. What Gets Deduplicated, and How
It is worth knowing the mechanism, because it explains the error messages you will see.
Inline functions and templates are emitted as weak symbols in every translation unit that needs them, and the linker keeps one. The cost is compile time and object size, not program size — each of 50 translation units compiles the same inline function, and 49 copies are thrown away. Chapter 24.2 §3 measures that cost.
Class and enum definitions emit nothing. They are consumed entirely at compile time, which is why §4's violation is undetectable.
Static data members declared in a class need a definition somewhere — historically in exactly one .cpp, and since C++17 inline static in the class, which is simpler and the modern default.
Function-local static variables are one per function, not one per translation unit, and are initialised on first use with a thread-safe guard. That is why the function-local-static singleton works where a header-level static does not:
inline std::vector<std::string>& registry()
{
static std::vector<std::string> r; // one, program-wide, because the
return r; // inline FUNCTION is deduplicated
}Chapter 24.6 §5 uses exactly this, and measures a different thing going wrong around it.
8. The Failures, With Their Causes
| symptom | cause | how it is found |
|---|---|---|
duplicate symbol 'f()' | a function or variable defined in a header without inline, or two .cpp files defining it | the linker names it and both files |
Undefined symbols: f(int) | a declaration with no matching definition; often a signature drift | Chapter 24.5 §3 |
Undefined symbols: f<T>(...) | a template defined in a .cpp | Chapter 24.2 §2 |
| fields read at the wrong offsets, no diagnostic | two different definitions of one class | §4 — nothing finds it |
| a counter that never increments | static in a header used by an inline function | §6 — nothing finds it |
| a value initialised in one place and zero in another | static in a header, two objects | §5 |
| a global constructor that never runs | an archive member nobody referenced | Chapter 24.6 §5 |
The two rows with "nothing finds it" are the ones worth internalising, and both are layout-or-identity disagreements between translation units rather than name conflicts.
9. What to Actually Do
- Define every type in exactly one header, and have every consumer include it. This makes §4's violation unrepresentable rather than merely unlikely.
- Mark header-level functions
inlineand header-level variablesinline(C++17). Notstatic. - Never put a
staticvariable at namespace scope in a header. Measured: one object per translation unit, and combined with an inline function, a counter that silently never moves. - Track header dependencies in the build. Chapter 24.8 §3 measures the stale-object version of §4 directly.
- Treat a layout-affecting
#ifdefas hostile. A macro that adds a member, defined for some targets, is §4 by construction. - Put a class's static data in
inline staticmembers, not in a.cpp-side definition. - Use a function-local
staticinside aninlinefunction for a shared singleton, which is deduplicated correctly. - Read a
duplicate symbolerror as good news. It is the rule catching something before it ran.
10. Mistakes Worth Making Once
A function defined in a header without inline. The duplicate-symbol error, which at least stops the build.
int verbose = 0; in a header. That is a definition. inline int verbose = 0; is what you meant.
static in a header because it "makes the warning go away". It does, by creating one object per translation unit.
static plus an inline function. Measured: A=1 B=0 after calling only B's path.
Rebuilding some objects after a header change. §4's experiment, run by your build system on your behalf.
A debug-only member inside #ifdef. Half your targets have a different layout.
Assuming -Weverything would have caught it. Measured: 0 diagnostics.
Reading duplicate symbol as a build-system problem. It is usually a missing inline.
11. Interview Relevance
The frozen curriculum marks this chapter Critical, and it is one of the most reliable interview topics in the language because the answer separates people who have debugged a build from people who have read about one.
The question is usually "what is the one-definition rule?". A complete answer states all three parts — one definition per program, at most one per translation unit, and the list of entities exempted on condition of being token-identical — and then immediately distinguishes the two failures: a duplicate symbol is a link error naming the entity, and an inconsistent definition is undefined behaviour with no diagnostic required by the standard.
The follow-up that distinguishes a strong answer is why the second one is undetectable. Saying that a class definition emits no symbol — that offsets are baked into instructions as constants before the linker runs, so there is nothing left to compare — shows you understand the model rather than the rule.
If you can add the static-in-a-header measurement, that is a concrete story most candidates do not have: two counters, one inline function, and a variable that is never incremented by the translation unit that appears to own it.
12. What You Should Be Able to Say Now
- That a translation unit is one source file after preprocessing, that it is compiled in complete ignorance of the others, and that the linker joins the results by name.
- All three parts of the ODR, including that the identical-definition condition is unchecked and its violation needs no diagnostic.
- That a duplicate symbol is a link error naming the entity and both object files, and that this is the friendly case.
- That two different definitions of one class link cleanly and produce wrong offsets — measured,
masterread as-286331154, with 0 diagnostics from-Weverythingand the linker. - Why: a class emits no symbol, and the offsets are constants inside instructions by the time the linker runs.
- That
inlinein a header gives one shared entity (weak external) andstaticgives one per translation unit (non-external,_ZL-mangled). - The combination bug: one surviving
inlinefunction bound to one translation unit'sstatic, so the other's is never touched — verified by runtime counts, symbol counts and a directed test. - That a function-local
staticin aninlinefunction is the correct shared singleton.
13. Exercises
Exercise 1 — the loud one. Define the same function in two .cpp files and link them. Read the error. Then add inline to both and confirm it links, and check with nm -m that the symbols became weak.
Exercise 2 — the silent one. Reproduce §4. Then move the inserted field to the end of the struct and observe which values are now correct — and explain why that version is more dangerous.
Exercise 3 — the diagnostics. Run §4's program under -Wall -Wextra -Wpedantic, -Weverything, -fsanitize=address and -fsanitize=undefined. Record what each reports.
Exercise 4 — the combination. Reproduce §6. Count the bumpS and staticCounter symbols in the executable with nm -m. Then change static to inline and confirm both counters become one.
Exercise 5 — the table. For each row of §5's table, write the header, include it from two translation units, and verify the claim by printing the address of the entity from both.
14. Summary
- A translation unit is one source file after preprocessing, compiled in ignorance of every other, and joined afterwards by a linker that matches names.
- The ODR has three parts: exactly one definition per program; at most one per translation unit; and a list of entities — classes, enums,
inlinefunctions and variables, templates,constexprfunctions — that may be defined in many translation units provided every definition is token-identical and its names mean the same things. - That condition is unchecked, and the standard requires no diagnostic for violating it.
- The loud failure:
duplicate symbol 'shared()', with both object files named. The build stops. Usually a missinginline. - The silent failure: two different definitions of one class compile, link and run. Measured, a field A wrote as
master=2was read by B asresp=2, and B'smasterread-286331154, with sizeof 8 against 12 and zero diagnostics from-Weverythingor the linker. - The asymmetry is structural. Functions and variables have names in the object file, so conflicts are detectable. A class emits no symbol — offsets become integer constants inside instructions — so the information needed to detect the disagreement is gone before the linker starts.
- So prevent it rather than detect it: one header per type, every consumer including it, and header dependencies tracked by the build.
- In a header use
inline, notstatic.inlinegives one shared entity (weak external);staticgives one per translation unit (non-external). Combined with an inline function,staticproduces a measured bug in which two counters exist, one function survives, and B's counter is never incremented —A=1 B=0after calling only B's path.
Chapter 24.2 turns this rule into a decision for every kind of entity you can put in a header, with a reason for each rather than a convention.
Standards & specifications
- Governing standard
- ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)
Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C++ Programming curriculum.
