第 14 章

程序如何组装:单一定义规则、函数声明与分离编译

一个名字在全程序里只能定义一次,类却可以在每个翻译单元各定义一次;函数可以拆成声明与定义,编译器各管一段、链接器负责拼接。这一章把「编译器错误」与「链接器错误」彻底分开。

How a Program Is Assembled: ODR, Declarations, and Separate Compilation

A name may be defined only once per program, yet a class may be defined once per translation unit; functions split into declaration and definition, the compiler handles one unit at a time, and the linker stitches the rest. This chapter draws a hard line between compiler errors and linker errors.

首发于 2022.02.17中 / EN
系统课程cppodrlinkagetranslation-unitfunctions
语言
字号
目录
篇内目录

这是 31 小时长视频的第 14 章,对应时间轴 14:12:29–15:42:29。这一章从「为什么同名定义会报错」出发,一路讲到函数、声明与定义、翻译单元与链接器,最后把函数拆进 .h 与 .cpp 两个文件。

这一章在讲什么

一个 C++ 程序在变成可执行文件之前,要经过三个阶段:

  1. 预处理:把 #include 的文件原样拷贝进来,得到翻译单元(translation unit)。
  2. 编译:每个翻译单元各自编译成一个目标文件(object file)。
  3. 链接:链接器把所有目标文件拼成一个可执行文件。

单一定义规则(ODR)约束的是「一个名字可以被定义几次」,而编译器与链接器各自只看得见其中一部分信息:编译器看声明,链接器找定义。把这两件事分开,就能解释为什么有些错误叫 redefinition(编译期),有些叫 undefined reference / multiple definition(链接期)。

时间轴

时间内容
14:12:29单一定义规则概述:变量、函数、类、静态成员
14:14:04VS Code:ODR 与自由变量
14:15:26同名变量跨文件定义 → 链接器报 multiple definition
14:16:34函数同样不能重复定义
14:18:54类的例外:可以在多个翻译单元各定义一次
14:20:47同一翻译单元内重复定义类 → 编译错误
14:21:51带静态成员的类:定义放在哪里
14:26:22静态成员与构造函数重复定义 → 链接错误
14:28:35函数是什么:可复用的「机器」
14:29:18函数语法:返回类型、名字、参数、函数体
14:30:39签名 = 函数名 + 参数列表(不含返回类型)
14:31:33调用语法:实参传给形参
14:32:24例子:into_bar、max、say_hello、lucky_number
14:37:13隐式转换:double 传给 int 参数
14:37:45按值传递:函数内改的是副本
14:40:10VS Code:函数实操
14:48:13编写 max 并调用
14:54:37同名同参数的两个函数 → 重定义
14:56:56按值传递的完整演示
15:00:45声明与定义分离
15:04:01函数原型:返回类型 + 名字 + 参数 + 分号
15:08:36只有声明没有定义 → undefined reference
15:15:24编译模型:预处理与翻译单元
15:17:52编译阶段生成目标文件
15:18:22链接阶段拼接目标文件
15:20:02把函数拆到多个文件
15:22:03VS Code:multiple files 项目
15:25:05新建 compare.h 与 compare.cpp
15:29:01定义可以放在任意翻译单元
15:32:02删掉定义后观察链接错误(gcc 与 MSVC)
15:36:51练习:operations.h / operations.cpp

单一定义规则(ODR)

规则本身很短:同一个名字在整个程序里只能有一个定义。但「程序」和「定义」都要精确理解。

自由变量与函数:跨文件只能定义一次

// main.cpp
double weight{7.5};      // 既是声明也是定义

// some_other_file.cpp
double weight{7.5};      // 又一次定义

两处一起编译,链接器会报:

