我第一次被链接器骂。
是在一个很小的程序里。
代码没几行。
错误倒是很长。
multiple definition of g_errors
我还以为自己哪里写重了。
后来才反应过来。
我把变量写进了头文件。
当年:#include 不是“引用”,它更像复制粘贴
那会儿我刚从 C 过来。
脑子里默认。
头文件就是“大家都能用的地方”。
后来我才逼自己记住一件事。
#include 基本就是把文本塞进来。
你在头文件里写的变量定义。
最后会出现在每个 include 它的 .cpp 里。
链接器把目标文件拼起来的时候。
符号一撞车。
它只能报错。
事故现场:我在头文件里放了一个“全局错误表”
我当时写了个小服务。
想把“错误码 -> 文案”做成一个表。
图省事。
我就这么写了。
// errors.h
#include <map>
#include <string>
std::map<int, std::string> g_errors = {
{1, "bad request"},
{2, "unauthorized"},
};
只编一个 .cpp 的时候。
你甚至可能觉得它挺合理。
等工程里第二个 .cpp 也 #include "errors.h"。
链接那一步就炸了。
最小复现:三份文件,稳定复现一次 multiple definition
为了不靠感觉。
我后来写了个最小复现。
三份文件就够。
// errors.h
int g_errorCount = 0;
两个 .cpp 都 include 它。
// a.cpp
#include "errors.h"
void incA() { ++g_errorCount; }
// b.cpp
#include "errors.h"
void incB() { ++g_errorCount; }
你只要把这两个文件一起编进一个程序。
链接器就会报同类错误。
g++ -std=c++17 -c a.cpp
g++ -std=c++17 -c b.cpp
g++ a.o b.o
你看到的通常是类似这样的信息。
multiple definition of g_errorCount
先把两个词讲清楚:声明、定义、还有 ODR
我当年卡住的点。
其实不是 inline。
而是“声明”和“定义”。
如果你只学过 C。
这一段可以先用 C 的例子来对齐。
extern int g;。
这是声明。
int g = 0;。
这是定义。
定义会真的把对象放出来。
翻译单元(translation unit)你先当成。
一个 .cpp 加上它 include 进来的所有内容。
linkage(链接属性)决定了。
这个名字能不能跨 .cpp 被别的目标文件看见。
external linkage。
全程序都能看到。
internal linkage。
只在当前翻译单元可见。
ODR 在这里先记一句话就行。
external linkage 的变量。
全程序只能有一个定义。
我先打的补丁:加 static,让链接器闭嘴
我的第一反应很直觉。
既然它嫌“重复”。
那我就让它们别互相看见。
// errors.h
static int g_hits = 0;
这招确实能让链接器不骂。
因为 static 让这个变量只在“当前翻译单元”可见。
说人话。
每个 .cpp 都有自己的一份 g_hits。
你以为是“一个全局计数器”。
实际变成了“每个 .cpp 各记一份”。
彼此互不相干。
坑也在这里。
我以为我拿到的是“全局计数器”。
实际拿到的是“每个 cpp 各自记一份”。
// a.cpp
#include "errors.h"
void onReqA() { ++g_hits; }
// b.cpp
#include "errors.h"
int readHitsFromB() { return g_hits; }
你在 a.cpp 里加。
在 b.cpp 里读。
读到的可能还是 0。
这时候你会开始怀疑人生。
你以为是线程问题。
其实只是“一变量多份拷贝”。
如果你不喜欢 static 这个词。
匿名命名空间也是同一类手段。
本质还是 internal linkage。
旧办法(也是 C 的老路):extern + 一个 cpp 给出唯一实体
再后来我学乖了。
头文件只放声明。
// errors.h
#include <map>
#include <string>
extern std::map<int, std::string> g_errors;
然后在某个 .cpp 里放定义。
// errors.cpp
#include "errors.h"
std::map<int, std::string> g_errors = {
{1, "bad request"},
{2, "unauthorized"},
};
这招很稳。
它也很“工程”。
但你得保证。
全工程里只有一个 .cpp 去定义它。
而且声明和定义一直保持一致。
你还得处理。
库拆分、平台差异、条件编译。
然后你会发现。
只要你想做 header-only。
这种写法就开始别扭。
我真正想要的性质:头文件能写定义,但全程序只有一个对象
我当时脑子里要的其实是这个。
我希望:
我把变量写在头文件里。
谁 include 都行。
但整个程序里。
它还是同一个对象。
它的地址是同一个。
它的状态也是同一个。
这听起来像一句废话。
但它正好卡在“语言没承诺”的那条缝里。
C++17:inline 变量,把“合并成一个”写进标准
C++17 做的事其实很朴素。
它把 inline 从函数推广到了变量。
我可以继续把定义写在头文件里。
// errors.h
#include <map>
#include <string>
inline std::map<int, std::string> g_errors = {
{1, "bad request"},
{2, "unauthorized"},
};
每个 .cpp 仍然会看到一份定义。
但规则变了。
这些定义被要求是“同一个变量”的多次出现。
链接器必须把它们合并成一个实体。
一句话。
你可以多处写。
但程序里只有一个。
但它也有坑:inline 变量要求“每一处定义都长得一模一样”
inline 解决的是“多处定义怎么合并”。
它不允许你在不同翻译单元里。
给出不同版本的定义。
最常见的事故来源。
是条件编译。
// level.h
#ifdef DEBUG
inline int g_level = 1;
#else
inline int g_level = 0;
#endif
如果某个 .cpp 编译时开了 -DDEBUG。
另一个没开。
它们看到的 g_level 定义就不一样。
这属于 ODR 违反。
更麻烦的是。
它不一定当场报错。
有时候就是运行期的怪行为。
它的渊源:链接器早就会合并,标准只是把它说清楚
这个需求早就有。
比如 header-only 模板库。
比如为了性能把实现放头文件。
很多链接器也早就能做“弱符号/COMDAT 合并”。
不同平台语法不一样。
可移植性很差。
C++17 做的贡献是。
把这件事变成语言语义。
不用再靠某个平台的扩展凑合。
一个常见坑位:头文件里想放“非字面量常量对象”
int 这种小常量。
你可能一直没出事。
但只要你想放的是 std::string 这种对象。
以前就很难优雅。
// config.h
#include <string>
inline const std::string kDefaultHost = "localhost";
这在 C++17 之前。
你通常只能回到 extern + cpp。
现在它终于可以像“头文件里的函数”一样自然。
class 里的那根刺:static 成员终于不用在类外再补一刀
更常见的地方其实在类里。
以前你写一个 static 成员。
声明在类里。
定义还得在类外再写一次。
struct Counter {
static int total;
};
int Counter::total = 0;
你少写这行定义。
链接器照样骂。
你多写一份。
它也照样骂。
C++17 以后。
struct Counter {
inline static int total = 0;
};
类内定义。
到处 include。
但仍然是一个 Counter::total。
这对 header-only 代码特别友好。
trade-off:inline 变量解决 ODR,不解决“全局初始化顺序”
我后来也吃过另一种亏。
全局对象的动态初始化顺序。
inline 变量并不会帮你把这件事变成确定的。
如果一个 inline 变量的初始化。
依赖另一个翻译单元里的全局对象。
你仍然可能踩到“先后顺序不确定”的老坑。
所以我现在的习惯是。
需要“全程序唯一”的对象。
又确实想放头文件里。
才用 inline。
如果我真正想要的是“每个 cpp 各一份”。
那我就老老实实用 static(或者匿名命名空间)。
如果我在意初始化时机。
我更倾向于把它包进函数里。
用局部静态。
一个更实用的判断法
如果你要的是“全程序唯一的那一个对象”。
优先考虑 extern + cpp,或者 C++17 的 inline。
如果你要的是“每个 .cpp 各自一份”。
用 static(或匿名命名空间)。
如果你要的是“可控的初始化时机”。
把对象塞进函数里。
让局部静态去管初始化。
关键结论
inline 变量做的事很简单。
你可以把定义写在头文件里。
但标准保证。
它们会合并成同一个实体。