Contact
Evil Genius SocietyHomeLearnForumLeaderboardSocialsMessages
← Back to module

Lesson 2 of 10

Lesson 17.1 left a problem: every file that calls triple needs a declaration of it, and writing that declaration by hand in twenty files is twenty chances to get it wrong.

A header is the shared copy. You write the declaration once, and every translation unit pastes in the same text. What may go in that text is governed by one rule.

The One Definition Rule

Every function, variable, class and template used in a program must have exactly one definition across the whole program — with a specific list of exceptions, which is most of what this lesson is about.

Put a function definition in a header and include it twice, and you have two definitions:

// util.hpp
#pragma once

int doubled(int x) {
    return x * 2;
}
// a.cpp
#include "util.hpp"

int from_a() { return doubled(10); }
// main.cpp
#include <iostream>
#include "util.hpp"

int from_a();

int main() { std::cout << from_a() << " " << doubled(3) << "\n"; }
g++ -std=c++20 -c a.cpp -o a.o
g++ -std=c++20 -c main.cpp -o main.o
g++ a.o main.o -o prog
/usr/bin/ld: main.o: in function `doubled(int)':
main.cpp:(.text+0x0): multiple definition of `doubled(int)'; a.o:a.cpp:(.text+0x0): first defined here
collect2: error: ld returned 1 exit status

Both object files compiled without complaint — each is individually fine. The linker found the same symbol defined twice and refused.

inline is the fix, and it is not about inlining

// util.hpp
#pragma once

inline int doubled(int x) {
    return x * 2;
}
g++ -std=c++20 -c a.cpp -o a.o
g++ -std=c++20 -c main.cpp -o main.o
g++ a.o main.o -o prog && ./prog
echo "--- what the symbol looks like now:"
nm -C a.o | grep doubled
20 6
--- what the symbol looks like now:
0000000000000000 W doubled(int)

T became W — a weak symbol. That is the whole mechanism: inline tells the linker "this symbol may appear in several object files; they are all the same, pick one and discard the rest."

The keyword is badly named. It is a linkage instruction, not an optimisation hint — modern compilers decide what to actually inline on their own and ignore your opinion entirely. What inline buys you is permission to define something in a header.

What may go in a header

Allowed, because the ODR exempts them (provided every copy is token-for-token identical):

  • Class and struct definitions
  • Templates of every kind
  • inline functions and inline variables
  • constexpr functions (implicitly inline)
  • Type aliases, enums, constexpr constants
  • Anything declared but not defined

Not allowed:

  • A non-inline function definition
  • A non-inline, non-const namespace-scope variable definition

Note the first list contains classes and templates, which is why a header-only library is possible at all: nearly everything C++ lets you put in a header is on the exempt list.

The exception has a condition, and it is not checked

The ODR exempts a class defined in several translation units only if the definitions are identical. Nothing enforces that. Here are two files that disagree:

// big.cpp
#include <cstddef>

// This translation unit's idea of Config: two ints.
struct Config {
    int width;
    int height;
};

Config make_config() {
    Config c;
    c.width = 1920;
    c.height = 1080;
    return c;
}

std::size_t size_here() { return sizeof(Config); }
// main.cpp
#include <cstddef>
#include <iostream>

// This translation unit's idea of Config: an extra field in the middle.
struct Config {
    int width;
    int depth;      // <-- only this file believes this exists
    int height;
};

Config make_config();
std::size_t size_here();

int main() {
    Config c = make_config();
    std::cout << "sizeof here:  " << sizeof(Config) << "\n";
    std::cout << "sizeof there: " << size_here() << "\n";
    std::cout << "width  " << c.width << "\n";
    std::cout << "depth  " << c.depth << "\n";
    std::cout << "height " << c.height << "\n";
}
g++ -std=c++20 -Wall -Wextra -c big.cpp -o big.o
g++ -std=c++20 -Wall -Wextra -c main.cpp -o main.o
g++ big.o main.o -o prog
./prog
echo "exit code: $?"
sizeof here:  12
sizeof there: 8
width  1920
depth  1080
height 0
exit code: 0

Clean compile, clean link, exit code 0, and the answers are nonsense. depth printed 1080 — that is the height, read out of the slot where this file thinks depth lives. height printed 0, which is memory past the end of the object make_config actually returned.

-Wall -Wextra said nothing. They cannot: each translation unit is internally consistent, and neither compiler invocation ever saw the other file.

This is why the rule is stated as harshly as it is. An ODR violation is undefined behaviour with no diagnostic required, and in practice it manifests as fields reading each other's values, which is about as hard to debug as C++ gets. In a real codebase it comes from a header included with different -D flags in different targets, or two libraries that both define a Config.

