跳转到内容

面向复用的设计

正如第 4 章“设计专业级 C++ 程序”所说,在程序里复用库和其他代码,是一种非常重要的设计策略。不过,这其实只完成了“复用策略”的一半。另一半,则是设计并编写你自己的代码,让它也能在不同程序里反复使用。你大概已经体会过:设计良好的库和设计糟糕的库之间,差别非常大。好库用起来令人愉快;差库则常常会让你一边嫌弃,一边干脆自己重写一份。无论你是在编写一个明确面向其他程序员使用的库,还是只是在设计自己的类层次结构,都应该从一开始就把“可复用性”纳入考虑。你永远不知道,未来的某个项目里,什么时候又会需要一块类似的功能。

第 4 章 已经介绍过“复用”这一设计主题,并解释了如何通过把库和已有代码纳入设计来实践它;但它并没有真正展开说明:怎样设计可复用的代码。这正是本章的主题。本章将建立在第 5 章“用类进行设计”所介绍的面向对象设计原则之上,继续往前走。

你应当设计那些既能被自己复用、也能被其他程序员复用的代码。这个原则不仅适用于那些你明确打算交给别人使用的库和框架,也适用于你为某个程序设计的任何类、子系统或组件。下面这些格言应该始终放在心里:

  • “写一次,反复使用。”
  • “尽量避免代码重复。”
  • “DRY——不要重复自己。”

之所以如此,有好几个原因:

  • 代码很少只在一个程序里出现一次。 你几乎可以肯定,它迟早还会以某种形式再次被使用,所以一开始就该把它设计好。
  • 面向复用的设计能节省时间与金钱。 如果你的设计从一开始就把未来复用排除在外,那么日后你或你的同事一旦再次遇到类似需求,就只能重新发明轮子。
  • 团队里的其他程序员必须能够使用你写的代码。 你大概率不是一个人独立完成整个项目。你的同事会感谢你提供那些设计良好、功能完整、拿来就能用的库和代码片段。从这个意义上说,面向复用的设计也可以被叫做“协作式编码”。
  • 缺乏复用会导致代码重复,而代码重复会带来维护噩梦。 如果重复代码里发现了 bug,你就不得不在所有副本中逐一修复。每当你发现自己在复制粘贴一段代码时,至少都应该认真考虑一下:它是否应该被抽到一个辅助函数或类里。
  • 你自己也会是最大的受益者。 有经验的程序员不会轻易丢掉代码。随着时间推移,他们会建立起一个由各种工具逐步演化而来的个人代码库。你永远不知道未来什么时候又会需要类似功能。

当你以公司员工身份设计或编写代码时,知识产权通常归公司所有,而不是归你自己。当你离职时,保留这些设计或代码的副本往往是违法的。你作为自由职业者替客户工作时,情况通常也是如此。

可复用的代码需要同时满足两个主要目标:

  • 首先,它必须足够通用,以便能够服务于略有差异的用途,或者完全不同的应用领域。那些夹带着太多特定应用细节的程序组件,往往很难在别的程序中复用。
  • 其次,它也必须易于使用。一个可复用组件不应要求程序员花大量时间去理解它的接口和工作方式;它应该能被比较轻松地并入现有应用。

你将库“交付”给使用者的方式,同样会影响设计。你可以直接交付源代码,让客户把你的源码合并到他们的项目中;也可以交付静态库形式的二进制文件,让他们在构建时链接进应用;或者交付动态链接库(Windows 下的 .dll)或共享对象(Linux 下的 .so)。这些不同交付方式,都会对你如何设计可复用代码施加额外约束。

设计可复用代码时,最重要的策略就是抽象(abstraction)。

抽象的关键,在于有效地把接口(interface) 和实现(implementation) 分离开。实现,是你为完成任务而写下的具体代码;接口,则是其他人使用你代码的方式。在 C 中,描述库函数的头文件就是接口;而在面向对象编程里,一个类对外公开的成员函数和属性集合,就构成了它的接口。不过,一个好的接口理应只暴露公共成员函数。类的属性本身不应该直接公开,而应通过公共成员函数——也就是常说的 gettersetter——来访问。

第 4 章 已经介绍过抽象原则,并举了电视机这个现实世界类比:你可以通过它的外部接口使用它,而不需要知道它内部究竟如何工作。同样,在设计代码时,你也应当清晰地把接口与实现分开。这种分离会让代码更容易使用,因为客户端不需要理解内部实现细节,就能利用你提供的功能。

使用抽象,对你和使用你代码的客户双方都有好处。客户获益,是因为他们不必操心实现细节;他们可以直接利用你提供的功能,而无需理解代码到底怎么工作。你自己也获益,因为你可以修改底层实现,而无需改变对外接口。这样,你就能发布修复和升级,而不要求客户修改用法。若使用动态链接库,客户端甚至可能连可执行文件都不用重建。最后,双方都受益的一点在于:作为库作者,你可以在接口中准确规定自己期望的交互方式,以及你真正支持哪些功能。关于如何编写文档,可参见第 3 章“使用风格进行编码”。清晰地分离接口与实现,还能防止客户端以你本不打算支持的方式来使用库,否则很容易引发意外行为和 bug。

设计接口时,不要向客户端暴露实现细节。

有时候,库会要求客户端保留从某个接口返回的信息,以便之后再把它传给另一个接口。这种信息常常被称为“句柄”(handle),它通常用于在多次调用之间追踪某个特定实例的状态。一个现实中的例子是 OpenGL——一个 2D/3D 渲染库。OpenGL 的许多函数都会返回并使用句柄,而这些句柄通常由类型 GLuint 表示。例如,如果你使用 OpenGL 的 glGenBuffers() 创建一个缓冲区,它就会把这个缓冲区以 GLuint 句柄的形式返回给你。之后每当你想调用别的函数去操作这个缓冲区时,都必须把这个 GLuint 句柄传进去。

如果你的库设计需要句柄,就不要暴露它的内部结构。应当把这个句柄设计成一个不透明(opaque) 的类,使程序员既无法直接访问其内部数据成员,也无法通过公共 getter / setter 间接访问这些成员。客户端代码不应该被要求去调整句柄内部的变量。一个糟糕设计的例子是:某个库宣称它提供的是不透明句柄,却又要求你为了打开错误日志,在这个句柄里设置某个特定结构成员。

