跳转到内容

设计专业级 C++ 程序

在你的应用里写下第一行代码之前,你就应该先设计程序。你会使用哪些数据结构? 你要编写哪些类? 当你是团队协作开发时,这一规划尤其重要。想象一下,你坐下来开始写程序,却完全不知道与你一起开发同一程序的同事到底打算怎么做! 在本章中,你将学习如何以 Professional C++ 的方式开展 C++ 设计。

尽管设计极其重要,但它很可能也是整个软件工程流程中最容易被误解、最容易被忽视的部分。程序员太常在没有清晰计划的情况下就一头扎进应用开发中:他们一边写代码一边设计。这种方式往往会导向纠结且过度复杂的设计,也会让后续开发、调试和维护都变得更加困难。虽然听起来有点违反直觉,但在项目一开始时额外花时间把设计做好,实际上会在项目整个生命周期中节省大量时间。

当你开始一个新程序,或者为现有程序开发一个新功能时,第一步就是分析需求。这通常意味着与你的利益相关者(stakeholders) 进行讨论。分析阶段最重要的产出之一,是一个功能需求(functional requirements) 文档,用来描述这段新代码究竟要做什么,但它并不解释应当如何做。需求分析还可能产出一份非功能需求(non-functional requirements) 文档,用于描述最终系统应该“具备什么性质”,而不仅仅是“做什么”。例如,系统必须安全、必须可扩展、必须满足某些性能指标等等,这些都属于非功能需求。

一旦所有需求收集完毕,项目就可以进入设计阶段。你的程序设计(program design),也就是软件设计(software design),指的是你将要实现的体系结构规范,它必须满足程序全部的功能和非功能需求。非正式地说,设计就是你准备如何编写这个程序。通常,你应当把设计写成一份设计文档。尽管每家公司或每个项目对设计文档格式都有各自不同的版本,但大多数设计文档的总体布局都很相似,通常包括两大部分:

  • 程序在整体上如何划分为多个子系统,包括子系统之间的接口与依赖关系、数据如何在子系统之间流动、每个子系统的输入输出,以及总体线程模型
  • 各个子系统的细节,包括它们如何进一步细分为类、类层次结构、数据结构、算法、具体线程模型以及错误处理细节

设计文档通常还会包含图和表,用来展示子系统之间的交互以及类层次结构。统一建模语言(UML, Unified Modeling Language) 是业内用于此类图示的标准,本章及后续章节中的图也会使用 UML。关于 UML 语法的简要介绍,可参见 附录 D“UML 简介”。不过话说回来,设计文档的具体格式远不如“认真思考设计”这个过程本身重要。

通常来说,你应尽量在开始编码之前把设计做到尽可能完善。设计应当为程序提供一张地图,让任何一位合格程序员都能据此实现该应用。当然,一旦真正开始编码,你难免会遇到先前没想到的问题,于是设计也势必要修改。软件工程流程正是为了让你能灵活完成这些修改而被设计出来的。Scrum 是一种敏捷软件开发方法,就是这类迭代流程的一个例子:应用会按周期开发,这些周期被称为 sprints。随着每次 sprint 的推进,设计可以调整,新需求也可以纳入。第 28 章“最大化软件工程方法的价值”会更详细介绍各种软件工程流程模型。

人们很容易被诱惑,直接跳过分析与设计步骤,或者只草草做一下,以便尽快开始写代码。毕竟,没有什么比看到代码编译通过并跑起来更能让人觉得“我正在推进工作”了。当你已经大致知道自己想怎样组织程序时,似乎再去形式化设计、再写功能需求文档就像是在浪费时间。更何况,写设计文档哪有写代码有趣? 如果你想整天写文档,那你干嘛还来做程序员! 作为程序员,我完全理解这种想立刻开写的冲动,而且我自己也确实有时会这么做。不过,除了最简单的项目外,这大概率会把你带进问题堆里。无论你作为程序员有多有经验,对常用设计模式有多熟练,对 C++、问题领域和需求理解得多深入,“设计(思考)”都仍然是你工作说明的一部分。没有这一步的前期设计,事情就是行不通。

如果你在团队中工作,而且团队成员会分别负责项目的不同部分,那么拥有一份全体成员都能遵循的设计文档就至关重要。设计文档还可以帮助新加入项目的人迅速理解项目设计。如果没有设计文档,新成员根本不知道这些设计本该是什么样子,于是他们就可能修改代码、破坏那些没有记录下来的设计,进而在项目后续阶段引发问题。

有些公司会有专门的业务分析师来编写功能需求,也会有专门的软件架构师来制定软件设计。在这种公司里,开发人员通常只需要专注于项目中的编程部分。另一些公司则不同,开发人员必须亲自做需求收集和设计。还有些公司介于这两者之间;比如,也许他们只有一位软件架构师负责较大的架构决策,而开发人员则自行完成较小范围的设计。

为了帮助你真正理解编程设计的重要性,不妨想象一下:你拥有一块地,准备在上面盖房子。当施工队到场时,你要求先看看图纸。工头却说:“图纸? 什么图纸? 我知道自己在干什么,不用提前把每个细节都规划好。你想要两层楼的房子? 没问题。我几个月前刚盖过一栋一层房子——我就从那个模型出发,边做边改。”

假设你按下不信任,居然还真让这位工头继续干。几个月后,你发现房子的水管居然沿着屋外走,而不是藏在墙里。你去问工头为什么会这样,他说:“哦,这个嘛,我忘了在墙里预留走管空间了。当时我太兴奋于这种新型石膏板技术,一不留神就给忘了。不过装在外面其实也一样能用,而且功能才是最重要的嘛。” 此时你大概已经开始怀疑他的做法了,但你还是违背直觉,允许他继续施工。

等到房子终于盖完,你第一次进去参观时,却发现厨房里竟然没有水槽。工头解释说:“我们把厨房做了三分之二的时候才意识到根本没留出水槽的位置。与其返工重来,我们干脆就在隔壁加了一个单独的‘水槽房’。能用不就行了吗?”

如果把工头的这些借口翻译到软件领域,是不是听起来很熟悉? 你有没有做过那种“很丑但先凑合”的解决方案,就像把水管铺到房子外面一样? 比如,你也许忘了给多线程共享的队列数据结构加锁。等你意识到问题时,便决定在所有使用该队列的地方手工加锁。你心想,是挺丑,但总归能跑。直到项目里新来了一位同事,她以为锁是队列数据结构内部自带的,于是访问共享数据时没有确保互斥,导致了一个需要花三周才定位出来的竞争条件 bug。专业 C++ 程序员绝不会决定在每一次队列访问时手工加锁,而是会直接把加锁整合进队列类内部,或者把队列类设计成无锁线程安全的形式。

在编码前先把设计形式化,能帮助你搞清楚所有部分究竟如何拼在一起。就像房屋蓝图展示了各个房间如何彼此关联、协同满足整栋房子的需求一样,程序设计则展示了程序中的各个子系统如何彼此关联、协同满足软件需求。没有设计方案,你很可能会遗漏子系统之间的联系、遗漏复用机会或共享信息的可能性,也容易错过完成任务的最简方法。没有设计所提供的那张“全局图景”,你很可能会深陷在零碎实现细节中,从而失去对整体架构和目标的把握。此外,设计还提供了书面文档,使项目所有成员都有可参照的依据。如果你采用前面提到的 Scrum 这类迭代流程,那就必须确保在每一个迭代周期中及时更新设计文档——至少在它仍然有价值的前提下。敏捷方法论的一条核心原则就是“更重视可工作的软件,而不是详尽的文档”。至少,对于项目中较大组成部分如何协作的设计文档,你应保持其持续更新。而至于那些关于项目细粒度部分的设计文档是否仍有维护价值,在我看来取决于团队自身的判断。如果没有价值,那么请要么删掉它们,要么明确标记为已过期。

如果前面的比喻还没能说服你“先设计再编码”,那再来看一个直接跳进编码会导致非最优设计的例子。假设你要写一个国际象棋程序。你没有在动手前先设计整个程序,而是决定从最容易的部分开始写,逐渐推进到难的部分。按照 第 1 章“C++ 与标准库速成”中引入、并在 第 5 章“用类进行设计”中深入讨论的面向对象视角,你决定用类来建模棋子。你觉得兵(pawn) 是最简单的棋子,于是决定从它入手。考虑完兵的特性和行为后,你写出了一个类,其属性和成员函数如 图 4.1 的 UML 类图所示。

