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

30
假设我有两个 .cpp 文件 file1.cppfile2.cpp:
// file1.cpp
#include <iostream>

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

void f1()
{
    foo();
}

and

// 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();
}

结果(与构建无关,对于调试/发布版本的结果相同):

f1
f1

哇:编译器以某种方式仅从file1.cpp中选择定义,并在f2()中使用它。这种行为的确切解释是什么?
请注意,将inline更改为static是解决此问题的方法。将内联定义放在未命名名称空间中也可以解决问题,程序将打印:
f1
f2

20
你正在违反ODR(一次定义规则)。请参见http://en.cppreference.com/w/cpp/language/definition。 - Richard Critten
4个回答

40

这是未定义行为,因为同一具有外部链接的内联函数的两个定义违反了C++中可在多个位置定义的对象的要求,即所谓的一次定义规则:

3.2 一次定义规则

...

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

6.1 D的每个定义都应该由相同的标记序列组成; [...]

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


注意:ODR规则非常具体,旨在防止您执行任何可能使可怜的链接器混淆的聪明操作。使用“static”或匿名命名空间可以解决这个问题。 - Cort Ammon

30
编译器可能会假设所有具有相同“inline”函数定义的翻译单元在所有翻译单元中都是相同的,因为标准是这样规定的。所以它可以选择任何一个定义。在您的情况下,它选择了带有“f1”的那个定义。
请注意,您不能指望编译器总是选择相同的定义,违反上述规则将使程序无效。编译器也可能诊断并报错。
如果函数是“静态的”或在匿名名称空间中,则您有两个名为“foo”的不同函数,编译器必须从正确的文件中选择其中一个。
相关标准内容供参考:
“内联”函数应在每个使用了odr的翻译单元中定义,并且在每种情况下都应该具有完全相同的定义。[...] N4141的7.1.2/4,强调是我的。

在这个例子中不是有三个翻译单元吗?“翻译单元是C++编译的基本单位。它由单个源文件的内容以及直接或间接包含在其中的任何头文件的内容组成,减去使用条件预处理语句忽略的那些行。”所以我认为应该为file1file2创建两个具有不同foo主体的目标文件,然后将它们全部链接起来。这样不是更正确吗? - Aleksei Petrenko
1
@AlexPetrenko 抱歉,我不明白你的意思。当然有三个翻译单元,但我不知道这与问题有何关系。重要的是同一个内联函数有两个不同的定义。 - Baum mit Augen
我觉得你稍微编辑了一下你的帖子,现在一切都清楚了。无论如何还是谢谢 :) - Aleksei Petrenko

12

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

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

此属性称为内联,因为在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中文件的顺序,或者链接库文件的顺序,或者升级编译器等操作也可能导致内存破坏。

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

我曾在生产环境中多次捕获到此错误。鉴于它的微妙性,我不知道它已经悄悄地溜过了多少次,等待着它被发现的时刻。


谢谢提供另一个例子。答案非常有帮助! - Narek Atayan
“通常情况下,您应该考虑将源文件中的所有内容放在匿名命名空间中。” - Lightness Races in Orbit
我在生产环境中多次遇到了这个 bug。其中一次是由于两个不同的共享库在未命名空间化的自由函数方面发生冲突。非常棘手。 - Lightness Races in Orbit
1
@Yakk:我知道未命名的命名空间是什么,谢谢。.cpp文件中的大多数内容都设计为从其他翻译单元访问,不是吗?如果您将所有内容放入未命名的命名空间中,很少有东西能正常工作。 - Lightness Races in Orbit
1
@BoundaryImposition 是的,我的意思是除了你完全限定为实现命名空间的内容之外的其他东西。 - Yakk - Adam Nevraumont
显示剩余2条评论

-3

澄清一点:

虽然基于C++内联规则的答案是正确的,但仅适用于两个源文件一起编译的情况。如果它们被分别编译,则每个生成的目标文件都将包含自己的“foo()”,正如一位评论者所指出的那样。但是:如果这两个目标文件随后链接在一起,由于两个“foo()”均为非静态,因此名称“foo()”将出现在两个目标文件的导出符号表中;然后,链接器必须合并两个表项,因此所有内部调用都将重新绑定到其中一个例程(可能是第一个处理的目标文件中的例程,因为它已经绑定[即使绑定,链接器也会将第二个记录视为“extern”])。


3
“它只适用于同时编译两个源文件的情况”是错误的。只要同一个程序使用这两个翻译单元,它就始终适用。 - Baum mit Augen
@Baum mit Augen - 或许我们对“适用”这个词的理解略有不同。我同意,无论源代码是分别编译还是一起编译,都会违反“法律的字面意义”。但实际上,我不明白为什么分别编译不能在单独的.obj文件中产生多个'foo()'实例,这必须由链接器解决。在“有规则”和“编译器可以执行规则”之间存在差距。 - PMar
这个回答似乎在一定程度上是正确的,但我不明白为什么这是相关的。我理解你的观点是,由于存在多个翻译单元,每个单元在其各自的目标文件中都有一个foo的定义,而链接器的工作是解决它们。好的。那又怎样呢?这对问题或其答案有什么影响,为什么需要从其他答案已经说过的内容中澄清这一点? - Cody Gray

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