前言
C++ 的编译模型和大多数人的直觉不一样:编译器每次只看到一个翻译单元(translation unit,TU),也就是一个 .cpp 文件连同它包含的所有头文件展开后的结果。它完全不知道别的 .cpp 里写了什么,两边的联系全靠链接器在最后一步用符号(symbol)对接起来。多文件编程里的绝大多数报错,根源都在这个"编译期彼此看不见、链接期才见面"的模型上。
最常见的两个误解:
第一个是把声明(declaration)和定义(definition)当成一回事。声明是"我保证有这么个东西",定义是"这就是它的实体"。一个函数可以在很多个 .cpp 里被声明,但整个程序里只能被定义一次——这条规则叫ODR(One Definition Rule,单一定义规则)。违反它的后果就是链接期那句让人摸不着头脑的multiple definition of ...或者 MSVC 的LNK2005。
第二个误解是把#include当成"导入模块"。它不是。#include是文本替换:预处理器把头文件内容原样粘进当前位置。所以头文件里每多一行可执行的定义,每个包含它的 .cpp 就多一份。
本文以 C++17 为基准,用一个三文件的小工程把编译、链接、链接性、名字修饰这几件事讲清楚,代码在 GCC 13 / Clang 17 / MSVC 19.3x 上均可编译。
一、声明、定义与链接性
在文件作用域里,同一个名字可以有外部链接(external linkage)、内部链接(internal linkage)或无链接。这决定了链接器会不会把它暴露给别的翻译单元。
| 写法 | 是声明还是定义 | 链接性 |
|---|---|---|
int add(int, int); | 声明 | 外部 |
int add(int a, int b) { return a + b; } | 定义 | 外部 |
extern int g_count; | 声明 | 外部 |
int g_count = 0; | 定义 | 外部 |
static int helper() { ... } | 定义 | 内部 |
namespace { int helper() { ... } } | 定义 | 内部 |
const int kMax = 10;(命名空间作用域) | 定义 | 内部 |
inline int f() { ... } | 定义 | 外部,允许在多个 TU 中出现同一份定义 |
inline constexpr int kMax = 10;(C++17) | 定义 | 外部,全程序唯一 |
这里有两个 C 和 C++ 的关键差别,务必分清:
- 在C里,文件作用域的
const int kMax = 10;是外部链接的;在C++里它是内部链接。所以 C++ 里把const常量写进头文件,每个 TU 各拿一份拷贝,既不报重复定义,也会让不同 TU 里&kMax的地址不同。 - 在C里函数内不能定义函数;C++ 里 class 定义可以放进头文件,因为在类体内定义的成员函数是隐式
inline的。
C++17 新增了inline 变量,这才让"在头文件里定义一个真正的全局变量"变成可能:inline修饰的变量在全程序里是同一个实体,多个 TU 都能看到同一份定义而不违反 ODR。
二、一个最小可编译的三文件工程
下面这个工程把该注意的点都放进去了:函数声明与定义分离、类定义在头文件、模板定义在头文件、C++17 inline 变量。
math_utils.h
#ifndef MATH_UTILS_H #define MATH_UTILS_H #include <string> // 声明:定义在 math_utils.cpp 里 int add(int a, int b); // 类定义放在头文件;类体内定义的成员函数是隐式 inline,不会重复定义 class Accumulator { public: explicit Accumulator(int init = 0) : sum_(init) {} void add(int v) { sum_ += v; } int sum() const { return sum_; } private: int sum_; }; // C++17 inline 变量:头文件里定义,全程序只有一份 inline constexpr int kMaxItems = 128; // 函数模板的定义必须对使用点可见,所以只能放头文件 template <class T> T twice(T v) { return v + v; } #endif // MATH_UTILS_Hmath_utils.cpp
#include "math_utils.h" // 这里才是 add 的唯一一份定义 int add(int a, int b) { return a + b; }main.cpp
#include "math_utils.h" #include <iostream> #include <string> int main() { Accumulator acc(10); acc.add(kMaxItems); std::cout << add(acc.sum(), 1) << '\n'; // 139 std::cout << twice(std::string("ab")) << '\n'; // abab 后换行 return 0; }编译分两种方式。一次性编译:
g++ -std=c++17 -Wall -Wextra -pedantic math_utils.cpp main.cpp -o app分步编译(更接近真实工程,便于只重编改动的文件):
g++ -std=c++17 -c math_utils.cpp -o math_utils.o g++ -std=c++17 -c main.cpp -o main.o g++ math_utils.o main.o -o app如果用的是 CMake,等价的最小配置是:
cmake_minimum_required(VERSION 3.16) project(demo CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(app math_utils.cpp main.cpp)三、链接期报错怎么读
多文件编程里 90% 的困惑来自身链接器的两类消息。它们的含义完全相反:
| 报错 | 含义 | 常见原因 |
|---|---|---|
undefined reference to 'add(int, int)'(GCC/Clang)/LNK2019(MSVC) | 有人用了这个符号,但没人定义 | 忘了把 .cpp 加进编译;只写了声明;模板定义被放进了 .cpp |
multiple definition of 'add(int, int)'(GCC/Clang)/LNK2005(MSVC) | 同一个符号被定义了不止一次 | 把非 inline 的函数定义或全局变量定义写进了头文件 |
GCC 的报错里会带上两个文件名,告诉你这两个定义分别来自哪个目标文件;MSVC 的LNK2005则会顺带给出already defined in ...的行。看到undefined reference的第一反应应该是"这个符号的定义在哪个 .o 里",而不是去改头文件。
模板是undefined reference的一个高频陷阱,原因和上面说的编译模型直接相关:编译器在实例化点必须看到模板的定义。如果你把模板定义放进impl.cpp,而在main.cpp里调用它,main.cpp这个 TU 里只有声明,编译器认为"实例化留给别处",链接时又找不到,于是报undefined reference。三种解法:
- 把模板定义放回头文件(最常用)。
- 在
impl.cpp里做显式实例化:template std::string twice<std::string>(std::string);,这样该 TU 里就会真的产生这个符号。 - 在头文件里用
extern template(C++11 起)告诉其他 TU"别在本 TU 实例化,去别处找",以缩短编译时间。
四、名字修饰与 C 互调
C 和 C++ 编译出的符号名不一样,原因是C++ 支持重载:add(int,int)和add(double,double)必须能共存,所以编译器要把参数类型编码进符号名,这就是名字修饰(name mangling)。C 没有重载,符号名就是函数名本身。
在 Linux 上用 Itanium ABI(GCC / Clang 在 Linux 上遵循的 C++ ABI)时,int add(int, int)修饰后的符号名是_Z3addii:_Z是前缀,3add是"长度为 3 的名字 add",ii是两个int参数。MSVC 用的是另一套修饰规则,名字会长得多。
用nm -C就能看到目标文件里的符号(-C表示按 C++ 规则反修饰成可读形式):
g++ -std=c++17 -c math_utils.cpp -o math_utils.o nm -C math_utils.o | grep -i add一个提供 C 接口的头文件应当是这么写的:
/* c_abi.h —— 既能被 C 编译,也能被 C++ 编译 */ #ifndef C_ABI_H #define C_ABI_H #ifdef __cplusplus extern "C" { #endif int c_add(int a, int b); /* 实现在一个 .c 文件里 */ #ifdef __cplusplus } #endif #endif /* C_ABI_H */extern "C"的作用只有一个:关掉名字修饰,并采用 C 的调用约定。它不会"把 C++ 变成 C",函数体里照样可以写 C++。需要注意两点:extern "C"里的函数不能重载(重载会撞名),也不能直接套在模板上;extern "C"只影响链接规范,不影响类型系统,所以头文件里还得配#ifdef __cplusplus让它对 C 编译器也合法——因为extern "C"这个写法本身在 C 里根本不存在。
反过来,C 代码调 C++ 时,常见做法是提供一个extern "C"的薄封装层,在 .cpp 里实现,头文件用上面的条件编译暴露给 C 侧。
常见坑点
| 场景 | ❌ 错误写法 | ✅ 正确写法 | 说明 |
|---|---|---|---|
| 头文件里放函数定义 | 头文件里直接写int add(int a,int b){return a+b;} | 只留声明,定义放 .cpp;确需放头文件就标inline | 被两个 .cpp 包含就是multiple definition |
| 头文件里放全局变量 | int g_count = 0;写在头文件 | extern int g_count;加一处定义,或 C++17inline int g_count = 0; | 前者每个 TU 一份,链接期冲突 |
| 模板定义分离 | 模板定义写进 .cpp,头文件只有声明 | 模板定义放头文件,或在 .cpp 里显式实例化 | 实例化点看不到定义就是undefined reference |
| 头文件被重复包含 | 头文件没有 include guard | #ifndef/#define/#endif,或#pragma once | 类、结构体被重复定义会直接编译失败 |
| 循环包含 | a.h 包含 b.h,b.h 又包含 a.h | 一处改成前向声明class B;,需要用到成员时在 .cpp 里再包含 | 前向声明只能用于指针、引用和参数类型 |
| 默认参数写两遍 | 声明和定义里都写= 0 | 只在声明处写一次 | GCC 会报 "default argument given for parameter after previous specification" |
| 依赖传递包含 | main.cpp 用了std::vector却不包含<vector>,靠别的头文件带进来 | 谁用谁包含 | 哪天头文件改了包含关系,代码就突然编不过 |
头文件里using namespace | 头文件顶层写using namespace std; | 在 .cpp 里写,或只using std::string; | 污染会扩散到所有包含者,重载决议可能被改变 |
关于#pragma once:它不是 ISO C++ 标准的一部分,但 GCC、Clang、MSVC 都支持。可移植性要求高时用标准 include guard 更稳妥;#pragma once的好处是不用费心给宏起不重名的名字,而且编译器可以在文件级做去重,避免打开文件的开销。
总结
| 概念 | 要点 |
|---|---|
| 翻译单元 | 一个 .cpp 加上它展开后的所有头文件;编译器只看得到这一个 |
| 声明与定义 | 声明可以有很多份,定义在整个程序里只有一份(ODR) |
| 链接性 | 文件作用域的static和匿名命名空间是内部链接;C++ 里const常量默认也是内部链接 |
inline | 函数和 C++17 起的变量:允许多个 TU 持有同一份定义 |
| 模板 | 定义必须对使用点可见,否则链接期undefined reference |
extern "C" | 关掉名字修饰,用于 C 与 C++ 互调;本质是链接规范问题 |
多文件编程不需要背很多规则,抓住一句话就够了:编译期每个翻译单元是孤岛,链接期靠符号把孤岛连起来。凡是"编译器怎么看不到"的困惑,答案通常是"它在另一个 TU 里";凡是"重复定义"的困惑,答案通常是"这段定义被预处理器复制了多份"。