multiple definition of `weight'

注意错误出现的阶段:同一个文件里重复定义是编译错误(redefinition),不同文件里重复定义是链接错误(multiple definition)。区别在于编译器一次只看一个翻译单元,只有链接器才看得到全局。

函数同理:

// main.cpp
double add(double a, double b) { return a + b; }

// some_other_file.cpp
double add(double a, double b) { return a + b; }   // multiple definition

类:唯一允许「每个翻译单元各定义一次」

类定义通常写在头文件里,被多个 .cpp 包含。如果类也必须全程序只定义一次,那头文件根本无法使用。所以 ODR 给类开了例外:

一个类可以在多个翻译单元中各有一个定义,但这些定义必须完全相同(token 序列一致),且每个翻译单元内最多一个。

// point.h
struct Point {
    double m_x{};
    double m_y{};
};
// main.cpp
#include "point.h"

// some_other_file.cpp
#include "point.h"

程序可以正常编译链接。但在同一个 .cpp 里写两遍 struct Point {...},立刻得到 redefinition of 'Point'——例外只放宽了「跨翻译单元」,没有放宽「同一翻译单元内」。

静态成员与成员函数

类的静态成员变量必须在某个地方给出类外定义,而且只能有一处:

// person.h
class Person {
public:
    Person(std::string name, int age);
    void print_info() const;
private:
    std::string m_full_name;
    int m_age{};
    static int s_person_count;
};
// person.cpp
#include "person.h"

int Person::s_person_count{8};        // 唯一一处定义

Person::Person(std::string name, int age)
    : m_full_name{std::move(name)}, m_age{age} {
    ++s_person_count;
}

若在 main.cpp 里再写一遍 int Person::s_person_count{8};,链接器会报两条错误:multiple definition of Person::s_person_count 和构造函数的重复定义。成员函数的定义同样受 ODR 约束,只能出现一次(写在类体内的成员函数默认是 inline,这是另一条例外,后面章节再展开)。

函数:语法与术语

课件用一个类比解释函数:它是一台机器,可以给输入、做处理、可选地给输出,而且可以反复使用。

返回类型 函数名(参数列表) {
    // 函数体
    return 结果;   // 若返回类型不是 void
}

四个组成部分:

部分含义
返回类型函数交回什么;void 表示不返回
函数名调用时使用的名字
参数列表调用者要提供什么;定义时叫形参 parameter
函数体具体做什么

调用时传入的值叫实参 argument:

int max(int a, int b) {        // a、b 是形参
    if (a > b) return a;
    return b;
}

int result = max(10, 20);      // 10、20 是实参

这两个词经常被混用,但在「定义 vs 调用」的语境下区分清楚很有用。

签名:名字 + 参数

函数签名(signature)= 函数名 + 参数列表,不包含返回类型。因此下面两个写法冲突:

int  max(int a, int b) { return a > b ? a : b; }
void max(int x, int y) { }        // redefinition:签名相同

改参数名(a,b 改成 x,y)没有用——签名只看类型和个数,不看名字。返回类型不同也不行。

值传递:函数拿到的是副本

double increment_and_multiply(double a, double b) {
    ++a;
    ++b;
    return a * b;
}

int main() {
    double h{3}, i{4};
    double result = increment_and_multiply(h, i);
    // h 仍是 3,i 仍是 4
}

调用时编译器把 h、i 的副本交给 a、b。函数里 ++a 改的是副本,函数结束副本就销毁,原变量不受影响。想修改调用者的变量,需要传指针或引用——这是第 15 章的主题。

隐式转换

int minimum(int a, int b) { return a < b ? a : b; }

double f{3.9}, g{4.1};
int m = minimum(f, g);    // 编译通过,f、g 被截断为 3、4

编译器发现实参类型与形参不匹配时,会自行插入转换。这里 3.9 被截断成 3 而不是四舍五入,结果可能与你预期不同。隐式转换不会报错,只会悄悄发生,这是排错困难的常见来源。

声明与定义分离

原型(prototype)

把函数头单独写出来、末尾加分号,就是函数声明:

int max(int a, int b);      // 声明,也叫原型

原型需要出现在调用之前。上一章之所以不写原型也能编译,是因为函数定义本身写在 main 之前——定义同时充当了声明。

int max(int a, int b);      // 声明

int main() {
    int m = max(5, 3);      // 编译器此时只需要知道签名
    return 0;
}

int max(int a, int b) {     // 定义可以放在后面
    return a > b ? a : b;
}

只有声明会怎样

删掉定义、只留声明,编译阶段仍然通过,因为编译器只需要签名就能生成调用代码;但链接阶段找不到函数体:

undefined reference to `max()'

在 MSVC 下对应 unresolved external symbol。看到 undefined reference 就该想到:链接器在找定义,没找到。

一个容易忽略的细节:声明里的参数名可以省略。

int max(int, int);          // 合法

课件仍然建议保留参数名,让声明自解释。

编译模型:三个阶段

