为什么在 2 个不同的 cpp 文件中定义内联全局函数会导致神奇的结果?

Why does defining inline global function in 2 different cpp files cause a magic result?

假设我有两个 .cpp 文件 file1.cppfile2.cpp:

// file1.cpp
#include <iostream>

inline void foo()
{
    std::cout << "f1\n";
}

void f1()
{
    foo();
}

// file2.cpp
#include <iostream>

inline void foo()
{
   std::cout << "f2\n";
}

void f2()
{
    foo();
}

并且在 main.cpp 中我已经向前声明了 f1()f2():

void f1();
void f2();

int main()
{
    f1();
    f2();
}

结果(不依赖于构建,debug/release 构建 的结果相同):

f1
f1

哇哦:编译器以某种方式仅从 file1.cpp 中选取定义并在 f2() 中使用它。这种行为的确切解释是什么?

请注意,将 inline 更改为 static 是解决此问题的方法。将内联定义放在未命名的命名空间中也可以解决问题,程序打印:

f1
f2

编译器可能会假定同一 inline 函数的所有定义在所有翻译单元中都是相同的,因为标准是这么说的。所以它可以选择它想要的任何定义。在你的例子中,恰好是 f1 的那个。

请注意,您不能依赖编译器总是选择相同的定义,违反上述规则会使程序格式错误。编译器也可以诊断并出错。

如果函数是 static 或在匿名命名空间中,则有两个不同的函数称为 foo,编译器必须从正确的文件中选择一个。


相关标准参考:

An inline function shall be defined in every translation unit in which it is odr-used and shall have exactly the same definition in every case (3.2). [...]

N4141中的7.1.2/4,强调我的。

这是未定义的行为,因为具有外部链接的同一个内联函数的两个定义打破了 C++ 对可以在多个地方定义的对象的要求,称为 一个定义规则 :

3.2 One definition rule

...

  1. There can be more than one definition of a class type (Clause 9), enumeration type (7.2), inline function with external linkage (7.1.2), class template (Clause 14),[...] in a program provided that each definition appears in a different translation unit, and provided the definitions satisfy the following requirements. Given such an entity named D defined in more than one translation unit, then

6.1 each definition of D shall consist of the same sequence of tokens; [...]

这不是 static 函数的问题,因为一个定义规则不适用于它们:C++ 认为在不同翻译单元中定义的 static 函数是相互独立的。

澄清点:

虽然根植于 C++ 内联规则的答案是正确的,但它仅适用于同时编译两个源代码的情况。如果它们是单独编译的,那么,正如一位评论员指出的那样,每个生成的目标文件都将包含自己的 'foo()'。但是:如果这两个目标文件随后链接在一起,那么因为两个 'foo()'-s 都是非静态的,所以名称 'foo()' 出现在两个目标文件的导出符号 table 中;然后 链接器 必须合并两个 table 条目,因此所有内部调用都重新绑定到两个例程之一(大概是处理的第一个目标文件中的那个,因为它已经绑定 [即链接器将第二条记录视为 'extern' 而不管绑定])。

正如其他人指出的那样,编译器符合 C++ 标准,因为 一个定义规则 规定您只能定义一个函数,除非函数是内联的,那么定义必须相同。

在实践中,函数被标记为内联,在 linking 阶段,如果它遇到一个内联标记标记的多个定义,linker 会默默地丢弃所有但一个。如果它遇到未内联标记的令牌的多个定义,则会生成错误。

这个 属性 被称为 inline 因为,在 LTO 之前(link 时间优化),在调用站点获取函数体并 "inlining"要求编译器具有函数的主体。 inline 函数可以放在头文件中,每个 cpp 文件都可以看到主体和 "inline" 调用站点中的代码。

这并不意味着代码 实际上 将被内联;相反,它使编译器更容易内联它。

但是,我不知道编译器会在丢弃重复项之前检查定义是否相同。这包括以其他方式检查函数体定义是否相同的编译器,例如 MSVC 的 COMDAT 折叠。这让我很难过,因为这是一组非常微妙的错误。

解决您的问题的正确方法是将函数放在匿名名称空间中。通常,您应该考虑将 所有内容 放入匿名命名空间的源文件中。

另一个非常糟糕的例子:

// A.cpp
struct Helper {
  std::vector<int> foo;
  Helper() {
    foo.reserve(100);
  }
};
// B.cpp
struct Helper {
  double x, y;
  Helper():x(0),y(0) {}
};

在 class 的主体中定义的方法是 隐式内联 。 ODR 规则适用。这里我们有两个不同的 Helper::Helper(),都是内联的,而且它们不同。

两个 classes 的大小不同。在一种情况下,我们用 0 初始化两个 sizeof(double) (因为在大多数情况下零浮点数是零字节)。

在另一个例子中,我们首先将 three sizeof(void*) 初始化为零,然后在这些字节上调用 .reserve(100) 将它们解释为向量。

在link时,这两个实现中的一个被丢弃并被另一个使用。更重要的是,在完整构建中丢弃哪个可能是非常确定的。在部分构建中,它可能会更改顺序。

所以现在您的代码可以在完整构建中构建和工作 "fine",但部分构建会导致内存损坏。更改 makefile 中文件的顺序可能会导致内存损坏,甚至更改 lib 文件的顺序 linked,或升级编译器等

如果两个 cpp 文件都有一个 namespace {} 块,其中包含除您要导出的内容(可以使用完全限定的命名空间名称)之外的所有内容,则不会发生这种情况。

我已经多次在生产中发现这个错误。如此微妙,不知溜过多少次,只待扑上去。