ODR的目的是什么?

4

我理解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违规。
还有其他什么呢?

1
ODR规定了分离编译模型的自然后果。本质上,它描述了链接器的工作原理。它的发明并不是为了让您的生活更加困难。 - Igor Tandetnik
2
他们没有足够的信息来这样做。他们看不到源代码,只能看到目标文件。很多语义信息在那时都会丢失。基本上,一个目标文件说“我定义了一个名为f的函数”,另一个说“我需要一个名为f的函数的地址”。这就是链接器所拥有的所有信息。 - Igor Tandetnik
1
你会深入探究吗?假设结构体包含指向另一个结构体的指针 - 你会包括那个结构体的描述吗?编译器甚至可能不知道它的定义(你只提供了前向声明)。如果一个函数接受一个类的指针,你会包括每个派生类的定义吗(同样,编译器可能不知道所有这些类)? - Igor Tandetnik
1
一个类型可以被前向声明,但在任何给定的翻译单元中都没有定义。这种情况经常发生 - 例如看看 FILE 或 "pimpl 惯用法"。 - Igor Tandetnik
pimpl 的目的是将实现细节从头文件中移到实现文件中。包含该头文件的其他翻译单元不会看到 impl 类的定义,只有前向声明。 - Igor Tandetnik
显示剩余6条评论
4个回答

5
当函数期望获取这些结构体之一时,如果您重新声明为不同的结构体,则该函数会收到哪个结构体,以及如何处理?请记住,C++是静态的,因此如果按值发送结构体,则函数必须知道其结构。由于C++是类型安全的,允许违反ODR将违反此类型安全性。
最重要的是,缺乏ODR会带来什么好处?我可以想到成百上千的事情,没有它会让事情变得更难,而没有获得任何好处。从同一命名空间中能够践踏先前声明的类型,实际上没有任何灵活性可言。至多,这只不过使多重包含不需要头文件保护,而这只是一个非常微小的收益。

@MisterNobody 你说的“它在哪里使用”是什么意思?它被广泛应用。同一事物不能有多个定义。 - 463035818_is_not_a_number
1
@MisterNobody 违反规则并不能否定它。实际上,我不确定在这种特殊情况下是否如此,但通常在C++中有许多不允许的事情,而且你通常会得到你应得的:未定义行为。 - 463035818_is_not_a_number
@tobi303 ODR 违规通常是“程序形式不良,无需诊断”,而不是“未定义行为”。 对于大多数实际目的来说,这是没有区别的。 - Igor Tandetnik
是的,我同意 :) 你的回答完全没问题,只是不是我想要的。 - Mister Nobody
1
@curiousguy [cpp] 中的预处理部分多次提到未定义行为。显然,预处理器困难不会发生在运行时。请参见**[defns.undefined]**中的(非规范性)注释;它概述了可能的一种结果是“终止翻译”。 - Igor Tandetnik
显示剩余6条评论

5
ODR规定了C++程序的格式。ODR违规意味着您的程序格式不正确,标准并不规定程序应该做什么或是否应该编译等。大多数ODR违规都被标记为“无需诊断”,以便于编译器编写者的工作。
这使得C++编译器可以对您提供的代码进行某些简化假设,例如::A在任何地方都是相同的结构类型,并且不必在每个使用点进行检查。
编译器可以自由地将您的代码编译成c:格式,或任何其他格式。它可以检测ODR违规,并用它来证明该代码分支不能运行,并消除通往该分支的路径。

谢谢 @Yakk - Adam Nevraumont,但是你能否详细解释一下第二段提供的例子呢?为什么需要这样简化的假设?是因为编译器只能一次处理一个TU,并且不能记住之前遇到的标识符吗? - user51462
@user51462 我从未使用过"require"这个词。我不是专业的编译器开发者,我只写过一些临时项目中的编译器。因此,我无法对当前的C++编译器有何要求以及ODR规则的具体限制发表意见。我鼓励你撰写一个问题,并使用本页面上的[提问]按钮向广大社区提问。 - Yakk - Adam Nevraumont