One tool does catch it

Link-time optimisation gives the compiler both translation units at once:

g++ -std=c++20 -Wall -Wextra -flto -O2 big.cpp main.cpp -o prog_lto 2>&1 | head -20
big.cpp:5:8: warning: type ‘struct Config’ violates the C++ One Definition Rule [-Wodr]
    5 | struct Config {
      |        ^
main.cpp:6:8: note: a different type is defined in another translation unit
    6 | struct Config {
      |        ^
big.cpp:7:9: note: the first difference of corresponding definitions is field ‘height’
    7 |     int height;
      |         ^
main.cpp:8:9: note: a field with different name is defined in another translation unit
    8 |     int depth;      // <-- only this file believes this exists
      |         ^
main.cpp:12:8: warning: ‘make_config’ violates the C++ One Definition Rule [-Wodr]
   12 | Config make_config();
      |        ^
big.cpp:10:8: note: return value type mismatch
   10 | Config make_config() {
      |        ^
big.cpp:5:8: note: type ‘struct Config’ itself violates the C++ One Definition Rule
    5 | struct Config {

-Wodr names both definitions, points at height as the first field that differs, and then reports a second violation: make_config has a "return value type mismatch", because a function returning two different Config types is two different functions. It is on by default under -flto, and it is a good reason to run an LTO build in CI even if you ship without it.

static in a header is a different trap

// counter.hpp
#pragma once

// WRONG: `static` at namespace scope means internal linkage, so every
// translation unit that includes this header gets its OWN counter.
static int calls = 0;

// Right: one object, shared by every translation unit (C++17).
inline int shared_calls = 0;
// worker.cpp
#include "counter.hpp"

void work() { ++calls; ++shared_calls; }
int worker_sees() { return calls; }
bool worker_same_object(const int* p) { return p == &calls; }
// main.cpp
#include <iostream>
#include "counter.hpp"

void work();
int worker_sees();
bool worker_same_object(const int* p);

int main() {
    work(); work(); work();
    std::cout << "main sees calls:       " << calls << "\n";
    std::cout << "worker sees calls:     " << worker_sees() << "\n";
    std::cout << "same object?           "
              << (worker_same_object(&calls) ? "yes" : "no") << "\n";
    std::cout << "both see shared_calls: " << shared_calls << "\n";
}
g++ -std=c++20 -Wall -Wextra -c worker.cpp -o worker.o
g++ -std=c++20 -Wall -Wextra -c main.cpp -o main.o
g++ worker.o main.o -o prog && ./prog
echo "--- symbols in worker.o:"
nm -C worker.o | grep calls
main sees calls:       0
worker sees calls:     3
same object?           no
both see shared_calls: 3
--- symbols in worker.o:
0000000000000000 b calls
0000000000000000 u shared_calls

Three calls were made. main sees zero of them, because main.cpp and worker.cpp have different calls objects that merely share a name.

The symbol table says it outright: b is a local BSS symbol — private to this object file — while u is a unique global, the same object everywhere. static at namespace scope does not mean what it means inside a class or a function; it means internal linkage, and in a header that is almost always a bug.

This one links cleanly and produces no warning, so the only defence is knowing the rule: inline for a shared variable, static only when you genuinely want one copy per file.

Try it

Build a small header-only utility that is included by three translation units, containing a function, a class, a template, a constant, and a counter that all three increment. Get it wrong first — non-inline function, static counter — then fix both and prove the fix with nm.

Checkpoint

  • The One Definition Rule: one definition per entity across the whole program.
  • A non-inline function defined in a header, included twice, is a multiple definition link error.
  • inline makes the symbol weak (W instead of T in nm) so the linker may discard duplicates.
  • inline is a linkage instruction, not an optimisation hint — the compiler ignores your opinion about actual inlining.
  • Classes, templates, constexpr functions, inline functions and inline variables may all live in a header.
  • The class exemption requires every copy to be identical, and nothing checks that.
  • Measured: two files disagreeing about one struct compiled clean, linked clean, exited 0, and printed a field's value under another field's name.
  • -Wall -Wextra cannot catch it; each translation unit is internally consistent.
  • -flto enables -Wodr, which names both definitions and the first differing field.
  • static at namespace scope in a header gives one copy per translation unit — b in nm.
  • Measured: three increments, and the other file saw zero of them.
  • Forgetting inline on a function fails loudly; using static on a variable fails silently.

Questions about this lesson

Log in to ask a question.