抽象极其重要,它应当贯穿你的整个设计过程。每当你做一个设计决定时,都应该问自己:这个选择是否符合抽象原则?设身处地站在客户端的角度想一想:他们真的需要知道接口背后的内部实现吗?除非极其特殊,否则你几乎不应该对这条规则做例外。

在使用抽象来设计可复用代码时,你应当重点关注两件事:

  • 第一,你必须把代码的结构搭对:应该采用什么类层次结构?要不要用模板?代码该如何划分成不同子系统?
  • 第二,你必须设计好接口,因为接口就是使用者进入你库、获取功能的“入口”。

这两个主题会在接下来的小节里展开讨论。

你必须从设计一开始,就在所有层次上考虑复用——从单个函数、单个类,到整个库与框架。在接下来的讨论里,这些不同层次的东西都会统称为组件(component)。下面这些策略会帮助你把代码组织正确。需要注意的是:这些策略主要关注的是如何让代码更通用;而“让代码更易用”则是设计可复用代码的另一面,那部分会在本章后面讨论接口设计时展开。

避免组合不相关或逻辑上独立的概念

Section titled “避免组合不相关或逻辑上独立的概念”

当你设计一个组件时,应当让它专注于单一任务,或者一组彼此紧密相关的任务;换句话说,你要追求高内聚(high cohesion)。这也就是所谓的“单一职责原则”(SRP)。不要把互不相关的概念硬塞进同一个组件里,例如随机数生成器和 XML 解析器。

即使你并不是在专门为“复用”设计代码,也应当记住这条策略。整个程序通常很少被原封不动地整体复用;真正会被拿去复用的,往往是程序中的某个部分、某个子系统,或者某种稍作调整后可用于其他用途的功能。因此,你应当把程序设计成:逻辑上彼此独立的功能,被划分为不同组件,每个组件都可以在不同程序中被复用,并且每个组件都拥有清晰定义的职责。

这种程序设计策略,其实是在模拟现实世界中“零件离散、彼此可替换”的设计原则。比如,你当然可以写一个 Car 类,然后把发动机相关的所有属性和行为都塞进里面。但发动机本身其实是一个可以拆开的独立组件,它并不天然和汽车的其他部分绑定在一起。发动机完全可以从一辆车上拆下来,再装到另一辆车上。因此,更合理的设计,是单独引入一个 Engine 类,把一切与发动机相关的功能都放进去,而 Car 只需包含一个 Engine 实例。

你应当把子系统设计成可以彼此独立复用的离散组件,也就是尽量追求低耦合(low coupling)。比如,如果你正在设计一款联网游戏,就应当把网络功能与图形用户界面功能分离成不同子系统。这样一来,你就可以单独复用其中任意一个,而不必把另一个也一起拖进来。例如,你可能之后想写一个不联网的游戏,那就可以直接复用图形界面子系统,而不需要网络部分;同样,如果你设计的是一个点对点文件共享程序,那么也许你只想复用网络子系统,而完全不需要图形界面功能。

确保在每个子系统上都贯彻抽象原则。把每个子系统都看成一个迷你库:你需要为它提供连贯、好用的接口。即使你自己是这些“迷你库”的唯一使用者,你仍然会从设计良好的接口和实现中获益——因为它们能把逻辑上不同的功能清楚地分离开来。

使用类层次结构来分隔逻辑概念
Section titled “使用类层次结构来分隔逻辑概念”

除了把程序划分成逻辑子系统之外,你也应避免在类的层面把不相关的概念混在一起。例如,假设你要为自动驾驶汽车写一个类,于是从一个基本 Car 类开始,然后把所有自动驾驶逻辑直接塞进其中。但如果某个程序里只想要一辆“非自动驾驶汽车”呢?这时,那些与自动驾驶有关的全部逻辑就都成了负担,而且很可能还会逼着你的程序链接一些本来完全不需要的库,比如视觉库、LIDAR 库等等。一种更合理的做法,是建立一个类层次结构(这一点在第 5 章介绍过),让自动驾驶汽车成为通用汽车类的派生类。这样一来,在不需要自动驾驶能力的程序中,你就能直接使用汽车基类,而不必承担那堆自动驾驶算法的代价。图 6.1 展示了这种层次结构。

两块牌子的示意图。上面的牌子写着 Car,下面的牌子写着 self-driving car。

[^FIGURE 6.1]

当只有两个逻辑概念(例如“自动驾驶”和“汽车”)时,这种策略很有效。但一旦涉及三个或更多概念,事情就会复杂起来。例如,假设你既想支持卡车,也想支持汽车,而它们又都可能是自动驾驶的,也可能不是。逻辑上,卡车和汽车都属于车辆的特例,因此它们都可以从 Vehicle 类派生出来,如图 6.2 所示。

三块牌子的示意图。第一块写着 Vehicle,第二块和第三块分别写着 car 和 truck。

[^FIGURE 6.2]

同样,“自动驾驶版本”也可以从“非自动驾驶版本”派生出来。但问题在于:你没法用一条简单的线性层次结构同时表达这些分离关系。一种可能办法,是把自动驾驶能力改造成一个 mixin 类。上一章已经展示过:在 C++ 中,可以通过多重继承来实现 mixin。例如,PictureButton 可以同时继承 Image 类和 Clickable mixin 类。不过在自动驾驶这个设计里,更合适的办法,是使用另一种 mixin 实现方式——也就是通过类模板来做。

这个例子会稍微提前使用一些类模板语法,而这些内容会在第 12 章“用模板编写通用代码”中详细解释。不过,对当前讨论而言,这些细节并不重要。它也稍微提前用到了继承语法;而第 10 章“深入继承技巧”会系统讲解继承。不过现在你只需要知道:下面这段语法表示 Derived 类继承(派生)自 Base 类:

class Derived : public Base {};

SelfDrivable mixin 类模板可以定义如下:

template <typename T>
class SelfDrivable : public T
{
};

这个 SelfDrivable mixin 类提供了实现自动驾驶功能所需的全部算法。有了它之后,你就可以分别实例化一个自动驾驶汽车和一个自动驾驶卡车,如下所示:

SelfDrivable<Car> selfDrivingCar;
SelfDrivable<Truck> selfDrivingTruck;

这两行代码的效果是:编译器利用 SelfDrivable mixin 类模板生成两个实例化。第一个实例化里,模板中的所有 T 都被 Car 替换,因此得到一个派生自 Car 的类;第二个实例化里,T 被替换为 Truck,于是得到一个派生自 Truck 的类。第 32 章“结合设计技术和框架”会更详细地介绍 mixin 类。