单列表格,标题为 Pawn。第一行包含 location、color 和 bool。第二行包含若干 void 与 bool 类型成员。

[^图 4.1]

在这个设计中,属性 m_color 用来表示兵是黑方还是白方。成员函数 promote() 则在兵走到棋盘对侧时执行。

当然,实际上你并没有先画出这个类图。你是直接跳进了实现阶段。写完这个类之后,你心满意足地转向下一个最简单的棋子:主教(bishop)。考虑完它的属性和功能后,你又写出一个类,其属性和成员函数如 图 4.2 所示。

单列表格,标题为 Bishop。第一行包含 location、color 和 bool。第二行包含 void、bool 和 void 类型成员。

[^图 4.2]

同样地,你并没有先生成类图,因为你是直接跳进编码阶段的。不过这时候你开始隐约觉得自己可能做错了。主教和兵看起来非常相似。事实上,它们的属性完全一致,成员函数也有许多重叠。虽然 move 成员函数在兵和主教中的实现可能不同,但两者都需要具备移动能力。如果你在编码前先设计整个程序,你就会意识到:不同棋子其实极其相似,你应当想办法让这些通用功能只写一次。第 5 章 会解释如何运用面向对象设计技术来做到这一点。

更进一步地说,国际象棋棋子的许多方面还依赖于程序中的其他子系统。例如,如果你还不知道棋盘会被建模成什么样,你就无法准确决定棋子类中应如何表示“在棋盘上的位置”。另一方面,也许你的程序会被设计成:棋盘统一管理所有棋子,从而棋子自身并不需要知道自己的位置。再比如,如果你还没决定程序的用户界面长什么样,又怎么去编写棋子的 draw 成员函数? 它会是图形界面还是文本界面? 棋盘长什么样? 问题就在于,程序中的子系统并不是彼此隔绝存在的——它们相互关联。设计工作中的大部分内容,正是在确定和定义这些关系。

在面向 C++ 做设计时,你需要牢记这门语言的几个特点:

  • C++ 提供面向对象能力。 这意味着你的设计可以包含类层次结构、类接口和对象交互。相比 C 等语言中常见的过程式设计,面向对象设计有很大不同。第 5 章 会专门讲解 C++ 中的面向对象设计。
  • C++ 是一门多范式编程语言。 除了前一点所说的面向对象能力之外,C++ 还支持其他范式,例如过程式编程。到底选面向对象还是过程式,本身就是设计过程的一部分。
  • C++ 拥有大量用于设计泛型和可复用代码的机制。 除了面向对象和过程式能力之外,C++ 还支持诸如模板这类泛型编程设施。有关可复用代码的设计技术,本章后面会进一步讨论,第 6 章“为复用而设计”也会详细展开。
  • C++ 提供了庞大的标准库。 其中包括字符串类、字符串格式化、I/O 设施、多线程基础构件、各种常见数据结构和算法等等。这些都极大地方便了 C++ 编码。
  • C++ 很适合承载众多设计模式(design patterns)。 换句话说,它很好地支持那些常见的问题求解方式。

着手做设计时,你很容易感到不知所措。我曾经整整花了数天时间,在纸上写满设计思路,再一条条划掉,写新的,再划掉,如此循环。有时这个过程非常有帮助,几天(甚至几周)后真的会收敛出一个干净、高效的设计。另一些时候它则令人沮丧、毫无进展,但那也绝不是白费功夫。毕竟,如果你最后不得不推倒重来一个本来就有问题的设计,你大概率会浪费更多时间。关键在于,你必须时刻意识到自己是否真的在取得进展。如果你发现自己卡住了,可以采取以下行动之一:

  • 寻求帮助。 向同事、导师、书籍、新闻组或网页求助。
  • 暂时换个问题做。 过一阵再回来处理这个设计决策。
  • 做出一个决定并继续前进。 即使它不是理想方案,也先定下来并尝试继续做下去。错误的选择通常很快就会暴露。不过,它也可能最终证明是个可接受的解决方案。也许对这个设计问题而言,根本不存在什么“特别优雅”的办法。有时候,如果某个丑陋方案是唯一现实可行、又能满足需求的策略,那你就必须接受它。不管你最终怎么决定,都务必把这个决定记录下来,让未来的你和其他人知道当初为什么这么选。这也包括把你否决掉的设计方案及其否决理由记录下来。

你自己的 C++ 设计应遵循的两条规则

Section titled “你自己的 C++ 设计应遵循的两条规则”

当你为自己的 C++ 程序做设计时,有两条最基本的规则必须遵守:抽象(abstraction) 与 复用(reuse)。这两条指导原则重要到足以称为本书的两大主题。它们会在整本书中反复出现,也会反复出现在所有领域里那些真正高效的 C++ 程序设计中。

抽象原则最容易通过现实世界中的类比来理解。电视机是大多数家庭里都能见到的一项技术。你大概熟悉它的功能:可以开关机、换台、调音量,还可以接外部组件,例如音箱、DVR 和蓝光播放器。但你能解释它在那个黑盒子内部究竟是怎么工作的吗? 也就是说,你知道它如何通过电缆接收信号、如何转换这些信号、以及如何把图像显示在屏幕上吗? 大多数人当然解释不清楚电视的工作原理,但却完全能熟练使用它。原因就在于,电视把内部的实现(implementation) 和外部的接口(interface) 清晰地区分开了。我们通过接口与电视交互:电源键、换台键、音量控制。我们既不知道,也不关心电视内部是怎么运作的;我们不在乎它到底是用阴极射线管还是某种外星科技来生成屏幕图像。之所以无所谓,是因为这些细节并不会影响接口。

软件中的抽象原则与此类似。你可以在不了解底层实现的情况下使用代码。举个极其简单的例子,程序可以调用声明于 <cmath> 中的 sqrt() 函数,而不必知道它到底使用什么算法来计算平方根。事实上,即使平方根计算的底层实现版本在库的不同发布之间发生变化,只要接口保持不变,你的函数调用就仍然能正常工作。

抽象原则同样适用于类。正如 第 1 章 引入过的,vector 类可以当作动态数组来用;你可以随意添加和删除元素。例如:

vector<int> myVector;
myVector.push_back(33);
myVector.push_back(44);

你是通过 vector 类的公开接口把元素 33 和 44 添加到 myVector 中的。但你完全不需要知道 vector 类在内部是如何管理内存的。你只需要知道它的公开接口即可。vector 的底层实现完全可以自由变化,只要它暴露出来的行为和接口保持不变。

你设计函数和类时,应确保你自己和其他程序员都能在不了解、也不依赖其底层实现的情况下使用它们。为了看清“暴露实现”和“通过接口隐藏实现”之间的差别,再回到国际象棋程序的例子。你可能会想用一个二维数组来实现棋盘,其中存放的是指向 ChessPiece 对象的指针。那你也许会这样声明和使用棋盘:

ChessPiece* chessBoard[8][8]{}; // Zero-initialized array.
chessBoard[0][0] = new Rook{};

然而,这种做法完全没有体现抽象概念。所有使用棋盘的程序员都知道:它的底层实现就是一个二维数组。如果将来你想把实现改成别的东西,比如大小为 64 的一维扁平化 vector,那就会很痛苦,因为整个程序中所有对棋盘的使用都得跟着改。所有使用棋盘的人还都得自己处理好内存管理。这里根本谈不上接口与实现的分离。

更好的做法是把棋盘建模成一个类。这样你就可以暴露一个接口,同时把底层实现细节隐藏起来。下面给出一个 ChessBoard 类开端的例子:

class ChessBoard
{
public:
void setPieceAt(std::size_t x, std::size_t y, ChessPiece* piece);
ChessPiece* getPieceAt(std::size_t x, std::size_t y) const;
bool isEmpty(std::size_t x, std::size_t y) const;
// …
private:
// Private implementation details…
};

请注意,这个接口根本没有承诺任何具体底层实现。ChessBoard 类当然可以使用二维数组,但接口本身并不要求必须如此。改变实现时,不需要连带改变接口。更进一步地,实现还可以额外提供一些功能,例如边界检查。要做到这一点,就必须严格遵守下面这条规则。

