据我所知,该规则的目的是防止一个对象在不同的翻译单元中被定义为不同的形式。
#include <iostream>
class SharedClass {
int a, b, c;
bool d;
int e, f, g;
public:
};
void a(const SharedClass& sc) {
std::cout << "sc.a: " << sc.getA() << '\n'
<< "sc.e: " << sc.getE() << '\n'
<< "sc.c: " << sc.getC() << std::endl;
}
class SharedClass {
int b, e, g, a;
bool d;
int c, f;
public:
};
void b(SharedClass& sc) {
sc.setA(sc.getA() - 13);
sc.setG(sc.getG() * 2);
sc.setD(true);
}
int main() {
SharedClass sc;
a(sc);
b(sc);
a(sc);
}
考虑到这个程序,你可能认为
SharedClass
在
a.cpp
和
b.cpp
中的行为是相同的,因为它们具有相同名称的相同字段。然而请注意,这些字段的顺序不同。因此,每个翻译单元将会看到以下内容(假设使用4字节整数并且4字节对齐):
如果编译器使用隐藏的对齐成员:
Class layout:
0x00: int {a}
0x04: int {b}
0x08: int {c}
0x0C: bool {d}
0x0D: [alignment member, 3 bytes]
0x10: int {e}
0x14: int {f}
0x18: int {g}
Size: 28 bytes.
Class layout:
0x00: int {b}
0x04: int {e}
0x08: int {g}
0x0C: int {a}
0x10: bool {d}
0x11: [alignment member, 3 bytes]
0x14: int {c}
0x18: int {f}
Size: 28 bytes.
One of the above, up to the compiler.
Alternatively, may be seen as undefined.
如果编译器将相同大小的字段按从大到小的顺序放在一起:
Class layout:
0x00: int {a}
0x04: int {b}
0x08: int {c}
0x0C: int {e}
0x10: int {f}
0x14: int {g}
0x18: bool {d}
Size: 25 bytes.
Class layout:
0x00: int {b}
0x04: int {e}
0x08: int {g}
0x0C: int {a}
0x10: int {c}
0x14: int {f}
0x18: bool {d}
Size: 25 bytes.
One of the above, up to the compiler.
Alternatively, may be seen as undefined.
请注意,尽管该类在两个定义中大小相同,但其成员的顺序完全不同。
Field comparison (with alignment member):
a.cpp field b.cpp field
a b
b e
c g
d & {align} a
e d & {align}
f c
g f
Field comparison (with hidden reordering):
a.cpp field b.cpp field
a b
b e
c g
e a
f c
g f
d d
因此,从a()
的角度来看,b()
实际上会更改sc.e
、sc.c
和sc.a
或sc.d
中的一个(取决于它如何编译),从而完全改变第二个调用的输出。[请注意,即使在你永远不会预料到的看似无害的情况下,例如如果a.cpp
和b.cpp
都对SharedClass
进行了相同的定义,但指定了不同的对齐方式。这将更改对齐成员的大小,再次在不同的翻译单元中给类提供不同的内存布局。]
现在,如果不同的翻译单元中具有完全不同的字段,想象一下会发生什么。
#include <string>
#include <utility>
class SharedClass {
char c;
std::string str;
short s;
unsigned long long ull;
float f;
public:
};
void c(SharedClass& sc, std::string str) {
sc.setStr(std::move(str));
}
在这个文件中,我们的
SharedClass
可能会是这样的:
Class layout (32-bit, alignment member):
0x00: char {c}
0x01: [alignment member, 3 bytes]
0x04: string {str}
0x14: short {s}
0x16: [alignment member, 2 bytes]
0x18: unsigned long long {ull}
0x20: float {f}
Size: 36 bytes.
Class layout (64-bit, alignment member):
0x00: char {c}
0x01: [alignment member, 3 bytes]
0x04: string {str}
0x1C: short {s}
0x1E: [alignment member, 2 bytes]
0x20: unsigned long long {ull}
0x28: float {f}
Size: 44 bytes.
Class layout (32-bit, reordered):
0x00: string {str}
0x10: unsigned long long {ull}
0x18: float {f}
0x1C: short {s}
0x1E: char {c}
Size: 31 bytes.
Class layout (64-bit, reordered):
0x00: string {str}
0x18: unsigned long long {ull}
0x20: float {f}
0x24: short {s}
0x26: char {c}
Size: 39 bytes.
这个SharedClass
不仅有不同的字段,而且它的大小也完全不同。试图将每个翻译单元视为具有相同的SharedClass
可能会导致某些问题,并且无法默默地将每个定义与彼此协调。想象一下,如果我们在同一个SharedClass
实例上调用a()
、b()
和c()
,或者甚至尝试创建SharedClass
实例,会发生什么混乱。由于有三个不同的定义,编译器无法确定哪一个是实际定义,因此事情可能会变得很糟糕。
这完全破坏了单元间的互操作性,要求使用类的所有代码要么在同一个翻译单元中,要么在每个单元中共享完全相同的类定义。由于这个原因,ODR要求每个单元只定义一次类,并在所有单元中共享相同的定义,以确保它始终具有相同的定义,并防止出现整个问题。
同样地,考虑这个简单的函数:
func()
。
#include <cmath>
int func(int x, int y) {
return static_cast<int>(round(pow((2 * x) - (3 * y), x + y) - (x / y)));
}
int func(int x, int y) { return x + y; }
int q = func(9, 11);
编译器无法确定您指的是哪个版本的func()
,实际上会将它们视为同一个函数。这自然会导致问题。当其中一个版本具有副作用(例如更改全局状态或导致内存泄漏)而另一个版本没有时,情况会变得更糟。
在这种情况下,ODR旨在保证任何给定函数在所有翻译单元中共享相同的定义,而不是在不同单元中具有不同的定义。这个问题可以通过将所有函数视为ODR目的的inline
来解决,但如果未经预料地明确或隐式声明,则可能会引起麻烦。
现在,考虑一个更简单的情况,全局变量。
int global_int;
namespace Globals {
int ns_int = -5;
}
int global_int;
namespace Globals {
int ns_int = 5;
}
在这种情况下,每个翻译单元都定义了变量
global_int
和
Globals::ns_int
,这意味着程序将具有两个具有完全相同的混淆名称的不同变量。这只能在链接阶段结束时得到很好的解决,链接器将看到每个符号实例都指向
相同的实体。
Globals::ns_int
将比
global_int
更具问题,因为它在文件中硬编码了两个不同的初始化值;假设链接器没有崩溃,程序保证会出现未定义行为。
ODR的复杂程度取决于实体本身。有些东西在整个程序中只能有一个定义,但有些可以有多个定义,只要它们完全相同,并且每个翻译单元中只有一个。无论如何,意图是每个单元都将以完全相同的方式看到实体。
然而,这样做的主要原因是为了方便。编译器假设在每个翻译单元中都遵循了ODR,不仅更容易,而且更快速、CPU、内存和磁盘资源消耗更少。如果没有ODR,编译器将不得不比较每个翻译单元,以确保每个共享类型和内联函数定义都相同,并且每个全局变量和非内联函数只在一个翻译单元中定义。这自然需要在编译任何单元时从磁盘加载每个单元,使用大量系统资源,而实际上它并不需要,如果程序员遵循良好的编程实践。鉴于此,强制程序员遵循ODR让编译器假设一切正常,使其工作(以及程序员在等待编译器时的工作或娱乐)更加轻松。[与此相比,在单个单元内确保遵循ODR就像儿童游戏一样简单。]
f
的函数”,另一个说“我需要一个名为f
的函数的地址”。这就是链接器所拥有的所有信息。 - Igor TandetnikFILE
或 "pimpl 惯用法"。 - Igor Tandetnik