什么情况下可以使用头文件库?

30

个人而言,我相当喜欢只有头文件的库,但有人声称它们会导致代码膨胀,原因是过度内联(以及更长的编译时间等其他明显问题)。

我想知道这些说法有多少真实性(关于膨胀的说法)。

此外,这些成本是否“合理”?(显然有无法避免的情况,比如它是仅使用模板实现的库,但我更感兴趣的是有选择余地的情况。)

我知道在这方面没有硬性规定、指南等,但我只是想了解别人对这个问题的看法。

附言:是的,这是一个非常模糊和主观的问题,我知道,并且已经标记为这样的问题。


相关链接:https://dev59.com/HHI95IYBdhLWcg3wvwoQ | https://softwareengineering.stackexchange.com/questions/305618/are-header-only-libraries-more-efficient - Ciro Santilli OurBigBook.com
有趣的是,自从这个问题(以及类似的问题)提出以来,C++已经发生了很多变化。链接时间优化现在对某些人来说更具可能性,而inline关键字不再实际影响编译器是否将函数内联。另外,不确定编译器是否普遍改进,但似乎担心编译器过度内联的问题已经不存在了。 - thomasrutter
4个回答

9

根据我的经验,代码膨胀并不是一个问题:

  • 仅有头文件库给编译器提供了更大的能力来进行内联,但它们并不会强制编译器进行内联操作——很多编译器将`inline`关键字视为忽略多个相同定义的命令。

  • 编译器通常有优化选项来控制内联的数量。例如,在微软的编译器上可使用`/Os`。

  • 通常最好允许编译器管理速度和大小之间的问题。只有当被调用的函数实际上已经被内联后,你才会看到代码膨胀,而编译器只会在其启发式算法表明内联可以提高性能时才进行内联操作。

我不认为代码膨胀是避免使用仅有头文件库的原因,但我会建议你考虑一下头文件库会增加编译时间的程度。


8
我为一家公司工作,该公司拥有一个“中间件”部门来维护许多团队普遍使用的几百个库。尽管在同一家公司,但我们避免使用仅头文件的方法,而是更喜欢优先考虑二进制兼容性而不是性能,因为后者更容易维护。普遍认为,性能提升(如果有的话)不值得麻烦。此外,所谓的“代码膨胀”可能会对性能产生负面影响,因为需要加载更多代码到缓存中,这些会导致性能下降。在理想情况下,我认为编译器和链接器应该足够聪明,可以不生成那些“多个定义”规则,但只要还没有实现这种情况,我将(个人)更喜欢:二进制兼容性和非内联(对于超过几行的方法)。为什么不试一下?准备两个库(一个仅包含头文件,另一个则不会包含超过几行的内联方法),并在您的情况下检查它们各自的性能。编辑: 'jalf'指出(感谢),我应该明确我所说的二进制兼容性的精确含义。给定库的两个版本被称为二进制兼容,如果您可以(通常)链接其中一个而不需要更改自己的库。因为只能链接给定库Target的一个版本,所有加载使用Target的库都将有效地使用相同的版本......这就是此属性的传递性的原因。
MyLib --> Lib1 (v1), Lib2 (v1)
Lib1 (v1) --> Target (v1)
Lib2 (v1) --> Target (v1)

现在,假设我们需要修复Target中仅由Lib2使用的功能,我们会发布一个新版本(v2)。如果(v2)(v1)二进制兼容,则我们可以执行以下操作:

Lib1 (v1) --> Target (v2)
Lib2 (v1) --> Target (v2)

然而,如果不是这种情况,我们将会有以下情况:
Lib1 (v2) --> Target (v2)
Lib2 (v2) --> Target (v2)

没错,尽管Lib1没有要求修复,但是您需要针对新版本的Target重新构建它,因为这个版本是更新的Lib2所必需的,而Executable只能链接一个版本的Target

对于头文件库来说,由于您没有库,因此实际上不具有二进制兼容性。因此,每次进行一些修复(安全性、关键错误等),您都需要提供一个新版本,并且所有依赖于您的库(甚至是间接的)都必须针对这个新版本进行重建!


