[译文] 编写 C++ 库(一): 设计

这篇文章介绍了编写一个 C++ 库的基本知识.

这篇文章最初来自一些人的疑问. 于是我为希望深入了解关于”库”的总体概念, 尤其是 C++ 库的开发者们写下了这篇简短的介绍文.

这是两篇系列文章的第一部分, 第二部分可以在这里找到.

如果你有兴趣将你编写库的能力提高到一个新的水平, 这篇博文将是为你准备的 😃


0. 什么是库? ​

库 (library, 图书馆) 是一个精心设计的信息来源与相似资源的集合.

每个软件开发者都很熟悉”库”这个词.

在 C++ (尤其是基于面向对象设计的项目) 中, 几乎每个类都定义了一个 API (Application Programming Interface, 应用程序接口). 但并不是每个 API 都是库.

尽管上面引用的这句话指的是物理意义上的”图书馆”, 但它对于软件的”库”而言也十分贴切.

请你想想一个物理世界中的图书馆: 有这许许多多各种各样不同主题的书籍, 这些书籍可以在目录上依次排开, 也可以把一部分书放在一起, 形成一个书单. 对于软件的库来说, 一个库可以是单一的一个功能的集合 (比如一个文本解码器), 也可以是一系列多个功能的集合 — 例如 C++ 标准库 (standard C++ library, STL).

“库”通常意味着通过它定义的 API 提供功能, 但实际上还有更多东西 — 写一个库听起来简单, 但这是一个十分复杂的问题.

在开始之前, 让我们定义两个基础用词:

  • 库代码: 也就是这个库本身的代码, 用户需要写 #include “mylib.hpp” 这样的代码来引用这些库代码.
  • 用户代码: 在程序中引入库, 调用库的功能的代码.

在接下来的文章中, 我们会使用这份样例 (极简版, 实际上并不能运行) 进行解释:

库 (”仅头文件” 版)

cpp
// Shape.hpp

class Shape
{
    enum Color {
        RED = 1,
        BLUE = 2
    };
 
    Shape (Color color) {   print("I'm a new shape");  }    // Constructor
 
    void printType()  {   print("I'm a shape");  }                     
    void drawOnScreen() {   print("drawing a shape on screen");  } 
}

用户代码

cpp
// main.cpp

#include "Shape.hpp"

int main()
{
    Shape my_shape = Shape(RED);    // "I'm a new shape"
 
    my_shape.printType();           // "I'm a shape"
    my_shape.drawOnScreen();        // "drawing a shape on screen"
}

请注意, 一个库既可以可以写作”仅头文件”模式 (也就是仅有 .hpp 文件), 也可以写作 .hpp + .cpp 模式 (将定义和声明分离开, 定义部分写在 .cpp 文件里).

在头文件中编写代码会产生一些小的技术差异 (这里就不解释了), 但是当 template (模板) 被用作编写你的库的技术时, 通常会使用“仅头文件"的模式.

其主要区别在于, 使用“开放”的 template 模板意味着它可以”按需”实现代码, 而这只能在 .hpp 源代码提供给用户代码时才能做到.

(template 模板库将在本文的后面出现, 但关于 template 模板技术的更多解释准备在另一篇博客中发表)

1. 如何设计一个库? ​

这儿有些问题, 你应当在准备编写库的时候思考清楚.

a. 这个库的 API 接口是什么? ​

这是你需要做的最重要的决定.

当你定义 API 接口时, 你也就定义了用户将如何使用这个库. 更重要的是, 你定义了在你的库的不同版本之间不应该改变的内容.

如果你改变了 API 接口 — 你就在你的库中产生了一个破坏性的变化 (breaking change). 所以在定义它之前, 要仔细考虑.

最好的做法 (来自于面向对象的设计) 是将所有的实现细节封装起来, 只展示用户需要调用的部分.

b. 这个库的需求是什么? ​

你得想想这个库的用途.

你的库是给系统开发者使用的, 还是给其他库的编写者使用的, 或者是介于两者之间?

它是一个局部设施 (旨在被程序的特定部分使用, 如写入文件系统), 还是一个全局设施, 与所有程序有关? (比如说, 日志, 或普适的封装打包器)

c. 目标用户期望这个库有什么重要特性? ​

这可能是以下一个或多个方面: 精简的代码量, 快速的运行性能, 可读性, 简单性, 向后兼容性, 等等. 请注意, 这其中的一些是相互矛盾的.

d. 用户会如何使用这个库? ​

这一点可以分为以下的两个部分讨论. 务必注意. 这两个部分可能相关, 但不一定相互绑定.

  1. 设计的使用方式
  2. 代码的使用方式

设计的使用方式