类中的所有数据成员都必须是 private。如果你希望从类外部有控制地访问这些数据成员,就提供 public 的 getter 和 setter。

把所有数据成员都设为 private,通常也叫做数据隐藏(data hiding)。为什么这如此重要? 因为遵循这条规则,你就为类提供了最高层次的抽象:

  • 你可以修改底层实现,而不必改变公共接口。
  • 只允许外部代码通过 getter 和 setter 访问数据成员,意味着你可以在取值或设值时额外执行一些步骤。例如,你可以加入合理性检查,确保数据成员永远不会被设为非法值;也可以在某个数据成员发生变化时发出事件通知,等等。
  • 借助调试器,你可以直接在 getter 和 setter 上打断点,从而更容易弄清楚到底是哪段代码在读取或写入某个数据成员。调试器会在 第 31 章“征服调试”中讨论。

理想情况下,这个例子应该已经说服你:抽象是 C++ 编程中的重要技术。第 5 章 会更详细地讲解面向对象设计,第 6 章 则会进一步深入抽象原则。第 8 章“熟悉类和对象”、第 9 章“精通类和对象”以及第 10 章“理解继承技术”会给出编写你自己的类所需的全部细节。

C++ 设计中的第二条基本规则是复用(reuse)。同样,通过一个现实世界的类比来理解这个概念会很有帮助。假设你放弃了程序员生涯,改行去做面包师。上班第一天,主厨告诉你去烤饼干。为了完成任务,你找到巧克力曲奇的配方,把原料混合,把饼干胚排到烤盘上,再把烤盘送进烤箱。主厨对结果非常满意。

现在,我要指出一件显而易见到近乎好笑的事实:你并没有自己造一个烤箱来烤这些饼干。你也没有自己打黄油、磨面粉,或者亲手制造巧克力豆。你心里可能会想:“这还用说?” 对真正的厨师来说当然如此,可如果你是一个在写烘焙模拟游戏的程序员呢? 那种情况下,你说不定就会理所当然地自己从巧克力豆一路写到烤箱的全部代码。或者,你也可以通过寻找可复用的代码来节省时间。也许某位同事写过烹饪模拟游戏,手头就有一套不错的烤箱代码。它也许并不完全符合你的需求,但你也许可以在其基础上修改并补足所需功能。

还有一件你理所当然接受的事:你是照着现成配方烤饼干,而不是凭空编一个自己的配方。没错,这听上去也理所当然。然而,在 C++ 编程里,这件事却常常“不言自明”不起来。尽管在 C++ 中,很多问题都会反复出现,也早已有标准的解决思路,许多程序员却依然坚持在每次设计时都重新发明这些策略。

其实,利用已有代码这个想法并不新鲜。早在你第一次用 std::println() 打印内容时,你就在复用代码了。你并没有自己编写真正把数据输出到屏幕上的代码。你只是复用了现成的 println() 实现。同样地,第 1 章 中的员工数据库也复用了 C++ 标准库里的 std::vector 容器来存储 Employee 列表;你并没有自己从头写一个用于存放 Employee 的数据结构。

可惜的是,并不是所有程序员都会利用已有代码,很多人经常在重复造轮子。你的设计应当主动考虑现有代码,并在合适时复用它。

“复用”这一设计主题,既适用于你使用的代码,也适用于你编写的代码。你应设计程序,使得自己和同事都能复用你写出的类、算法和数据结构。无论在当前项目还是未来项目中,这些组件都应当是可用的。一般来说,你应避免把代码设计得过于针对当前这个特定案例,以至于除了眼前这一处之外根本无法复用。

在 C++ 中,编写通用代码的一种语言级技术是使用模板(templates)。还记得前面提到的国际象棋例子吗? 再往前一步想,你将来可能不仅需要一个存放 ChessPieceChessBoard 类,还会需要一个存放 CheckersPieceCheckersBoard 类。你当然可以分别写出一个 ChessBoard 类和一个 CheckersBoard 类,让它们彼此毫无关系,但那样你就会重复很多代码。为了避免这种重复,你完全可以写一个通用的 GameBoard 类模板,然后把它用于任何二维棋盘游戏,例如国际象棋或跳棋。你只需要修改类声明,让它把要存储的棋子类型作为模板参数 PieceType,而不是把棋子类型硬编码在接口里。这个类模板大致可以长这样。如果你从未见过这套语法,也别担心! 第 12 章“使用模板编写泛型代码”会详细讲解。

template <typename PieceType>
class GameBoard
{
public:
void setPieceAt(std::size_t x, std::size_t y, PieceType* piece);
PieceType* getPieceAt(std::size_t x, std::size_t y) const;
bool isEmpty(std::size_t x, std::size_t y) const;
// …
private:
// Private implementation details…
};

仅仅通过接口上的这一处小改动,你现在就拥有了一个通用棋盘类,可用于任何二维棋盘游戏。虽然代码改动很小,但像这样的决策必须在设计阶段就做出来,这样你后续的实现才能既高效又得当。

第 6 章 会更详细地讨论:怎样在设计代码时把复用性考虑进去。

学会 C++ 这门语言,和成为一个优秀的 C++ 程序员,是两件截然不同的事。即使你坐下来把整个 C++ 标准从头读到尾,把每条事实都背下来,你也不过只是像别人一样“知道 C++”。但在你真正通过阅读代码和亲手写程序积累经验之前,你并不一定是个好程序员。原因就在于:C++ 语法只能定义“语言本身能做什么”,却并不会告诉你“每个语言特性应当怎样使用”。

正如前面的面包师例子所示,如果你每做一种点心都重新发明一套配方,那就太荒谬了。然而程序员在设计中却经常犯类似错误。他们不会去使用现有的“配方”,也就是程序设计中的模式(patterns),而是每做一个程序都重新设计一遍解决手法。

随着程序员对 C++ 语言越来越熟悉,他们会逐步发展出自己使用这些语言特性的方式。更大范围的 C++ 社区也同样积累出了一些标准的、对语言加以利用的方法,有些是正式的,有些则比较非正式。在本书中,我会持续指出这些可复用的语言应用方式,它们被称为设计技术(design techniques) 和 设计模式(design patterns)。此外,第 32 章“融入设计技术与框架”以及 第 33 章“应用设计模式”几乎专门围绕这些设计技术和模式展开。有些模式在你看来会非常显然,因为它们只是把显而易见的解法形式化了。另一些则会描述一些你以前遇到过问题的全新解决方案。还有一些甚至会带来一种完全新的组织程序思维方式。

例如,你也许希望国际象棋程序里有一个单一的 ErrorLogger 对象,用来把不同组件的所有错误统一串行化写入日志文件。当你开始设计 ErrorLogger 类时,你会意识到:你希望程序中只存在一个 ErrorLogger 实例。但同时,你又希望程序中的多个组件都能使用这个 ErrorLogger 实例;也就是说,这些组件都想使用同一个 ErrorLogger 服务(service)。用来实现这种服务机制的一种标准模式,是策略模式(strategy pattern) 与 依赖注入(dependency injection) 的结合。策略模式会先为每个服务创建一个接口。随后,你就可以为该接口提供多个不同实现。例如,你可以有多个 logger 服务实现:一个写入本地文件,另一个把日志通过网络发送到远程服务器,等等。一旦你定义好了这样的接口,就可以通过依赖注入,把组件所需的接口“注入”进组件中。因此,在这个点上,一个良好的设计应明确写明:这里要采用“策略模式 + 依赖注入”。

你必须让自己熟悉这些模式和技术,这样当某个具体设计问题摆在你面前时,你才能认出它恰好需要哪种解法。适用于 C++ 的技术和模式远不止本书中描述的这些。尽管本书已经涵盖了一批很不错的内容,你仍然也许想额外参考专门讲设计模式的书,去了解更多不同模式。相关书目建议见 附录 B“带注释的参考书目”。

有经验的 C++ 程序员从不会“从零开始”做项目。他们会整合来自各种来源的代码,例如标准库、开源库、工作场所内部的专有代码库,以及自己以前项目中的代码。你也应该在项目中大胆复用代码。为了真正贯彻这一原则,本节会先解释可复用代码的不同类型,然后讨论“复用现有代码”和“自己从头写”之间的权衡,最后再给出一组指导原则,帮助你在决定复用现有代码时选择合适的库。

