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

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

本文关键字:函数 全局 结果 神奇 为什么 文件 cpp 定义      更新时间:2023-10-16

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

// file1.cpp
#include <iostream>
inline void foo()
{
std::cout << "f1n";
}
void f1()
{
foo();
}

// file2.cpp
#include <iostream>
inline void foo()
{
std::cout << "f2n";
}
void f2()
{
foo();
}

main.cpp中,我向前宣布了f1()f2()

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

结果(不依赖于构建,调试/发布版本的结果相同):

f1
f1

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

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

f1
f2

这是未定义的行为,因为具有外部链接的同一内联函数的两个定义中断C++可以在多个位置定义的对象的要求,称为一个定义规则

3.2 一个定义规则

    在程序中,类类型(条款 9)、枚举类型 (7.2)、
  1. 具有外部链接的内联函数 (7.1.2)、类模板(条款 14)、[...] 可以有多个定义,前提是每个定义出现在不同的翻译单元中,并且定义满足以下要求。给定这样一个在多个翻译单元中定义的名为 D 的实体,则

6.1 D的每个定义应由相同的令牌序列组成;[...]

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

编译器可能会假定同一inline函数的所有定义在所有翻译单元中都是相同的,因为标准是这样说的。因此,它可以选择所需的任何定义。在您的情况下,那恰好是带有f1的那个.

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

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


相关标准供参考:

内联函数应在使用它的每个翻译单元中定义,并且应具有 在每种情况下(3.2)的定义相同。[...]

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

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

实际上,发生的情况是函数被标记为内联,在链接阶段,如果它遇到内联标记令牌的多个定义,链接器会静默丢弃除一个之外的所有定义。 如果它遇到未内联标记的令牌的多个定义,则会生成错误。

之所以调用此属性inline是因为在 LTO(链接时间优化)之前,获取函数的主体并在调用站点"内联"它要求编译器具有函数的主体。inline函数可以放在头文件中,每个 cpp 文件都可以看到正文并将代码"内联"到调用站点中。

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

但是,我不知道有编译器在丢弃重复项之前检查定义是否相同。 这包括编译器,否则检查函数体的定义是否相同,例如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) {}
};

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

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

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

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

因此,现在您的代码可以在完整版本中生成并"正常工作",但部分生成会导致内存损坏。 更改 makefile 中文件的顺序可能会导致内存损坏,甚至更改 lib 文件的链接顺序,或升级编译器等。

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

我已经在生产中多次捕获了此错误。 鉴于它是多么微妙,我不知道它溜走了多少次,等待它的时机扑上来。

澄清点:

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