这个方案要求你编写四个不同的类(VehicleCarTruckSelfDrivable),但为了换来功能上的清晰分离,这点额外工作是值得的。

同样地,你应当避免把不相关概念硬塞在一起——也就是说,在设计的任何层级上都应追求高内聚,而不只是类层面。例如,在成员函数层面,一个成员函数也不应执行逻辑上彼此无关的工作,不应把状态修改(set)与状态查询(get)混在一起,等等。

聚合(aggregation)在第 5 章中已经讨论过,它用来建模 has-a 关系:一个对象通过包含其他对象,来完成自己功能中的某些部分。正如第 5 章 所解释的,只要你有选择,通常应优先考虑 has-a,而不是 is-a。

例如,假设你想写一个 FamilyTree 类来存储某个家庭的成员信息。显然,树形数据结构非常适合承担这种存储任务。与其把树结构代码直接塞进 FamilyTree 类中,不如单独编写一个 Tree 类。然后,FamilyTree 只需包含并使用一个 Tree 实例。用面向对象术语来说,就是 FamilyTree has-a Tree。通过这种做法,这个树形数据结构在其他程序中也会更容易被复用。

如果你的库负责的是数据操作,那么你就应当把“数据操作”与“用户界面”彻底分开。这意味着:对于这类库,你永远不应假设它最终会运行在什么样的界面环境中。库本身不应直接使用任何标准控制台输入输出功能,例如 std::println()cin,因为如果这个库最终被用在图形界面程序中,这样的假设可能根本不成立。举例来说,Windows 下的 GUI 程序通常压根没有控制台 I/O。即使你确信这个库只会运行在 GUI 应用里,你仍然不应该让库自己向最终用户弹出消息框或其他通知,因为那是客户端代码的职责。消息该如何展示给用户,应当由客户端代码决定。这类依赖不仅会严重降低可复用性,还会妨碍客户端对错误做出合适响应,比如选择静默处理某个错误。

第 4 章 中介绍过的模型—视图—控制器(MVC)范式,就是一种著名的设计模式,用来把数据存储与数据可视化分离开来。在这种范式下,模型可以位于库内部,而视图与控制器则由客户端代码提供。

使用模板构建通用数据结构与算法

Section titled “使用模板构建通用数据结构与算法”

C++ 有一个叫“模板”的机制,它允许你围绕类型或类编写通用结构。比如,你可能已经写好了一套“整数数组”的代码;如果接下来想支持 double 数组,传统做法往往要把整套代码重写一遍。模板的思想是:把“类型”本身变成参数,这样就能写出一份可作用于多种类型的代码。借助模板,你既可以实现适用于多类型的数据结构,也可以实现适用于多类型的算法。

最简单的例子,就是第 1 章介绍过的 std::vector。它是标准库的一部分:要创建存放整数的向量,写 std::vector<int>;要创建存放 double 的向量,写 std::vector<double>。模板编程整体上非常强大,但也可能变得复杂。好消息是,很多按“类型参数化”的模板用法其实并不难。关于如何自己编写模板,第 12 章第 26 章“高级模板”会详细展开;本节先关注其中与设计相关的关键点。

只要条件允许,你就应当优先采用“通用设计”,而不是把某个程序的细节硬编码进数据结构或算法里。不要写一个“只能存放图书对象”的平衡二叉树;应把它设计成可存放任意类型对象的通用结构。这样它就不仅能用于书店系统,也能用于音乐商店、操作系统,或任何需要平衡二叉树的场景。标准库正是建立在这种策略之上:提供对任意类型都可用的通用数据结构与算法。

当然也要看到代价:与非通用实现相比,通用数据结构通常更花时间。你需要更充分地分析需求,也需要用更多不同类型做更广泛的测试。如果某个数据结构只服务于非常狭窄、非常具体的用例,这些额外投入可能并不划算;这种情况下,从一个简单的非通用实现起步,往往是更务实的选择。

为什么模板优于其他通用编程技术
Section titled “为什么模板优于其他通用编程技术”

模板并不是实现通用数据结构的唯一手段。另一个较老、如今不再推荐的做法,是在 C/C++ 中使用 void* 来存储“任意类型”指针。客户端确实可以把任何东西转成 void* 存进去,但它的核心问题是:不具备类型安全。容器无法检查、也无法约束实际存储元素的类型。你可以把任何类型转成 void* 存进去,而取出来时又必须手动转回你“以为”的类型。因为没有检查,后果可能非常严重。比如,一个程序员把 int* 转成 void* 存进容器;另一个程序员却误以为这些是 Process*,并直接按 Process* 使用。结果可想而知,程序很可能会以灾难性方式出错。

在“非模板的通用结构”里,如果你不想直接用 void*,也可以考虑 C++17 引入的 std::any第 24 章“其他词汇类型”会详细介绍它。这里你只需知道:std::any 可以保存任意类型对象,并且会跟踪实际保存的类型,因此仍然保持类型安全。它在某些实现路径下可能会使用 void*,但对使用者暴露出来的语义是受类型信息保护的。

还有一种方案,是把数据结构写死为某个基类类型,再依靠多态存放其派生类对象。Java 在早期就大量使用这种思路:许多容器以 Object 为共同基类来容纳“任意对象”。但这种方案同样存在类型安全问题:当你把对象从容器中取出时,必须记得它的真实类型,并进行向下转型;一旦记错或转错,就会出现运行时问题。

相比之下,模板在正确使用时是类型安全的。每个模板实例化只对应一种类型;如果你试图在同一个实例化里混入不同类型,代码会在编译阶段直接失败。此外,模板还能让编译器为每个实例化生成高度优化的代码。与 void* 或部分 std::any 场景相比,模板常常还能减少堆分配,从而获得更好的性能。新版 Java 也引入了类型安全的泛型概念,在目标上与 C++ 模板类似。

模板并不完美。首先,它的语法可能让人困惑,尤其是第一次接触模板的人。其次,模板通常要求“同构容器”:单个实例化里只能存放同一种类型对象。也就是说,如果你写了一个平衡二叉树类模板,你可以创建一棵存 Process 的树,也可以创建一棵存 int 的树,但不能在同一棵树里同时混放 Processint。这正是模板类型安全带来的直接结果。

