库设计:允许用户在“仅标题”和动态链接之间做出决定?

问题描述 投票:28回答:7

我创建了几个目前只有标头的C ++库。我的类的接口和实现都是用相同的.hpp文件编写的。

我最近开始认为这种设计不是很好:

  1. 如果用户想要编译库并动态链接它,他/她不能。
  2. 更改单行代码需要完全重新编译依赖于库的现有项目。

我真的很喜欢只有头文件库的方面:所有函​​数都可以内联,并且它们非常容易包含在你的项目中 - 不需要编译/链接任何东西,只需要一个简单的#include指令。

是否有可能充分利用两个世界?我的意思是 - 允许用户选择他/她想要如何使用库。它还可以加快开发速度,因为我以“动态链接模式”处理库以避免荒谬的编译时间,并以“仅标题模式”发布我的成品以最大化性能。

第一个逻辑步骤是在.hpp.inl文件中划分接口和实现。

不过,我不确定如何前进。我已经看到很多库将LIBRARY_API宏添加到它们的函数/类声明中 - 可能需要类似的东西来允许用户选择?


我的所有库函数都以inline关键字为前缀,以避免“多个定义......”错误。我假设关键字将被LIBRARY_INLINE文件中的.inl宏替换?宏将解析为inline为“仅标题模式”,而对于“动态链接模式”则无效。

c++ c++11 libraries library-design header-only
7个回答
14
投票

初步说明:我假设是Windows环境,但这应该可以轻松转移到其他环境。

您的图书馆必须为四种情况做好准备:

  1. 用作仅限标头的库
  2. 用作静态库
  3. 用作动态库(导入函数)
  4. 构建为动态库(导出功能)

因此,让我们为这些情况组成四个预处理器定义:INLINE_LIBRARYSTATIC_LIBRARYIMPORT_LIBRARYEXPORT_LIBRARY(这只是一个例子;你可能想要使用一些复杂的命名方案)。用户必须根据他/她想要的内容定义其中一个。

然后你可以这样写你的标题:

// foo.hpp

#if defined(INLINE_LIBRARY)
#define LIBRARY_API inline
#elif defined(STATIC_LIBRARY)
#define LIBRARY_API
#elif defined(EXPORT_LIBRARY)
#define LIBRARY_API __declspec(dllexport)
#elif defined(IMPORT_LIBRARY)
#define LIBRARY_API __declspec(dllimport)
#endif

LIBRARY_API void foo();

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

您的实现文件看起来像往常一样:

// foo.cpp

#include "foo.hpp"
#include <iostream>

void foo()
{
    std::cout << "foo";
}

如果定义了INLINE_LIBRARY,则函数将内联声明,并且实现将像.inl文件一样包含在内。

如果定义了STATIC_LIBRARY,则声明函数时不带任何说明符,并且用户必须将.cpp文件包含到他/她的构建过程中。

如果定义了IMPORT_LIBRARY,则导入函数,并且不需要任何实现。

如果定义了EXPORT_LIBRARY,则导出函数,用户必须编译这些.cpp文件。

在静态/导入/导出之间切换是一个非常常见的事情,但我不确定是否在方程式中添加标题是一件好事。通常,有充分的理由来定义内联或不内联。

就个人而言,我喜欢将所有内容放入.cpp文件,除非它真的必须内联(如模板),或者它在性能方面有意义(非常小的功能,通常是单行)。这减少了编译时间和 - 更重要的 - 依赖性。

但是,如果我选择内联定义内容,我总是把它放在单独的.inl文件中,只是为了保持头文件清晰易懂。


4
投票

它是特定于操作系统和编译器的。在Linux上使用最新的GCC编译器(版本4.9),您可以使用interprocedural linktime optimization生成静态库。

这意味着您在编译和库链接时使用g++ -O2 -flto构建库,并且在调用程序的编译和链接时使用g++ -O2 -flto库。


2
投票

这是为了补充@Horstling的答案。


您可以创建静态库或动态库。创建静态链接库时,所有函数/对象的已编译代码将保存到文件中(在Windows中具有.lib扩展名)。在主项目(使用库的项目)的链接时,这些代码将与主项目代码一起链接到最终的可执行文件中。所以最终的可执行文件不会有任何运行时依赖。

动态链接的库将在运行时(而不是链接时)合并到主项目中。编译库时,会得到一个.dll文件(包含实际编译的代码)和一个.lib文件(其中包含足够的数据供编译器/运行时查找.dll文件中的函数/对象)。在链接时,可执行文件将配置为加载.dll并根据需要使用该.dll中的编译代码。您需要使用可执行文件分发.dll文件才能运行它。

在设计库时,无需在静态或动态链接(或仅标题)之间进行选择,您可以创建多个项目/生成文件,一个用于创建静态.lib,另一个用于创建.lib / .dll对,以及分发两个版本,供用户选择。 (您需要使用@Horstling建议的预处理器宏)。


除非使用名为Explicit Instantiation的技术来限制模板参数,否则不能在预编译的库中放置任何模板。

另请注意,现代编译器/链接器通常不遵循内联修饰符。它们可以内联函数,即使它没有被指定为内联函数,也可以动态调用另一个具有内联修饰符的函数,因为它们认为合适。 (无论如何,我建议明确地将内联放在适用的位置以获得最大兼容性)。因此,如果使用静态链接库而不是仅限头库(并且当然启用编译器/链接器优化),则不会有任何运行时性能损失。正如其他人所建议的那样,对于确实受益于内联调用的非常小的函数,最好将它们放在头文件中,这样动态链接的库也不会遭受任何重大的性能损失。 (在任何情况下,内联函数只会影响经常调用的函数的性能,内部循环将被调用数千次/数百万次)。


