连接C++17、C++14和C++11对象是否安全?

157
假设我有三个编译对象,它们都是由同一编译器/版本编译生成的:
  1. A 使用C++11标准编译
  2. B 使用C++14标准编译
  3. C 使用C++17标准编译
为简单起见,让我们假设所有头文件都是用C++11编写的,仅使用在三个标准版本之间语义未发生变化的构造,因此任何相互依赖关系都已正确地通过头文件包含表达,并且编译器没有抱怨。
哪些组合是安全的可以链接到单个二进制文件中?哪些组合不是?为什么?
注:欢迎涵盖主要编译器(例如gcc、clang、vs++)的答案。

13
非学校/面试问题。这个问题源于一个特定的案例:我正在开展一个依赖于开源库的项目。我从源代码构建了这个库,但它的构建系统只接受一个标志来选择C++03/C++11编译。尽管我使用的编译器支持其他标准,但我正在考虑将自己的项目升级到C++17。我不确定这是否是一个安全的决定。可能会在ABI中断或以其他方式导致方法不可行吗?我没有找到明确的答案,决定发布一个关于一般情况的问题。 - ricab
6
这完全取决于编译器。正式的C++规范中没有对这种情况进行规定。此外,使用C++03或C++11标准编写的代码在C++14和C++17级别可能会出现一些问题。有足够的知识和经验(以及良好编写的代码),应该能够修复任何这些问题。但是,如果您不熟悉较新的C++标准,最好坚持使用构建系统支持并已测试可用的内容。 - Sam Varshavchik
12
@Someprogrammerdude说:“这是一个非常有价值的问题,我希望我能提供一个答案。我所知道的是,通过RHEL devtoolset使用的libstdc++是按设计具备向后兼容性的,通过静态链接新版本并在运行时动态解析旧版本,使用发行版的“本地”libstdc++。但这并没有回答这个问题。” - Lightness Races in Orbit
4
大多数情况下,分发独立于发行版的C++库的人都会以动态库形式(1)和在接口边界上不使用C++标准库容器(2)的方式进行。来自Linux发行版的库很容易处理,因为它们都是使用相同的编译器、相同的标准库和几乎相同的默认标志构建的。 - Matteo Italia
3
为了澄清之前来自@MatteoItalia的评论:"当从C++03切换到C++11模式时(特别是std::string)",这不是正确的。在libstdc++中,活动的std::string实现与使用的-std模式无关。这是一个重要的特性,_恰好_支持像OP的情况。你可以在C++03代码中使用新的std::string,也可以在C++11代码中使用旧的std::string(请参见Matteo稍后的评论中的链接)。 - Jonathan Wakely
显示剩余19条评论
3个回答

204
哪些对象的组合可以安全地链接成一个单一的二进制文件?哪些不能?为什么?
对于GCC编译器,任何组合的A、B和C对象都可以安全地链接在一起。如果它们都是用相同版本的编译器构建的,则它们是ABI兼容的,标准版本(即-std选项)没有影响。
为什么呢?因为这是我们实现的一个重要特性,我们努力确保这一点。
问题出现在你链接了使用不同版本GCC编译的对象,并且在GCC的对某个新的C++标准的支持完成之前使用了不稳定的功能的情况下。例如,如果你使用GCC 4.9编译了一个带有-std=c++11选项的对象,又使用GCC 5编译了另一个带有-std=c++11选项的对象,你就会遇到问题。GCC 4.x中的C++11支持是实验性的,因此在GCC 4.9和5版本的C++11功能之间存在不兼容的更改。同样,如果你使用GCC 7和-std=c++17选项编译一个对象,并使用GCC 8和-std=c++17选项编译另一个对象,你也会遇到问题,因为GCC 7和8中的C++17支持仍处于实验阶段并且在不断发展。
另一方面,以下对象的任何组合都可以工作(但请注意下面关于libstdc++.so版本的说明):
- 使用GCC 4.9和-std=c++03选项编译的对象D - 使用GCC 5和-std=c++11选项编译的对象E - 使用GCC 7和-std=c++17选项编译的对象F这是因为所有三个编译器版本都支持C++03,使得C++03组件在所有对象之间兼容。自GCC 5以来,C++11支持稳定,但对象D没有使用任何C++11功能,而对象E和F都使用了C++11支持稳定的版本。在这些已使用的编译器版本中,C++17支持都不稳定,但只有对象F使用了C++17功能,因此与其他两个对象没有兼容性问题(它们唯一共享的功能来自于C++03或C++11,并且使用的版本可以处理那些部分)。如果您之后想要使用GCC 8和-std=c++17编译第四个对象G,则需要重新编译带有相同版本的F(或者不链接到F),因为F和G中的C++17符号不兼容。
上面所述的D,E和F之间的兼容性的唯一注意点是,您的程序必须使用GCC 7(或更高版本)的libstdc++.so共享库。由于对象F是使用GCC 7编译的,因此您需要使用该版本的共享库,因为使用GCC 7编译程序的任何部分可能会引入对GCC 4.9或GCC 5的libstdc++.so中不存在的符号的依赖项。同样,如果您链接到使用GCC 8构建的对象G,则需要使用GCC 8的libstdc++.so,以确保找到G所需的所有符号。简单的规则是确保程序在运行时使用的共享库至少与用于编译任何对象的版本一样新。当使用GCC时需要注意一个细节,正如你问题中的评论中提到的,自从GCC 5以来,libstdc++中有两个实现std::string。这两个实现不兼容(它们有不同的符号名称,因此无法链接在一起),但可以在同一个二进制文件中共存(它们有不同的符号名称,因此如果一个对象使用std::string,而另一个对象使用std::__cxx11::string,它们就不会冲突)。如果您的对象使用std::string,那么通常应该使用相同的字符串实现编译它们。使用-D_GLIBCXX_USE_CXX11_ABI=0编译选项选择原始的gcc4-compatible实现,或者使用-D_GLIBCXX_USE_CXX11_ABI=1编译选项选择新的cxx11实现(别被名称愚弄了,它也可以用于C++03,它之所以叫cxx11是因为它符合C++11的要求)。哪个实现是默认的取决于GCC的配置,但默认值可以在编译时通过宏进行覆盖。