模板还可能带来所谓“代码膨胀”(code bloat):最终二进制体积增大。因为每个模板实例化都可能生成一份更专门化的代码,它通常比“单一通用实现”占用更多空间。不过在今天,这往往已不再是主要问题。

程序员常会纠结:这里该用模板,还是该用继承?下面是几个实用判断标准。

当你希望“为不同类型提供同样功能”时,用模板。比如写一个适用于任意类型的排序算法,就应使用函数模板;写一个能存放任意类型的容器,就应使用类模板。核心点在于:类模板或函数模板默认会“平等地”对待所有类型。当然,如果确有需要,你也可以通过模板特化让某些特定类型走不同逻辑。第 12 章会讨论模板特化。

当你希望“为相关类型提供不同行为”时,用继承。比如在图形绘制应用中,要支持圆、方形、线段等不同图形,通常就可以让这些具体图形从 Shape 基类派生。

模板与继承还有一个关键区别:模板在编译期处理,因此参与的类型必须在编译期已知,这对应的是编译期多态;继承则主要提供运行期多态。

另外,模板和继承并不是互斥关系,你完全可以把两者组合使用。例如,可以编写一个从“基类模板”派生出来的“类模板”。模板语法细节见第 12 章

当你以“可复用”为目标设计代码时,必须特别关注安全性,确保它在不同用例下都能被安全使用,而不仅仅是在你当前手头这个用例里刚好可用。

设计安全代码大体有两种看似相反的风格。实践中,最佳做法通常是两者结合。第一种是按契约设计(design-by-contract):函数或类的文档就是一份契约,明确界定客户端代码负责什么、你的函数或类负责什么。按契约设计有三个核心概念:前置条件、后置条件和不变量。前置条件是“调用前客户端必须满足的条件”;后置条件是“函数执行完成后必须成立的条件”;不变量则是“函数整个执行过程中始终应成立的条件”。

标准库里就大量使用了按契约设计。比如 std::vector 的数组下标访问就有一份隐含契约:它不做边界检查,这个责任由客户端承担。换句话说,用下标访问 vector 元素的前置条件,就是索引必须有效。这样设计是为了让“已经确保索引有效”的代码获得更高性能。

第二种风格,是尽可能把函数和类设计得“主动防御”。这类实践里最关键的一点,是在实现中执行必要的错误检查。比如你的随机数生成器要求种子位于特定范围,就不要只“相信调用者”;应检查传入值,无效就拒绝调用。再比如,std::vector 除了提供不做边界检查的下标访问,还提供了会做边界检查的 at() 成员函数。若索引无效,at() 会抛出异常。于是客户端可以自行权衡:要性能就用下标;要安全检查就用 at()

可以用会计师报税做类比:你把全年财务信息交给会计师,会计师不仅帮你把 IRS1 表格填完,还会检查数据是否合理。比如你有房产却漏填房产税,会计师会提醒你补齐;又比如你声称房贷利息支出很高但总收入很低,会计师通常也会追问数字是否正确(或者至少建议你考虑更便宜的房子)。

把这个类比映射到编程:会计师就像一个“程序”,输入是财务信息,输出是报税表;而会计师真正的附加价值,正是那些检查与保护。你的实现也应尽可能提供这样的检查与保障。

为了把这些保障落地,你可以使用多种语言特性。向客户端报告错误时,可以返回错误码、falsenullptr第 1 章介绍过的 std::optional,或者直接抛出异常。第 14 章“处理错误”会详细讨论异常机制。

你应当努力把类设计成这样:它可以通过派生新类来扩展,但应尽量避免为了扩展而去改动既有实现;也就是说,行为应当“可扩展”,同时“对修改关闭”。这就是“开放/封闭原则”(OCP)。

例如,假设你正在实现一个绘图应用程序,而第一个版本只支持正方形。你的设计里有两个类:SquareRenderer。前者定义正方形,例如它的边长;后者负责把正方形画出来。你也许会写出这样的代码:

class Square { /* Details not important for this example. */ };
class Renderer
{
public:
void render(const vector<Square>& squares)
{
for (auto& square : squares) { /* Render this square object… */ }
}
};

接着,你决定加入对圆形的支持,于是又创建了一个 Circle 类:

class Circle { /* Details not important for this example. */ };

为了能够渲染圆形,你不得不修改 Renderer 类的 render() 成员函数,于是它变成了这样:

void Renderer::render(const vector<Square>& squares,
const vector<Circle>& circles)
{
for (auto& square : squares) { /* Render this square object… */ }
for (auto& circle : circles) { /* Render this circle object… */ }
}

当你这样做时,也许会直觉地觉得哪里不太对,而这种直觉是对的!为了扩展功能、加入对圆形的支持,你不得不修改 render() 的现有实现,因此它并没有做到“对修改关闭”。

在这种情况下,继承可能是一种更好的设计方式。下面是一种使用继承的可能方案:

class Shape
{
public:
virtual void render() = 0;
};
class Square : public Shape
{
public:
void render() override { /* Render square… */ }
// Other members not important for this example.
};
class Circle : public Shape
{
public:
void render() override { /* Render circle… */ }
// Other members not important for this example.
};
class Renderer
{
public:
void render(const vector<Shape*>& objects)
{
for (auto* object : objects) { object->render(); }
}
};

有了这种设计,如果你想支持一种新的形状类型,只需要再写一个从 Shape 派生出来并实现 render() 成员函数的新类即可。你完全不需要修改 Renderer 类中的任何内容。因此,这种设计可以在不改动现有代码的情况下扩展;换句话说,它对扩展开放,对修改关闭。

除了正确地抽象和组织代码之外,面向复用的设计还要求你关注程序员与之交互的“接口”。即使你拥有最优雅、最高效的实现,如果你的库接口糟糕透顶,它也依然不会真正好用。

请注意,程序中的每个组件都应当拥有良好的接口,即便你并不打算让它们在多个程序中被复用。首先,你永远不知道某样东西将来会不会被拿去复用。其次,即使只是第一次使用,好的接口也依然非常重要,尤其是在团队开发中——因为其他程序员也必须使用你设计和编写的代码。

在 C++ 中,类的属性和成员函数都可以是 publicprotectedprivate。把某个属性或成员函数设成 public,意味着任何代码都能访问它;protected 表示只有类本身及其派生类能访问它;private 则更严格,不仅其他代码无权访问,连派生类也碰不到它。还要注意,访问说明符是定义在“类层级”而不是“对象层级”上的。这意味着,一个类的成员函数可以访问同类其他对象的 private 属性或 private 成员函数。

