30

假设我有两个 .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()and f2()

void f1();
void f2();

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

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

f1
f1

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

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

f1
f2
4

4 回答 4

40

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

3.2 一定义规则

...

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

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

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

于 2017-05-02T11:19:06.820 回答
30

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

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

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


相关标准供参考:

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

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

于 2017-05-02T11:14:27.273 回答
12

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

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

之所以调用此属性,是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()的,都是内联的,而且它们不同。

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

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

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

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

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

我已经在生产中多次准确地发现了这个错误。考虑到它是多么的微妙,我不知道它滑过多少次,等待它突袭的时刻。

于 2017-05-02T13:52:30.640 回答
-3

澄清点:

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

于 2017-05-02T13:42:02.730 回答