2
我刚刚意识到在这个问题上提供赏金存在一个巨大的缺陷。 - Lightness Races in Orbit
5
我90%确定Clang/libc++的答案与此相同,但我不知道MSVC的情况。 - Jonathan Wakely
3
这个回答非常出色。有没有文件记录说5.0+对11/14来说是稳定的? - Barry
1
并非集中在一个地方很清楚。https://gcc.gnu.org/gcc-5/changes.html#libstdcxx和https://gcc.gnu.org/onlinedocs/libstdc++/manual/api.html#api.rel_51声明库对C++11的支持是完整的(语言支持早期已经实现而且仍处于“试验”阶段)。 C++14库支持在6.1之前仍被列为试验性质,但我认为在5.x和6.x之间实际上没有影响ABI的任何变化。 - Jonathan Wakely
1
@afp_2008 这并没有改变任何事情。 - Jonathan Wakely
显示剩余17条评论

22
回答如下:

答案分为两部分:编译器级别的兼容性和链接器级别的兼容性。我们先从前者开始。

假设所有头文件都是用C++11编写的

使用相同的编译器意味着将使用相同的标准库头文件和源文件(与编译器相关的文件),无论目标C++标准如何。因此,标准库的头文件被编写为与编译器支持的所有C++版本兼容。

话虽如此,如果用于编译翻译单元的编译器选项指定了特定的C++标准,则新标准中提供的任何功能都不应该可访问。这是使用__cplusplus指令完成的。有关如何使用它的有趣示例,请参见vector源文件。同样,编译器将拒绝任何由较新版本的标准提供的语法特性。

所有这些意味着您的假设只适用于您编写的头文件。当这些头文件包含在针对不同C++标准的不同翻译单元中时,这些头文件可能会导致不兼容性。这在C++标准的附录C中进行了讨论。有4个条款,我只讨论第一个,并简要提到其余部分。

C.3.1 条款2:词法约定

在C++11中,单引号用于界定字符字面值,而在C++14和C++17中,它们是数字分隔符。假设您在纯C++11的某个头文件中具有以下宏定义:

#define M(x, ...) __VA_ARGS__

// Maybe defined as a field in a template or a type.
int x[2] = { M(1'2,3'4) };

考虑包含头文件的两个翻译单元,分别针对C++11和C++14。当针对C++11时,引号内的逗号不被视为参数分隔符;只有一个参数。因此,代码等效于:
int x[2] = { 0 }; // C++11

另一方面,当目标为C++14时,单引号被解释为数字分隔符。因此,代码等效于:

int x[2] = { 34, 0 }; // C++14 and C++17

这里的重点是,在纯C++11头文件中使用单引号可能会导致针对C++14/17的翻译单元出现意外错误。因此,即使头文件是用C++11编写的,也必须小心编写,以确保其与标准的后续版本兼容。在这里,__cplusplus指令可能很有用。
标准的另外三个条款包括:
C.3.2 条款3:基本概念

Change: New usual (non-placement) deallocator

Rationale: Required for sized deallocation.

Effect on original feature: Valid C++2011 code could declare a global placement allocation function and deallocation function as follows:

void operator new(std::size_t, std::size_t); 
void operator delete(void*, std::size_t) noexcept;

In this International Standard, however, the declaration of operator delete might match a predefined usual (non-placement) operator delete (3.7.4). If so, the program is ill-formed, as it was for class member allocation functions and deallocation functions (5.3.4).

C.3.3第7条款:声明

Change: constexpr non-static member functions are not implicitly const member functions.

Rationale: Necessary to allow constexpr member functions to mutate the object.

Effect on original feature: Valid C++2011 code may fail to compile in this International Standard.

For example, the following code is valid in C++2011 but invalid in this International Standard because it declares the same member function twice with different return types:

struct S {
constexpr const int &f();
int &f();
};

C.3.4第27条款:输入/输出库

更改:未定义gets。

原理:使用gets被认为是危险的。

