代码库的概念

为什么要使用 "库"

在上一节中, 我们引入了 "多文件" 这样的方法, 来减少代码的重复, 提高代码的复用性.

而当我们把一类经常会被使用到的代码整理在一起, 就成了一个代码库.

而要理解库的概念, 建议从 "源代码" 和 "编译后" 两个层面进行.

提取代码成库

从源代码的层面来理解很直观, 就是把代码有条理的整理在一起.

接下来将通过一个例子, 来介绍之后的内容, 包括代码的提取整理成库, 以及之后的使用. 该示例所使用的代码也可以在 gitlab.com/kLiHz/cpp-multi-file-demo 查看.

假设我们现在有这样一个 C++ 文件 single_file_demo.cpp, 发现其中的 Date 相关代码有复用的价值, 因此计划将其拆分出来, 单独成库.

#include <iostream>

auto is_leap(int y) {
    return (y % 4 ==0 && y % 100 != 0) || y % 400 == 0;
}

struct Date
{
    int y;
    int m;
    int d;
    Date(int y_, int m_, int d_) : y(y_), m(m_), d(d_) {}
};

auto calc_days(Date const & date) {
    int days[] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
    if (is_leap(date.y)) {
        days[2] = 29;
    }
    auto cnt = 0;
    for (int i = 1; i < date.m; ++i) {
        cnt += days[i];
    }
    cnt += date.d;
    return cnt;
}

int main() {
    int y, m, d;
    std::cin >> y >> m >> d;
    Date d1(y, m, d);
    std::cout << calc_days(d1);
}

具体来说, 可以将代码中相关的 is_leapcalc_days 等函数, 变成 Date 类的成员方法. 结果如下:

Date/Date.hpp

// Date.hpp

// 头文件需要进行保护,目的是避免重复包含

#ifndef DATE_HPP
#define DATE_HPP

// 头文件中不能包含具体的定义
// 由于头文件可能会被多个 cpp (编译单元)包含
// 如果头文件中包含具体的定义,这些编译单元在最后链接过程中,会出现多个重复的符号
// 如果确需在头文件中包含具体函数的定义,需要声明为 inline(内联)

class Date {
    int y;
    int m;
    int d;
    bool is_leap(int year) {
        return (year % 4 ==0 && year % 100 != 0) || year % 400 == 0;
    }

public:
    // 类的声明中直接书写的函数是 inline 的
    Date(int y_, int m_, int d_) : y(y_), m(m_), d(d_) {}

    // 也可以只写声明,再在 cpp 文件中实现这个函数
    int get_days();
};


#endif // DATE_HPP

Date/Date.cpp

// Date.cpp

// 由于需要实现 Date.hpp 中声明的函数,需要包含 Date.hpp

#include "Date.hpp"

int Date::get_days()
{
    int days[] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
    if (is_leap(this->y)) {
        days[2] = 29;
    }
    auto cnt = 0;
    for (int i = 1; i < this->m; ++i) {
        cnt += days[i];
    }
    cnt += this->d;
    return cnt;
}

如此, 我们便得到了一个库 (或者说是库的源代码/源代码形式的库), 该库由 Date.cppDate.hpp 组成. 使用该库的用户, 可以调用该库中的功能, 而不必重新编写代码.

库的分发形式

我们可以将这个库分享给其他用户使用, 一种方式便是以源代码的形式.

对于使用以源代码形式分发的库的用户来说, 一般会是像上一节中介绍的方式那样, 在需要使用库的地方, 引入头文件中的声明, 之后将自己的源文件和库的源文件均编译得到目标文件, 最后链接得到完整的可执行程序.

除此之外, 假如我们不希望公开库的源代码, 或者希望该库可以更方便地进行分发, 我们还可以将这个库构建为 (某个操作系统平台上的) 库文件. 构建成库文件的过程像是把多个目标文件 (假如库中包含多个源文件) 打包在一起, 但是不完全如此; 虽然目标文件中也是编译后的二进制代码, 但是它们更像是构建的中间产物, 而库文件可能会经过一些专门的优化, 更像是完善的商品.

相较于以源文件形式分发的库, 使用 (编译后的) 库文件不必花时间重新编译, 并且可以享受来自发布者的优化; 缺点则是, 使用过程中很可能遇到平台兼容性的问题.

详情可以参考 "ABI (应用程序二进制接口) 兼容性".

对于 C++ 来说, 有一种特殊的 "头文件库 (header-only libraries)". 顾名思义, 这种库只有头文件组成, 库中包含的代码都 (以源代码形式) 存储在头文件里, 因此用户只需在自己的程序中引用头文件即可使用库中的功能. 这种库的好处是携带使用起来都很方便, 缺点是会增加编译的时间.

此外, 对于以解释型语言 (比如 Python, JavaScript 等) 写成的库, 通常不存在编译这样的说法, 因为代码均需要解释器来执行, 所以分发时一般都是人类可以阅读 (但是不一定方便阅读) 的文本形式.

但是有几种情况涉及到 "编译" 或者类似的概念.

一是该库由一种更 "高级" 的语言写成, 需要编译器处理, 才能得到能够直接被解释器解释的代码;

二是为了便于传输, 代码会经过一种叫做 Minification (最小化) 的过程, 简单来说就是在不影响代码功能的情况下, 尽可能减少不必要的字符 (如空白, 换行等等), 缩短函数或变量的名字等等, 从而减小文件的大小, 便于传输.

类似的, 在 Obfuscation (混淆) 这一技术中, 也会涉及到函数和变量的重命名. 不过二者的目标不尽相同, 混淆的目的主要是为了增加用户破解软件的难度, 除了重命名之外, 可能还会增加一些无意义的干扰代码.