在分析代码复用的优缺点之前,先把相关术语说清楚会很有帮助,同时也有助于给可复用代码做分类。可供复用的代码大致分为三类:

  • 你自己过去写过的代码
  • 你同事写的代码
  • 来自当前组织或公司之外的第三方代码

而你复用的代码本身,又可能以几种不同形式组织起来:

  • 独立函数或类。 当你复用自己的旧代码或同事写的代码时,通常最常遇到的就是这一类。
  • 库(libraries)。 (library) 是一组用来完成某个特定任务的代码集合,例如解析 XML,或者服务于某个特定领域,例如密码学。线程与同步支持、网络功能、图形功能等,也常常作为库提供。
  • 框架(frameworks)。 框架(framework) 是一组围绕它来设计程序的代码。例如,Microsoft Foundation Classes(MFC) 就提供了一个用于在 Microsoft Windows 上创建图形用户界面应用的框架。框架通常会在很大程度上决定你的程序结构。
  • 完整应用程序(entire applications)。 你的项目可能本身就包含多个应用程序。比如,也许你需要一个 web 服务器前端来支撑新的电商基础设施。把完整的第三方应用(例如一个 web 服务器)捆绑进你的软件中,也是有可能的。这是把代码复用推向极致的一种形式——因为你复用的是整个应用程序。

另一个经常出现的术语是应用程序编程接口(application programming interface),也就是 API。API 指的是某个库或某段代码为特定用途暴露出来的接口。例如,程序员们常说 sockets API,指的其实是 sockets 网络库暴露出来的接口,而不一定是在说库的实现本身。

为了行文简洁,本章后文统一用 library 一词来泛指任何可复用代码,无论它实际上是真正意义上的库、框架、完整应用,还是你同事随手写的一堆函数集合。

“复用代码”这条规则在抽象层面很好理解,但落实到细节时就会显得有些模糊。你该如何判断:什么时候应该复用代码? 又该复用哪一份代码? 这里总是存在权衡,而最终决策取决于具体情境。不过,复用代码确实有一些普遍性的优点和缺点。

复用代码能给你和你的项目带来巨大的优势:

  • 你也许根本不知道怎么写自己需要的那段代码,或者即便知道,也根本无法为此合理辩护所需投入的时间。比如,你真的会想自己去写格式化输出功能吗? 当然不会。这正是你会直接使用标准 C++ 的 std::format()print() 的原因。
  • 你的设计会更简单,因为那些被复用的应用组件你就不需要自己从头设计了。
  • 你所复用的代码通常不需要你自己再调试。很多时候你可以假设库代码是没 bug 的,因为它已经经过了大量测试和广泛使用。
  • 库往往比你第一次自己写的代码处理得更多的错误场景。项目开始时,你很可能会遗漏一些冷门错误或边界情况,并在以后浪费时间补这些坑。而被你复用的库代码通常已经接受过大量测试,并被许多程序员用过,因此你可以合理地假定它已对绝大多数错误做了妥善处理。
  • 库通常会在大量平台上接受测试,涉及不同硬件、不同操作系统及版本、不同显卡等等——远远超出你个人可能掌握的测试环境。有时,库内部甚至还包含了专门为某些平台编写的兼容性 workaround。
  • 库通常都被设计成会对坏输入保持警惕。无效请求,或者在当前状态下不恰当的请求,通常都会产生明确合理的错误反馈。例如,对数据库中不存在的记录进行查找、或者在数据库尚未打开时试图读取记录,这些行为在一个成熟库中都应有明确规定的处理方式。
  • 复用由领域专家编写的代码,通常比自己硬写更安全。例如,除非你本身就是安全专家,否则就不应试图自己编写安全相关代码。如果你的程序需要安全或密码学能力,就应使用现成库。那类代码里很多看似微不足道的小细节,一旦处理错误,可能危及整个程序,甚至整个系统的安全。
  • 库代码会不断进化改进。只要你复用了它,就能在不亲自做这些工作的情况下享受到这些改进。事实上,如果库作者把接口与实现正确分离开来,你甚至只需要升级库版本,而无需改动自己与该库交互的方式。好的升级,应当是在不改变接口的前提下改进底层实现。

当然,复用代码也有一些缺点:

  • 当你使用不是自己写的库时,你必须先花时间理解它的接口与正确用法,然后才能真正上手。这种在项目开头多出来的学习时间,会拖慢最初的设计和编码速度。但好处在于,后面会节省大量时间,因为你需要自己维护的代码变少了,最终代码也会更简单。
  • 自己写的代码可以完全按你想要的方式工作。而库代码未必会刚好提供你所需要的那种功能。
  • 即便库确实提供了你想要的功能,它也不一定能给你带来你期待的性能。它可能总体性能较差,也可能只是对你的具体使用场景不够好,甚至压根没有明确性能保证。
  • 使用库代码会打开一个“支持问题的潘多拉魔盒”。如果你在库中发现了 bug,怎么办? 你往往拿不到源码,因此即便想修也修不了。可如果你已经投入了大量时间去学习这个库的接口并在项目中接入它,你大概率又不愿轻易放弃它;而与此同时,你可能也很难说服库的开发者按照你的时间表来修复 bug。另外,如果你使用的是第三方库,而库作者在你的产品停止维护之前就先停止支持该库了,那你怎么办? 在决定使用“拿不到源码的库”之前,请务必认真思考这一点。
  • 除了支持问题之外,库还会带来许可证问题,这些问题会涉及源代码披露、二进制分发费用(通常叫 binary license fees)、署名要求以及开发许可等。你在使用任何库之前,都应仔细检查它的许可证问题。例如,有些开源库会要求你把自己的代码也开源。
  • 复用代码意味着必须建立在“信任”基础上。你必须相信编写这段代码的人做得足够好。有些人则更倾向于完全控制项目的方方面面,包括每一行源代码。
  • 升级到库的新版本也可能带来问题。新版本可能引入 bug,而这些 bug 甚至可能对你的产品造成致命后果。一次以性能为目的的升级,也许会在某些场景下提升性能,却让你的具体用法变得更慢。
  • 当你依赖仅提供二进制版本的库时,升级编译器也可能带来问题。只有在库供应商提供了与你新编译器版本兼容的二进制文件之后,你才能升级编译器。

现在,你已经熟悉了相关术语、代码复用的优缺点,因此你也就更有能力判断“是否应当复用代码”。很多时候,这个决策其实一目了然。例如,如果你想在 Microsoft Windows 上用 C++ 编写图形用户界面(GUI),那你就应该使用 MFC 或 Qt 这样的框架。你大概既不知道如何从底层开始在 Windows 中手写 GUI 代码,更重要的是,你也根本不想浪费时间去学它。在这种情况下,使用框架将为你节省数以“人年”计的开发工作量。

但在另一些时候,选择就没有那么明显了。例如,如果你对某个库并不熟悉,而你只是需要一个非常简单的数据结构,那也许并不值得为了复用这一个组件而去投入时间学习整个库——因为这个组件你自己几天就能写出来。

归根结底,你必须基于自己当下的具体需求做决定。很多时候,问题会落回一个简单权衡:究竟是自己从头写更省时间,还是去寻找并学习一个库来解决问题更省时间? 认真思考前面列出的那些优点和缺点在你当前场景中的适用性,并判断哪些因素对你最重要。最后请记住,你总是可以改变主意——而如果你在抽象上做得足够好,这种改变甚至可能并不困难。

一旦你决定要复用库、框架、同事的代码、完整应用程序,甚至你自己的旧代码,就有一些指导原则值得牢记,以便你挑选出最适合复用的那份代码。

花时间去熟悉这份代码。理解它的能力与局限都非常重要。先从文档以及公开接口/API 入手。理想情况下,这些信息应该已经足够让你弄明白如何使用该代码。不过,如果某个库没有清晰地区分接口和实现,那么在源码可用的情况下,你可能还得亲自去翻源码。另外,也可以去问那些用过这份代码的程序员,他们也许能解释其中不易察觉的细节。你应先了解基础功能:如果它是个库,它究竟提供哪些函数? 如果它是个框架,你的代码应如何嵌入其中? 你应该继承哪些类? 哪些代码必须你自己编写? 此外,根据所复用代码的类型不同,你还应考虑一些更具体的问题。

