[译文] 编写 C++ 库(一): 设计
Link
这篇文章介绍了编写一个 C++ 库的基本知识.
这篇文章最初来自一些人的疑问. 于是我为希望深入了解关于”库”的总体概念, 尤其是 C++ 库的开发者们写下了这篇简短的介绍文.
这是两篇系列文章的第一部分, 第二部分可以在这里找到.
如果你有兴趣将你编写库的能力提高到一个新的水平, 这篇博文将是为你准备的 :)
0. 什么是库?
库 (library, 图书馆) 是一个精心设计的信息来源与相似资源的集合.
每个软件开发者都很熟悉”库”这个词.
在 C++ (尤其是基于面向对象设计的项目) 中, 几乎每个类都定义了一个 API (Application Programming Interface, 应用程序接口). 但并不是每个 API 都是库.
尽管上面引用的这句话指的是物理意义上的”图书馆”, 但它对于软件的”库”而言也十分贴切.
请你想想一个物理世界中的图书馆: 有这许许多多各种各样不同主题的书籍, 这些书籍可以在目录上依次排开, 也可以把一部分书放在一起, 形成一个书单. 对于软件的库来说, 一个库可以是单一的一个功能的集合 (比如一个文本解码器), 也可以是一系列多个功能的集合 — 例如 C++ 标准库 (standard C++ library, STL).
“库”通常意味着通过它定义的 API 提供功能, 但实际上还有更多东西 — 写一个库听起来简单, 但这是一个十分复杂的问题.
在开始之前, 让我们定义两个基础用词:
- 库代码: 也就是这个库本身的代码, 用户需要写 #include “mylib.hpp” 这样的代码来引用这些库代码.
- 用户代码: 在程序中引入库, 调用库的功能的代码.
在接下来的文章中, 我们会使用这份样例 (极简版, 实际上并不能运行) 进行解释:
库 (”仅头文件” 版)
用户代码
请注意, 一个库既可以可以写作”仅头文件”模式 (也就是仅有 .hpp 文件), 也可以写作 .hpp + .cpp 模式 (将定义和声明分离开, 定义部分写在 .cpp 文件里).
在头文件中编写代码会产生一些小的技术差异 (这里就不解释了), 但是当 template (模板) 被用作编写你的库的技术时, 通常会使用“仅头文件"的模式.
其主要区别在于, 使用“开放”的 template 模板意味着它可以”按需”实现代码, 而这只能在 .hpp 源代码提供给用户代码时才能做到.
(template 模板库将在本文的后面出现, 但关于 template 模板技术的更多解释准备在另一篇博客中发表)
1. 如何设计一个库?
这儿有些问题, 你应当在准备编写库的时候思考清楚.
a. 这个库的 API 接口是什么?
这是你需要做的最重要的决定.
当你定义 API 接口时, 你也就定义了用户将如何使用这个库. 更重要的是, 你定义了在你的库的不同版本之间不应该改变的内容.
如果你改变了 API 接口 — 你就在你的库中产生了一个破坏性的变化 (breaking change). 所以在定义它之前, 要仔细考虑.
最好的做法 (来自于面向对象的设计) 是将所有的实现细节封装起来, 只展示用户需要调用的部分.
b. 这个库的需求是什么?
你得想想这个库的用途.
你的库是给系统开发者使用的, 还是给其他库的编写者使用的, 或者是介于两者之间?
它是一个局部设施 (旨在被程序的特定部分使用, 如写入文件系统), 还是一个全局设施, 与所有程序有关? (比如说, 日志, 或普适的封装打包器)
c. 目标用户期望这个库有什么重要特性?
这可能是以下一个或多个方面: 精简的代码量, 快速的运行性能, 可读性, 简单性, 向后兼容性, 等等.
请注意, 这其中的一些是相互矛盾的.
d. 用户会如何使用这个库?
这一点可以分为以下的两个部分讨论. 务必注意. 这两个部分可能相关, 但不一定相互绑定.
- 设计的使用方式
- 代码的使用方式
设计的使用方式
这个库设计的内容将会如何被用户在代码中使用. 以下是两个典型例子:
- 用户继承了这个库中定义的类
- 用户使用库中定义的类创建了一个对象, 并使用其方法来执行功能. (这可以是直接创建该类的对象, 也有可能被另一个类作为成员. 这里的例子是后者)
这两种用法之间的选择, 自然是由期望的设计模式决定的.
*另外还有第三种选择, 不过这并不是面向对象的. 这种做法在 C 语言中更常见, 也可见于 C++ 标准库 STL 中 (仅限特定的使用场景). 你应当谨慎地使用这种做法, 因为这可能影响命名空间 namespace.
- 用户的代码引用了非成员 (不属于一个类) 的函数 / 模板函数
代码的使用方式
这意味着库的实现代码如何被用户添加到自己的项目中.
有多种方法来添加一个库, 它们取决于你打包库的方式.
注意, 在下面的章节中, 我提到了基于 Linux 操作系统的文件扩展名. Windows 操作系统有不同的文件扩展名, 但其余的技术细节都是一样的.
一般来说, 有两种主要方式:
- 将库作为附加的源代码, 加入到用户的项目中 (例如“仅头文件”库).
- 将库作为独立的制品 (artifact), 在链接 (link) 时添加到用户的代码中 (.o / .a / .so). 这又可以分为两种类型:
- 将库的代码编译为静态库 (.o / .a), 用户在编译时链接静态库, 以将库的代码嵌入到自己的代码中 (也就是加入到 main.out 的可执行文件中)
- 将库的代码编译为动态库 (.so)
译注: 在 Window 下, 可执行文件的拓展名是 .exe
译注: 在 Window 下, 动态库的拓展名是 .dll
编译为动态库的方式, 又可以分为两种类型:
- 用户代码 (.o) 和 .so 之间动态链接, 这意味着:
- 用户代码 (main.out) 和库代码 (.so) 要一并复制到目标环境中
- main.out 不能在缺少 .so 的环境中运行.
- 用户代码 (.o) 和 .so 之间动态插入式链接, 这意味着:
- 用户代码 (main.out) 和库代码 (.so) 要一并复制到目标环境中
- main.out 可以运行, 而且我们可以在更改动态库 (.so) 版本时无需重启 main.out 的进程. (只需调用其”重载”的功能)
这种做法要求 `main.cpp` 程序使用特定的代码 (dlopen) 来支持它. (通过添加特定的函数来加载动态库)
这些形式的选择应当取决于需求. 编写C++ 库(二): 实现 的第 5 章展现了每种形式的优点和缺点.
e. 版本控制 — 这个库的更新有多频繁?
在这里, 你需要为你的用户考虑, 并对版本管理进行相应的规划.
他们是在离线系统上工作吗? 他们是保留多个版本还是只有一个版本?
以下是一些需要考虑的事:
- 你的用户群体越大, 你就越有可能需要维护多个版本.
- 根据你的用户群体, 你应该决定是否 "允许" 你的库在不同版本之间发生 API 的破坏性改变 (breaking change).
如今通常的, 制定库的版本号的方式可见:
如你所见, 设计一个新的库绝不是一件简单的工作. 请记住 — 这篇文章只是一个概述. 在实现你的库之前, 建议你深入研究上述主题. 其中在 API 部分, 我还建议你了解 CPO‘s (Customization Points, 自定义点)
在 编写C++ 库(二): 实现 中, 我们将深入探讨关于创建, 打包和与链接库的技术.