对原始功能的影响:使用gets函数的有效C++2011代码可能无法在此国际标准中编译。

C++14和C++17之间的潜在不兼容性在C.4中进行了讨论。由于所有非标准头文件都是用C++11编写的(如问题中所指定的),因此这些问题不会发生,因此我不会在此处提及它们。

现在我将讨论链接器级别的兼容性。一般来说,不兼容性的潜在原因包括以下内容:

  • 目标文件格式。
  • 程序启动和终止例程以及main入口点。
  • 整个程序优化(WPO)。

如果生成的目标文件格式取决于目标C++标准,则链接器必须能够链接不同的目标文件。在GCC、LLVM和VC++中,幸运的是这并不是问题。也就是说,对象文件的格式与目标标准无关,尽管它高度依赖于编译器本身。实际上,GCC、LLVM和VC++的链接器都不需要了解目标C++标准。这也意味着我们可以链接已经编译的对象文件(静态链接运行时)。

如果程序启动例程(调用main的函数)在不同的C++标准下不同,并且不兼容,则无法链接目标文件。在GCC、LLVM和VC++中,幸运的是不存在这种情况。此外,main函数的签名(以及适用于它的限制,请参见标准的第3.6节)在所有C++标准中都相同,因此它存在于哪个翻译单元中并不重要。
通常情况下,使用不同的C++标准编译的目标文件可能无法很好地与WPO一起使用。这取决于编译器的哪些阶段需要了解目标标准,哪些阶段不需要,以及跨目标文件进行的过程间优化所产生的影响。幸运的是,GCC、LLVM和VC++都设计得很好,没有这个问题(至少我不知道有)。
因此,GCC、LLVM和VC++已经被设计为支持不同版本的C++标准之间的二进制兼容性。然而,这并不是标准本身的要求。
顺便提一下,尽管VC++编译器提供了std开关,使您能够针对特定版本的C++标准进行目标设置,但它不支持针对C++11的目标设置。可以指定的最低版本是C++14,这是从Visual C++ 2013 Update 3开始的默认版本。您可以使用旧版的VC++来针对C++11进行目标设置,但是那样您将不得不使用不同的VC++编译器来编译针对不同C++标准版本的不同翻译单元,这至少会破坏WPO。

注意:我的答案可能不完整或不太精确。


1
确实,但我发现答案太长且令人困惑,特别是直到“现在我将讨论链接器级别的兼容性”。你可以用类似于“如果无法假定包含的头文件在C++11和C++14/17中具有相同的含义,则首先包含它们是不安全的”的内容替换上面的所有内容。对于剩下的部分,您是否有显示这三个要点是不兼容的唯一潜在原因的来源?无论如何感谢您的回答,我仍然会投票支持。 - ricab
@ricab 我不能确定。这就是为什么我在答案结尾添加了警告语的原因。如果我漏掉了什么,欢迎其他人扩展答案以使其更加精确或完整。 - Hadi Brais
这让我感到困惑:“使用同一编译器意味着将使用相同的标准库头文件和源代码文件(...)” 这怎么可能?如果我有旧的代码是使用gcc5编译的,那么属于那个版本的“编译器文件”就无法未来兼容。 对于在不同时间使用不同编译器版本编译的源代码,我们可以确定库头文件和源代码文件是不同的。 根据您的规则,这些文件应该是相同的,您必须使用gcc5重新编译旧的源代码,并确保它们都使用最新的(相同的)“编译器文件”。 - user2943111
如果你有一个拥有许多团队的大型公司,他们可能不会同时切换到最新版本的编译器(无论是gcc、clang还是cl.exe)。因此,有趣的是要知道我们是否可以链接由不同版本的编译器生成的目标文件,这些文件可能具有不同的标准库头文件和源文件。 - user2943111
非常好的回答。教育性和详细。谢谢。 - afp_2008
显示剩余2条评论

2
新的C++标准分为两部分:语言特性和标准库组件。
如果你指的是语言本身的变化(例如范围for循环),几乎没有问题(有时候可能会与第三方库头文件中的新语言特性发生冲突)。
但是标准库……
每个编译器版本都带有一个C++标准库实现(gcc的libstdc++,clang的libc++,VC++的MS C++标准库等),只有一个实现,而不是每个标准版本都有多个实现。在某些情况下,您可以使用除编译器提供的标准库实现之外的其他标准库实现。您应该关心的是将旧的标准库实现链接到新的标准库实现。
可能会发生冲突的是第三方库和您的代码之间的标准库(和其他库)链接。

每个编译器版本都附带有STL的实现。不,它们并不是这样的。 - Lightness Races in Orbit
@LightnessRacesinOrbit 你的意思是说,例如libstdc++和gcc之间没有关系吗? - E. Vakili
9
不,我的意思是STL已经过时超过20年了。你指的是C++标准库。至于答案的其余部分,你能提供一些支持你观点的参考/证据吗?我认为对于这样的问题很重要。 - Lightness Races in Orbit
3
抱歉,从文本中无法明确。您提出了一些有趣的主张,但尚未用任何证据支持它们。 - Lightness Races in Orbit

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