在选择库时,下面这些点值得重点留意:

  • 这个库能否安全地用于多线程程序?
  • 这个库是否会强制依赖某些特定编译器设置? 如果有,在你的项目里是否可接受?
  • 这个库还依赖哪些其他库?

此外,针对某些具体库,你可能还需要进一步做更细致的调查:

  • 需要执行哪些初始化和清理调用?
  • 如果你需要从某个类继承,应该调用它的哪个构造函数? 需要 override 哪些虚成员函数?
  • 如果某个调用返回了内存指针,释放这块内存的责任在谁——调用方还是库本身? 如果由库负责,那它会在什么时候释放? 强烈建议你提前确认:是否能够用智能指针(见 第 7 章“内存管理”) 来管理该库分配的内存。
  • 某个调用的所有返回值(按值返回还是按引用返回) 都是什么?
  • 该调用有可能抛出哪些异常?
  • 库函数会检查哪些错误条件,又默认假设了哪些前提? 它如何处理错误? 客户端程序会如何获知错误? 应避免使用那些会自己弹 message box、向标准输出打印信息、或者直接终止程序的库。该如何把错误呈现给用户,应由客户端程序决定,而不是由库来替你做主。

学习成本(learning cost) 指的是:开发人员学会如何使用某个库所需的时间。它不仅仅是项目刚开始接触这个库时的一次性成本,也是一个会随着时间不断反复出现的成本。每当有新成员加入项目,她都必须再学一遍如何使用这个库。

对于某些库来说,这种成本可能相当可观。因此,如果你在某个知名库里已经找到了自己需要的功能,我会建议优先使用它,而不是转去使用某个冷门、鲜为人知的库。比如,如果标准库已经提供了你所需要的数据结构或算法,就应优先使用标准库,而不是再去引入另一个库。

你必须弄清楚该库或那段代码能提供怎样的性能保证。即便你的程序本身并不是性能敏感型应用,你也依然应确保自己所使用的代码,在你的具体使用场景下不会表现得特别糟糕。

程序员通常会用大 O 记号(big-O notation) 来讨论和记录算法及库的性能。本节会解释算法复杂度分析与大 O 记号的基本概念,尽量不过多掺杂不必要的数学。如果你已经对这些概念很熟悉,可以直接跳过本节。

大 O 记号描述的是相对性能,而不是绝对性能。例如,它不会说“某个算法运行需要 300 毫秒”,而是描述“随着输入规模增加,算法性能如何变化”。所谓输入规模,可以是排序算法要处理的元素个数、哈希表查找时表内元素数量,也可以是待复制文件的大小等等。

更形式化一点地说,大 O 记号把算法运行时间表示成输入规模的函数,这也叫算法的复杂度(complexity)。其实没它听起来那么复杂。比如,某个算法处理两倍数据时就花两倍时间。那么,如果它处理 200 个元素需要 1 秒,处理 400 个元素就需要 2 秒,处理 800 个元素就需要 4 秒。图 4.3 把这个关系直观画了出来。像这样的算法,其复杂度被称为输入规模的线性函数(linear function),因为从图形上看,它是一条直线。

一张“执行时间(秒)”对“输入规模”的折线图,数据点大致为 1 对应 200、2 对应 400、4 对应 800、5 对应 1000,整体呈上升趋势。

[^图 4.3]

大 O 记号会把这种线性性能概括为: O(n)。其中 O 表示你正在使用大 O 记号,n 表示输入规模。O(n) 意味着算法运行时间与输入规模呈直接线性关系。

当然,并不是所有算法的性能都对输入规模呈线性关系。下表总结了一些常见复杂度,并按性能从好到坏排序:

算法复杂度大 O 记号说明典型算法
常数O(1)运行时间与输入规模无关。访问数组中的单个元素。
对数O(log n)运行时间与输入规模的以 2 为底的对数相关。在有序列表中使用二分查找寻找元素。
线性O(n)运行时间与输入规模成正比。在无序列表中查找元素。
线性对数O(n log n)运行时间与输入规模和其对数的乘积相关。归并排序。
二次O(n2)运行时间与输入规模平方相关。类似选择排序那样较慢的排序算法。
指数O(2n)运行时间与输入规模呈指数关系。经过优化的旅行商问题。

把性能表示为“输入规模的函数”而不是绝对数值,有两个优点:

  • 它是平台无关的。说一段代码在某台机器上运行了 200 毫秒,对另一台机器而言并没有多少参考意义。若不在同一台机器、同样负载下运行,你甚至很难比较两个算法。相比之下,把性能表示为输入规模的函数,则适用于任何平台。
  • “作为输入规模函数的性能”可以用一次描述覆盖算法的所有可能输入。而一个具体的“运行多少秒”的数字,只对应某一个具体输入,对其他输入几乎没有说明力。

既然你已经熟悉了大 O 记号,那你就已经可以理解绝大多数性能文档了。尤其是 C++ 标准库,它在描述算法和数据结构性能时大量使用大 O 记号。不过,大 O 记号有时也并不充分,甚至还会误导。每当你思考大 O 性能指标时,都应牢记以下几点:

  • 如果一个算法处理两倍数据会花两倍时间,这并没有说明它最开始到底有多慢! 如果一个算法写得很差、但扩展性很好,它仍然不是你想要的。例如,假设算法包含了很多不必要的磁盘访问。这可能几乎不影响大 O 时间复杂度,但对整体性能却会非常糟糕。
  • 同理,当两个算法具有相同的大 O 运行时间时,也很难直接判断谁更快。例如,如果两个不同排序算法都声称是 O(n log n),那在不自己做测试之前,你很难断言到底哪个更快。
  • 大 O 记号描述的是算法当输入规模趋近于无穷大时的渐近时间复杂度。对于小输入来说,大 O 往往非常具有迷惑性。一个 O(n²) 算法在小输入规模下,完全可能比某个 O(log n) 算法表现更好。在做决策前,请结合你很可能面对的输入规模来判断。

除了考虑大 O 特性之外,你还应关注算法性能的其他侧面。下面给出一些应牢记的指导原则:

  • 你应考虑自己打算多频繁使用某段特定库代码。有些人觉得 90/10 规则很有帮助:大多数程序 90% 的运行时间,只花在 10% 的代码上(Hennessy 和 Patterson, Computer Architecture: A Quantitative Approach, Fifth Edition, 2011, Morgan Kaufmann)。如果你打算使用的库代码恰好落在那“经常被执行的 10%”类别里,那你就必须认真分析它的性能特征。反过来,若它落在那“经常被忽略的 90% 代码”里,你就不应花太多时间纠结它的性能,因为它对整体程序性能的影响很小。第 29 章“编写高效的 C++”会介绍 profiler,也就是帮助你找到代码性能瓶颈的工具。
  • 不要只依赖文档。务必亲自运行性能测试,确认库代码在你的具体使用场景下确实具有可接受的性能特征。

在你开始使用某个库代码之前,务必弄清它究竟支持哪些平台。如果你打算写一个跨平台应用,那就要确保你选择的库也同样具备跨平台可移植性。听起来这似乎不言自明,但就连那些声称自己“跨平台”的库,在不同平台上也可能暗藏一些细微差异。

此外,所谓平台,不仅包括不同操作系统,也包括同一操作系统的不同版本。如果你要编写一个必须运行在 Solaris 8、9 和 10 上的应用,那么你就必须确保自己使用的库也支持这几个版本。你不能假设操作系统版本之间天然具备向前或向后兼容性。也就是说,不能因为某个库能在 Solaris 9 上跑,就当然地以为它也能在 Solaris 10 上跑,反之亦然。

使用第三方库时,往往会引入复杂的许可证问题。有时你必须向第三方供应商支付许可证费用,才能使用他们的库。也可能存在其他许可证限制,包括国际出口限制等等。此外,某些开源库会采用这样的许可证:任何与你的代码进行链接的代码,也必须一并开源。本章后面会讨论一些开源库常用的许可证类型。

如果你打算分发或销售自己开发的代码,请务必确保自己理解所用第三方库的许可证限制。若有疑问,请咨询专门从事知识产权的法律专家。

理解支持机制,并知道去哪里寻求帮助

Section titled “理解支持机制,并知道去哪里寻求帮助”

