优秀 API 的特征

问题域建模

API 应该对它所解决的问题提供良好的逻辑抽象。当把 API 文档提供给用户时,他应该能够理解接口中的概念并且知道它的工作机制。

API 应该对问题域的关键对象建模,即面向对象设计。一般使用 UML 类图或对象图表示对象模型。

隐藏实现细节

API 应该隐藏所有的实现细节,避免修改 API 对已有的客户造成影响。

物理隐藏

将接口放到 .h 文件,内部细节实现放到 .cpp 文件 。

逻辑隐藏

利用面向对象的封装特性。 C++ 中使用访问控制关键字实现: public protected private

隐藏成员变量

使用 getter 或者 setter 访问成员变量,它有以下的好处:

  • 有效性验证:验证输入值的有效性
  • 惰性求值:使用 getter 时再计算
  • 缓存:将第一次读取的值缓存,以后直接返回缓存的值
  • 额外计算:使用 setter 后可能有保存文件的操作
  • 通知:setter 后可能通知用户
  • 调试:增加调试或日志语句
  • 同步:方便在访问值的地方加锁
  • 更精细的访问控制:不提供 setter 方法就不能设置
  • 维护不变式关系:设置一个值可能内部需要同时更改其它值

隐藏实现方法

类只应该定义做什么而不是如何做。在 C++ 中,用户能够看到头文件内容,所以尽量将实现方法声明到 .cpp 文件中,常见的手法是 pimpl 惯用法。

隐藏实现类

实现细节的类不应该暴露给用户。

最小完备性

优秀的 API 应该是最小完备的。即它应该尽量简洁,但不要过分简洁。

若无必要,勿增实体 – 奥卡姆剃刀原理

不要过度承诺

当不确定是否需要某个接口时,就不要提供此接口。保证 API 尽量简单,类及类中的公有成员暴露的越少越好。

谨慎添加虚函数

便捷化 API

基于最小化的核心 API ,以独立的模块或库的形式构建便捷 API

易用性

优秀的 API 设计应该使简单的任务更简单,使人一目了然。最容易使用的 API 是简化用户学习过程的 API ,比如大多数 C++ 程序员都熟悉 STL ,所以如果编写有相似目标的 API ,应该模仿 STL 。

可发现性

可发现的 API 要求用户能够通过 API 自身明白如何使用它们。

不易误用

使用以下技巧降低 API 的误用,比如用枚举类型代替布尔类型。避免编写拥有多个相同类型参数的函数。

一致性

包括命名,参数顺序,异常的使用和错误处理等。

正交

正交的 API 意味的函数没有副作用。调用一个 API 不会影响到其它 API 的调用。设计正交 API 时需要铭记如下两个重要因素:

  • 减少冗余:只用一种方式表示相同的信息
  • 增加独立性:确保暴露的概念没有重叠

健壮的资源分配

使用 RAII 技术,将资源的申请与释放当作对象的构造和析构。

平台独立

设计精良的 C++ API 不应该在公共头文件中出现平台相关的 #if/#ifdef 语句

松耦合

  • 耦合:系统中每个组件对其它组件的依赖程度
  • 内聚:单个软件内的各种方法相互关联或聚合强度的度量

优秀的 API 表现为松耦合和高内聚

仅通过名字耦合

除非确实需要 #include 类的完整定义,否则应该为类使用前置声明

降低类耦合

优先使用非成员,非友元的方法能够降低耦合度

刻意的冗余

使用数据冗余降低类之间的耦合是合理的,但是要小心使用

管理器类

使用管理器类封装低层次的类降低耦合,增加了一个抽象层隔离底层和高层。

回调,观察者和通知

应该明确告知用户在回调函数中他们能做什么和不能做什么。

稳定的,稳定详细且经过测试的 API

优秀的 API 设计应该是稳定的且具有前瞻性:

  • 稳定并不一定意味者 API 不会改变,而是应该将接口版本化,并且在版本升级时保证向后兼容。
  • 前瞻性表示 API 应该可以设计为可扩展的,能够优雅的升级。