main.cpp ──预处理──> 翻译单元 ──编译──> main.o ─┐
other.cpp ─预处理──> 翻译单元 ──编译──> other.o ─┼──链接──> rooster
...                                              ┘
  • 预处理:把 #include <iostream> 替换成 iostream 文件的内容,宏展开,条件编译。产物是完整的翻译单元。
  • 编译:每个翻译单元独立编译成一个目标文件(.o / .obj),里面是机器码加上符号表。
  • 链接:链接器把所有目标文件拼成一个可执行文件,顺便解析跨文件的符号引用。

理解这个模型,才能理解错误的归属:

现象阶段原因
redefinition of ...编译同一翻译单元内重复定义
undefined reference链接有声明,全程序找不到定义
multiple definition链接多个翻译单元各定义了一次

gcc 的链接错误末尾常见 ld returned 1 exit status,ld 就是链接器。

拆到多个文件:头文件与实现文件

组织方式:

compare.h      // 声明
compare.cpp    // 定义
main.cpp       // #include "compare.h",调用
// compare.h
#pragma once
int max(int a, int b);
int min(int a, int b);
// compare.cpp
#include "compare.h"

int max(int a, int b) { return a > b ? a : b; }
int min(int a, int b) { return a < b ? a : b; }
// main.cpp
#include <iostream>
#include "compare.h"

int main() {
    std::cout << max(134, 56) << "\n";
    std::cout << min(146, 23) << "\n";
}

两个必须记住的点:

  1. 自己的头文件用双引号:#include "compare.h";标准库用尖括号:#include <iostream>。
  2. 定义不必和同名头文件放在一起。把 max 的定义挪到任意 .cpp 文件,链接器仍能找到;头文件只负责让每个翻译单元「看见」声明。

若把 min 的定义注释掉,链接器会遍历所有翻译单元后报 undefined reference to min——这正是上一节错误的实际版。

本章结论

  • ODR:自由变量与函数全程序只能定义一次;类可在多个翻译单元各定义一次,但必须完全相同,且同一翻译单元内只能有一次。
  • 类的静态成员变量要在类外给出唯一定义;成员函数定义同样只能出现一次。
  • 函数签名 = 名字 + 参数列表,不含返回类型;参数名不影响签名。
  • 按值传递传的是副本,函数内修改不影响调用者。
  • 隐式转换会静默发生,必要时用显式转换或改参数类型。
  • 原型让调用方无需看到定义;缺定义会在链接期报 undefined reference。
  • 编译 = 预处理 + 编译 + 链接;编译只看单个翻译单元,链接才看全局。

理解检验

1. 为什么同一个文件里写两次 double weight{7.5}; 报的是编译错误,写在不同文件里却报链接错误?

参考答案:编译器一次只处理一个翻译单元,它能看到同一个 .cpp 里出现了两次 weight 的定义,于是直接报 redefinition。当两处定义分处不同 .cpp 时,每个翻译单元单独看都没有问题,编译器放行;直到链接阶段,链接器合并符号表,发现两个同名的强符号,才报 multiple definition。错误的归属由「谁看得见这两个定义」决定。

2. 类为什么被允许在多个翻译单元中重复定义?这种例外有边界吗?

参考答案:类的定义通常写在头文件里,而头文件会被多个 .cpp 包含;如果类也必须全程序只定义一次,头文件就无法使用。因此 ODR 允许类在每个翻译单元中各有一份定义,条件是各份定义完全一致。边界是:同一翻译单元内仍然只能有一次,把 struct Point 在同一个 .cpp 里写两遍,编译器立刻报 redefinition。

3. int max(int a, int b); 与 int max(int a, int b) { return a > b ? a : b; } 的区别是什么?只保留前者会发生什么?

参考答案:前者是声明(原型),只告诉编译器签名,不产生函数体;后者是定义,包含函数体并生成代码。只保留声明时,编译阶段仍能通过,因为调用处只需要签名;但链接器在所有目标文件里找不到 max 的定义,报 undefined reference to max()(MSVC 下为 unresolved external symbol)。

4. 函数签名包含返回类型吗?下面两行能否共存?

int  max(int a, int b);
void max(int x, int y);

参考答案:签名只包含函数名与参数列表的类型和个数,不包含返回类型,参数名也不参与。两行的签名都是 max(int, int),因此不能共存;第二个会被判为重复声明/定义,即使参数名不同、返回类型不同也一样。

5. 下面这段代码输出什么?为什么?

void bump(int a) { ++a; }

int main() {
    int x{5};
    bump(x);
    std::cout << x << "\n";
}