设计公开接口,本质上就是决定哪些东西应该成为 public。你应当把“公开接口设计”视为一个完整过程。接口最主要的目标,是让代码易于使用;同时,一些接口设计技巧也能帮助你更好地遵循通用性原则。

设计公开接口的第一步,是先搞清楚你究竟在为谁设计它。你的受众是团队里的其他成员吗?还是你自己会亲自使用的接口?会不会有公司外部的程序员来用它?也许是客户,或者离岸承包商?除了帮你判断“将来谁会因为接口问题来找你”,这一步也会帮助你澄清一部分设计目标。

如果这个接口只是供你自己使用,那么你通常会拥有更多的迭代自由。你可以在使用过程中不断修改它,让它更贴合自己的需要。不过,你也应当记住:团队中的角色会变化,迟早很可能会有别人也开始使用这个接口。

设计供其他内部程序员使用的接口,情况就稍微不同了。从某种意义上说,你的接口变成了你和他们之间的一份契约。例如,如果你正在实现程序中的数据存储组件,那么其他组件会依赖这个接口来支持某些操作。你需要搞清楚团队其他成员期望你的类能做哪些事情:他们需要版本控制吗?需要存储哪些类型的数据?

在为外部客户设计接口时,理想情况下,他们也应当参与决定接口该公开哪些功能——就像设计面向内部客户的接口一样。你既要考虑他们现在需要什么,也要考虑他们未来可能想要什么。接口中使用的术语,必须对应客户熟悉的表达;文档的写法,也必须以这类受众为中心。内部玩笑、代号和程序员黑话,都不应该出现在你的设计中。

无论你的接口是面向内部程序员还是外部客户,它本质上都是一份契约。如果在编码开始之前,大家已经就接口达成一致,那么在代码写完之后你再决定改动它,使用者多半会一片抱怨。

接口的目标受众,也会影响你应当在设计上投入多少时间。例如,如果你设计的是一个只有几个成员函数、只会在少数地方被少数用户使用的接口,那么以后再修改它,通常还能接受。但如果你设计的是一个复杂接口,或者一个会被很多用户长期使用的接口,那么你就应当在设计阶段投入更多精力,并尽力避免在用户真正开始依赖它之后再去修改它。这正是 Hyrum 定律(见 www.hyrumslaw.com)所提醒你的事情。

编写接口的原因有很多。在真正写下任何代码,甚至在决定要公开什么功能之前,你都必须先弄清楚这个接口的用途。

应用程序编程接口(API)是一种对外可见的机制,用来扩展某个产品,或者让其功能在另一种上下文中被使用。如果说内部接口更像契约,那么 API 就更接近于一套轻易不能改动的法律。一旦连不在你公司工作的人都开始依赖你的 API,他们通常就不会希望它发生变化——除非你是在增加对他们有帮助的新功能。因此,在把 API 提供给客户之前,你应当对它进行谨慎规划,并和客户充分讨论。

设计 API 时,最大的权衡通常发生在“易用性”和“灵活性”之间。由于接口的目标受众并不熟悉产品的内部工作方式,因此 API 的学习曲线应当尽量平缓。毕竟,公司之所以向客户公开这个 API,就是希望它真的被使用;如果用起来太困难,这个 API 就算失败了。问题在于,灵活性往往会和易用性对冲。你的产品可能有很多不同用途,你当然希望客户能够利用所有这些能力;但如果 API 允许客户做产品内部能做的任何事,它往往就会变得过于复杂。

正如一句常见格言所说:“一个好的 API,会让常见情况很简单,而让高级/不太常见情况成为可能。”换句话说,API 应该拥有简单的学习曲线;大多数程序员最常做的事情,理应很容易实现。与此同时,API 也要允许更高级的使用方式,而为了保证常见场景的简单性,接受稀有场景更复杂一些,通常是完全合理的。本章后面“设计易于使用的接口”一节,会更详细地讨论这一策略,并给出一些可操作的设计建议。

很多时候,你的任务只是开发某项特定功能,以供应用中其他地方通用复用,例如一个日志类。在这种情况下,接口往往更容易确定,因为你通常会倾向于公开大部分甚至全部功能,同时尽量不暴露太多实现细节。这里必须认真考虑的问题是“通用性”:既然这个类或库的目标就是通用用途,那么设计时就必须把可能出现的那组用例纳入考虑。

你也可能正在设计应用中两个主要子系统之间的接口,例如一个数据库访问机制。在这类场景中,把接口与实现分离开来至关重要,原因有很多。

其中一个最重要的原因,是可模拟性(mockability)。在测试场景里,你往往希望把某个接口的真实实现,换成同一接口的另一个实现。例如,当你为数据库接口编写测试代码时,通常并不想访问真实数据库。这时,那个“访问真实数据库”的接口实现,就可以被一个“模拟数据库访问”的实现替代。

另一个原因是灵活性。即使抛开测试场景,你也可能希望为某个接口提供几种不同实现,并让它们可以互换使用。例如,你可能想把一个使用 MySQL 的数据库接口实现,换成一个使用 SQL Server 的实现;甚至还可能希望在运行时动态切换不同实现。

还有一个原因是:只要接口先定义好,其他程序员就可以在你的实现尚未完成时,先开始针对这个接口编程。

在开发子系统时,首先要考虑它最核心的目的是什么。一旦明确了这个子系统负责的主要任务,就继续思考它有哪些具体用途,以及它应该如何呈现给代码的其他部分。尽量站在使用它的那部分代码的角度来思考,而不要被实现细节拖住。

你定义的大多数接口,很可能比子系统接口或 API 小得多。这些通常就是你在自己其他代码里使用的类。这里最大的陷阱在于:接口会在渐进演化的过程中逐步失控。即便这些接口最初只是供你自己使用,也要把它们当作“不是只供自己用”的东西来设计。和设计子系统接口一样,始终抓住每个类的主要目的,并谨慎地只暴露那些真正有助于这个目的的功能。

你的接口应当易于使用。这并不意味着它必须简单到近乎幼稚,而是说:在功能允许的前提下,它应当尽量简单、直观。这遵循的正是 KISS 原则:Keep It Simple, Stupid。你不应该让库的使用者为了用好一个简单数据结构,不得不翻大量源码或文档,也不应该逼他们在自己的代码里做各种怪异绕行,才能获得所需功能。本节会给出几条设计易用接口的具体策略。

