Table of Contents generated with DocToc
一个定义原则(One Definition Rule,ODR)是组织结构良好的C++程序的基石。ODR在编程实践中的表现是:
- 非内联函数或者对象在所有文件中只定义一次。
- 类、内联函数、内联变量最多在每个编译单元定义一次,并且需要确保同一个实体的所有定义必须完全相同。
在C++编码实践中,基本单元是文件,但文件本身在ODR中并不重要,重要的是编译单元(translation units)。
- 编译单元是在源文件基础上应用预处理器得到的结果。预处理器会丢弃没有被条件指令选择的分支,丢弃注释,插入包含的头文件内容,并扩展宏。
- 编译单元之间是通过具有外部链接的相同的声明联系起来的。通常写在头文件中,被不同编译单元包含。
- 简单来说,编译单元是和最后生成的目标文件对应起来的,一个编译单元生成一个目标文件。链接时将所有目标文件链接起来构成最终的库或者可执行文件。
在某些场景中,声明和定义这两个词可以互换使用,但是在ODR的上下文,这两个词的精确含义是很重要的。
声明是一种在程序中用来引入或者重新引入名称的C++结构,一个声明可以同时是一个定义,但是定义不一定是声明:
- 命名空间和命名空间别名同时也是定义。
- 类、类模板、函数、函数模板、成员函数、成员函数模板:仅当他们具有花括号
{}
包含的定义时,他们的声明才是定义。所有类似于类定义或者函数定义的实体都是这个规则(比如联合、运算符重载、成员运算符、静态成员函数、构造函数、模板的显式特化等东西)。 - 枚举:仅当具有花括号
{}
包围的枚举值定义时是定义。 - 局部变量和非静态数据成员:声明同时被当做定义。某些时候还是有一些区别,比如函数形参列表中的局部变量,只有当函数声明本身是定义时才能看做定义。
- 全局变量:没有直接使用
extern
声明或者具有初始化器的全局变量声明是定义。否则的话(既有extern
又没有初始化器)不是定义。 - 静态数据成员:当类的静态数据成员出现在类外或者在类内被声明为
inline
或者constexpr
时是定义。 - 显式特化与偏特化:显式特化或者偏特化本身就是定义。除非是静态数据成员或者静态数据成员模板的显式特化或偏特化,这种情况只有他们具有初始化器的才是定义,否则就是声明。
- 其他情况的声明都不是定义:包括类型别名声明、
using
声明、using
指令、模板形参声明、显式实例化指令、static_assert
声明等。
整个程序仅一份约束:
- 下列实体在整个程序中仅能定义一份:
- 非内联函数、非内联成员函数(包括函数模板的全特化)。
- 非内联变量:定义在全局作用域或者命名空间中的非静态变量。
- 非内联静态数据成员。
- 内部链接的实体不受限制:
static
变量、匿名命名空间中的变量。 - 本质上来说所有实体都只能在程序有一份定义,内部链接的实体就算看起来命名一样,其实他们的名称对链接器来说也是不一样的。
- 定义的缺失或者重复定义会由链接器在链接时报告。
每个编译单元仅有一份约束:
- 没有任何实体能在同一个编译单元中定义两次。
- 这也是为什么头文件中需要写包含守卫(include guard)。
- 在编译单元中,对一个类的下列使用之前必须有其类定义可见:
- 创建对象,这种创建可以是间接的,比如创建包含该对象的对象。
- 声明其数据成员。
- 对其使用了
sizeof
或者typeid
运算符。 - 显式或者隐式获取成员。
- 进行与该类有关的类型转换。
- 对该类型对象赋值。
- 定义将该类型作为参数或者返回值的函数,光是声明则不需要。
- 这些规则同样适用于从类模板生成的类,在实例化点(POI)类模板的定义必须可见。
inline
函数必须在每个使用到的编译单元中定义一次(所以通常他们会定义在头文件中)。但是不像类类型,函数的定义可以在使用点之后,使用时只需要声明可见。- 同理,函数模板的使用将会创建出POI,但是函数模板的定义可以在POI之后。
- 有一个例外是如果缺失函数模板的定义,那么不会诊断,会假定在其他编译单元中。
跨编译单元等价约束:
- 上述每个编译单元都可以有一份的实体,必须在多个编译单元内保持相同的定义。不然的话就是UB。
- 通常来说将这些实体放到头文件中能够避免大部分因为同一个实体不同编译单元中定义不同导致的问题。
- 但是可能有某些比较微妙的场景还是会有问题,比如:
- 不同编译单元声明同一个函数时使用了不同默认参数,和重载配合后导致选择了不同的函数,这可以通过将声明放在头文件中使用相同声明解决。
- 有宏开关控制同一份代码不同的类定义,需要在不同源文件中包含该头文件时使用相同的宏,保证定义一致。