参考答案:输出 5。bump 的参数是按值传递,调用时把 x 的副本交给 a,++a 只修改副本;函数返回后副本销毁,x 保持原值。若要在函数内修改 x,参数应写成 int& a 或 int* a。

6. 把 min 的函数体从 compare.cpp 删掉、但保留 compare.h 里的声明,构建会出现什么错误?它发生在哪个阶段?

参考答案:链接阶段报 undefined reference to min(int, int)(MSVC 下 unresolved external symbol)。编译阶段可以通过,因为 main.cpp 只需要声明就能生成调用;链接器在合并所有目标文件、解析 min 的引用时找不到它的定义,于是报错。

与前后章的关系

上一章处理字符与字符串,开始接触「类型把细节封装起来」;本章把封装推到文件层面:头文件只暴露接口,实现藏在 .cpp 里。下一章专门讨论函数参数的三种传递方式(值、指针、引用),这正是本章「按值传递只给副本」留下的问题。

This is chapter 14 of a 31-hour single video, covering 14:12:29–15:42:29. It starts from "why does a duplicated definition fail", moves through functions and the declaration/definition split, explains translation units and the linker, and ends by splitting functions into .h and .cpp files.

What this chapter covers

Before a C++ program becomes an executable it passes through three stages:

  1. Preprocessing: every #include is replaced by the file's contents, producing a translation unit.
  2. Compilation: each translation unit is compiled independently into an object file.
  3. Linking: the linker merges all object files into one executable.

The one definition rule (ODR) constrains how many times a name may be defined, while the compiler and the linker each see only part of the picture: the compiler sees declarations, the linker looks for definitions. Separating those two explains why some failures are redefinition (compile time) and others are undefined reference or multiple definition (link time).

Timeline

TimeTopic
14:12:29ODR overview: variables, functions, classes, static members
14:14:04In VS Code: the ODR and a free-standing variable
14:15:26Same variable defined in two files → linker reports multiple definition
14:16:34Functions cannot be defined twice either
14:18:54The class exception: once per translation unit
14:20:47Two class definitions in one translation unit → compile error
14:21:51A class with a static member: where the definition goes
14:26:22Duplicated static member and constructor → linker error
14:28:35What a function is: a reusable machine
14:29:18Function syntax: return type, name, parameters, body
14:30:39Signature = name + parameter list (no return type)
14:31:33Calling syntax: arguments fill parameters
14:32:24Examples: into_bar, max, say_hello, lucky_number
14:37:13Implicit conversion: passing double to an int parameter
14:37:45Pass by value: the function modifies a copy
14:40:10In VS Code: hands-on functions
14:48:13Writing and calling max
14:54:37Two functions with the same signature → redefinition
14:56:56A full demonstration of pass by value
15:00:45Separating declaration from definition
15:04:01The prototype: return type + name + parameters + semicolon
15:08:36Declaration without definition → undefined reference
15:15:24Compilation model: preprocessing and translation units
15:17:52Compilation produces object files
15:18:22Linking stitches object files together
15:20:02Splitting functions across files
15:22:03In VS Code: the multiple-files project
15:25:05Creating compare.h and compare.cpp
15:29:01Definitions may live in any translation unit
15:32:02Removing a definition to observe linker errors (gcc and MSVC)
15:36:51Exercise: operations.h / operations.cpp

The one definition rule

The rule is short: a name may have only one definition in the whole program. Both "program" and "definition" need precision.

Free-standing variables and functions: one definition across all files

// main.cpp
double weight{7.5};      // both a declaration and a definition

// some_other_file.cpp
double weight{7.5};      // another definition

Compiled together, the linker reports:

multiple definition of `weight'

Note which stage produced the error. Two definitions in one file are a compile error (redefinition); two definitions in different files are a linker error (multiple definition). The compiler sees one translation unit at a time; only the linker sees the whole program.

Functions behave identically:

// main.cpp
double add(double a, double b) { return a + b; }

// some_other_file.cpp
double add(double a, double b) { return a + b; }   // multiple definition

Classes: the one exception

Class definitions live in headers and are included by many .cpp files. If a class also had to be defined once per program, headers would be unusable. So the ODR carves out an exception:

A class may be defined once in each translation unit, provided the definitions are identical (same token sequence) and there is at most one per translation unit.

// point.h
struct Point {
    double m_x{};
    double m_y{};
};
// main.cpp
#include "point.h"

// some_other_file.cpp
#include "point.h"

This compiles and links. But writing struct Point {...} twice in the same .cpp immediately yields redefinition of 'Point': the exception relaxes "across translation units", not "within one translation unit".

Static members and member functions

A class's static member variable needs exactly one out-of-class definition:

// person.h
class Person {
public:
    Person(std::string name, int age);
    void print_info() const;
private:
    std::string m_full_name;
    int m_age{};
    static int s_person_count;
};
// person.cpp
#include "person.h"

int Person::s_person_count{8};        // the single definition

Person::Person(std::string name, int age)
    : m_full_name{std::move(name)}, m_age{age} {
    ++s_person_count;
}

Adding int Person::s_person_count{8}; in main.cpp as well produces two linker errors: multiple definition of Person::s_person_count and a duplicate constructor. Member function definitions are bound by the ODR too — one definition per program. (Defining a member function inside the class body makes it implicitly inline, an exception covered in later chapters.)

Functions: syntax and vocabulary

The lecture uses a physical analogy: a function is a machine that accepts input, performs processing, may return output, and can be reused indefinitely.

return_type function_name(parameter_list) {
    // body
    return result;   // unless the return type is void
}

Four parts:

PartMeaning
Return typeWhat the function gives back; void means nothing
NameHow callers refer to it
Parameter listWhat callers must supply; called parameters in the definition
BodyWhat it does

Values supplied at the call site are arguments:

int max(int a, int b) {        // a and b are parameters
    if (a > b) return a;
    return b;
}

int result = max(10, 20);      // 10 and 20 are arguments

The two words are often used interchangeably, but the distinction is useful when talking about definition versus call.

Signature: name plus parameters

A function's signature is its name plus its parameter list, and it excludes the return type. So these two conflict:

int  max(int a, int b) { return a > b ? a : b; }
void max(int x, int y) { }        // redefinition: same signature

Renaming parameters from a,b to x,y does not help: the signature records types and count, not names. A different return type does not help either.

Pass by value: the function gets a copy

double increment_and_multiply(double a, double b) {
    ++a;
    ++b;
    return a * b;
}

int main() {
    double h{3}, i{4};
    double result = increment_and_multiply(h, i);
    // h is still 3, i is still 4
}

The compiler hands a and b copies of h and i. ++a modifies a copy that dies when the function returns, so the caller's variables are untouched. Modifying the caller's variables requires pointers or references — the subject of chapter 15.

Implicit conversions

int minimum(int a, int b) { return a < b ? a : b; }

double f{3.9}, g{4.1};
int m = minimum(f, g);    // compiles; f and g are truncated to 3 and 4

When argument types do not match the parameters, the compiler inserts a conversion on its own. Here 3.9 is truncated to 3, not rounded, which may not be what you intended. Implicit conversions do not raise errors; they happen silently, making them a common source of hard-to-find bugs.

Separating declaration from definition

The prototype

Writing the function header alone, terminated by a semicolon, gives a declaration:

int max(int a, int b);      // declaration, also called a prototype

The prototype must appear before any call. The previous chapter compiled without one only because the definition sat above main — a definition doubles as a declaration.

int max(int a, int b);      // declaration

int main() {
    int m = max(5, 3);      // the compiler only needs the signature here
    return 0;
}

int max(int a, int b) {     // the definition may come later
    return a > b ? a : b;
}

What happens with only a declaration

Delete the definition and keep the declaration, and compilation still succeeds because the compiler only needs the signature to emit the call. Linking then fails:

undefined reference to `max()'

MSVC phrases it as unresolved external symbol. undefined reference means the linker went looking for a definition and did not find one.

One detail: parameter names in a declaration may be omitted.

int max(int, int);          // legal

The lecture still recommends keeping them for self-documentation.

The compilation model: three stages

main.cpp ──preprocess──> translation unit ──compile──> main.o ─┐
other.cpp ─preprocess──> translation unit ──compile──> other.o ─┼──link──> rooster
...                                                              ┘
  • Preprocessing: #include <iostream> is replaced by the contents of iostream; macros expand; conditionals resolve. The result is a complete translation unit.
  • Compilation: each translation unit is compiled independently into an object file (.o / .obj) holding machine code plus a symbol table.
  • Linking: the linker merges object files into one executable and resolves cross-file symbol references.

This model explains which stage owns which error:

SymptomStageCause
redefinition of ...compileduplicate definition in one translation unit
undefined referencelinkdeclaration exists, no definition anywhere
multiple definitionlinkone definition in each of several units

gcc link failures usually end with ld returned 1 exit status; ld is the linker.

Splitting across files: headers and implementation

compare.h      // declarations
compare.cpp    // definitions
main.cpp       // #include "compare.h" and call
// compare.h
#pragma once
int max(int a, int b);
int min(int a, int b);
// compare.cpp
#include "compare.h"

int max(int a, int b) { return a > b ? a : b; }
int min(int a, int b) { return a < b ? a : b; }
// main.cpp
#include <iostream>
#include "compare.h"

int main() {
    std::cout << max(134, 56) << "\n";
    std::cout << min(146, 23) << "\n";
}

Two rules to remember:

  1. Your own headers take double quotes: #include "compare.h"; standard headers take angle brackets: #include <iostream>.
  2. A definition need not live next to its header. Move max's definition to any .cpp and the linker still finds it; the header only makes the declaration visible to each translation unit.

Comment out min's definition and the linker walks every translation unit before reporting undefined reference to min — the previous section's error in practice.

Takeaways

  • ODR: free-standing variables and functions get one definition per program; a class may be defined once per translation unit if all copies are identical, and at most once per unit.
  • Static member variables need one out-of-class definition; member function definitions also occur once.
  • A signature is the name plus parameter list, excluding the return type; parameter names do not matter.
  • Pass by value hands over a copy; changes inside the function do not reach the caller.
  • Implicit conversions happen silently; use explicit casts or change the parameter type when needed.
  • A prototype lets callers compile without the definition; a missing definition surfaces at link time as undefined reference.
  • Compilation is preprocessing + compilation + linking; the compiler sees one unit, the linker sees everything.

Comprehension check

1. Why is writing double weight{7.5}; twice in one file a compile error, while doing it in two files is a linker error?

Reference answer: the compiler processes one translation unit at a time and can see both definitions inside the same .cpp, so it reports redefinition immediately. When the two definitions sit in different .cpp files, each unit looks fine on its own and compilation succeeds; only at link time does the linker merge symbol tables and discover two strong symbols with the same name, producing multiple definition. The stage that reports the error is whichever tool can see both definitions.

2. Why are classes allowed to be defined in multiple translation units, and where does that exception stop?

Reference answer: class definitions live in headers that many .cpp files include; if a class could be defined only once per program, headers would be unusable. The ODR therefore permits one definition per translation unit, provided all copies are identical. The exception stops at the translation unit boundary: writing struct Point twice in the same .cpp still triggers redefinition.

3. What is the difference between int max(int a, int b); and int max(int a, int b) { return a > b ? a : b; }, and what happens if only the first is kept?

Reference answer: the first is a declaration (prototype) that only tells the compiler the signature; the second is a definition with a body that generates code. With only the declaration, compilation still succeeds because call sites need only the signature, but the linker finds no definition in any object file and reports undefined reference to max() (MSVC: unresolved external symbol).

4. Does a function's signature include the return type? Can these two lines coexist?

int  max(int a, int b);
void max(int x, int y);

Reference answer: the signature consists of the function name and the types and count of its parameters; it excludes the return type, and parameter names do not participate. Both lines have the signature max(int, int), so they cannot coexist — the second is rejected as a redefinition even though the parameter names and return types differ.

5. What does this program print, and why?

void bump(int a) { ++a; }

int main() {
    int x{5};
    bump(x);
    std::cout << x << "\n";
}

Reference answer: it prints 5. bump takes its parameter by value, so a receives a copy of x; ++a modifies only the copy, which is destroyed on return, leaving x unchanged. To modify x, the parameter would have to be int& a or int* a.

6. If min's body is deleted from compare.cpp while its declaration remains in compare.h, what error appears and at which stage?

Reference answer: a link-time error, undefined reference to min(int, int) (MSVC: unresolved external symbol). Compilation succeeds because main.cpp needs only the declaration to emit the call; the linker, while merging object files and resolving the reference to min, finds no definition and fails.

Relation to neighbouring chapters

The previous chapter dealt with characters and strings, introducing the idea of a type hiding details. This chapter pushes the same idea to the file level: headers expose an interface, .cpp files hide the implementation. The next chapter examines the three ways to pass function arguments — by value, by pointer, and by reference — which is exactly the problem left open by "pass by value hands over a copy".

来源