优秀的 API 设计也应该有很好的文档支持,方便用户获取 API 的功能、行为、最佳实践以及错误处理的明确信息。

应该为每个 API 编写可扩展的自动化测试程序,确保新的变更不会破坏现有的用例。

实现优秀 API 的模式或手法

Pimpl

指针指向具体实现

优点

  • 信息隐藏:私有成员可以完全隐藏在公有接口之外
  • 降低耦合
  • 加速编译
  • 更好的二进制兼容性:Pimpl 对象的大小从不改变
  • 惰性分配:实现类可以在需要时再构造

缺点

  • 额外的性能开销:可以使用 Fast Pimpl 手法,该方法重载了 newdelete
  • 开发人员负担更重
  • 编译器无法捕捉 const 方法中对成员变量的修改

单例模式

确保一个类只有一个实例,更加优雅的全局变量实现

优点

  • 确保一个类只创建一个实例
  • 为对象分配和销毁提供控制
  • 支持线程安全地访问对象的全局状态
  • 避免污染全局命名空间

缺点

使用单例的类不好测试,增加了其它类和单例类的耦合。一些替代方案如下:

  • 依赖注入:可以使用依赖注入的方式代替单例,不要在类的内部使用单例创建对象
  • 单一状态模式:允许创建多个实例,但是每个实例使用相同的静态数据
  • 会话状态:使用单一实例维护代码的所有状态,而不是用多个单例。

工厂模式

使用工厂模式刻意提供更强大的类构造语义并隐藏子类的细节。

API 包装器模式

编写基于另一组类的包装器接口是一项常见的 API 设计任务。结构化设计模式刻意处理这个任务,按照包装器层和原始接口的差异递增程度划分:代理,适配器和外观。

代理模式

代理设计模式为另一个类提供一对一的转发接口,适用于无法修改原始类,但是要做一些操作,比如:

  • 实现原始对象的惰性实例
  • 实现对原始对象的访问控制
  • 支持调试或演习模式
  • 保证原始类线程安全
  • 支持资源共享
  • 应对原始类将来被修改的情况

适配器模式

适配器模式将一个类的接口转换为一个兼容的但不相同的接口,适配器模式有以下优点:

  • 强制 API 始终保持一致性
  • 包装 API 的依赖库
  • 转换数据类型
  • 为 API 暴露一个不同的调用约定

外观模式

外观模式为一组类提供简化的接口,它定义一个更高层次的接口,使得底层子系统更加易用。它有以下用途:

  • 隐藏遗留代码
  • 创建便捷 API
  • 支持简化功能或替代功能的 API

观察者模式

观察者模式支持组件解耦,并且避免循环依赖。

设计 API 的流程

收集需求

要实现好的软件设计,第一步就是理解软件真正需要做的是什么。

  • 功能性需求描述软件的行为,即软件应该完成什么功能
  • 非功能性需求评价 API 的运行约束,如性能,平台兼容性,易用性等

当有新的需求时,需要对该需求能带来的业务价值进行评估。从实用角度评估新需求有助于权衡变更的好处和实现的代价。

创建用例

用例从用户的角度描述 API 的需求,用例可以是面向目标的简短描述信息的列表,也可以是更为正式的模板定义的结构化说明。

使用用户故事从用户那获取最小需求。

API 设计元素

好的 API 设计秘诀:对问题领域进行合理抽象,然后设计相应的对象与类的层次结构来表达该抽象。

设计阶段主要由两个活动组成:

  1. 架构设计:描述软件的顶层结构和组织
  2. 详细设计:在一个足够详细的层面描述设计中单独的组件

架构设计

软件架构描述软件中顶层对象的集合以及它们彼此之间的关系。

创建 API 的架构过程可以分解为 4 个基本步骤:

  1. 分析影响架构的功能性需求
  2. 识别架构的约束并加以说明
  3. 创造系统中的主要对象,并确认它们之间的关系
  4. 架构的交流与文档

架构设计被众多独特的组织、环境和运行等因素约束。

在完成对系统的需求和约束的分析后,我们就需要开始构建高层对象模型。尝试从不同的角度看问题,不断迭代和完善模型。

架构模式