这个库设计的内容将会如何被用户在代码中使用. 以下是两个典型例子:

  1. 用户继承了这个库中定义的类

    cpp
    // main.cpp
    
    #include "Shape.hpp"
     
    using Radios = size_t;
     
    class Circle : public Shape
    {
        Circle(Color color, Radios radios)
        : Shape (color)                         // calling shape constructor
        , m_radios(radios)                      // initializing the radios of the circle
        {  
            Print("I'm a new circle");
        }
        void printType()  {   print("I'm a circle");  }                    
        void drawOnScreen() {   print("drawing a circle on screen");  }
     
        Radios m_radios;
    }
     
    int main()
    {
        Circle my_circle = Circle(RED, 3);      // "I'm a new Shape" "I'm a new circle"
     
        my_circle.printType();                  // "I'm a circle"
        my_circle.drawOnScreen();               // "drawing a circle on screen"
    }
  2. 用户使用库中定义的类创建了一个对象, 并使用其方法来执行功能. (这可以是直接创建该类的对象, 也有可能被另一个类作为成员. 这里的例子是后者)

    cpp
    // main.cpp
    
    #include "Shape.hpp"
    
    using Radios = size_t;
     
    class Circle
    {
        Circle(Color color, Radios radios)
        : m_shape(color)                        // calling shape constructor
        , m_radios(radios)                      // initializing the radios of the circle
        {  
            Print("I'm a new circle");
        }
        void printType()  {   m_shape.printType(); print("I'm a circle");  }                       
        void drawOnScreen() {  m_shape.drawOnScreen(); print("drawing a circle on screen");  } 
     
        Shape m_shape
        Radios m_radios;
    }
     
    int main()
    {
        Circle my_circle = Circle(RED, 3);      // "I'm a new Shape" "I'm a new circle"
     
        my_circle.printType();                  // "I'm a shape" "I'm a circle"
        my_circle.drawOnScreen();               // "drawing a shape on screen" "drawing a circle on screen"
    }

这两种用法之间的选择, 自然是由期望的设计模式决定的.

*另外还有第三种选择, 不过这并不是面向对象的. 这种做法在 C 语言中更常见, 也可见于 C++ 标准库 STL 中 (仅限特定的使用场景). 你应当谨慎地使用这种做法, 因为这可能影响命名空间 namespace.

  1. 用户的代码引用了非成员 (不属于一个类) 的函数 / 模板函数

    cpp
    // ShapeFreeFuncLib.hpp
    
    template <typename T>
    void drawShapeOnScreen(T shape) {
      print ("draw shape on screen");
    }
    cpp
    // main.cpp
    
    #include "ShapeFreeFuncLib.hpp"
    
    int main()
    {
      SomeShapeType my_shape;
      
      drawShapeOnScreen(my_shape);
    }

代码的使用方式

这意味着库的实现代码如何被用户添加到自己的项目中.

有多种方法来添加一个库, 它们取决于你打包库的方式.

注意, 在下面的章节中, 我提到了基于 Linux 操作系统的文件扩展名. Windows 操作系统有不同的文件扩展名, 但其余的技术细节都是一样的.

一般来说, 有两种主要方式:

  1. 将库作为附加的源代码, 加入到用户的项目中 (例如“仅头文件”库).
  2. 将库作为独立的制品 (artifact), 在链接 (link) 时添加到用户的代码中 (.o / .a / .so). 这又可以分为两种类型:
    1. 将库的代码编译为静态库 (.o / .a), 用户在编译时链接静态库, 以将库的代码嵌入到自己的代码中 (也就是加入到 main.out 的可执行文件中)

      译注: 在 Window 下, 可执行文件的拓展名是 .exe

    2. 将库的代码编译为动态库 (.so)

      译注: 在 Window 下, 动态库的拓展名是 .dll

编译为动态库的方式, 又可以分为两种类型:

  1. 用户代码 (.o) 和 .so 之间动态链接, 这意味着:

    • 用户代码 (main.out) 和库代码 (.so) 要一并复制到目标环境中
    • main.out 不能在缺少 .so 的环境中运行.
  2. 用户代码 (.o) 和 .so 之间动态插入式链接, 这意味着:

    • 用户代码 (main.out) 和库代码 (.so) 要一并复制到目标环境中
    • main.out 可以运行, 而且我们可以在更改动态库 (.so) 版本时无需重启 main.out 的进程. (只需调用其”重载”的功能)

    这种做法要求 main.cpp 程序使用特定的代码 (dlopen) 来支持它. (通过添加特定的函数来加载动态库)

这些形式的选择应当取决于需求. 编写C++ 库(二): 实现 的第 5 章展现了每种形式的优点和缺点.

e. 版本控制 — 这个库的更新有多频繁? ​

在这里, 你需要为你的用户考虑, 并对版本管理进行相应的规划.

他们是在离线系统上工作吗? 他们是保留多个版本还是只有一个版本?

以下是一些需要考虑的事:

  • 你的用户群体越大, 你就越有可能需要维护多个版本.
  • 根据你的用户群体, 你应该决定是否 "允许" 你的库在不同版本之间发生 API 的破坏性改变 (breaking change).

如今通常的, 制定库的版本号的方式可见:

Semantic Versioning 2.0.0


如你所见, 设计一个新的库绝不是一件简单的工作. 请记住 — 这篇文章只是一个概述. 在实现你的库之前, 建议你深入研究上述主题. 其中在 API 部分, 我还建议你了解 CPO‘s (Customization Points, 自定义点)

在 编写C++ 库(二): 实现 中, 我们将深入探讨关于创建, 打包和与链接库的技术.