开发易用接口的最佳策略之一,就是尽量遵循标准且熟悉的做事方式。当人们遇到一个和自己过去用过的接口相似的接口时,他们会更容易理解它,更容易接受它,也更不容易用错。

例如,假设你在设计汽车的转向机构。可选方案有很多:操纵杆、两个左右按钮、一根水平滑杆,或者传统的方向盘。你觉得哪种接口最容易使用?哪种接口形式的汽车最容易卖出去?因为消费者最熟悉方向盘,所以这两个问题的答案当然都是方向盘。哪怕你开发出另一种在性能和安全性上都更优的机制,想把它卖出去、并教会人们使用它,仍然会非常困难。当你必须在“遵循标准接口模型”和“另辟蹊径”之间做选择时,通常更好的做法,是坚持使用人们早已习惯的接口。

创新当然很重要,但你应当把创新重点放在底层实现上,而不是放在接口本身上。例如,消费者会为某些车型中的创新型纯电动力系统感到兴奋;而这些车之所以能卖得好,很大一部分原因就在于:它们的使用方式和传统燃油车并没有本质差别。

落实到 C++ 上,这意味着你应当设计那些符合 C++ 程序员既有习惯的接口。例如,C++ 程序员会理所当然地认为:构造函数负责初始化对象,析构函数负责清理对象(这两点会在第 8 章“深入掌握类与对象”中详细讨论)。如果需要“重新初始化”一个现有对象,标准做法通常是把一个新构造的对象赋值给它。当你设计自己的类时,也应当遵循这些惯例。如果你要求程序员调用 initialize()cleanup() 成员函数来完成初始化和清理,而不是把这些逻辑放进构造函数和析构函数中,那么每个试图使用你类的人都会被搞糊涂。因为你的类行为方式与其他 C++ 类不同,程序员不仅要花更长时间去理解它,也更容易因为忘记调用 initialize()cleanup() 而把它用错。

C++ 提供了一种叫做“运算符重载”的语言特性,可以帮助你为对象设计更易用的接口。运算符重载允许你编写类,使标准运算符可以像作用于 intdouble 这样的内建类型一样作用于这些类。例如,你可以写一个 Fraction 类,让它像下面这样支持分数的加法、减法和输出:

Fraction f1 { 3, 4 };
Fraction f2 { 1, 2 };
Fraction sum { f1 + f2 };
Fraction diff { f1 – f2 };
println("{} {}", f1, f2);

把它和使用成员函数调用来完成同样行为的写法对比一下:

Fraction f1 { 3, 4 };
Fraction f2 { 1, 2 };
Fraction sum { f1.add(f2) };
Fraction diff { f1.subtract(f2) };
f1.print();
print(" ");
f2.print();
println("");

正如你看到的,运算符重载能够为你的类提供更自然、更容易使用的接口。不过,一定要小心别滥用它。你当然可以把 + 重载成减法,把 - 重载成乘法,但那样的实现会严重违背直觉。当然,这也不意味着每个运算符都必须和内建类型上的行为完全一致。例如,string 类就使用 + 来表示字符串拼接,而这恰恰是一个很直观的接口。关于运算符重载的更多细节,请参见第 9 章第 15 章“重载 C++ 运算符”。

在设计接口时,始终要把未来也考虑进去。这会是一个你未来很多年都要被它绑定住的设计吗?如果是,也许就需要通过插件架构之类的方式,为扩展预留空间。你是否有证据表明,人们会尝试把你的接口拿去做超出原始设计目标的事情?如果有,就和他们谈谈,进一步理解他们的用例。否则,将来你就只能重写它,或者更糟——零散地往上面补新功能,最后得到一个混乱不堪的接口。不过也要小心!“投机性通用化”本身也是一个陷阱。如果未来用途根本不清楚,就不要一开始就试图设计一个“终极日志类”,因为那只会徒增设计、实现和公共接口的复杂度。

这一策略有两层含义。第一,接口应当包含客户端可能真的需要的全部行为。乍一听似乎很显然。回到汽车的类比:如果一辆车连速度表都没有,让驾驶员无法查看当前速度,那它根本就不算一辆设计完整的车!同样,如果没有一种机制让客户端代码访问分子和分母的值,那 Fraction 类也就称不上完善。

不过,其他潜在行为有时并没有这么明显。这条策略要求你尽可能预判:客户端将来可能会把你的代码拿去做什么。如果你只从某一种特定使用方式来理解接口,就很可能会忽略那些在其他用法下才会暴露出来的需求。例如,假设你设计的是一个棋盘类。你也许只考虑了国际象棋这种典型棋类,于是决定“棋盘上的每个位置最多只允许一个棋子”。但如果你后来想写一款双陆棋游戏,而双陆棋允许一个位置上堆叠多个棋子,又怎么办?一旦你把这种可能性预先排除了,也就等于直接排除了这个棋盘类作为双陆棋棋盘的可能性。

显然,要准确预测一个库未来的所有可能用途,即便不是不可能,也几乎总是非常困难。你不必为了设计一个完美接口而对所有未来可能性反复焦虑。只要认真想一想,并尽力做到最好,就已经足够了。

这条策略的第二层含义是:尽可能把更多必要功能放在实现内部。不要要求客户端去提供那些你在实现中本来就知道、或者通过稍微不同的设计完全可以自己知道的信息。例如,如果你的库需要一个临时文件,就不要要求库的使用者手动指定这个路径。他们根本不关心你到底用了哪个临时文件;你应当自己想办法找到合适的临时文件路径。

此外,也不要让库的使用者去做那些没有必要的“结果拼装”工作。如果你的随机数库底层算法会分别计算一个随机数的高位和低位,那么在把结果交给用户之前,就应当先由你把这些位拼成一个完整的数。

为了避免在接口中遗漏功能,有些程序员会走向另一个极端:把所有能想到的功能统统塞进去。这样一来,使用接口的程序员似乎永远不会缺少完成任务的手段。不幸的是,这种接口往往会乱到让人根本不知道该怎么下手!这类接口通常被称为“胖接口”(fat interface)。

不要在接口中提供不必要的功能;应尽量保持接口干净、简单。乍一看,这条建议似乎和前面“不要遗漏必要功能”的策略直接矛盾。但事实上并不冲突:虽然“把所有能想到的接口都加进去”确实是一种避免遗漏功能的方法,但它并不是合理的方法。你应当提供的是必要的功能,并省略那些无用甚至适得其反的接口。