流行架构的分类:

  • 结构化模式:分层模式、管道与过滤器模式和黑板模式
  • 交互式系统:模型-视图-控制器模式、模型-视图-表示器模式以及表示-抽象-控制模式
  • 分布式系统:客户端/服务器模式、三次架构、点对点模式以及代理模式
  • 自适应系统:微内核模式与反射模式

避免 API 各个组件间的循环依赖

在 API 的附属文档中要描述其高层架构并阐述其原理

类的设计

使用 8-2 法则,集中精力设计定义了 API 80% 功能的 20% 的类。

类设计时应该思考的选项:

  • 继承的使用
  • 组合的使用
  • 抽象接口的使用
  • 标准设计模式的使用
  • 初始化与析构模型
  • 定义复制构造函数和赋值操作符
  • 模板的使用
  • const 和 explicit 的使用
  • 定义操作符
  • 定义类型转换操作符
  • 友元的使用
  • 非功能性约束

在使用继承时需要思考的点:

  • 避免生成继承层次结构
  • 除非使用接口和 mixin 类,否则要避免多重继承
  • 使用 Liskov 替换原则思考一个类是否应该设计为另一个类的子类
  • 是否可以用组合代替继承

使用开闭原则创建可以长期使用的稳定接口,使用迪米特法则降低类之间的耦合

类命名时应该思考的点:

  • 使用描述性强,自解释的名字。在建模所在的问题域中应该是有意义的
  • 如果一个类难以命名,往往是缺乏设计的信号
  • 有时候可以使用符合名字,但是如果超过两个单词,说明设计太过混乱或复杂了
  • 接口类往往使用对象模型中的形容词表示,如 Renderable (可渲染的)
  • 避免含义模糊的缩写词
  • 为顶层符号包含某种形式的命名空间

函数设计

设计自由函数调用时需要思考的点:

  • 静态还是非静态函数
  • 参数以值、引用还是指针的形式传递
  • 参数以常量还是非常量形式传递
  • 是否通过默认值使用可选参数
  • 结果以值、引用还是指针的形式返回
  • 结果以常量还是非常量形式返回
  • 使用操作符函数还是非操作符函数
  • 异常规格说明的使用

设计成员函数还需要考虑的点:

  • 虚成员函数还是非虚成员函数
  • 纯虚成员函数还是非纯虚成员函数
  • const 成员函数还是非 const 成员函数
  • 成员函数的访问权限设置为 public, protected 还是 private
  • 对于非默认构造函数是否使用 explicit 关键字
  • 友元函数还是非友元函数
  • 内联函数还是非内联函数

函数命名

函数名一般表示为系统中的动词,描述函数要执行的动作或要返回的值,下面是一些指导原则:

  • 用于设置或返回值的函数,函数名应该使用标准前缀(如 Get 或 Set)
  • 需要回答是否问题的函数应该使用适当的前缀(如 Is, Are, Has 等)
  • 用于执行某些动作的函数应该用一个语气强烈的动词命名,如 Enable, Print, Save 等,如果给自由函数命名,还应该将动作所作用的对象包含进去,如 FileOpen, FormatString 等
  • 使用正面的概念命名函数,如使用 IsConnected 而不是 IsUnconnected
  • 函数名应该能够描述函数所做的所有事情,比如,图像库中一个函数锐化图像并保存文件,名称应该是 SharpenAndSaveImage ,而不能仅是 SharpenImage
  • 避免使用缩写
  • 不能使用下划线开头
  • 形成自然对仗的函数,比如 OpenWindow 应该与 CloseWindow 结对

常用互补术语:

Add/RemoveBegin/EndCreate/Destory
Enable/DisableInsert/DeleteLock/Unlock
Next/PreviousOpen/ClosePush/Pop
Send/ReceiveShow/HideSource/Target

函数参数

避免太长的参数列表,如果有很多可选参数,考虑传递结构体或映射来代替

错误处理

接口三定律:

  1. 接口的实现应该按方法所说的行事
  2. 接口的实现应该是无害的
  3. 如果接口的实现无法履行职责,应该通知调用者

