29

就个人而言,我非常喜欢仅包含标头的库,但有人声称它们会由于过度内联而导致代码膨胀(以及另一个明显的编译时间较长的问题)。

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

此外,成本是否“合理”?(显然有不可避免的情况,例如当它是一个纯粹或主要使用模板实现的库时,但我更感兴趣的是实际上有可用选择的情况。)

我知道就这样的事情而言,没有硬性规定、指导方针等,但我只是想了解其他人对这个问题的看法。

PS 是的,这是一个非常模糊和主观的问题,我知道,因此我将其标记为这样。

4

4 回答 4

9

根据我的经验,膨胀不是问题:

  • 仅标头库为编译器提供了更大的内联能力,但它们不会强制编译器内联 - 许多编译器将 inline 关键字视为忽略多个相同定义的命令。

  • 编译器通常有优化选项来控制内联的数量;/Os 在微软的编译器上。

  • 通常最好让编译器来管理速度与大小的问题。您只会看到实际已内联的调用的膨胀,并且编译器只有在其启发式表明它内联会提高性能时才会内联它们。

我不会认为代码膨胀是远离仅标头库的理由 - 但我会敦促您考虑仅标头方法会增加多少编译时间。

于 2010-02-01T11:58:07.300 回答
7

我为一家拥有自己的“中间件”部门的公司工作,该部门负责维护数百个被许多团队常用的库。

尽管在同一家公司,但我们避免仅使用标头方法,而是更喜欢二进制兼容性而不是性能,因为易于维护。

普遍的共识是性能增益(如果有的话)不值得麻烦。

此外,所谓的“代码膨胀”可能会对性能产生负面影响,因为要在缓存中加载更多代码意味着更多的缓存未命中,而这些都是性能杀手。

在一个理想的世界中,我认为编译器和链接器可能足够智能,不会生成那些“多重定义”规则,但只要不是这种情况,我会(个人)赞成:

  • 二进制兼容性
  • 非内联(对于多于几行的方法)

你为什么不测试?准备两个库(一个只有标题,另一个没有几行内联方法)并检查它们在您的情况下各自的性能。

编辑:

'jalf' (谢谢)已经指出,我应该准确地说二进制兼容性是什么意思。

如果您可以(通常)链接到一个或另一个而不更改您自己的库,则称给定库的 2 个版本是二进制兼容的。

因为您只能链接给定库的一个版本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.

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

于 2010-02-01T07:41:27.540 回答
3

我同意,内联库更容易使用。

内联膨胀主要取决于您正在使用的开发平台 - 特别是编译器/链接器功能。我不认为它会成为 VC9 的主要问题,除非在少数极端情况下。

我在大型 VC6 项目的某些地方看到了最终大小的一些显着变化,但很难给出一个具体的“可接受的,如果...”。您可能需要尝试在您的开发环境中使用您的代码。

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

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


我已经看到以下机制给用户一个选择:

// 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() { ... }
于 2010-02-01T12:32:31.360 回答
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。

于 2010-02-01T11:20:58.067 回答