在使用某个库之前,你应确保自己明白:如何提交 bug,以及 bug 修复通常需要多长时间。如果可能,还应设法弄清该库大概还会继续支持多久,以便你据此做规划。

有趣的是,即便是你自己组织内部提供的库,也同样可能带来支持问题。你可能会发现:要说服同公司另一个部门的同事去修他们库里的 bug,并不比去说服另一家公司里的陌生人容易。事实上,甚至可能更难,因为你还不是对方的付费客户。在使用内部库之前,你必须先理解自己组织内部的政治和协作问题。

如果你复用的是完整应用程序,支持问题可能会更加复杂。假设客户在你捆绑发布的 web 服务器上遇到了问题,那他们到底应该联系你,还是联系那个 web 服务器的厂商? 这类责任划分问题,务必要在你发布软件之前就先搞清楚。

库和框架在刚开始上手时有时会令人望而生畏。幸运的是,可获得支持的渠道其实很多。首先,先查阅随库附带的文档。如果这个库使用足够广泛,例如标准库或 MFC,你通常还能找到一本不错的专题书籍。事实上,如果你需要标准库方面的帮助,完全可以参考 第 16 章第 25 章。如果你的具体问题没有在书籍和产品文档里得到解答,那就去网上搜索。把问题丢进你最喜欢的搜索引擎,就能找到讨论该库的网页。例如,搜索 introduction to C++ Standard Library 这个短语,你会得到成百上千个与 C++ 和标准库有关的网站。此外,很多网站还会围绕特定主题建立自己的新闻组或论坛,你可以注册并参与讨论。

不过要提醒一句:别对网上看到的内容全盘照信! 网页内容并不一定像纸质书和正式文档那样经过严格审校,其中完全可能包含错误。

当你第一次真正坐下来接触某个新库或框架时,通常写一个快速原型是个好主意。亲自试用代码,是了解其能力的最佳方式。甚至在真正进入程序设计之前,你都应该考虑先拿这个库做些实验,这样你才能真正熟悉其能力与局限。这种经验性的测试还可以帮助你一并评估这个库的性能特征。

即便你的原型程序和最终应用长得完全不像,花在原型上的时间也绝不是浪费。你不必勉强自己去写“真实应用的原型”。完全可以写一个假的小程序,只专门测试你想使用的那些库能力。重点只是让自己尽快熟悉这个库而已。

由于时间压力,程序员有时会发现:原型代码不知不觉就演变成了最终产品。如果你只是草草拼凑了一个原型,而它根本不足以作为最终产品基础,那你就必须确保它不会被误用成那样。

开源库(open-source libraries) 是一种越来越流行的可复用代码类别。开源(open-source) 的一般含义是:源代码对任何人都可见。关于“必须在分发时附带源代码”等问题,其实还涉及一些正式定义和法律规则,但关于开源软件,你真正需要记住的是:任何人(包括你自己)都可以查看其源代码。还要注意,开源并不仅仅适用于库。事实上,最著名的开源产品大概就是 Android 操作系统。Linux 也是另一个开源操作系统。Google Chrome 与 Mozilla Firefox 则是著名的开源浏览器例子。

遗憾的是,开源社区在术语上存在一定混乱。首先,围绕这一运动本身,有两个彼此竞争的叫法(有些人甚至会说是“两场彼此相似但独立的运动”)。Richard Stallman 和 GNU 项目使用的术语是 free software(自由软件)。请注意,这里的 free 并不意味着最终产品必须免费提供。开发者完全可以收取高价或低价。这里的 free 指的是:人们有自由查看源代码、修改源代码并重新分发软件。它更接近 free speech 里的“自由”,而不是 free beer 里的“免费”。如果你想进一步了解 Richard Stallman 和 GNU 项目,可以访问 www.gnu.org

开放源代码促进会(Open Source Initiative) 则使用 open-source software(开源软件) 这一术语,来描述那些必须提供源代码的软件。和自由软件一样,开源软件也并不要求产品或库必须免费提供。不过,与自由软件的一个重要区别在于:开源软件并不一定要求你拥有使用、修改并重新分发它的自由。你可以在 www.opensource.org 上了解更多关于开放源代码促进会的信息。

开源项目可选择的许可证种类非常多。比如,项目可以采用某个 GNU Public License(GPL) 版本。不过,如果你使用的是 GPL 下的库,那就意味着你自己的产品也必须按 GPL 方式开源。另一方面,开源项目也可以采用 Boost Software License、Berkeley Software Distribution(BSD) license、MIT License、Apache License 等许可证,这些都允许开源项目被用于闭源产品。有些许可证还有多个不同版本。例如,BSD license 实际上就有四个版本。另一个选择是使用 Creative Commons(CC) license 的六种变体之一。

有些许可证要求你把库的许可证一并附带到最终产品中。有些许可证要求在使用库时给出署名。总之,如果你想在闭源项目里使用库,那么所有这些许可证里那些细微差别都非常重要,必须真正理解清楚。网站 opensource.org/licenses 对已获批准的开源许可证做了非常全面的整理。

由于“open-source”这个名称比“free software”歧义更少,因此本书统一使用“开源”来指代那些源码可获得的产品和库。采用这个称呼,并不意味着本书更支持“开源哲学”而不是“自由软件哲学”;这只是为了阅读理解更容易。

无论你使用哪种术语,借助开源软件都能获得惊人的好处。最主要的好处就是“现成功能”。可用的开源 C++ 库多到难以计数,覆盖了各种任务,从 XML 解析、跨平台错误日志,一直到借助人工神经网络进行深度学习与数据挖掘。

虽然开源库并不一定在分发和许可上都“免费”,但很多开源库确实可以在不支付金钱成本的前提下使用。因此,通过采用开源库,你通常也能节省许可证费用。

最后,在很多情况下(虽然并非总是如此),你还可以自由修改开源库,使其精确适配自己的需要。

大多数开源库都能在网上获取。例如,搜索 open-source C++ library XML parsing 就会得到一串 C++ XML 解析库的链接。此外,你也可以从一些开源门户网站开始搜索,例如:

  • www.boost.org
  • www.gnu.org
  • github.com/open-source
  • www.sourceforge.net

开源库会带来一些独特问题,也要求你采用一些新的策略。首先,开源库通常是人们在“业余时间”写出来的。源码通常对任何愿意加入开发或修 bug 的程序员开放。作为一个有责任感的程序员公民,如果你正在从开源库中受益,那你也应尝试为开源项目做出贡献。如果你在公司工作,管理层也许会抵触这一想法,因为它并不会直接为公司带来收入。不过,你也许可以说服管理层:像“提升公司名称曝光度”以及“让外界觉得公司支持开源运动”这类间接收益,也足以说明你从事这类活动是有价值的。

其次,由于开源项目通常是分布式协作开发,并且缺乏单一所有者,开源库往往也会带来支持问题。如果你极度迫切地需要修掉某个库里的 bug,那么自己动手修通常会比等待别人来修更高效。如果你真的修了 bug,务必把修复提交回该库的公共代码库。有些许可证甚至会要求你这么做。即便你没有亲自修任何 bug,也务必把自己发现的问题报告出来,以免其他程序员也在同样的问题上浪费时间。

作为 C++ 程序员,你最重要、也最常用到的库,就是 C++ 标准库(C++ Standard Library)。顾名思义,它是 C++ 标准的一部分,因此任何符合标准的编译器都必须提供它。标准库并不是一个单一整体,而是由若干彼此不同的组件构成,其中一些你其实早就已经用过了。你甚至可能曾经把它们误以为是核心语言本身的一部分。第 16 章第 25 章 会更详细地介绍标准库。

由于 C++ 基本上是 C 的超集,所以 C 标准库依然可用。它提供的功能包括数学函数,例如 abs()sqrt()pow(),也包括错误处理辅助设施,例如 assert()errno。另外,用于把字符数组当作字符串来处理的那些 C 标准库函数,例如 strlen()strcpy(),以及 printf()scanf() 这样的 C 风格 I/O 函数,在 C++ 中也全都可用。关于这些 C 库的细节,请查阅标准库参考资料;见 附录 B