您可以更改makefile /项目设置并将foo.cpp添加到要编译的源文件列表中,而不是将内联函数放在头文件中(标题中包含#include "foo.cpp")。这样,如果你改变任何函数实现,就不需要重新编译整个项目,只会重新编译foo.cpp。正如我之前提到的,优化编译器仍然会内联您的小函数,您无需担心这一点。


如果使用/设计预编译库,则应考虑使用与主项目不同版本的编译器编译库的情况。每个不同的编译器版本(甚至不同的配置,如Debug或Release)使用不同的C运行时(诸如memcpy,printf,fopen等...)和C ++标准库运行时(诸如std :: vector <>之类的东西,std :: string,...)。这些不同的库实现可能使链接复杂化,甚至创建运行时错误。

作为一般规则,始终避免跨库共享编译器运行时对象(标准未定义的数据结构,如FILE *),因为不兼容的数据结构将导致运行时错误。

链接项目时,必须将C / C ++运行时函数链接到库.lib或.lib / .dll或可执行文件.exe中。 C / C ++运行时本身可以链接为静态或动态库(您可以在makefile / project设置中设置它)。

您会发现在库和主项目中动态链接到C / C ++运行库(即使将库本身编译为静态库)也可以避免大多数链接问题(在多个运行时版本中具有重复的函数实现)。当然,您需要为您的可执行文件和库分发所有使用版本的运行时DLL。

有些场景需要静态链接到C / C ++运行时,在这些情况下,最好的方法是使用与主项目相同的编译器设置来编译库,以避免链接问题。


2
投票

Rationale

尽可能在头文件中尽可能少地放在库模块中,因为你提到的原因是:编译时依赖性和编译时间长。标题模块的唯一好处是:

  1. 用户定义模板参数的通用模板;
  2. 内联时非常短的便利功能提供了显着的性能。

在案例1中,通常可以隐藏一些不依赖于.cpp文件中用户定义类型的功能。

Conclusion

如果你坚持这个基本原理,那就没有选择:必须允许用户定义类型的模板化功能不能预编译,但需要一个只有头的实现。应该在库中向用户隐藏其他功能,以避免将它们暴露给实现细节。


1
投票

您可以使用预编译的静态库和瘦头文件,而不是动态库。在交互式快速构建中,如果实现细节发生变化,您将获得不必重新编译世界的好处。但是,完全优化的发布版本可以进行全局优化,并且仍然可以确定它可以内联函数。基本上,通过“链接时代码生成”,工具集可以实现您所考虑的技巧。

我熟悉微软的编译器,我从Visual Studio 2010(如果不是更早的话)中确实知道这一点。


1
投票

模板化代码必须是仅标头:为了实例化此代码,必须在编译时知道类型参数。无法在共享库中嵌入模板代码。只有.NET和Java支持字节码的JIT实例化。

Re:非模板代码,对于简短的单行代码,我建议保留它仅限标题。内联函数为编译器提供了更多优化最终代码的机会。

为了避免“疯狂的编译时间”,Microsoft Visual C ++具有“预编译头”功能。我不认为GCC有类似的功能。

在任何情况下都不应该内联长函数。

我有一个项目只有标题位,编译库位和一些我无法确定属于哪个位。我最终得到.inc文件,有条件地包含在.hpp或.cxx中,具体取决于#ifdef。说实话,项目总是以“最大内联”模式编译,所以过了一段时间我摆脱了.inc文件,只是将内容移动到.hpp文件。


0
投票

是否有可能充分利用两个世界?

就...而言;由于工具不够智能,因此出现了限制。 This answer提供了目前最好的努力,仍然足够便携,可以有效地使用。

我最近开始认为这种设计不是很好。

它应该是。仅头文件库是理想的,因为它们简化了部署:使语言的重用机制几乎与所有其他语言相似,这只是理智的事情。但这是C ++。当前的C ++工具仍然依赖于半个世纪的链接模型,这些模型消除了重要的灵活性,例如选择在单个级别导入或导出哪些入口点而不必强制更改库的原始源代码。此外,C ++缺乏适当的模块系统,仍然依赖于美化的复制粘贴操作(尽管这只是问题的一个副因素)。

事实上,MSVC在这方面要好一些。它是尝试在C ++中实现某种程度的模块化的唯一主要实现(通过尝试例如C++ modules)。它是唯一实际允许的编译器,例如下列:

//// Module.c++
#pragma once
inline void Func() { /* ... */ }

//// Program1.c++
#include <Module.c++>
// Inlines or "vague" links Func(), whatever is better.
int main() { Func(); }

//// Program2.c++
// This forces Func() to be imported.
// The declaration must come *BEFORE* the definition.
__declspec(dllimport) __declspec(noinline) void Func();
#include <Module.c++>
int main() { Func(); }

//// Program3.c++
// This forces Func() to be exported.
__declspec(dllexport) __declspec(noinline) void Func();
#include <Module.c++>

请注意,这可以用于有选择地从库中导入和导出单个符号,尽管仍然很麻烦。

GCC也接受这一点(但必须更改声明的顺序)并且Clang没有任何方法可以在不更改库的源的情况下实现相同的效果。

© www.soinside.com 2019 - 2024. All rights reserved.