3
仅包含头文件的库如何暗示代码膨胀?如果这会导致代码显著变大,编译器通常不会进行内联。此外,仅包含头文件的库如何影响二进制兼容性? - jalf
@Matthieu:测试确实总是有益的,可以确定哪种方法可以产生最佳结果。 - DrYak
1
@Jalf:*** 1. 仅有头文件的情况:对于库的每一次更改,更新库意味着重新编译所有依赖它的项目。(想象一下Matthieu的情况:数百个库,许多团队 - 需要大量重新编译)。*** 2. 仅有二进制文件的情况:对于许多不同的更改,只需放置一个新的.SO或.DLL文件即可进行更新,只要ABI保持不变。虽然新功能可能会更改方法签名和数据结构,但关键更新(安全或稳定性)很少这样做。 - DrYak
1
关于重新编译是正确的,但这与代码膨胀或二进制兼容性无关。 - jalf
@Matthieu: "只有一个地址" 是指 @peterchen 说,如果将多个函数定义为 inline,则无法依赖链接器将它们合并在一起。你是正确的,如果允许代码的不同部分看到函数的不同版本,那么这就构成了明显的 ODR 违规,你将需要花费很多时间进行调试。但是这种情况可能会发生,无论是否使用头文件库,并且假设在更改其依赖的头文件时不重新编译代码。我理解你想说什么,但我不同意这是头文件库固有的问题。 - jalf
显示剩余10条评论

3

我认为,内联库更易于使用。

内联库的膨胀程度主要取决于您正在使用的开发平台,具体而言,取决于编译器/链接器的能力。除非出现少数特殊情况,否则我不认为在VC9中这会成为一个重大问题。

我曾经在一个庞大的VC6项目中看到过一些地方的最终大小发生了显着变化,但很难确定一个具体的“可接受的”,您可能需要尝试在您的devenv中使用您的代码。

第二个问题可能是编译时间,即使使用预编译头文件(也有一些权衡)。

第三个问题是某些结构是有问题的 - 例如,在翻译单元之间共享静态数据成员 - 或者避免在每个翻译单元中拥有单独的实例。


我见过以下机制给用户选择:

// foo.h
#ifdef MYLIB_USE_INLINE_HEADER
#define MYLIB_INLINE inline
#else 
#define MYLIB_INLINE 
#endif

void Foo();  // a gazillion of declarations

#ifdef MYLIB_USE_INLINE_HEADER
#include "foo.cpp"
#endif

// foo.cpp
#include "foo.h"
MYLIB_INLINE void Foo() { ... }

1
过度内联可能应该由调用者通过调整编译器选项来解决,而不是由被调用者尝试通过头文件中的inline关键字和定义来控制它。例如,GCC有-finline-limit等选项,因此您可以为不同的翻译单元使用不同的内联规则。对于您来说过度内联可能并非如此,这取决于架构、指令缓存大小和速度以及函数的使用方式等因素。在实践中,当值得担心时,重写就很有意义,但这可能只是巧合。无论哪种方式,如果我是库的用户,其他条件相同,我宁愿选择内联选项(取决于我的编译器,我可能不会采用)而不是无法内联。

我认为来自仅包含头文件的库的代码膨胀的恐惧更多地来自于担心链接器无法删除冗余的代码副本。因此,无论函数是否在调用站点内联,关注点都是您最终会得到一个可调用的函数(或类)副本,每个使用它的对象文件都有一个。我不记得在C ++中不同翻译单元中内联函数的地址是否必须相等,但即使假设它们确实如此,以便在链接代码中有一个“规范”函数的副本,这并不一定意味着链接器实际上会删除死亡的重复函数。如果该函数仅在一个翻译单元中定义,则可以合理地确信每个使用它的静态库或可执行文件只有一个独立的副本。

我真的不知道这种担忧有多合理。我所做的一切要么受到严格的内存限制,我们只使用 inline 作为 static inline 函数,这些函数非常小,我们并不指望内联版本比调用代码明显更大,也不介意重复;要么受到宽松的约束,我们不在乎任何地方是否存在重复。我从未在各种不同编译器中找到和计算重复的中间地带。虽然我偶尔听说过模板代码会出现问题,所以我相信这些说法是真实的。
现在随着我的思路,我认为如果你发布一个仅包含头文件的库,用户总是可以自行修改。编写一个新的头文件来声明所有的函数,再编写一个包含定义的新的翻译单元。类中定义的函数必须移到外部定义位置,所以如果你想支持这种使用方式而不需要用户分叉你的代码,你可以避免这样做,并提供两个头文件:
// declare.h
inline int myfunc(int);

class myclass {
    inline int mymemberfunc(int);
};

// define.h
#include "declare.h"
int myfunc(int a) { return a; }

int myclass::mymemberfunc(int a) { return myfunc(a); }

呼叫者如果担心代码膨胀,可以通过在所有文件中包含declare.h然后编写以下内容来愚弄编译器:
// define.cpp
#include "define.h"

他们可能还需要避免整个程序优化,以确保代码不会被内联,但是这样一来,即使非内联函数也无法保证不会被整个程序优化内联。

不担心代码膨胀的调用者可以在所有文件中使用define.h。


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