标准库在设计时,把功能性、性能和正交性(orthogonality) 放在优先级最高的位置。使用它的好处极其显著。试想一下:如果你不得不自己去追踪链表或平衡二叉树实现中的指针错误,又或者调试一个明明就没把数据排对的排序算法,那会有多痛苦。如果你正确使用标准库,你几乎永远都不需要亲自去写或调试这类底层基础能力。另一大好处是:绝大多数 C++ 开发者都知道如何使用标准库所提供的功能。因此,在项目中使用标准库时,新团队成员往往能比面对某些学习成本很高的第三方库时更快进入状态。第 16 章第 25 章 会系统深入地介绍标准库功能。

本节会结合一个简单国际象棋游戏应用,介绍一套系统化的 C++ 程序设计方法。为了让示例足够完整,其中有些步骤会提到后续章节才会讲到的概念。你现在就应阅读这个示例,先对设计流程建立一个整体印象;当然,等你读完后续章节后,再回头重读一遍也会很有帮助。

在着手设计之前,你必须清楚掌握程序在功能和效率上的需求。理想情况下,这些需求应该以需求规格说明文档的形式记录下来。对于这个国际象棋程序,需求大致应包含以下类型的规格说明——当然,在实际设计中它们会更详细、数量也更多:

  • 程序应支持国际象棋的标准规则。
  • 程序应支持两名人类玩家。程序不提供人工智能电脑玩家。
  • 程序应提供一个基于文本的界面:
    • 程序应以纯文本方式渲染棋盘与棋子。
    • 玩家应通过输入表示棋盘位置的数字来表达自己的走法。

需求的作用,就在于确保你设计出来的程序能如其用户所期望的那样运行。

你应采用一种系统化的方法来设计程序,从整体逐步推进到细节。下面这些步骤并不一定适用于所有程序,但它们提供了一个很好的通用指导。设计中应当在合适之处配上图和表。UML 是业内用于绘制这类图的标准。你可以查阅 附录 D 获取一个简要介绍。简单来说,UML 定义了大量可用于记录软件设计的标准图示,例如类图、时序图等等。我建议你尽量使用 UML,或者至少使用类似 UML 的图示方式。不过,我并不主张你拘泥于 UML 的每一个语法细节,因为清晰、易懂的图示,比语法上完全标准化的图更重要。

第一步,应把程序划分为总体功能层面的若干子系统,并说明这些子系统之间的接口和交互关系。此时你还不必纠结具体数据结构、算法,甚至也不用马上去想类。你只是想先对程序由哪些大块组成、它们之间如何交互形成一个整体印象。你可以把这些子系统整理到一个表格中,表格内容包括:子系统在高层上的行为/功能、它向其他子系统导出的接口,以及它自身从其他子系统消费(使用) 的接口。

对于这个国际象棋游戏,推荐的设计方式是:通过模型-视图-控制器(Model-View-Controller, MVC) 范式,把“数据存储”和“数据展示”清晰分离开来。这一范式建立在这样一个现实之上:许多应用都围绕着“一组数据、一个或多个数据视图,以及对数据的操纵”来展开。在 MVC 中,这组数据叫做模型(model),某种对模型的具体可视化叫做视图(view),而响应某些事件并改变模型的那部分代码叫做控制器(controller)。MVC 的三个组成部分以反馈循环方式协作:动作由控制器处理,控制器调整模型,模型变化再导致视图(或多个视图)发生变化。控制器也可以直接修改视图,例如修改 UI 元素。图 4.4 展示了这种交互。采用这种范式后,程序的不同组成部分会被清楚分离开来,使你能够修改其中一个部分而无需牵动其他部分。例如,你可以在不碰底层数据模型和逻辑的情况下,轻松在“文本界面”和“图形界面”之间切换,或者在“桌面端界面”和“手机端界面”之间切换。

模型-视图-控制器范式的网络图。图中包含 user、controller、model 和 view 四个节点。

[^图 4.4]

下表展示了这个国际象棋游戏中各个可能子系统的一种划分方式:

子系统名称实例数功能导出接口消费接口
GamePlay1启动游戏 控制游戏流程 控制绘制 宣布赢家 结束游戏Game OverTake Turn(on Player) Draw(on ChessBoardView)
ChessBoard1存储棋子 检查和棋与将死Get Piece At Set Piece AtGame Over(on GamePlay)
ChessBoardView1绘制关联的 ChessBoardDrawDraw(on ChessPieceView)
ChessPiece32自己移动 检查走法是否合法Move Check MoveGet Piece At(on ChessBoard) Set Piece At(on ChessBoard)
ChessPieceView32绘制关联的 ChessPieceDrawNone
Player2通过提示用户输入走法与用户交互,并获取用户走法 移动棋子Take TurnGet Piece At(on ChessBoard) Move(on ChessPiece) Check Move(on ChessPiece)
ErrorLogger1将错误信息写入日志文件Log ErrorNone

正如这张表所显示的,这个国际象棋游戏的功能子系统包括:一个 GamePlay 子系统、一个 ChessBoard 和一个 ChessBoardView、32 个 ChessPiece 及其 ChessPieceView、两个 Player,以及一个 ErrorLogger。不过,这当然不是唯一合理的做法。在软件设计中,就像在编程本身中一样,往往有很多种不同方式都能达成同一个目标。并不是所有方案都一样好;有些肯定比另一些更优。但与此同时,也常常存在多种同样合理有效的设计方案。

一个好的子系统划分,应把程序拆解成最基础的功能部分。例如,Player 显然是一个不同于 ChessBoard、ChessPiece 和 GamePlay 的独立子系统。把玩家直接塞进 GamePlay 子系统就不太合理,因为从逻辑上讲,它们本来就是不同的功能块。至于其他选择,就未必总有这么显然了。

在这个 MVC 设计中,ChessBoard 和 ChessPiece 子系统属于 Model。ChessBoardView 和 ChessPieceView 属于 View,而 Player 则属于 Controller。

由于单靠表格往往很难直观理解子系统之间的关系,因此通常还会有必要用图来展示程序子系统,让图中的连线表示“一个子系统调用另一个子系统”。图 4.5 就以一种 loosely based on UML communication diagram 的方式,可视化展示了国际象棋游戏的子系统。

国际象棋游戏子系统的流程图,参考 UML communication diagram。图中包括 player、gameplay、chessboard、chesspiece、ChessBoardView 和 ChessPieceView。

[^图 4.5]

在设计阶段,现在去思考“你以后要写的算法里某个具体循环该怎样多线程化”还为时过早。不过在这一步,你应当确定程序中高层线程的数量,以及它们之间如何交互。所谓高层线程,例如 UI 线程、音频播放线程、网络通信线程等等。

在多线程设计中,你应尽可能避免共享数据,因为那会让设计更简单、更安全。如果无法避免共享数据,就必须把加锁要求明确写出来。

如果你对多线程程序并不熟悉,或者你的平台本身不支持多线程,那就应把程序设计成单线程。不过,如果程序中存在多个彼此独立、并且可以并行工作的任务,它就很可能是多线程的候选者。例如,图形用户界面应用通常会有一个线程负责主要应用逻辑,另一个线程负责等待用户按按钮或选择菜单项。多线程编程会在 第 27 章“使用 C++ 进行多线程编程”中讲解。

对于这个国际象棋程序而言,只需要一个线程来控制游戏流程。

在这一步,你要决定程序中打算实现哪些类层次结构。国际象棋程序可以用一个类层次结构来表示不同棋子。这个层次结构大致可以像 图 4.6 那样工作。通用的 ChessPiece 类充当抽象基类。ChessPieceView 类也同样需要一个类似的层次结构。

一个表示国际象棋棋子层次结构的流程图,其中包括 rook、bishop、knight、king、pawn 和 queen。

[^图 4.6]

ChessBoardView 类也可以采用另一套类层次结构,以便让游戏既能提供文本界面,也能提供图形用户界面。图 4.7 展示了这样一种层次结构示例,它允许棋盘以控制台文本方式显示,也允许通过 2D 或 3D 图形用户界面显示。类似地,ChessPieceView 层次结构中的各个类也需要一套对应层次。

一个表示 ChessBoardView 层次结构的流程图,其中包括 ChessBoardViewConsole、ChessBoardViewGUI2D 和 ChessBoardViewGUI3D。

[^图 4.7]

第 5 章 会解释设计类与类层次结构的细节。

为每个子系统明确类、数据结构、算法与模式

