ODR 的目的是什么?
What is the intention of ODR?
我确实理解 ODR 所说的内容,但我不理解它试图实现的目标。
我看到违反它的两个后果 - 用户将得到语法错误,这完全没问题。并且还可能存在一些致命错误,并且用户将再次成为唯一有罪的人。
作为违反 ODR 并出现一些致命错误的示例,我想象如下:
a.cpp
struct A
{
int a;
double b;
};
void f(A a)
{
std::cout << a.a << " " << a.b << std::endl;
}
main.cpp
struct A
{
int a;
int b;
};
void f(A a);
int main()
{
A a = {5, 6};
f(a);
return 0;
}
如果示例与 ODR 无关,请纠正我。
那么,ODR 是在试图禁止用户做这种有害的事情吗?我不这么认为。
它是否试图为编译器编写者设置一些规则,以避免违反它的潜在危害?可能不会,因为大多数编译器不检查 ODR 违规。
还有什么?
当函数期望获得这些结构之一,而您将其重新声明为不同的东西时,该函数接收哪个结构,如何接收?请记住,C++ 是静态的,因此如果您按值发送结构,函数必须知道它的结构。因为 C++ type-safe,允许违反 ODR 将违反此类型安全。
最重要的是,没有 ODR 会有什么好处?我可以想到数百种没有它会变得更难的事情,而且什么也得不到。能够在同一命名空间中踩踏先前声明的类型实际上没有实现灵活性。在最好的情况下,它只会使多重包含不需要 header 守卫,这充其量是一个非常小的收益。
ODR 决定了哪些 C++ 程序是良构的。 ODR 违规意味着您的程序格式错误,并且标准没有规定程序将做什么,是否应该编译等。大多数 ODR 违规被标记为 "no diagnostic required" 以使编译器编写者的工作更容易。
这允许 C++ 编译器对您提供给它的代码做出某些简化假设,例如 ::A
在任何地方都是相同的结构类型,而不必在每个使用点进行检查。
编译器可以随意获取您的代码并将其编译为 c: 格式。或其他任何东西。它可以免费检测 ODR 违规,并用它来证明代码分支不能 运行,并消除通向那里的路径。
据我所知,该规则的目的是防止一个对象在不同的翻译单元中有不同的定义。
// a.cpp
#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;
}
// -----
// b.cpp
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);
}
// -----
// main.cpp
int main() {
SharedClass sc;
/* Assume that the compiler doesn't get confused & have a heart attack,
* and uses the definition in "a.cpp".
* Assume that by the definition in "a.cpp", this instance has:
* a = 3
* b = 5
* c = 1
* d = false
* e = 42
* f = -129
* g = 8
*/
// ...
a(sc); // Outputs sc.a, sc.e, and sc.c.
b(sc); // Supposedly modifies sc.a, sc.g, and sc.d.
a(sc); // Does NOT do what you think it does.
}
考虑到这个程序,您可能会认为 SharedClass
在 a.cpp
和 b.cpp
中的行为相同,因为它具有相同名称的相同字段。但是,请注意字段的顺序不同。因此,每个翻译单元都会这样看(假设 4 字节整数和 4 字节对齐):
如果编译器使用隐藏对齐成员:
// a.cpp
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.
// b.cpp
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.
// main.cpp
One of the above, up to the compiler.
Alternatively, may be seen as undefined.
如果编译器将相同大小的字段放在一起,从大到小排序:
// a.cpp
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.
// b.cpp
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.
// main.cpp
One of the above, up to the compiler.
Alternatively, may be seen as undefined.
请注意,如果您愿意的话,虽然 class 在两个定义中具有相同的大小,但其成员的顺序完全不同。
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
的定义相同,但指定的不同路线。这将改变对齐成员的大小,再次在不同的翻译单元中提供 class 不同的内存布局。]
现在,如果相同字段在不同翻译单元中的布局不同,就会发生这种情况。想象一下,如果 class 在不同的单位中完全 不同的 字段会发生什么。
// c.cpp
#include <string>
#include <utility>
// Assume alignment of 4.
// Assume std::string stores a pointer to string memory, size_t (as long long), and pointer
// to allocator in its body, and is thus 16 (on 32-bit) or 24 (on 64-bit) bytes.
// (Note that this is likely not the ACTUAL size of std::string, but I'm just using it for an
// example.)
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.
不仅 this SharedClass
具有不同的字段,而且大小也完全不同。试图将每个翻译单元视为它们具有相同的 SharedClass
可以并且 将 破坏某些东西,并且默默地协调每个定义彼此是不可能的。想象一下如果我们在 SharedClass
的同一个实例上调用 a()
、b()
和 c()
会发生什么混乱,甚至如果我们尝试 使成为SharedClass
的实例。使用三个不同的定义,并且编译器不知道哪个是 实际 定义,事情可能 将 变得很糟糕。
这完全破坏了单元间的可操作性,要求所有使用 class 的代码要么在同一个翻译单元中,要么共享完全相同的 class 定义每个单位。因此,ODR 要求每个单元只定义一次 class,并在所有单元中共享相同的定义,以保证它始终具有相同的定义,并防止整个问题。
同样,考虑这个简单的函数,func()
。
// z.cpp
#include <cmath>
int func(int x, int y) {
return static_cast<int>(round(pow((2 * x) - (3 * y), x + y) - (x / y)));
}
// -----
// y.cpp
int func(int x, int y) { return x + y; }
// -----
// x.cpp
int q = func(9, 11);
// Compiler has a heart attack, call 911.
编译器无法判断您指的是哪个版本的 func()
,实际上会将它们视为相同的函数。这自然会破坏事情。当一个版本有副作用(例如更改全局状态或导致内存泄漏)而另一个版本没有时,情况会变得更糟。
在这种情况下,ODR 旨在保证任何给定函数将在所有翻译单元中共享相同的定义,而不是在不同的单元中具有不同的定义。这个有点容易改变(出于 ODR 的目的,将所有函数视为 inline
,但如果显式或隐式声明为 inline
,则仅将它们视为 inline
),但这可能会导致出乎意料的麻烦。
现在,考虑一个更简单的情况,全局变量。
// i.cpp
int global_int;
namespace Globals {
int ns_int = -5;
}
// -----
// j.cpp
int global_int;
namespace Globals {
int ns_int = 5;
}
在这种情况下,每个翻译单元都定义了变量 global_int
和 Globals::ns_int
,这意味着程序将有两个不同的变量,它们的名称完全相同。这只能在链接阶段结束,链接器将符号的每个实例视为引用 相同 实体。 Globals::ns_int
将比 global_int
有更多问题,因为有两个不同的初始化值硬编码到文件中;假设链接器不只是爆炸,程序肯定有未定义的行为。
ODR 的复杂程度因所涉及的实体而异。有些东西在整个程序中只能有一个定义,但有些东西可以有多个定义,只要它们完全一样,每个翻译单元只有一个。无论如何,目的是每个单位都将以完全相同的方式看到实体。
不过,这样做的主要原因是方便。编译器不仅更容易假设每个翻译单元都遵循 ODR,而且速度更快,CPU-、内存和磁盘密集型。如果没有 ODR,编译器将不得不比较每个单独的翻译单元,以确保每个共享类型和内联函数定义都是相同的,并且每个全局变量和非内联函数只在一个翻译单元中定义。自然地,这将要求它在编译任何单元时从磁盘加载每个单元,使用大量系统资源,如果程序员遵循良好的编程习惯,则实际上不需要这些资源。有鉴于此,强制程序员遵循 ODR 让编译器假设一切都很好而且花花公子,使其工作(以及程序员在等待编译器时的工作 and/or 偷懒)变得容易得多。 [与此相比,确保在单个单元内遵循 ODR 简直就是小儿科。]
简单来说,一个定义规则保证:
在程序中应该只定义一次的实体恰好定义了一次。
可以在多个翻译单元(类、内联函数、模板函数)中定义的实体具有等效的定义,从而产生等效的编译代码。等价性必须是完美的才能在 运行 时使用任何一个定义:许多定义无法区分。
我确实理解 ODR 所说的内容,但我不理解它试图实现的目标。
我看到违反它的两个后果 - 用户将得到语法错误,这完全没问题。并且还可能存在一些致命错误,并且用户将再次成为唯一有罪的人。
作为违反 ODR 并出现一些致命错误的示例,我想象如下:
a.cpp
struct A
{
int a;
double b;
};
void f(A a)
{
std::cout << a.a << " " << a.b << std::endl;
}
main.cpp
struct A
{
int a;
int b;
};
void f(A a);
int main()
{
A a = {5, 6};
f(a);
return 0;
}
如果示例与 ODR 无关,请纠正我。
那么,ODR 是在试图禁止用户做这种有害的事情吗?我不这么认为。
它是否试图为编译器编写者设置一些规则,以避免违反它的潜在危害?可能不会,因为大多数编译器不检查 ODR 违规。
还有什么?
当函数期望获得这些结构之一,而您将其重新声明为不同的东西时,该函数接收哪个结构,如何接收?请记住,C++ 是静态的,因此如果您按值发送结构,函数必须知道它的结构。因为 C++ type-safe,允许违反 ODR 将违反此类型安全。
最重要的是,没有 ODR 会有什么好处?我可以想到数百种没有它会变得更难的事情,而且什么也得不到。能够在同一命名空间中踩踏先前声明的类型实际上没有实现灵活性。在最好的情况下,它只会使多重包含不需要 header 守卫,这充其量是一个非常小的收益。
ODR 决定了哪些 C++ 程序是良构的。 ODR 违规意味着您的程序格式错误,并且标准没有规定程序将做什么,是否应该编译等。大多数 ODR 违规被标记为 "no diagnostic required" 以使编译器编写者的工作更容易。
这允许 C++ 编译器对您提供给它的代码做出某些简化假设,例如 ::A
在任何地方都是相同的结构类型,而不必在每个使用点进行检查。
编译器可以随意获取您的代码并将其编译为 c: 格式。或其他任何东西。它可以免费检测 ODR 违规,并用它来证明代码分支不能 运行,并消除通向那里的路径。
据我所知,该规则的目的是防止一个对象在不同的翻译单元中有不同的定义。
// a.cpp
#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;
}
// -----
// b.cpp
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);
}
// -----
// main.cpp
int main() {
SharedClass sc;
/* Assume that the compiler doesn't get confused & have a heart attack,
* and uses the definition in "a.cpp".
* Assume that by the definition in "a.cpp", this instance has:
* a = 3
* b = 5
* c = 1
* d = false
* e = 42
* f = -129
* g = 8
*/
// ...
a(sc); // Outputs sc.a, sc.e, and sc.c.
b(sc); // Supposedly modifies sc.a, sc.g, and sc.d.
a(sc); // Does NOT do what you think it does.
}
考虑到这个程序,您可能会认为 SharedClass
在 a.cpp
和 b.cpp
中的行为相同,因为它具有相同名称的相同字段。但是,请注意字段的顺序不同。因此,每个翻译单元都会这样看(假设 4 字节整数和 4 字节对齐):
如果编译器使用隐藏对齐成员:
// a.cpp
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.
// b.cpp
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.
// main.cpp
One of the above, up to the compiler.
Alternatively, may be seen as undefined.
如果编译器将相同大小的字段放在一起,从大到小排序:
// a.cpp
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.
// b.cpp
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.
// main.cpp
One of the above, up to the compiler.
Alternatively, may be seen as undefined.
请注意,如果您愿意的话,虽然 class 在两个定义中具有相同的大小,但其成员的顺序完全不同。
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
的定义相同,但指定的不同路线。这将改变对齐成员的大小,再次在不同的翻译单元中提供 class 不同的内存布局。]
现在,如果相同字段在不同翻译单元中的布局不同,就会发生这种情况。想象一下,如果 class 在不同的单位中完全 不同的 字段会发生什么。
// c.cpp
#include <string>
#include <utility>
// Assume alignment of 4.
// Assume std::string stores a pointer to string memory, size_t (as long long), and pointer
// to allocator in its body, and is thus 16 (on 32-bit) or 24 (on 64-bit) bytes.
// (Note that this is likely not the ACTUAL size of std::string, but I'm just using it for an
// example.)
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.
不仅 this SharedClass
具有不同的字段,而且大小也完全不同。试图将每个翻译单元视为它们具有相同的 SharedClass
可以并且 将 破坏某些东西,并且默默地协调每个定义彼此是不可能的。想象一下如果我们在 SharedClass
的同一个实例上调用 a()
、b()
和 c()
会发生什么混乱,甚至如果我们尝试 使成为SharedClass
的实例。使用三个不同的定义,并且编译器不知道哪个是 实际 定义,事情可能 将 变得很糟糕。
这完全破坏了单元间的可操作性,要求所有使用 class 的代码要么在同一个翻译单元中,要么共享完全相同的 class 定义每个单位。因此,ODR 要求每个单元只定义一次 class,并在所有单元中共享相同的定义,以保证它始终具有相同的定义,并防止整个问题。
同样,考虑这个简单的函数,func()
。
// z.cpp
#include <cmath>
int func(int x, int y) {
return static_cast<int>(round(pow((2 * x) - (3 * y), x + y) - (x / y)));
}
// -----
// y.cpp
int func(int x, int y) { return x + y; }
// -----
// x.cpp
int q = func(9, 11);
// Compiler has a heart attack, call 911.
编译器无法判断您指的是哪个版本的 func()
,实际上会将它们视为相同的函数。这自然会破坏事情。当一个版本有副作用(例如更改全局状态或导致内存泄漏)而另一个版本没有时,情况会变得更糟。
在这种情况下,ODR 旨在保证任何给定函数将在所有翻译单元中共享相同的定义,而不是在不同的单元中具有不同的定义。这个有点容易改变(出于 ODR 的目的,将所有函数视为 inline
,但如果显式或隐式声明为 inline
,则仅将它们视为 inline
),但这可能会导致出乎意料的麻烦。
现在,考虑一个更简单的情况,全局变量。
// i.cpp
int global_int;
namespace Globals {
int ns_int = -5;
}
// -----
// j.cpp
int global_int;
namespace Globals {
int ns_int = 5;
}
在这种情况下,每个翻译单元都定义了变量 global_int
和 Globals::ns_int
,这意味着程序将有两个不同的变量,它们的名称完全相同。这只能在链接阶段结束,链接器将符号的每个实例视为引用 相同 实体。 Globals::ns_int
将比 global_int
有更多问题,因为有两个不同的初始化值硬编码到文件中;假设链接器不只是爆炸,程序肯定有未定义的行为。
ODR 的复杂程度因所涉及的实体而异。有些东西在整个程序中只能有一个定义,但有些东西可以有多个定义,只要它们完全一样,每个翻译单元只有一个。无论如何,目的是每个单位都将以完全相同的方式看到实体。
不过,这样做的主要原因是方便。编译器不仅更容易假设每个翻译单元都遵循 ODR,而且速度更快,CPU-、内存和磁盘密集型。如果没有 ODR,编译器将不得不比较每个单独的翻译单元,以确保每个共享类型和内联函数定义都是相同的,并且每个全局变量和非内联函数只在一个翻译单元中定义。自然地,这将要求它在编译任何单元时从磁盘加载每个单元,使用大量系统资源,如果程序员遵循良好的编程习惯,则实际上不需要这些资源。有鉴于此,强制程序员遵循 ODR 让编译器假设一切都很好而且花花公子,使其工作(以及程序员在等待编译器时的工作 and/or 偷懒)变得容易得多。 [与此相比,确保在单个单元内遵循 ODR 简直就是小儿科。]
简单来说,一个定义规则保证:
在程序中应该只定义一次的实体恰好定义了一次。
可以在多个翻译单元(类、内联函数、模板函数)中定义的实体具有等效的定义,从而产生等效的编译代码。等价性必须是完美的才能在 运行 时使用任何一个定义:许多定义无法区分。