再想想汽车。驾驶一辆车时,你真正会去交互的组件并不多:方向盘、刹车和油门踏板、变速杆、后视镜、车速表,以及仪表盘上的少数其他仪表。现在,请想象一辆车的仪表板却长得像飞机驾驶舱一样,拥有成百上千个表盘、控制杆、显示器和按钮。那几乎根本没法用!驾驶汽车远比驾驶飞机简单,因此它的接口也必须更简单:你不需要查看海拔、不需要和塔台通话,也不需要直接控制飞机里那些繁多的部件,例如机翼、发动机和起落架。

避免胖接口的一种方式,是把一个大接口拆成多个更小的接口。另一种方式,则是使用外观设计模式(façade design pattern),在一个臃肿接口之上再包一层或多层更简单的接口。例如,一个“胖汽车接口”可能会囊括从加速、制动、转弯等基础操作,到各种更高级的功能——例如大量调整发动机性能的选项。更好的做法,是提供多个更易用的接口:一个接口只负责加速、刹车、转弯等基础操作;另一个接口则提供发动机调校相关能力;必要时还可以继续拆分。

此外,从库开发角度看,小而清晰的库也更容易维护。如果你试图讨好所有人,就会给自己留下更多出错空间;而一旦实现复杂到各部分彼此纠缠,哪怕只有一个错误,也可能让整个库变得不可靠。

遗憾的是,“设计整洁接口”在纸面上听起来很美,但真正做起来往往非常困难。毕竟,这条规则在很大程度上仍然是主观的:什么算必要,什么算多余,最终都需要由你来判断。当然,当你判断失误时,客户通常会很快提醒你这一点。

无论你的接口多么容易使用,你都应该为它们提供使用文档。除非你明确告诉程序员这个库该怎么用,否则你不能指望他们一定会正确使用它。把你的库或代码当成一种面向其他程序员的产品来看待,而产品就应当配有说明其正确用法的文档。

你可以通过两种方式为接口提供文档:一是直接写在接口里的注释,二是接口之外的外部文档。理想情况下,这两种方式你都应该提供。许多公共 API 往往只提供外部文档——在很多标准 Unix 和 Windows 头文件里,注释都是稀缺资源。在 Unix 世界中,文档通常以 man page 的形式出现;而在 Windows 环境里,文档往往随 IDE 一起提供,或者可以在互联网上找到。

尽管大多数 API 和库会在接口本身中省略注释,但我其实认为:这种形式的文档恰恰最重要。你永远不应该把一个只包含代码的“裸模块”或头文件直接扔给别人。即便你的注释和外部文档完全重复,带有清晰友好注释的模块或头文件,看起来也远比一份纯代码要容易接近得多。即使是最优秀的程序员,也依然会喜欢偶尔看到一些书面语言!关于应该注释什么、如何写注释,第 3 章 已经给出了具体建议,也解释了有哪些工具可以根据你写在接口里的注释自动生成外部文档。

接口应当足够通用,以适应多种不同任务。如果你在一个所谓“通用接口”里硬编码了某个特定应用的细节,那么它几乎不可能再用于别的目的。下面是一些在设计通用接口时应当牢记的准则。

提供多种方法来执行相同的功能
Section titled “提供多种方法来执行相同的功能”

为了满足不同“客户”的需求,有时候为同一功能提供多种使用方式会很有帮助。不过,这种技术一定要谨慎使用,因为一旦过度,就很容易把接口再次搞乱。

再想想汽车。如今,大多数新车都会提供远程无钥匙进入系统,你可以通过按钥匙扣上的按钮来解锁汽车。但与此同时,这些车通常仍然会配一把传统钥匙,以便在钥匙扣没电时依然可以手动开门。虽然这两种方式在功能上有些重复,但大多数用户都很高兴能拥有这两种选择。

接口设计里也会出现类似情况。还记得本章前面提到的 std::vector 吗?它提供了两种方式来访问某个索引位置上的元素:你可以使用会进行边界检查的 at() 成员函数,也可以使用不做边界检查的数组表示法。如果你已经非常确定索引有效,那就可以使用数组表示法,省掉 at() 为边界检查带来的那点开销。

请注意,这条策略应当被看作“保持接口整洁”那条规则的一个例外。在某些情况下,这样的例外是合理的;但大多数时候,你仍然应当优先遵循“整洁”的原则。

为了提高接口的灵活性,你应当提供可定制性。所谓可定制性,简单到甚至可以只是允许客户端打开或关闭错误日志。它的基本前提是:你向所有客户提供同样的基础功能,但允许他们在此基础上做一些轻量调整。

实现这一点的一种方式,是通过接口来倒置依赖关系,也就是所谓的依赖倒置原则(DIP)。而依赖注入(dependency injection) 则是这一原则的一种具体实现。第 4 章“设计专业级 C++ 程序”曾经简要提到过 ErrorLogger 服务的例子。你应当定义一个 ErrorLogger 接口,并使用依赖注入,把这个接口的具体实现注入给每一个想要使用 ErrorLogger 服务的组件。

通过回调和模板参数,你还可以提供更强的可定制性。例如,你可以允许客户端设置自己的错误处理回调。第 19 章“函数指针、函数对象和 Lambda 表达式”会详细讨论回调。

标准库则把这种“可定制性”策略发挥到了极致,它允许客户端为容器指定自己的内存分配器。如果你想使用这一能力,就必须编写一个遵循标准库规则、并满足所需接口约束的分配器类。标准库中的大多数容器,都把分配器作为自己的模板参数之一。第 25 章“定制和扩展标准库”会进一步介绍这一点。

易用性与通用性这两个目标,有时确实会彼此冲突。通常,一旦你引入更强的通用性,接口复杂度也会随之上升。例如,假设你需要在一个地图程序中使用图结构来存储城市。出于通用性的考虑,你也许会用模板写出一个可存储任意类型、而不仅仅是城市的通用图结构。这样一来,如果下一个程序恰好是网络模拟器,你就可以复用同一个图结构来存储网络中的路由器。不幸的是,一旦你这样做,接口就会变得稍微笨重,也更难使用——尤其是当潜在使用者并不熟悉模板时。

不过,通用性与易用性并不是非此即彼的。虽然在某些情况下,提高通用性确实会牺牲一些易用性,但设计出既通用、又容易使用的接口,依然是可能的。

