78 / 80 · C++17 · 约 12 分钟
ODR、头文件定义与 inline 的真正作用
头文件可以被多个翻译单元包含,但其中的定义必须满足单一定义规则。inline 主要解决跨翻译单元的定义与实体身份,不是强制展开调用;模板可见性、名字查找和构建宏的一致性同样属于接口正确性。
先区分声明、定义与实体
声明告诉编译器一个名字是什么;定义还提供函数体、对象或类型的完整描述。对于普通非 inline、非模板外部函数,程序需要符合 ODR 的唯一定义;被 odr-use 的对象或函数不能只有声明。头文件保护只能防止同一翻译单元重复展开,不能阻止 a.cpp 与 b.cpp 各自产生一份定义。
例如头文件写 extern int counter;,并在唯一的 state.cpp 写 int counter = 0;,多个调用者就访问同一个对象。若把带初始化器的 extern int counter = 0; 放进头文件,它仍是定义。不要用 static 随手消除链接报错:它通常会把一个共享状态改成各翻译单元独立的状态。
inline 允许什么,不承诺什么
传统头文件中的外部 inline 函数,以及 C++17 起的 inline 变量,可以在不同翻译单元有符合条件的定义,仍表示同一实体,地址也相同。定义必须使用相同的记号序列,相关名字查找通常还必须指向相同实体;只有文本相似不够。宏让不同源文件看到不同函数体,就可能悄悄违反规则。
inline 不要求机器代码内联展开;编译器也能展开未写 inline 的函数。类内定义的成员函数在普通头文件场景隐式 inline,constexpr 函数也隐式 inline;附属于命名模块的类内定义有不同规则,不能把头文件经验无条件套给模块。inline 定义还必须在需要它的翻译单元可达。
模板定义为何常放在头文件
隐式实例化通常需要看见模板定义,只有函数模板声明不足以生成任意类型的特化。因此可把下例 larger 的完整定义放入头文件;它不必仅为多翻译单元使用而再加 inline。普通类定义、模板定义也能按 ODR 的条件出现在不同翻译单元。
若支持的类型集合固定,可把定义藏在实现文件,由它提供显式实例化定义,头文件用 extern template 声明显式实例化以抑制重复实例化。代价是新类型不能随意使用,必须增加对应实例化。函数模板的显式完全特化不自动继承原模板的 inline;其定义放在头文件时要单独处理,或移到唯一实现文件。
从单文件示例推导多翻译单元实验
下例用于展示接口与共享对象,单文件运行本身不能验证跨文件身份。做实际实验时,把 namespace guide 块移入带包含保护的 guide.hpp;把 from_a、from_b 分别放入 a.cpp、b.cpp,两者都包含该头文件。main.cpp 包含它并声明 unsigned* from_a(); unsigned* from_b();,保留 main 和所需标准头文件。
执行 g++ -std=c++17 -Wall -Wextra -pedantic main.cpp a.cpp b.cpp -o odr-demo,再执行 ./odr-demo。两个指针应相等,且两次递增作用于同一计数器。构建组织要统一公共头文件及影响定义的选项;链接器合并了符号,不代表它已证明整个程序符合 ODR。
容易答错的地方
- 头文件中 namespace 作用域的 static 变量会产生各自的对象,不等价于 C++17 的外部 inline 变量;这会改变地址和状态共享语义。
- 跨翻译单元 ODR 违规通常属于不要求诊断的非法程序;一次成功链接、一次正确输出,均不能证明定义一致。
运行一个例子
最低标准 C++17 · 完整程序 · 下载 .cpp
#include <cassert>
#include <iostream>
namespace guide {
inline unsigned visits = 0;
inline unsigned next() {
return ++visits;
}
template<class T>
T larger(T a, T b) {
return a < b ? b : a;
}
}
unsigned* from_a() { return &guide::visits; }
unsigned* from_b() { return &guide::visits; }
int main() {
assert(from_a() == from_b());
const auto first = guide::next();
const auto second = guide::next();
assert(first == 1u && second == 2u);
assert(*from_a() == 2u);
assert(guide::larger(3, 7) == 7);
std::cout << guide::visits << '\n';
}
在本地编译
g++ -std=c++17 -Wall -Wextra -Wpedantic -pthread tooling-odr.cpp -o example && ./example预期结果
2
CHECK YOUR UNDERSTANDING
合上答案,试着解释。
准备一个供 a.cpp、b.cpp 共用的 C++17 头文件:包含 int limit = 10;、int twice(int x) { return x * 2; }。怎样分别改为共享变量与允许头文件定义的函数?如果必须支持 C++11 呢?
查看参考答案
C++17 可写 inline int limit = 10; 和 inline int twice(int x) { return x * 2; },加包含保护并保证各翻译单元看到一致定义。twice 的调用仍须限制在乘法结果可用 int 表示的范围,inline 不改变算术规则。C++11 把变量改为头文件里的 extern int limit;,在一个 .cpp 中包含该头文件并写 int limit = 10;;函数仍可 inline。不要把变量改成 static,否则会变成两份状态。
继续查证
标准草案链接会随工作草案更新;本文版本标记对应示例最低要求,不表示草案中的所有新规则都适用于旧标准。