0
简单来说,One Definition Rules(ODR)保证了以下几点:
1. 程序中只应该定义一次的实体确实只被定义了一次。 2. 可以在多个翻译单元(类、内联函数、模板函数)中定义的实体具有等效的定义,从而产生等效的编译代码。这种等效性必须是完美的,以便在运行时使用任何一个定义:多个定义是无法区分的。

0
据我所知,该规则的目的是防止一个对象在不同的翻译单元中被定义为不同的形式。
// 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.
}

考虑到这个程序,你可能认为SharedClassa.cppb.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.

请注意,尽管该类在两个定义中大小相同,但其成员的顺序完全不同。
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.esc.csc.asc.d中的一个(取决于它如何编译),从而完全改变第二个调用的输出。[请注意,即使在你永远不会预料到的看似无害的情况下,例如如果a.cppb.cpp都对SharedClass进行了相同的定义,但指定了不同的对齐方式。这将更改对齐成员的大小,再次在不同的翻译单元中给类提供不同的内存布局。]

现在,如果不同的翻译单元中具有完全不同的字段,想象一下会发生什么。

// 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.

这个SharedClass不仅有不同的字段,而且它的大小也完全不同。试图将每个翻译单元视为具有相同的SharedClass可能会导致某些问题,并且无法默默地将每个定义与彼此协调。想象一下,如果我们在同一个SharedClass实例上调用a()b()c(),或者甚至尝试创建SharedClass实例,会发生什么混乱。由于有三个不同的定义,编译器无法确定哪一个是实际定义,因此事情可能会变得很糟糕。

这完全破坏了单元间的互操作性,要求使用类的所有代码要么在同一个翻译单元中,要么在每个单元中共享完全相同的类定义。由于这个原因,ODR要求每个单元只定义一次类,并在所有单元中共享相同的定义,以确保它始终具有相同的定义,并防止出现整个问题。


同样地,考虑这个简单的函数: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来解决,但如果未经预料地明确或隐式声明,则可能会引起麻烦。


现在,考虑一个更简单的情况,全局变量。

// i.cpp
int global_int;

namespace Globals {
    int ns_int = -5;
}

// -----

// j.cpp
int global_int;

namespace Globals {
    int ns_int = 5;
}

在这种情况下,每个翻译单元都定义了变量global_intGlobals::ns_int,这意味着程序将具有两个具有完全相同的混淆名称的不同变量。这只能在链接阶段结束时得到很好的解决,链接器将看到每个符号实例都指向相同的实体。Globals::ns_int将比global_int更具问题,因为它在文件中硬编码了两个不同的初始化值;假设链接器没有崩溃,程序保证会出现未定义行为。

ODR的复杂程度取决于实体本身。有些东西在整个程序中只能有一个定义,但有些可以有多个定义,只要它们完全相同,并且每个翻译单元中只有一个。无论如何,意图是每个单元都将以完全相同的方式看到实体。

然而,这样做的主要原因是为了方便。编译器假设在每个翻译单元中都遵循了ODR,不仅更容易,而且更快速、CPU、内存和磁盘资源消耗更少。如果没有ODR,编译器将不得不比较每个翻译单元,以确保每个共享类型和内联函数定义都相同,并且每个全局变量和非内联函数只在一个翻译单元中定义。这自然需要在编译任何单元时从磁盘加载每个单元,使用大量系统资源,而实际上它并不需要,如果程序员遵循良好的编程实践。鉴于此,强制程序员遵循ODR让编译器假设一切正常,使其工作(以及程序员在等待编译器时的工作或娱乐)更加轻松。[与此相比,在单个单元内确保遵循ODR就像儿童游戏一样简单。]


“并且每个全局变量和非内联函数只在一个翻译单元中定义。” 链接器确实会做到这一点。 - curiousguy
“多个定义,只要它们完全相同就可以。” 它们通常不是完全相同的:它们是等效的。在某些情况下,它们仍然可能指向不同的实体。 - curiousguy
有趣的,@curiousguy。我可能需要稍微修改一下这个答案。 - Justin Time - Reinstate Monica

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接