Section titled “为每个子系统明确类、数据结构、算法与模式”

在这一步,你就要下探到更细的层次,明确每个子系统的具体细节,包括你将为每个子系统编写哪些具体类。很可能最终你会发现,每个子系统本身都正好可以建模为一个类。这些信息同样可以总结成一张表。

子系统数据结构算法模式
GamePlayGamePlayGamePlay 对象中包含一个 ChessBoard 对象和两个 Player 对象。轮流让每位玩家行棋。None
ChessBoardChessBoardChessBoard 对象存储一个二维 8×8 网格,其中最多容纳 32 个 ChessPiece每步走完后检查胜负或和棋。None
ChessBoardViewChessBoardView 抽象基类 具体派生类 ChessBoardViewConsoleChessBoardViewGUI2D存储如何绘制棋盘的信息。绘制棋盘。Observer
ChessPieceChessPiece 抽象基类 RookBishopKnightKingPawnQueen 派生类每个棋子保存自己在棋盘上的位置。通过向棋盘查询不同位置上的棋子,判断某次移动是否合法。None
ChessPieceViewChessPieceView 抽象基类 派生类 RookViewBishopView 等,以及具体派生类 RookViewConsoleRookViewGUI2D存储如何绘制棋子的相关信息。绘制棋子。Observer
PlayerPlayer 抽象基类 具体派生类 PlayerConsolePlayerGUI2DNone.提示用户输入走法,检查走法是否合法,并移动棋子。Mediator
ErrorLoggerErrorLogger一个待写入日志的消息队列。缓冲消息并将其写入日志文件。Strategy using dependency injection

像这样的表已经能传达一些关于软件设计中不同类的信息,但它还不足以清晰描述这些类之间的交互。此时可以使用 UML 时序图(UML sequence diagram) 来建模这种交互。图 4.8 就展示了这样一张图,把前一张表中若干类之间的交互可视化了出来。

一张 UML 时序图风格的网络图,包含 gameplay、player、chessboard 和 chess piece,旁边还有一个注释框,写着 invalid move、ask user for new move 和 restart sequence。

[^图 4.8]

图 4.8 中只展示了一次单独的迭代,也就是 GamePlayPlayer 发出的一次 TakeTurn 调用;因此它只是一个局部的时序图。在一次 TakeTurn 调用结束之后,GamePlay 对象还应要求 ChessBoardView 绘制自身,而后者又应进一步要求不同的 ChessPieceView 绘制自己。此外,你还应扩展这张时序图,把“棋子吃掉对方棋子”的交互也画进去,并加入对王车易位(castling) 的支持——王车易位是玩家同时移动自己的王和一辆车的唯一走法。

在真正的设计文档中,这一节通常还会给出每个类的实际接口,不过在这个示例里,我们就不细化到那个程度了。

设计类、挑选数据结构、算法和模式,通常都不是一件轻松事。你必须始终牢记本章前面提到的两条规则:抽象与复用。对于抽象来说,关键在于把接口和实现分开思考。先站在使用者角度指定接口。先决定你希望该组件做什么,然后再通过选择数据结构与算法来决定它具体怎么做。至于复用,则要求你熟悉标准数据结构、算法和模式,并确保自己足够了解 C++ 标准库,以及你所在工作环境中可用的任何内部专有代码。

为每个子系统明确错误处理方式

Section titled “为每个子系统明确错误处理方式”

在这个设计步骤中,你要划定每个子系统中的错误处理策略。这里既包括系统错误,例如网络访问失败,也包括用户错误,例如无效输入。你还应明确说明每个子系统是否使用异常。相关信息同样可以再总结成一张表。

子系统处理系统错误处理用户错误
GamePlay使用 ErrorLogger 记录错误,向用户显示消息,并在发生意外错误时优雅地关闭程序。不适用(没有直接用户界面)。
ChessBoard / ChessPiece使用 ErrorLogger 记录错误,并在发生意外错误时抛出异常。不适用(没有直接用户界面)。
ChessBoardView / ChessPieceView如果在绘制过程中出错,则使用 ErrorLogger 记录错误并抛出异常。不适用(没有直接用户界面)。
Player使用 ErrorLogger 记录错误,并在发生意外错误时抛出异常。对用户输入的走法做合理性检查,确保它没有越出棋盘;若无效则提示用户重新输入。该子系统还会在真正移动棋子前检查每一步是否合法;若非法,则提示用户重新输入。
ErrorLogger尝试记录错误;当发生意外错误时通知用户。不适用(没有直接用户界面)。

错误处理的一般原则是:把所有情况都处理到。认真思考所有可能的错误条件。只要漏掉一种可能性,它将来就会以 bug 的形式出现在程序中! 不要把任何情况都简单视作“意外错误”。要把一切可能都视作会发生的事情:内存分配失败、用户无效输入、磁盘故障、网络故障,等等。不过,正如上面国际象棋示例中的表格所展示的,你应该把“用户错误”和“内部错误”区分开来处理。例如,玩家输入一个非法走法,就不应导致国际象棋程序直接退出。第 14 章“错误处理”会更深入讨论这一主题。

在本章中,你学习了以专业 C++ 方式开展设计的方法。我希望它已经说服你:软件设计是任何编程项目中都极其重要的第一步。事实上,它不仅仅是第一步而已——随着代码持续迭代改进,设计本身也必须始终保持同步更新。

你了解了若干让 C++ 设计变得困难的因素,包括这门语言同时支持面向对象和过程式的多范式能力、它庞大的特性集合和标准库,以及它用于编写泛型代码的种种设施。有了这些认识,你就更有准备去应对真正的 C++ 设计了。

本章引入了两个设计主题。第一个主题是抽象,也就是把接口与实现分离开来。这一思想会贯穿本书,并且应成为你所有设计工作的基本准则。

第二个主题是复用——既包括代码的复用,也包括设计思路的复用。这同样会频繁出现在真实项目中,也会在本书中反复出现。你已经了解到,你的 C++ 设计不仅应包含对代码的复用(例如复用库和框架),也应包含对思想和设计的复用(例如复用技术和模式)。你应把自己的代码设计得尽可能易于复用。同时也别忘了:代码复用始终伴随着权衡,也要结合一些具体准则来做选择,包括理解其能力与局限、性能、许可证和支持模型、平台限制、原型验证以及求助渠道。你也已经了解了性能分析和大 O 记号。既然你如今已明白设计的重要性和这两大基本设计主题,那就可以继续进入 第二部分 的其余内容了。第 5 章 会介绍如何在设计中运用 C++ 的面向对象特性。

通过完成下面这些练习,你可以巩固本章讨论的内容。所有练习的参考解答都包含在本书网站 www.wiley.com/go/proc++6e 的代码下载包中。不过,如果你在某道题上卡住了,请先回头重读本章相关部分,尝试自己找出答案,再去查看网站上的解答。

  1. 练习 4-1: 当你为自己的 C++ 程序做设计时,应遵循的两条基本设计规则是什么?

  2. 练习 4-2: 假设你有如下 Card 类。这个类只支持一副扑克牌中的普通牌,不支持 joker。

    class Card
    {
    public:
    enum class Number { Ace, Two, Three, Four, Five, Six, Seven, Eight,
    Nine, Ten, Jack, Queen, King };
    enum class Figure { Diamond, Heart, Spade, Club };
    Card() {}
    Card(Number number, Figure figure)
    : m_number { number }, m_figure { figure } {}
    private:
    Number m_number { Number::Ace };
    Figure m_figure { Figure::Diamond };
    };

    你如何看待下面这种用 Card 类来表示整副扑克牌的方式? 你能想到什么改进吗?

    int main()
    {
    Card deck[52];
    // …
    }
  3. 练习 4-3: 假设你和一位朋友一起想出了一个很棒的点子,要为移动设备制作一款 3D 游戏。你手里有 Android 设备,而你的朋友用的是 Apple iPhone,当然你们希望游戏能够在这两个平台上都可玩。请从高层角度说明,你会如何处理这两个不同的移动平台,以及你会如何为真正开始开发做准备。

  4. 练习 4-4: 已知以下几种大 O 复杂度:O(n)、O(n2)、O(log n) 和 O(1),你能按复杂度递增顺序把它们排出来吗? 它们分别叫什么名字? 你还能想到比这些更糟的复杂度吗?