相应地,API 中处理错误条件的三种主要方式:

  1. 返回错误码
  2. 抛出异常
  3. 中止程序

错误处理原则:使用一致的,充分文档化的错误处理机制

在出现故障时,让 API 快速干净地退出,并给出完整精确的诊断细节

API 的风格

暴露 API 形式:

  1. 纯 C API
  2. 面向对象的 C++ API
  3. 基于模板的 API
  4. 数据驱动型 API

纯 C API

C API 一般由下面几部分组成:

  1. 内置类型如 int, float, double, char, 数组以及指针
  2. 使用 typedef 和 enum 关键字创建的自定义类型
  3. 使用 struct 和 union 关键字声明的自定义结构
  4. 全局的自由函数
  5. 预处理指令

为了实现更严格的类型检查,并确保 C++ 程序可以使用 API ,可以用 C++ 编译器来编译 C API ,在 C API 的头文件中使用 extern "C" 限制,让 C++ 程序能够正确编译和链接 C API

使用 C API 能够更加容易的保持二进制兼容性

面向对象的 C++ API

优点:

  • 在代码中建模的物理实体和过程可以用对象描述,更加符合逻辑
  • 将一个概念单元中的所有数据和方法封装到一起

缺点:

  • 增加复杂性
  • 创建二进制兼容 API 困难

基于模板的 API

优点:

  • 提高运行时性能
  • 去除代码冗余

缺点:

  • 类模板的定义通常必须出现在公开的头文件中,暴露了实现细节
  • 会导致代码膨胀
  • 错误信息很冗长,难以定位

数据驱动型 API

通过每次运行时提供不同的输入数据,执行不同的操作,比如: send("func", a, b, c)

这种 API 可以很好地映射到 Web 服务和其他客户端/服务端形式的 API

API 的缺点是有运行时开销,也无法从接口的编译时检查中获益

C++ 用法

命名空间

命名空间是若干唯一符号的逻辑分组。它提供了一种避免命名冲突的方法,以防止两个 API 使用相同的名字定义符号。

在 API 中添加命名空间有两种流行的做法:

  1. 给所有的公有 API 添加唯一前缀
  2. 使用 C++ 的 namespace 关键字

应当始终使用一致的方法为 API 提供命名空间

构造函数和赋值

如果类分配了资源,应该同时定义析构函数,复制构造函数和赋值操作符,移动构造函数和移动赋值操作符

对只有一个参数的构造函数使用 explicit 关键字避免隐式转换

const 正确性

保证 API 的 const 正确性,尽可能早的将函数和参数声明为 const 。

首选传值方式而不是 const 引用方式返回函数结果。

操作符重载

除非操作符必须访问私有成员或受保护的成员,或者它是 =, [], ->, ->* (), (T), new, delete , 否则应该尽量将其声明为自由函数。

给类添加转换操作符,从而利用自动类型强制转换

函数参数

尽量在可行的地方为输入参数使用 const 引用,而非指针。对于输出参数,考虑使用指针而不是非 const 引用。

当默认值会暴露实现中的常量时,尽量选择函数重载,而不是默认参数

避免使用 #define 定义常量

缺点:

  • 没有指定类型
  • 没有指定作用域
  • 没有访问控制
  • 没有符号

使用静态 const 数据成员而非 #define 表示类常量

避免使用友元

友元往往预示着糟糕的设计,这就等于赋予用户访问 API 所有受保护成员和私有成员的权限。

导出符号

在物理文件层次有两个概念允许暴露 API 中的符号:

  1. 外部链接
  2. 导出可见性

外部链接指一个编译单元中的一个符号可以被其他编译单元访问。导出是只在库文件(如 DLL)中可见的符号,只有外部链接符号才可以导出。

使用内部链接隐藏 cpp 文件内部的,具有文件作用域的自由函数和变量。方法是使用 static 关键字或匿名命名空间。