为了在提供足够功能的同时降低接口复杂度,你可以提供多个彼此独立的接口。这就是所谓的接口隔离原则(ISP)。例如,你可以编写一个通用网络库,但把它拆成两个独立方面:一个提供游戏更常用的网络接口,另一个则提供与 Web 浏览相关的超文本传输协议(HTTP)接口。提供多个接口,也有助于让常用功能保持易用,同时仍然给高级功能保留入口。继续用地图程序做例子:你也许可以额外提供一个独立接口,允许地图客户端指定不同语言下的城市名称,同时把英语设为默认值,因为英语在很多场景下最常见。这样,大多数客户端就不必操心语言设置,而那些确实需要这个能力的用户,也仍然可以获得它。

经验与迭代,对于形成良好的抽象至关重要。真正设计优秀的接口,通常来自多年编写和使用各种抽象的积累。你还可以通过复用那些已经被证明设计良好的抽象——例如标准设计模式——来借用别人多年积累下来的经验。每当你接触到别人的抽象时,都试着记住:什么地方做得好,什么地方做得差。比如,你上周使用的 Windows 文件系统 API 到底缺了什么?如果当初是你而不是同事来写那个网络包装器,你会怎么设计得更好?最好的接口,几乎从来都不是你第一次写在纸上的那个版本,所以请不断迭代。把设计拿给同事看,主动寻求反馈。如果你的公司有代码审查流程,那就应该在实现开始前,先把接口规范拿出来审查。一旦编码开始,也不要害怕去调整抽象——即便这意味着其他程序员也要跟着适应。理想情况下,他们最终会意识到:好的抽象对所有人都有长远好处。

有时候,在和其他程序员沟通你的设计时,你还得稍微“布道”一下。也许团队其他成员并没有意识到旧设计的问题,或者他们觉得你的方案会给自己带来额外工作。在这种情况下,你既要准备好为自己的设计辩护,也要在适当的时候吸纳他们的想法。

良好的抽象意味着:导出的接口中,应当只包含那些稳定、不会轻易变化的公共成员函数。实现这一点的一种具体技术,被称为私有实现惯用法(private implementation idiom),也就是常说的 pimpl 惯用法;它会在第 9 章中讨论。

要警惕“单类抽象”这种倾向。如果你正在编写的代码本身有相当复杂的深度,就应当思考:围绕主接口,是否还需要配套的伴随类。例如,如果你要公开一个用于数据处理的接口,也许就应当顺手设计一个结果类,以便更容易查看与解释处理结果。

始终把属性访问转化为成员函数。换句话说,不要让外部代码直接操作类背后的数据。你当然不希望某个粗心、甚至恶意的程序员,把一个兔子对象的高度设成负数。正确做法是:提供一个“设置高度”的成员函数,并在里面完成必要的边界检查。

之所以值得再次强调“迭代”,是因为它真的太重要了。积极寻求并回应关于设计的反馈,在必要时勇于修改,并从每一次错误中吸取教训。

本章和前一章讨论了面向对象设计中的若干基本原则。为了把这些原则更容易记住,人们通常会把它们缩写成一个简洁的首字母缩略词:SOLID。下表概括了这五项原则:

S单一职责原则(SRP) 单个组件应当只有一个清晰、明确的职责,不应混合不相关的功能。
O开放/封闭原则(OCP) 类应当对扩展开放,但对修改关闭。继承是实现这一点的一种方式;其他手段还包括模板、函数重载等等。一般来说,我们也会在这种语境下谈“定制点”。
L里氏替换原则(LSP) 你应当能够用某个对象子类型的实例,替换该对象本身的实例。第 5 章在“has-a 与 is-a 之间的细线”一节中,通过 AssociativeArrayMultiAssociativeArray 的例子解释了这个原则。
I接口隔离原则(ISP) 保持接口干净而简单。相比一个庞大、笼统的通用接口,拥有多个更小、更明确、且各自职责单一的接口通常更好。
D依赖倒置原则(DIP) 使用接口来倒置依赖关系。支持依赖倒置原则的一种方式就是依赖注入;本章前面已经提到它,而第 33 章“应用设计模式”还会继续讨论。

通过阅读本章,你学到的是:到底“应该如何”设计可复用代码。你读到了复用的哲学,可以概括成“写一次,常使用”,也理解了可复用代码应当既通用又易于使用。你还看到:要设计可复用代码,就必须依靠抽象、正确地组织代码结构,并设计出良好的接口。

本章还介绍了若干构建代码的具体技巧:避免组合不相关或逻辑上彼此独立的概念;使用模板来构建通用数据结构与算法;提供适当的检查与保护措施;并以可扩展性为目标来设计代码。

本章还提出了若干设计接口的策略:遵循熟悉的做事方式,不省略真正需要的功能,保持接口整洁,提供文档,为相同功能提供多种用法,以及支持可定制性。它还讨论了:当通用性与易用性发生冲突时,应当如何设法协调两者。

本章最后以 SOLID 作为收束。这是一个便于记忆的首字母缩略词,用来概括本章以及其他相关章节中讨论过的最重要设计原则。

这一章也是本书第二部分的最后一章;这一部分主要关注的是较高层次的设计主题。接下来的一部分,将深入软件工程流程中的实现阶段,并更细致地讨论 C++ 编码本身。

通过完成下面这些练习,你可以巩固本章讨论的内容。所有练习的解答,都可以在本书网站 www.wiley.com/go/proc++6e 的代码下载中找到。不过,如果你在做题时遇到困难,我仍然建议先回头重读本章相关部分,尽量自己找到答案,再去查看网站上的解答。

  1. 练习 6-1: “让常见情况变得简单,让不太可能出现的情况成为可能”这句话是什么意思?
  2. 练习 6-2: 设计可复用代码时,最重要的首要策略是什么?
  3. 练习 6-3: 假设你正在编写一个需要处理人员信息的应用程序。程序的一部分需要维护客户列表,其中包含最近订单列表、会员卡号等数据;程序的另一部分则需要追踪公司员工的信息,例如员工 ID、职位等。为了满足这些需求,你决定设计一个名为 Person 的类,把姓名、电话号码、地址、最近订单列表、会员卡号、工资、员工 ID、职位(工程师、高级工程师……)等信息都放进去。你觉得这样的类设计如何?能想到哪些改进?
  4. 练习 6-4: 不回看前面的页面,试着解释一下 SOLID 的含义。
  1. 美国国税局(IRS)负责管理和执行美国联邦税法。