美丽的原生库
写于 2013 年 8 月 18 日,星期日
我痴迷于漂亮的 API。然而,不仅仅是 API,还包括使使用库的整体体验尽可能好。对于 Python,现在有很多最佳实践,但感觉关于如何正确构建原生库的信息并不多。原生库是什么意思?本质上是一个 dylib/DLL/so。
由于我目前在工作中花在 C 和 C++ 上的时间比 Python 多,我想我可能会抓住这个机会并收集我的想法,以了解如何编写不会惹恼用户的适当共享库。
共享库还是静态库?
这篇文章几乎完全假定您正在构建共享库,而不是静态库。虽然他们听起来只有链接方式不同,但并不是这样。
使用动态链接库,您可以更好地控制符号。动态链接库在不同编程语言之间也能更好地工作。没有什么能阻止您用 C++ 编写一个库,然后在 Python 中使用它。事实上,这也是我建议对此类类库进行单元测试的方式。稍后会详细介绍。
哪种语言?
您想写编写一个动态库,在一定程度上它应该独立于平台。那么您应当使用什么语言编写呢?现在最佳选择是 C 或 C++ ,很快 Rust 也将可用。为什么不是其他语言?很简单:因为 C 语言是真正唯一在某种程度定义了上稳定 ABI 的语言。严格来说,定义 ABI 稳定性的不是语言,而是操作系统。因此,C 语言是动态库的首选语言,C 语言调用约定也是共享库的通用约定。
“C 曾经使用过的最伟大的技巧就是让世界相信它没有运行时”。我不确定我是在哪里第一次听到这句话的,但在谈论库时它非常合适。从本质上讲,C 是如此普遍,以至于可以假定 C 标准库提供了一些基本功能。这是每个人都同意存在的一件事。对于 C++,情况要复杂得多。C++ 需要一些 C 标准库没有提供的额外功能。它首先需要支持异常处理。然而,C++ 在其他方面很好地降级为 C 调用约定,因此仍然很容易在其中编写库,这完全隐藏了幕后有 C++ 的事实。
然而,对于其他语言来说,这并不容易。例如,为什么用 Go 编写库不是一个好主意?这样做的原因是 Go for 需要相当繁重的运行时来执行垃圾收集并为其协程提供调度程序。Rust 越来越接近于除了 C 标准库之外没有任何运行时这一要求,这将使在其中编写库成为可能。
但是现在,C++ 很可能是您想要使用的语言。为什么不是 C?这样做的原因是微软的 C 编译器出了名的糟糕,它还只支持 C89 标准。显然,您可以在 Windows 上使用不同的编译器,但是如果您的库的用户想要自己编译它,这会给他们带来一大堆问题。要求非操作系统原生的工具链是减少开发人员受众的简单方法。
因此,我通常会向非常像 C 的 C++ 子集推荐:不要使用异常,不要使用 RTTI,不要使用构造函数。帖子的其余部分假定 C++ 确实是首选语言。
公有头文件
您正在构建的库最好只有一个公共头文件。但可以有任意数量的内部头文件。您的库应当存在一个公共头文件,这样 Python 的 CFFI 库可以解析头文件并从中构建绑定。所有语言的人都知道头文件是如何工作的,他们将查看它们以构建自己的绑定。
头文件中有哪些规则?
头文件保护
其他人使用的每个公共头文件都应该有足够独特的头文件保护,以确保它们可以安全地多次包含。另外,您还需要确保 C++ 有 extern "C"
标记。
这将是您的最小头文件:
#ifndef YOURLIB_H_INCLUDED
#define YOURLIB_H_INCLUDED
#ifdef __cplusplus
extern "C" {
#endif
/* code goes here */
#ifdef __cplusplus
}
#endif
#endif
导出标记
因为您自己也可能包含头文件,所以您需要确保定义了导出函数的宏。这在 Windows 上是必需的,在其他平台上也是一个非常好的主意。本质上它可以用来改变符号的可见性。我稍后会详细介绍,暂时只添加如下所示的内容:
#ifndef YL_API
# ifdef _WIN32
# if defined(YL_BUILD_SHARED) /* build dll */
# define YL_API __declspec(dllexport)
# elif !defined(YL_BUILD_STATIC) /* use dll */
# define YL_API __declspec(dllimport)
# else /* static library */
# define YL_API
# endif
# else
# if __GNUC__ >= 4
# define YL_API __attribute__((visibility("default")))
# else
# define YL_API
# endif
# endif
#endif
在 Windows 上,它会根据设置的标志适当地为 DLL 设置 YL_API(我在这里使用 YL 作为 “Your Library” 的缩写版本,选择适合您的前缀)。任何人在没有做任何花哨的事情之前包含头文件的人都会自动获得 __declspec(dllimport)
代替它。这在 Windows 上是一个非常好的默认行为。对于其他平台,除非使用较新的 GCC/clang 版本,否则不会设置任何内容,在这种情况下会添加默认可见性标记。如您所见,可以定义一些宏来更改采用的分支。例如,当您构建库时,您会告诉编译器还定义了 YL_BUILD_SHARED
。
在 Windows 上,DLL 的默认行为始终是:所有符号都不会默认导出,除非标有 __declspec(dllexport)
。不幸的是,在其他平台上,行为一直是导出所有内容。有多种方法可以解决这个问题,其中一种是 GCC 4 的可见性控制。这可以正常工作,但还有一些额外的事情需要考虑。
首先是源内可见性控制不是灵丹妙药。一开始,除非使用 -fvisibility=hidden`
编译库,否则标记将不执行任何操作。然而,比这更重要的是,这只会影响您自己的库。如果您将任何内容静态链接到您的库,该库可能会公开您不想公开的符号。想象一下,例如,您编写的库依赖于您要静态链接的另一个库。除非您阻止,否则该库的符号也将从您的库中导出。
这在不同平台上的工作方式不同。在 Linux 上,您可以将 --exclude-libs ALL
传递给 ld,链接器将自动删除这些符号。在 OS X 上它比较复杂,因为链接器中没有这样的功能。最简单的解决方案是为所有函数设置一个公共前缀。例如,如果您的所有函数都以 yl_开头,则很容易告诉链接器隐藏其他所有内容。为此,您可以创建一个符号文件,然后使用 -exported_symbols_list symbols.txt`
将链接器指向该文件。该文件的内容可以是单行 yl*
。我们可以忽略 Windows,因为 DLL 需要显式导出标记。
小心包含和定义
要注意的一件事是您的头文件不应包含太多内容。一般来说,我认为头文件包含诸如 stdint.h`
之类的东西以获得一些常见的整数类型是很好的。但是,您不应该做的是自作聪明和自己定义类型。例如:msgpack 有为 Visual Studio 2008 定义 int32_t 和一些其他类型的绝妙想法,因为它缺少 stdint.h
头文件。这是有问题的,因为那时只有一个库可以定义这些类型。相反,更好的解决方案是要求用户为旧版 Visual Studio 提供替换 stdint.h
头文件。
尤其不要在库头文件中包含 windows.h
。该头文件引入了太多内容,以至于微软添加了额外的定义以使其更精简(WINDOWS_LEAN_AND_MEAN
、WINDOWS_EXTRA_LEAN
和 NOMINMAX
)。如果您需要包含 windows.h
,请拥有一个仅包含在您的 cpp 文件中的私有头文件。
稳定的 ABI
不要将任何结构体放入公共头文件中,除非您 100% 确定您永远不会更改它们。如果您确实想公开结构并且您确实想稍后添加额外的成员,请确保用户不必分配该头文件。如果用户确实必须分配该头文件,请将版本或大小信息作为第一个成员添加到结构中。
Microsoft 通常将结构的大小放入结构中以允许稍后添加成员,但这会导致 API 使用起来并不有趣。如果可以您应当尝试避免头文件中有太多结构体,如果您不能,至少尝试提出替代方法来提高 API 的性能。
对于结构体,您还会遇到不同编译器之间的对齐方式可能不同的问题。不幸的是,在某些情况下,项目可能会强制整个项目使用不同的对齐方式,并且这显然也会影响头文件中的结构。结构越少越好 :-)
一些不言而喻的事情:不要让宏成为您的 API 的一部分。宏不是符号,不基于 C 的语言的用户会讨厌您在那里有宏。
关于 ABI 稳定性的另一个注意事项:将库的版本包含在头文件中以及编译到二进制文件中是一个非常好的主意。这样您就可以很容易地验证头文件是否与二进制文件匹配,这可以让您省去很多麻烦。
头文件中有这样的内容:
#define YL_VERSION_MAJOR 1
#define YL_VERSION_MINOR 0
#define YL_VERSION ((YL_VERSION_MAJOR << 16) | YL_VERSION_MINOR)
unsigned int yl_get_version(void);
int yl_is_compatible_dll(void);
And this in the implementation file:
unsigned int yl_get_version(void) { return YL_VERSION; } int yl_is_compatible_dll(void) { unsigned int major = yl_get_version()>> 16; return major == YL_VERSION_MAJOR; }
导出 C API
当向 C 公开 C++ API 时,不需要考虑太多。通常,对于您拥有的每个内部类,您都会有一个没有任何字段的外部不透明结构。然后提供调用内部函数的函数。想象这样一个类:
namespace yourlibrary {
class Task {
public:
Task();
~Task();
bool is_pending() const;
void tick();
const char *result_string() const;
};
}
内部 C++ API 非常明显,但如何通过 C 导出它?因为外部 ABI 现在不再知道结构有多大,所以您需要为外部调用者分配内存,或者给它一个方法来确定要分配多少内存。我通常更喜欢为外部用户分配并提供免费功能。至于如何让内存分配系统仍然灵活,请看下一部分。
现在这是外部头文件(必须在 extern "C"
大括号中):
struct yl_task_s;
typedef struct yl_task_s yl_task_t;
YL_API yl_task_t *yl_task_new();
YL_API void yl_task_free(yl_task_t *task);
YL_API int yl_task_is_pending(const yl_task_t *task);
YL_API void yl_task_tick(yl_task_t *task);
YL_API const char *yl_task_get_result_string(const yl_task_t *task);
这就是 shim 层在实现中的样子:
#define AS_TYPE(Type, Obj) reinterpret_cast<Type *>(Obj)
#define AS_CTYPE(Type, Obj) reinterpret_cast<const Type *>(Obj)
yl_task_t *yl_task_new()
{
return AS_TYPE(yl_task_t, new yourlibrary::Task());
}
void yl_task_free(yl_task_t *task)
{
if (!task)
return;
delete AS_TYPE(yourlibrary::Task, task);
}
int yl_task_is_pending(const yl_task_t *task)
{
return AS_CTYPE(yourlibrary::Task, task)->is_pending() ? 1 : 0;
}
void yl_task_tick(yl_task_t *task)
{
AS_TYPE(yourlibrary::Task, task)->tick();
}
const char *yl_task_get_result_string(const yl_task_t *task)
{
return AS_CTYPE(yourlibrary::Task, task)->result_string();
}
注意构造函数和析构函数是如何被完全包装的。现在标准 C++ 存在一个问题:它会引发异常。因为构造函数没有返回值来向外部发出出错的信号,所以如果分配失败,它将引发异常。然而,这不是唯一的问题。我们现在如何自定义库分配内存的方式?C++ 在这方面非常丑陋。但它在很大程度上是可以修复的。
在我们继续之前:请在任何情况下都不要创建一个库,它会用通用名称污染命名空间。始终在所有符号(如 yl_
)之前放置一个公共前缀,以降低命名空间冲突的风险。
上下文对象
全局状态很糟糕,那么解决方案是什么?一般来说,解决方案是使用我称之为 “上下文” 的对象来保存状态。这些对象将包含您将放入全局变量的所有重要内容。这样,您库的用户就可以拥有其中的多个。然后让每个 API 函数将该上下文作为第一个参数。
如果您的库不是线程安全的,这将特别有用。这样您至少可以每个线程有一个,这可能已经足以从您的代码中获得一些并行性。
理想情况下,这些上下文对象中的每一个都可以使用不同的分配器,但考虑到在 C++ 中这样做的复杂性,如果您没有做到这一点,我不会感到非常失望。
自定义内存分配
如前所述,构造函数可能会失败,而我们想要自定义内存分配,那么我们该怎么做呢?在 C++ 中,有两个系统负责内存分配:分配运算符 operator new
和 o`perator new[]` 以及容器分配器。如果您想自定义分配器,则需要同时处理这两者。首先,您需要一种方法让其他人覆盖分配器函数。最简单的是在公共头文件中提供类似这样的内容:
YL_API void yl_set_allocators(void *(*f_malloc)(size_t),
void *(*f_realloc)(void *, size_t),
void (*f_free)(void *));
YL_API void *yl_malloc(size_t size);
YL_API void *yl_realloc(void *ptr, size_t size);
YL_API void *yl_calloc(size_t count, size_t size);
YL_API void yl_free(void *ptr);
YL_API char *yl_strdup(const char *str);
然后在您的内部头文件中,您可以添加一堆内联函数,这些函数重定向到设置为内部结构的函数指针。因为我们不允许用户提供 calloc
和 strdup
您可能还想重新实现这些功能:
struct yl_allocators_s { void *(*f_malloc)(size_t); void *(*f_realloc)(void *, size_t); void (*f_free)(void *); }; extern struct yl_allocators_s _yl_allocators; inline void *yl_malloc(size_t size) { return _yl_allocators.f_malloc(size); } inline void *yl_realloc(void *ptr, size_t size) { return _yl_allocators.f_realloc(ptr, size); } inline void yl_free(void *ptr) { _yl_allocators.f_free(ptr); } inline void *yl_calloc(size_t count, size_t size) { void *ptr = _yl_allocators.f_malloc(count * size); memset(ptr, 0, count * size); return ptr; } inline char *yl_strdup(const char *str) { size_t length = strlen(str) + 1; char *rv = (char *)yl_malloc(length); memcpy(rv, str, length); return rv; }
对于分配器本身的设置,您可能希望将其放入单独的源文件中:
struct yl_allocators_s _yl_allocators = {
malloc,
realloc,
free
};
void yl_set_allocators(void *(*f_malloc)(size_t),
void *(*f_realloc)(void *, size_t),
void (*f_free)(void *))
{
_yl_allocators.f_malloc = f_malloc;
_yl_allocators.f_realloc = f_realloc;
_yl_allocators.f_free = f_free;
}
内存分配器和 C++
现在我们已经设置了这些函数,我们如何让 C++ 使用它们呢?这部分很棘手而且很烦人。要通过 yl_malloc
分配自定义类,您需要在所有类中实现分配运算符。因为这是一个相当重复的过程,所以我建议为它编写一个可以放在类的私有部分中的宏。我选择按照惯例选择它必须进入私有状态,即使它实现的功能是公开的。我这样做主要是为了让它靠近定义数据的位置,在我的例子中,这通常是私有的。您需要确保不要忘记将该宏添加到所有类的私有部分:
#define YL_IMPLEMENTS_ALLOCATORS \
public: \
void *operator new(size_t size) { return yl_malloc(size); } \
void operator delete(void *ptr) { yl_free(ptr); } \
void *operator new[](size_t size) { return yl_malloc(size); } \
void operator delete[](void *ptr) { yl_free(ptr); } \
void *operator new(size_t, void *ptr) { return ptr; } \
void operator delete(void *, void *) {} \
void *operator new[](size_t, void *ptr) { return ptr; } \
void operator delete[](void *, void *) {} \
private:
以下是示例用法:
class Task { public: Task(); ~Task(); private: YL_IMPLEMENTS_ALLOCATORS; // ... };
现在您所有的类都将通过您的分配器函数分配。但是如果您想使用 STL 容器怎么办?这些容器还不会通过您的函数分配。要解决该特定问题,您需要编写一个 STL 代理分配器。这是一个非常烦人的过程,因为界面非常复杂,基本上什么都不做。
#include <limits>
template <class T>
struct proxy_allocator {
typedef size_t size_type;
typedef ptrdiff_t difference_type;
typedef T *pointer;
typedef const T *const_pointer;
typedef T& reference;
typedef const T &const_reference;
typedef T value_type;
template <class U>
struct rebind {
typedef proxy_allocator<U> other;
};
proxy_allocator() throw() {}
proxy_allocator(const proxy_allocator &) throw() {}
template <class U>
proxy_allocator(const proxy_allocator<U> &) throw() {}
~proxy_allocator() throw() {}
pointer address(reference x) const { return &x; }
const_pointer address(const_reference x) const { return &x; }
pointer allocate(size_type s, void const * = 0) {
return s ? reinterpret_cast<pointer>(yl_malloc(s * sizeof(T))) : 0;
}
void deallocate(pointer p, size_type) {
yl_free(p);
}
size_type max_size() const throw() {
return std::numeric_limits<size_t>::max() / sizeof(T);
}
void construct(pointer p, const T& val) {
new (reinterpret_cast<void *>(p)) T(val);
}
void destroy(pointer p) {
p->~T();
}
bool operator==(const proxy_allocator<T> &other) const {
return true;
}
bool operator!=(const proxy_allocator<T> &other) const {
return false;
}
};
所以在我们继续之前,如何使用这个可憎的东西?像这样:
#include <deque>
#include <string>
typedef std::deque<Task *, proxy_allocator<Task *> > TaskQueue;
typedef std::basic_string<char, std::char_traits<char>,
proxy_allocator<char> > String;
我建议在某处制作一个头文件,定义您要使用的所有容器,然后强迫自己不要使用 STL 中的任何其他内容,而无需对其进行类型定义以使用正确的分配器。注意:不要像调用全局新运算符那样使用 new TaskQueue()
那些东西。将它们作为成员放在您自己的结构中,以便分配作为具有自定义分配器的对象的一部分发生。或者只是将它们放在堆栈上。
内存分配失败
在我看来,处理内存分配失败的最好方法是不去处理它们,而是尽量不要导致内存分配失败。对于一个容易实现的库,只要知道在最坏的情况下您会分配多少内存,如果您没有限制,为库的用户提供一种了解事情有多糟糕的方法。这样做的原因是也没有人处理分配失败。
首先,STL 完全依赖 operator new 抛出的 std::bad_alloc
异常(我们在上面没有这样做,嘿嘿)。并且只会冒出错误让您处理它。当您在没有异常处理的情况下编译您的库时,该库将终止该进程。这是非常可怕的,但如果您不小心的话,无论如何都会发生这种情况。我见过更多忽略 malloc 返回值的代码,而不是正确处理它的代码。
除此之外:在某些系统上,malloc 会完全骗您说有多少可用内存。Linux 会很乐意为您提供指向它无法用实际物理内存备份的内存的指针。这种法定内存行为非常有用,但也意味着您通常已经不得不假设分配失败可能不会发生。因此,如果您使用 C++ 并且还想坚持使用 STL,那么与其报告分配错误,不如放弃它,只是不要耗尽内存。
在计算机游戏中,一般概念是为子系统提供它们自己的分配器,并确保它们永远不会分配比给定的更多的东西。EA 似乎推荐分配器来处理分配失败。例如,当它无法加载更多内存时,它会检查是否可以释放一些不需要的资源(如缓存),而不是让调用者知道存在内存故障。这甚至适用于 C++ 标准为分配器提供的有限设计。
构建
既然您已经编写了代码,那么如何在不让用户不满意的情况下构建您的库呢?如果您像我一样来自 Unix 背景,其中 makefile 就是构建软件的基础。然而,这并不是每个人都想要的。autotools/autoconf 是非常糟糕的软件,如果您把它交给 Windows 专家,他们会用各种各样的名字称呼您。相反,请确保周围有 Visual Studio 解决方案。
如果您因为 Visual Studio 不是您选择的工具链而不想使用它怎么办?如果您想让解决方案和 makefile 保持同步怎么办?该问题的答案是 premake 或 cmake. 。您使用两者中的哪一个在很大程度上取决于您。两者都可以通过简单的定义脚本生成 Makefile、XCode 或 Visual Studio 解决方案。
我曾经是 cmake 的超级粉丝,但现在我转向了 premake。这样做的原因是 cmake 有一些我需要自定义的硬编码内容(例如,为 Xbox 360 构建 Visual Studio 解决方案是您无法使用 stock cmake 完成的)。Premake 有许多与 cmake 相同的问题,但它几乎完全用 lua 编写并且可以轻松定制。Premake 本质上是一个包含 lua 解释器和一堆 lua 脚本的可执行文件。重新编译很容易,如果您不想,您的预定义文件可以覆盖所有内容,只要您知道如何操作即可。
测试
最后:您如何测试您的库?现在显然有大量用 C 和 C++ 编写的测试工具可供您使用,但我认为最好的工具实际上在其他地方。共享库不只是供 C 和 C++ 使用,您可以在多种语言中使用它们。有什么更好的方法可以通过非 C++ 语言来测试您的 API?
最大的优势是提高了迭代速度。我根本不需要编译我的测试,它们只是运行。不仅没有编译步骤,我还可以利用 Python 的动态类型和 py.test 的良好断言语句。我自己编写助手来打印信息并在我的库和 Python 之间转换数据,我得到了良好的错误报告的所有好处。
第二个优点是隔离性好。 pytest-xdist 是 py.test 的一个插件,它将 --boxed
标志添加到 py.test,它在单独的进程中运行每个测试。如果您的测试可能因段错误而崩溃,这将非常有用。如果您在您的系统上启用了核心转储,您可以随后在 gdb 中加载段错误并找出问题所在。这也非常有效,因为您不需要处理由于断言失败而代码跳过清理而发生的内存泄漏。操作系统将分别清理每个测试。不幸的是,这是通过 fork()
系统调用实现的,所以它现在在 Windows 上不能很好地工作。
那么如何将您的库与 CFFI 一起使用呢?您需要做两件事:您需要确保您的公共头文件不包含任何其他头文件。如果您不能这样做,只需添加一个禁用包含的定义(如 YL_NOINCLUDE
)。
这就是使 CFFI 工作所需的全部内容:
import os
import subprocess
from cffi import FFI
here = os.path.abspath(os.path.dirname(__file__))
header = os.path.join(here, 'include', 'yourlibrary.h')
ffi.cdef(subprocess.Popen([
'cc', '-E', '-DYL_API=', '-DYL_NOINCLUDE',
header], stdout=subprocess.PIPE).communicate()[0])
lib = ffi.dlopen(os.path.join(here, 'build', 'libyourlibrary.dylib'))
将它放在测试旁边名为 testhelpers.py
的文件中。
现在很明显,这是只适用于 OS X 的简单版本,但它很容易扩展到不同的操作系统。本质上,这会调用 C 预处理器并添加一些额外的定义,然后将其返回值提供给 CFFI 解析器。之后您有一个漂亮的包装库可以使用。
这里有一个这样的测试的例子。只需将它放在一个名为 test_something.py
的文件中,然后让 py.test 执行它:
import time
from testhelpers import ffi, lib
def test_basic_functionality():
task = lib.yl_task_new()
while lib.yl_task_is_pending(task)
lib.yl_task_process(task)
time.sleep(0.001)
result = lib.yl_task_get_result_string(task)
assert ffi.string(result) == ''
lib.yl_task_free(task)
py.test 还有其他优点。例如,它支持允许您设置可在测试之间重复使用的公共资源的固定装置。这非常有用,例如,如果使用您的库需要创建某种上下文对象,在其上设置通用配置,然后销毁它。
为此,只需创建一个包含以下内容的 conftest.py
文件:
import pytest
from testhelpers import lib, ffi
@pytest.fixture(scope='function')
def context(request):
ctx = lib.yl_context_new()
lib.yl_context_set_api_key(ctx, "my api key")
lib.yl_context_set_debug_mode(ctx, 1)
def cleanup():
lib.yl_context_free(ctx)
request.addfinalizer(cleanup)
return ctx
现在要使用它,您需要做的就是将一个名为 context
的参数添加到您的测试函数中:
from testhelpers import ffi, lib def test_basic_functionality(context): task = lib.yl_task_new(context) ...
概括
由于这比平时更长,这里简要总结了构建本机共享库时要记住的最重要的事情:
用 C 或 C++ 编写它,带有运行时的语言。
不要使用全局状态!
不要在公共头文件中定义常见类型
不要在您的公共头文件中包含像
windows.h
这样疯狂的头文件。谨慎选择头文件中的 include。考虑添加一种通过定义禁用所有 include 的方法。
好好照顾您的命名空间。不要暴露您不想暴露的符号。
创建一个像
YL_API
这样的宏,为您要公开的每个符号添加前缀。尝试构建一个稳定的 ABI
不要对结构发疯
让人们自定义内存分配器。如果您不能针对每个 “上下文” 对象执行此操作,至少请针对每个库执行此操作。
使用 STL 时要小心,始终只能通过添加分配器的 typedef。
不要强迫您的用户使用您最喜欢的构建工具,始终确保库的用户找到合适的 Visual Studio 解决方案和 makefile。
就是这样!快乐的库建设!