对于具有外部链接的符号,还需要考虑导出符号,它决定一个符号在共享库中是否可见。行为一般域编译器相关:

  1. MSVC: DLL 中的符号默认是不可访问的。必须显示导出 DLL 中的函数,类以及变量。可以在符号前面使用 __declspec 修饰符,在构建 DLL 时,指定 __declspec(dllexport) 导出符号,客户通过 __declspec(dllimport) 访问相同的符号
  2. GNU C++: 动态库中具有外部链接的符号默认是可见的。使用 __fvisibility_hidden 标记强制所有声明在默认情况下隐藏可见性。个别符号可以使用 __attribute__((visibility("default"))) 显式导出

编译器也允许用户提供一个简单的 ASCII 文件,定义应该被导出的符号列表。 MSVC 支持 .def 文件 ,GNU 支持导出映射文件。

编码规范

为 API 指定编码风格标准,这有助于保证一致性,明确流程,并总结常见的工程陷进。

性能

不要以扭曲 API 的设计为代价换取高性能

为了优化 API ,应该使用工具收集代码在真实运行示例中的性能数据,然后把优化精力集中在实际的瓶颈上面。不要猜测性能瓶颈的位置。

使用 const 引用传递参数

这条规则仅适用与对象,对于 int 之类的内置类型不适用,因为它们已经很小了。

最小化 #include 依赖

减少头文件中 #include 语句的数量是缩短构建时间的常见技巧。

使用 constexpr 声明常量

能够在编译器计算,提高运行时性能

使用初始化列表

使用构造函数初始化列表,为每个数据成员减少一次调用构造函数的开销

优化内存

提高数据缓存效率的重要技巧是缩小对象的大小:

  1. 根据类型聚集成员变量
  2. 使用位域
  3. 使用联合
  4. 除非必要,不要添加虚方法
  5. 使用大小明确的类型

除非必要,勿用内联

内联的影响:

  1. 暴露实现细节
  2. 在客户应用程序中嵌入代码
  3. 代码膨胀
  4. 调试复杂化

如果能够证明代码会导致性能问题,并且确认内联可以解决该问题,那么可以使用内联。

写时复制

使用写时复制语义,为对象的多份副本减少内存消耗。

迭代元素

采用迭代器遍历简单的线性数据结构。对于链表或树型数据结构,如果迭代性能很重要,应该考虑使用数组引用。

性能分析

时效性分析方式:

  1. 内嵌测量:在代码关键位置嵌入计时器
  2. 二进制测量:创建函数调用跟踪记录
  3. 采样:使用独立的程序对被测试应用程序进行连续采样
  4. 监控计数器

基于内存的分析工具:

  1. Valgrind

多线程代码分析工具:

  1. Intel Thread Checker

版本控制

版本号

API 的每次发布都应该附带一个唯一的标志符,标准的做法就是使用版本号。常见的版本号使用三位:

  1. 主版本号:通常首次发布时设置为 1,每当有重大修改时增长。
  2. 次版本号:添加较小特性或修正重大错误时这个数会增长。通常不应该涉及任何不兼容的 API 修改
  3. 补丁版本号:通常对重大错误或安全问题的修复补丁后这个数会增长

通常可以在库名中包括 API 的主版本号,尤其在做了一些不能向后兼容的修改时,如 libFoo.so, libFoo2.so

API 的版本信息应该可以在代码中访问。

分支管理

必要时才创建分支。尽早且频繁地合并分支

生命周期

API 开发有 4 个常见的阶段:

  1. 发布前:发布前最显著的特征时接口可以经历重大修改和重新设计
  2. 维护:API 发布后依然可以修改,但是为了维护向后兼容,只能增加新的方法和类
  3. 完成:不对接口做进一步修改
  4. 弃用:某些 API 最终会达到生命终结的状态,此时它们会被弃用

API 发布后,可以改进但不应该改变

兼容性级别

通常 API 的“主,次和补丁”版本提供不同的兼容型承诺

向后兼容性

向后兼容性包括:

  1. 功能兼容性
  2. 源代码兼容性
  3. 二进制兼容性

向后兼容性意味着使用第 N 版本 API 的客户能够不加修改地升级到第 N+1 版本。

功能兼容性

功能兼容性意味者第 N+1 版本 API 的行为和 第 N 版本一致

源代码兼容性

源代码兼容性意味着用户使用第 N 版本 API 编写的代码可以使用第 N+1 版本进行编译,而不用修改源代码。

