代码之家  ›  专栏  ›  技术社区  ›  RaptorFactor

何时只接受头库?

  •  26
  • RaptorFactor  · 技术社区  · 15 年前

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

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

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

    我知道,就这类事情而言,没有硬性和快速性的规则、准则等,但我只是想了解别人对这个问题的看法。

    是的,这是一个非常模糊和主观的问题,我知道,所以我把它标记为这样。

    4 回复  |  直到 13 年前
        1
  •  7
  •   Matthieu M.    15 年前

    我为一家有自己的“中间件”部门的公司工作,以维护数百个库,这些库通常由许多团队使用。

    尽管我们在同一家公司,但由于易于维护,我们不喜欢只使用头部的方法,更喜欢使用二进制兼容性而不是性能。

    一般的共识是,性能提升(如果有的话)不值得麻烦。

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

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

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

    你为什么不测试?准备这两个库(一个只包含一个头,另一个不包含两行以上的内联方法),并在您的案例中检查它们各自的性能。

    编辑:

    “jalf”(谢谢)指出,我应该精确地定义二进制兼容性的含义。

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

    因为您只能链接到给定库的一个版本 Target ,加载的所有库使用 靶标 将有效地使用同一版本…这就是这个属性传递性的原因。

    MyLib --> Lib1 (v1), Lib2 (v1)
    Lib1 (v1) --> Target (v1)
    Lib2 (v1) --> Target (v1)
    

    现在,说我们需要修复 靶标 仅用于 Lib2 ,我们提供一个新版本 (v2) . 如果 (v2) 二进制与兼容 (v1) 然后我们可以做:

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

    但是,如果不是这样,那么我们将有:

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

    是的,你读得对,即使 Lib1 不需要修复,您将根据新版本的 靶标 因为此版本对于更新的 LIB 2 Executable 只能链接到的一个版本 目标 .

    对于只包含头的库,由于没有库,因此实际上不兼容二进制。因此,每次进行某些修复(安全性、严重错误等)时,都需要交付新版本,并且所有依赖于您的库(甚至间接地)都必须根据此新版本进行重建!

        2
  •  6
  •   JoeG    15 年前

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

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

    • 编译器通常有优化选项来控制Microsoft编译器上的inline;/os数量。

    • 允许编译器管理速度和大小问题通常更好。您将只看到来自实际已内联的调用的膨胀,并且编译器将只在其启发式方法指示内联将提高性能时内联它们。

    我不认为代码膨胀是远离只包含头部的库的一个原因——但我希望您考虑一下只包含头部的方法将使编译时间增加多少。

        3
  •  3
  •   peterchen    15 年前

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

    内联膨胀主要取决于您正在使用的开发平台——特别是编译器/链接器功能。我不认为这是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() { ... }
    
        4
  •  1
  •   Steve Jessop    15 年前

    过度内联可能是调用者应该解决的问题,调整他们的编译器选项,而不是被调用者试图通过 inline 标题中的关键字和定义。例如,GCC -finline-limit 和朋友,这样您就可以对不同的翻译单元使用不同的内嵌规则。对于您来说,什么是过度内联对我来说可能不是过度内联,这取决于体系结构、指令缓存大小和速度、函数的使用方式等,而不是我曾经需要做的调整:在实践中,当它值得担心时,它值得重写,但这可能是巧合。不管怎样,如果我是一个库的用户,那么其他所有的条件都一样,我宁愿选择内联(取决于我的编译器,我可能不会接受)而不是不能内联。

    我认为,担心链接器无法删除多余的代码副本,更多的原因是担心只从头部库中出现代码膨胀。因此,不管函数实际上是否在调用站点上内联,问题是最终会得到每个使用它的对象文件的函数(或类)的可调用副本。我不记得在C++中不同的翻译单元中对内联函数的地址是否需要比较相等,但即使假设它们是这样做的,因此在链接代码中有一个“规范”的函数副本,也不一定意味着链接器实际上会删除无效的重复函数。如果只在一个翻译单元中定义了函数,那么您可以合理地确信每个使用它的静态库或可执行文件只有一个独立的副本。

    我真的不知道这种恐惧有多根深蒂固。我所做的每件事要么是因为内存太紧,以至于我们使用了 内联的 仅作为 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。