二进制兼容性

二进制兼容性意味着使用第 N 版本 API 编写的应用程序可以仅通过替换或重新链接 API 的新动态链接库,就升级到第 N+1 版本。

这意味着 API 的任何修改一定不能影响任何类、方法或函数在库文件中的表示。 API 中的所有元素的二进制表示,包括类型,大小,结构体对齐和所有函数签名必须维持原样。这通常称为应用程序二进制接口 (ABI)兼容性。

二进制不兼容的 API 修改:

  • 移除类,方法或函数
  • 增加,移除类中的成员变量,或者重排序成员变量
  • 增加或移除一个类的基类
  • 修改任何成员变量的类型
  • 以任何方式修改已有方法的签名
  • 增加,移除模板参数或者重新排序模板参数
  • 把非内联方法改为内联方法
  • 把非虚方法改为虚方法,反之亦然
  • 改变虚方法的顺序
  • 给没有虚方法的类增加虚方法
  • 增加新的虚方法
  • 覆盖已有的虚方法

二进制兼容的 API 修改:

  • 增加新的类,非虚方法或者函数
  • 给类增加新的静态变量
  • 移除私有静态变量(前提是没有在内联方法中引用)
  • 移除非虚私有方法(前提是没有在内联方法中调用)
  • 修改内联方法的实现(要使用新的实现就必须重新编译)
  • 把内联方法改为非内联方法 (如果实现也被修改,那么必须重新编译)
  • 修改方法的默认参数(要使用新的默认参数就必须重新编译)
  • 给类增加或移除友元声明
  • 给类增加新的枚举
  • 使用位域中未声明的余留位

实现二进制兼容性的技巧:

  • 不要给已有方法增加参数,可以定义重载版本
  • Pimpl 模式帮助保持接口的二进制兼容性
  • 采用纯 c 风格的 API
  • 如果要做二进制不兼容的修改,可以为新库换个不同的名字,如带上主版本号

向前兼容性

使用未来版本 API 编写的客户代码如果无须修改就能够编译使用较老版本的 API ,则 API 是先前兼容的。因此向前兼容意味着用户可以降级到之前的发布版本,代码无须修改。

是 API 向前兼容的方法:

  • 在功能实现之前就添加参数,然后将参数标注为未使用
  • 如果预计将来会改用一种不同的内置类型,使用不透明指针或者 typedef ,不要直接使用内置类型

API 审查

在开发过程中增加 API 审查以确保 API 支持向后兼容性。

API 审查的目的:

  • 维护向后兼容性
  • 维护设计一致性
  • 对修改加以控制
  • 支持未来的改进
  • 重新审视解决方案

文档

良好的文档描述了如何使用 API ,并会解释 API 对不同输入产生的行为。在实现每个组件时编写 API 文档,在 API 文档完成后对文档进行修订。

在创作类和函数的文档时,应当考虑的问题:

  • 类是对什么东西的抽象
  • 有效的输入是什么
  • 有效的返回类型是什么
  • 需要检查哪些错误条件
  • 是否具有前置条件,后置条件和副作用
  • 是否具有未定义行为
  • 是否抛出异常
  • 是线程安全的吗
  • 各个参数的单位是什么
  • 空间复杂度和时间复杂度如何
  • 内存的所有权模型
  • 虚方法是否调用类中的其他方法
  • 有没有相关函数需要被交叉引用
  • 某特性是在 API 的哪个版本中添加的
  • 某方法是否已被弃用,如果是,替代方案是什么
  • 某特性是否存在已知的编程错误
  • 是否希望分享未来的改进计划
  • 能否提供任何示例代码
  • 文档是否带来了额外的价值或见解

所有良好的代码文档都应该追求的品质:

  1. 完整
  2. 一致
  3. 易于访问
  4. 没有重复

使用文档生成工具从代码中生成 API 文档,比如 Doxygen

测试

测试的好处:

  • 增强信心,确保修改不会破环功能
  • 确保向后兼容性
  • 节约成本,开发自动化测试能够尽早发现缺陷
  • 编写用例
  • 合规保证

API 测试的类型:

  1. 白盒测试:测试在理解源代码的基础上进行
  2. 黑盒测试:测试基于产品说明进行,不关心任何实现细节
  3. 灰盒测试:白盒测试和黑盒测试的组合,其中黑盒测试是在获知软件实现细节的基础上进行

测试包括功能性测试盒非功能性测试,其中功能性测试包括:

  • 单元测试:验证软件是否符合程序员预期设计
  • 集成测试:验证软件能否满足客户需求

非功能性测试包括:

  • 性能测试
  • 负载测试
  • 可扩展性测试
  • 浸泡测试:长期运行软件,保证软件能够持续使用,比如没有严重的内存错误
  • 安全性测试
  • 并发测试

单元测试

单元测试用于验证一个最小单元的源代码。单元测试是一种白盒测试技术,用于独立验证函数和类的行为。

如果测试依赖于不可靠的资源,比如数据库,文件系统或网络,那么可以使用桩对象或模拟对象创建更健壮的单元测试。

集成测试

集成测试关注几个组件一起协作时的交互过程。集成测试通常根据 API 说明书进行开发,因此不需要理解内部的实现,它是一种黑盒测试技术。

性能测试

如果 API 的性能很重要,考虑为关键用例编写性能测试,以避免引入性能损失

编写良好的测试

良好测试的特征:

  • 快速
  • 稳定
  • 可移植
  • 高编码标准
  • 错误可重现

测试用到的几个关键技术:

  • 条件测试
  • 等价类
  • 边界测试
  • 参数测试
  • 返回值断言
  • getter/setter 对
  • 操作顺序
  • 回归测试
  • 负面测试
  • 缓冲区溢出
  • 内存所有权
  • 空输入

编写可测试代码

使用测试驱动开发,可以强迫我们在开始编写代码时思考 API ,站在客户的立场思考问题。

使用 SelfTest 成员函数测试类的私有成员

使用断言记录盒验证那些绝不应该发生的程序设计错误

使用自动化测试工具

自动化测试框架:

  • CppUnit
  • Boost Test
  • Google Test
  • Catch2

测试覆盖率工具:

  • GCov
  • OpenCppCoverage

缺陷跟踪系统:

  • Jira

持续构建系统:

  • Jenkins

脚本化

脚本化指允许通过脚本语言访问 C++ API ,优点:

  • 跨平台
  • 更快速的开发
  • 需要编写的代码更少
  • 基于脚本的应用程序
  • 支持专家用户
  • 可扩展性
  • 有利于测试
  • 更具表现力

每种语言都有工具或库绑定 C++

可扩展性

通过插件扩展

插件一般是动态库,在运行时发现加载

插件系统设计问题

必须要设计的两个主要特性:

  1. 插件 API :要创建插件,用户必须编译并链接插件 API
  2. 插件管理器:核心 API 代码中的一个对象,负责管理所有插件的生命周期

设计决策:

  • C 还是 C++ : C 有更好的 API 兼容性
  • 版本控制
  • 内部元数据还是外部元数据:比如可读的名字和版本信息,使用外部的优点是不需要加载所有插件就能了解所有可用对象的集合。缺点是必须引入每个插件专用的数据文件
  • 插件管理器是通用的还是专用的
  • 安全性

以 C++ 实现插件

最佳实践:

  • 使用抽象基类:使插件与 ABI 问题隔离
  • 自由函数使用 C 链接
  • 避免使用 STL 和异常
  • 不要混用内存分配器

插件 API

当核心 API 加载一个插件时,为了让插件正常工作,它需要知道应该调用哪个函数或者要访问哪个符号,所以插件中应该明确定义具名的入口点,用户在创建插件时必须提供。

插件管理器

插件管理器的任务:

  • 加载所有插件的元数据
  • 将动态库加载到内存中,提供对库中符号的访问能力,并在必要时卸载
  • 当插件加载时,调用初始化例程;当插件卸载时,调用清理例程

通过继承扩展

对用户而言,将类的析构函数声明为虚拟的就是一个信号,说明该类是为继承而设计的

通过模板扩展