C---整洁代码教程-全-

C++ 整洁代码教程(全)

原文:Clean C++

协议:CC BY-NC-SA 4.0

一、介绍

如何做和让别人做同样重要。爱德华多·纳穆尔

许多软件开发项目处于糟糕的状况,有些甚至可能处于严重的危机之中,这仍然是一个令人悲伤的现实。原因是多方面的。例如,一些项目因为糟糕的项目管理而受到影响。在其他项目中,条件和需求是不断变化的,但是过程不支持这种高动态的环境。

在一些项目中有纯粹的技术原因:他们的代码质量很差。这并不一定意味着代码不能正常工作。它的外部质量,由质量保证部门使用黑盒、用户或验收测试来测量,可能相当高。它可以毫无怨言的通过 QA,检测报告上说他们没发现什么问题。此外,软件的用户可能会感到满意和高兴,而且它的开发已经按时按预算完成了(…我知道这很少见)。一切似乎都很好…真的一切吗?

然而,这段代码的内部质量可能会很差,虽然它可能会正常工作。通常代码很难理解,维护和扩展起来也很糟糕。无数的软件单元,像类或函数,都非常大,有些有数千行代码。软件单元之间过多的依赖会导致不必要的副作用。该软件没有可感知的架构。它的结构似乎是随机产生的,一些开发人员谈论“历史上生长的软件”或“偶然的架构”类、函数、变量和常量都有糟糕而神秘的名字,代码中充斥着大量的注释:其中一些已经过时,只是描述了显而易见的事情,或者完全错误。开发人员害怕改变一些东西或扩展软件,因为他们知道软件是腐烂的和脆弱的,他们知道单元测试覆盖率很差,如果根本没有单元测试的话。“永远不要碰正在运行的系统”是在这类项目中经常听到的说法。一个新特性的实现不需要几天就可以部署;需要几周甚至几个月。

这种糟糕的软件通常被称为一团泥巴。1997 年,Brian Foote 和 Joseph W. Yoder 在第四届程序模式语言会议的一篇论文中首次使用了这个术语。Foote 和 Yoder 将这个大泥球描述为“……一个杂乱无章、杂乱无章、杂乱无章、用胶带和铁丝捆成的意大利面条式的丛林。”这样的软件系统是昂贵且浪费时间的维护噩梦,它们会使一个开发组织陷入困境!

刚才描述的病态现象可以在所有工业部门和领域的软件项目中找到。使用的编程语言并不重要。您会发现用 Java、PHP、C、C#、C++ 或任何其他或多或少流行的语言编写的大量 Muds。但是为什么会这样呢?

软件熵

首先,有一种似乎像是自然规律的东西。就像任何其他封闭而复杂的系统一样,随着时间的推移,软件往往会变得凌乱不堪。这种现象被称为软件熵。这个术语是基于热力学第二定律。它指出,一个封闭系统的无序是无法减少的;只能保持不变或者增加。软件似乎就是这样运行的。每次添加新功能或更改某些内容时,代码都会变得更加混乱。也有许多影响因素可以传递软件熵,例如:

  • 不切实际的项目时间表将增加压力,因此将迫使开发人员修补东西,并以糟糕和不专业的方式工作。
  • 当今软件系统的巨大复杂性。
  • 开发人员有不同的技能水平和经验。
  • 全球分布的跨文化团队,加强沟通问题。
  • 开发主要关注软件的功能方面(功能需求和系统用例),从而关注质量需求(也称为非功能需求),如性能效率、可维护性、可用性、可移植性、安全性等。被忽视,或者在最坏的情况下被完全遗忘。
  • 不合适的开发环境和糟糕的工具。
  • 管理层专注于赚快钱,不理解可持续软件开发的价值。
  • 快速而肮脏的攻击和不符合设计的实现(也叫破窗)。

The Broken Window Theory

破窗理论是在美国犯罪研究中发展起来的。该理论指出,废弃建筑上的一扇被毁坏的窗户可能会引发整个街区的毁坏。破碎的窗户向环境发出了致命的信号:“你看,没人关心这栋楼!”这吸引了进一步的腐败、破坏和其他反社会行为。破窗理论已被用作若干刑事政策改革的基础,特别是零容忍战略的制定。

在软件开发中,这一理论被采纳并应用于代码质量。不符合软件设计的黑客和糟糕的实现被称为“破窗”如果这些糟糕的实现不被修复,更多的黑客可能会出现在他们的附近。因此,代码的破损开始了。

不要容忍代码中的“破窗”——修复它们!

然而,似乎特定的 C 和 C++ 项目更容易出错,比其他项目更容易陷入糟糕的状态。甚至万维网上也充斥着糟糕的、但显然非常快且高度优化的 C++ 代码示例,它们具有残酷的语法,完全忽略了良好设计和编写良好代码的基本原则。

其中一个原因可能是 C++ 是一种中间层次的多范例编程语言,也就是说,它包含了高级和低级语言特性。使用 C++ 你可以编写具有复杂用户界面的大型分布式商业软件系统,以及具有实时行为的小型嵌入式系统软件,这些软件与底层硬件紧密相关。多范例语言意味着你能够编写过程化的、函数式的或面向对象的程序,甚至是三种范例的混合。此外,C++ 允许模板元编程(TMP),这是一种编译器使用所谓的模板来生成临时源代码的技术,该临时源代码由编译器与其余源代码合并,然后进行编译。自从 ISO 标准 C++11 发布以来,甚至增加了更多的方式,例如,现在 lambda 表达式以非常优雅的方式支持带有匿名函数的函数式编程。由于这些不同的能力,C++ 以非常复杂、繁琐而著称。

坏软件的另一个原因可能是许多开发人员没有 IT 背景。如今,任何人都可以开始开发软件,无论她是否有大学学位或任何其他计算机科学学徒资格。绝大多数 C++ 开发人员都不是专家。特别是在汽车、铁路运输、航空航天、电气/电子或机械工程等技术领域,许多工程师在过去的几十年里在没有接受计算机科学教育的情况下从事编程工作。随着复杂性的增长和技术系统包含越来越多的软件,迫切需要程序员。现有劳动力满足了这一需求。电气工程师、数学家、物理学家以及许多严格意义上的非技术学科的人开始开发软件,并主要通过自学和简单动手来学习。他们已经尽了最大的努力。

基本上绝对没毛病。但是有时候仅仅知道工具和编程语言是不够的!软件开发不同于编程。这个世界充满了由未经适当培训的软件开发人员拼凑而成的软件。在抽象层次上,开发人员必须考虑许多事情来创建一个可持续的系统,例如,架构和设计。如何构建一个系统来实现特定的质量目标?这种面向对象的东西有什么用途,我如何高效地使用它?某个框架或者库的优缺点是什么?各种算法的区别是什么,为什么没有一种算法适合所有类似的问题?确定性有限自动机到底是什么,为什么它有助于应对复杂性?!

但是没有理由灰心丧气!对于一个软件的持续健康来说,真正重要的是有人关心它,干净的代码是关键!

干净的代码

一个主要的误解是将干净的代码与可以被称为“美丽的代码”的东西混淆了干净的代码没有美丽的理由。专业程序员不会因为写出漂亮的代码而获得报酬。他们被开发公司聘为创造客户价值的专家。

如果任何团队成员都能轻松理解和维护代码,那么代码就是干净的。

干净的代码是快速的基础。如果你的代码是干净的,测试覆盖率是好的,一个改变或者一个新的功能只需要几个小时或者几天——而不是几周或者几个月——直到它被实现、测试和部署。

干净的代码是可持续软件的基础,并保持软件开发项目长期运行,而不会积累大量的技术债务。开发人员必须积极地维护软件并确保它保持良好的状态,因为代码对于软件开发组织的生存至关重要。

干净的代码也是让你成为快乐的开发者的关键。它导致了一种没有压力的生活。如果你的代码是干净的,并且你对它感到满意,你可以在任何情况下保持冷静,即使是在一个紧张的项目截止日期前。

上面提到的所有观点都是正确的,但关键的一点是:干净的代码省钱!本质上是经济效率的问题。每年,开发组织都会因为他们的代码状态不佳而损失很多钱。

为什么是 C++?

C 很容易搬起石头砸自己的脚。C++ 让它变得更难,但是当你这么做的时候,你会炸掉你的整条腿!——比雅尼·斯特劳斯特鲁普,比雅尼·斯特劳斯特鲁普的常见问题:你真的这么说了吗?

每一种编程语言都是一种工具,每一种都有它的优点和缺点。软件架构师工作的一个重要部分是选择完全适合项目的编程语言——或者现在的编程语言集。这是一个重要的架构决策,不应该根据直觉或个人偏好来做出。同样,像“在我们公司,我们用做任何事情”这样的原则可能不是一个好的指南。

作为一种多范例编程语言,C++ 是一个融合了不同思想和概念的熔炉。当涉及到操作系统、设备驱动程序、嵌入式系统、数据库管理系统、大型计算机游戏、3D 动画和计算机辅助设计、实时音频和视频处理、大数据管理以及许多其他性能关键型应用程序的开发时,该语言一直是一个极好的选择。在某些领域,C++ 是通用语言。拥有数十亿行代码的大型 C++ 代码库仍然存在并在使用中。

几年前,一个广泛传播的观点是 C++ 很难学习和使用。对于经常承担编写大型复杂程序任务的程序员来说,这种语言可能很复杂,令人望而生畏。由于这个原因,主要是解释型和托管型语言,比如 Java 或 C#,开始流行起来。这些语言的制造商的过度营销完成了剩下的部分。结果,托管语言在某些领域占据了主导地位,但本机编译语言在其他领域仍然占据主导地位。编程语言不是宗教。如果你不需要 C++ 的性能,但是 Java 可以让你的工作变得更简单,那就使用它吧。

c++ 11——新时代的开始

有人说 C++ 目前正在复兴。有些人甚至谈到了一场革命。他们说今天的现代 C++ 已经不能与 20 世纪 90 年代早期的“历史 C++ 相提并论了。这一趋势的催化剂主要是 2011 年 9 月 C++ 标准 ISO/IEC 14882:2011 [ISO11]的出现,更好的说法是 C++11。

毫无疑问,C++11 带来了一些伟大的创新。看起来这个标准的发布已经启动了一些东西。在这本书出版的同时,C++ 标准化委员会已经完成了新 C++17 标准的工作,现在正处于 ISO 投票的最后阶段。此外,C++20 已经开始起步了。

目前,在本地开发领域发生了很多事情,尤其是在制造行业的公司中,因为软件已经成为技术系统最重要的增值因素。如今,C++ 的开发工具更加强大,并且有大量有用的库和框架可用。但是我不认为这整个发展是一场革命。我认为这是通常的进化。此外,编程语言必须不断改进和适应,以满足新的需求,C++98 和 C++03(主要是 C++98 上的一个错误修复版本)都有点过时了。

这本书是给谁的

作为一名培训师和顾问,我有机会了解许多正在开发软件的公司。此外,我非常密切地观察开发人员的情况。我发现了一个缺口。

我的印象是,C++ 程序员被那些提倡软件工艺和干净代码开发的人忽略了。许多原则和实践在 Java 环境中以及在 web 或游戏开发的时髦世界中是相对众所周知的,但在 C++ 世界中似乎基本上是未知的。开创性的书籍,如安德鲁·亨特和戴维·托马斯的《务实的程序员》,或者罗伯特·c·马丁的《干净的代码》,通常都不为人所知。

这本书试图缩小这一差距,因为即使使用 C++,代码也可以写得很干净!如果你想自学如何编写干净的 C++,这本书是给你的。

这本书不是 C++ 入门!你应该已经熟悉这门语言的基本概念,以便有效地使用本书中的知识。如果你只是想从 C++ 开发开始,还没有语言的基础知识,你应该先学习它们,这可以通过其他书籍或者一个好的 C++ 入门培训来完成。

此外,这本书不包含任何深奥的黑客或组装。我知道用 C++ 可以做很多疯狂和令人兴奋的事情,但是这些通常不符合干净代码的精神,应该很少用于干净和现代的 C++ 程序。如果你真的对神秘的 C++ 指针健身操着迷,这本书不适合你。

对于本书中的一些代码示例,使用了标准 C++11 (ISO/IEC 14882:2011)、C++14 (ISO/IEC 14882:2014)以及一些最新的 C++17 的各种语言特性。如果您不熟悉这些功能,不要担心。我将借助边栏简要介绍其中一些。请注意,实际上并不是每个 C++ 编译器都完全支持所有新的语言特性。

除此之外,本书旨在帮助所有技能水平的 C++ 开发人员,并通过示例展示如何编写可理解的、灵活的、可维护的和高效的 C++ 代码。即使你是一个经验丰富的 C++ 开发人员,我认为这本书中的一些金块和数据点也会对你的工作有所帮助。提出的原则和实践可以应用于新的软件系统,有时称为绿地项目;以及历史悠久的遗留系统,这些系统通常被轻蔑地称为棕色地带项目。

本书中使用的约定

本书使用了以下印刷惯例:

  • 斜体用于介绍新的术语和名称。
  • 粗体在段落中用于强调术语或重要陈述。
  • Monospaced font在段落中用来指代程序元素,如类名、变量名或函数名、语句和 C++ 关键字。这种字体也用于显示命令行输入、网站地址(URL)、击键序列或程序产生的输出。

侧栏

有时我会向你传递一些与周围内容无关的小信息,这些信息可以被认为是与内容无关的。这种部分被称为边栏。有时,我会使用侧边栏来呈现围绕主题的附加或对比讨论。示例:

This Header Contains the Title of a Sidebar

这是边栏中的文本。

注意事项、提示和警告

另一种特殊用途的侧边栏用于注释、提示和警告。它们用来给你一些特殊的信息,提供一个有用的建议,或者警告你一些危险的应该避免的事情。示例:

Note

这是通知的正文。

代码示例

代码示例和代码片段将与文本分开出现,语法突出显示(C++ 语言的关键字是粗体),并采用等宽字体。较长的代码段通常有标题。为了引用文本中代码示例的特定行,代码示例有时用行号来修饰。

01  class Clazz {
02  public:

03    Clazz();
04    virtual ∼Clazz();
05    void doSomething();
06
07  private:

08    int _attribute;
09
10    void function();
11  };
Listing 1-1.A line-numbered code sample

为了更好地关注代码的特定方面,不相关的部分有时会被隐藏起来,并用省略号(…)表示,如下例所示:

void Clazz::function() {
  // ...
}

编程风格

关于我在本书中使用的编码风格,只说几句话。

你可能会觉得我的编程风格与典型的 Java 代码非常相似,混合了 Kernighan 和 Ritchie (K&R)的风格。在我近 20 年的软件开发生涯中,甚至在我职业生涯的后期,除了 C++,我还学习了其他编程语言,例如 ANSI-C、Java、Delphi、Scala 和一些脚本语言。因此,我采用了自己的编程风格,这是一个不同影响的熔炉。

也许您不喜欢我的风格,您更喜欢 Linus Torvald 的内核风格、Allman 风格或任何其他流行的 C++ 编码标准。这当然是完全可以的。我喜欢我的风格,你喜欢你的。

配套网站和源代码库

本书附有配套网站: www.clean-cpp.com

该网站包括:

  • 一个讨论论坛,读者可以与其他读者讨论特定的主题,当然也可以与作者讨论。
  • 对本书中可能尚未涉及的其他主题的讨论。
  • 这本书里所有数字的高分辨率版本。

本书中的大部分源代码示例以及其他有用的附加内容都可以在 GitHub 上找到,网址是:

https://github.com/clean-cpp

您可以通过以下命令使用 Git 来检查代码:

$> git clonehttps://github.com/clean-cpp/book-samples.git

你可以进入 https://github.com/clean-cpp/book-samples ,点击“下载 zip”按钮,获得代码的. ZIP 存档。

UML 图

本书中的一些插图是 UML 图。统一建模语言(UML)是一种标准化的图形语言,用于创建软件和其他系统的模型。在当前的 2.5 版本中,UML 提供了 14 种图表类型来完整地描述一个系统。

如果您不熟悉所有的图类型,也不用担心;我在本书中只使用了其中的几个。我不时地展示 UML 图,以提供对某些问题的快速概述,这些问题可能无法通过仅仅阅读代码来足够快地检测出来。在附录 A 中,您可以找到所用符号的简要概述。

三、建立一个安全网

测试是一种技能。虽然这可能会让一些人感到惊讶,但这是一个简单的事实。——Mark few ster 和 Dorothy Graham,软件测试自动化,1999 年

我用关于测试的一章开始本书的主要部分可能会让一些读者感到惊讶,但这是有几个好的理由的。在过去的几年中,某些级别的测试已经成为现代软件开发的重要基石。一个好的测试策略的潜在好处是巨大的。所有类型的测试,如果设计得好,都是有益的。在这一章中,我将描述为什么我认为单元测试对于确保软件的高质量是必不可少的。

请注意,这一章是关于有时被称为 POUT(“普通旧单元测试”)的东西,而不是设计支持工具测试驱动开发(TDD),我将在本书后面讨论。

测试的需要

1962: NASA Mariner 1

水手 1 号宇宙飞船于 1962 年 7 月 22 日发射,作为一次飞越金星进行行星探索的任务。由于定向天线的问题,Atlas-Agena B 发射火箭工作不可靠,发射后不久就失去了地面控制的控制信号。

在火箭的设计和建造过程中已经考虑了这一例外情况。Atlas-Agena 运载火箭由机载制导计算机转为自动控制。不幸的是,这台计算机的软件中的一个错误导致了错误的控制命令,导致了严重的航向偏差,使转向不可能。火箭直接射向地球,指向一个关键区域。

在 T+293 秒时,靶场安全员发出自毁指令,炸毁火箭。美国宇航局的一份检查报告 1 提到了计算机源代码中的一个错别字,缺少一个连字符('-'),作为错误的原因。总损失为 1850 万美元,这在当时是一笔巨款。

如果问软件开发人员为什么测试是好的和必要的,我认为最常见的答案将是减少错误、错误或缺陷。毫无疑问,这基本上是正确的:测试是质量保证的基本部分。

软件错误通常被认为是令人讨厌的麻烦。用户对程序产生无效输出的错误行为感到恼火,或者对经常性的崩溃感到非常恼火。有时,即使是一些零碎的东西,如用户界面对话框中被截断的文本,也足以在日常工作中严重困扰软件用户。结果可能是对软件越来越不满意,最坏的情况是被另一种产品取代。除了经济损失之外,软件制造商的形象也受到了缺陷的影响。在最坏的情况下,公司会陷入严重的困境,许多人会失业。

但是前面描述的场景并不适用于每一个软件。一个 bug 的含义可能更加戏剧化。

1986: Therac-25 Medical Accelerator Disaster

这个案例可能是软件开发历史上最严重的失败。Therac-25 是一种放射治疗设备。它由国有企业加拿大原子能有限公司(AECL)从 1982 年到 1985 年研制和生产。在美国和加拿大的诊所生产并安装了 11 台设备。

由于控制软件的缺陷、质量保证过程的不足以及其他缺陷,三名患者因辐射过量而丧生。另外三名病人受到了辐射,并带走了永久的、严重的健康损害。

对这个案例的分析得出的结果是,除了其他事情之外,软件仅仅是由一个负责测试的人编写的。

当人们想到计算机时,他们脑海中通常会出现台式机、笔记本电脑、平板电脑或智能手机。如果他们想到软件,他们通常会想到网上商店、办公套件或商业 IT 系统。

但是这些软件和计算机只占我们每天接触的所有系统的很小一部分。我们周围的大多数软件控制着与世界进行物理交互的机器。我们的整个生活都由软件管理。一言以蔽之:没有软件就没有今天的生活!软件无处不在,是我们基础设施的重要组成部分。

如果我们登上电梯,我们的生命就掌握在软件手中。飞机是由软件控制的,整个世界范围的空中交通管制系统都依赖于软件。我们的现代汽车包含大量带有软件的小型计算机系统,这些系统通过网络进行通信,负责车辆的许多安全关键功能。空调、自动门、医疗设备、火车、工厂里的自动化生产线……无论我们现在在做什么,我们都会永久地接触到软件。随着数字革命和物联网(IoT)的发展,软件与我们生活的相关性将再次显著增加。几乎没有任何其他话题比自动驾驶汽车更明显了。

我认为没有必要强调这些软件密集型系统中的任何错误都可能带来灾难性的后果。这些重要系统的故障或失灵可能会威胁到生命或身体状况。在最坏的情况下,数百人可能在飞机坠毁中丧生,这可能是由电传操纵子系统的子程序中的错误if语句引起的。在这种系统中,质量是没有商量余地的。决不!

但是,即使在没有功能安全需求的系统中,错误也可能有严重的影响,特别是如果它们的破坏性更微妙的话。很容易想象,金融软件中的缺陷可能会引发当今世界范围的银行危机。假设任意一家大银行的财务软件由于一个 bug,每次过账都执行两次,这个问题几天内都不会被注意到。

1990: The AT&T Crash

1990 年 1 月 15 日,美国电话电报公司长途电话网络崩溃,9 个小时内 7500 万次电话中断。停电是由软件升级中的一行代码(一个错误的break声明)引起的,该软件升级在 1989 年 12 月被部署到所有 114 个计算机操作的电子开关(4ESS)中。问题始于 1 月 15 日下午,当时美国电话电报公司的曼哈顿控制中心发生故障,导致连锁反应,整个网络一半的交换机瘫痪。

美国电话电报公司的损失估计为 6000 万美元,对于依赖电话网络的企业来说,这可能是一笔巨大的损失。

测试简介

在软件开发项目中有不同层次的质量保证措施。这些级别通常以金字塔的形式出现,即所谓的测试金字塔。这个基本概念是由美国软件开发人员 Mike Cohn 开发的,他是 Scrum 联盟的创始人之一。他在他的书《敏捷的成功》中描述了测试自动化金字塔。借助于金字塔,Cohn 描述了高效软件测试所需的自动化程度。在接下来的几年里,测试金字塔被不同的人进一步发展。图 2-1 中描绘的是我的版本。

A429836_1_En_2_Fig1_HTML.jpg

图 2-1。

The Test Pyramid

当然,金字塔的形状不是巧合。其背后的信息是,你应该比其他类型的测试有更多的低级单元测试(大约 100%的代码覆盖率)。但这是为什么呢?

经验表明,关于测试的实现和维护的总成本正在向金字塔的顶端增加。大型系统测试和手工用户验收测试通常很复杂,经常需要大量的组织工作,并且不容易自动化。例如,自动化的 UI 测试很难编写,通常很脆弱,并且相对较慢。因此,这些测试通常是手动执行的,这适合于客户批准(验收测试)和 QA 的常规探索性测试,但是对于开发期间的日常使用来说太耗时和昂贵了。

此外,大型系统测试,或者 UI 驱动的测试,完全不适合检查整个系统中所有可能的执行路径。软件系统中有许多代码处理可选路径、异常和错误处理、横切关注点(安全性、事务处理、日志记录……)以及其他所需的辅助功能,但通常无法通过普通用户界面实现。

最重要的是,如果系统级的测试失败,错误的确切原因可能很难定位。系统测试通常基于系统的用例。在用例的执行过程中,会涉及到许多组件。这意味着要执行数百甚至数千行代码。哪一行是测试失败的原因?这个问题通常不容易回答,需要进行耗时且昂贵的分析。

不幸的是,在一些软件开发项目中,你会发现退化的测试金字塔,如图 2-2 所示。在这样的项目中,巨大的努力被投入到更高层次的测试中,而基本的单元测试被忽略了(冰淇淋甜筒反模式)。在极端情况下,它们完全消失了(杯形蛋糕反模式)。

A429836_1_En_2_Fig2_HTML.jpg

图 2-2。

Degenerated Test Pyramids (Anti-Patterns)

因此,一个廉价的、精心制作的、非常快速的、定期维护的、完全自动化的单元测试的广泛基础,在一系列有用的组件测试的支持下,可以成为确保软件系统相当高质量的坚实基础。

单元测试

没有测试的“重构”不是重构,它只是在移动狗屎。——科里·海恩斯(@科里·海恩斯),2013 年 12 月 20 日,在推特上

单元测试是一段代码,它在特定的上下文中执行生产代码库的一小部分。该测试将在一瞬间向您展示您的代码如您所期望的那样工作。如果单元测试覆盖率相当高,并且您可以在不到一分钟的时间内检查到您正在开发的系统的所有部分都工作正常,那么它将有许多优点:

  • 大量的调查和研究已经证明,在软件发布后修复 bug 比进行单元测试要昂贵得多。
  • 单元测试给你的整个代码库一个即时的反馈。假设测试覆盖率足够高(大约。100%),开发人员只需几秒钟就能知道代码是否正常工作。
  • 单元测试让开发人员有信心重构他们的代码,而不用担心做错什么会破坏代码。事实上,在没有单元测试安全网的情况下,代码库的结构变化是危险的,不应该被称为重构。
  • 单元测试的高覆盖率可以避免耗时和令人沮丧的调试会话。使用调试器查找错误原因的时间通常长达数小时,这可以大大减少。当然,您永远也不可能完全消除调试器的使用。这个工具仍然可以用来分析微妙的问题,或者找到单元测试失败的原因。但它将不再是确保代码质量的关键开发工具。
  • 单元测试是一种可执行的文档,因为它们准确地显示了代码是如何被设计使用的。可以说,它们是某种用法的例子。
  • 单元测试可以很容易地检测到回归,也就是说,它们可以立即显示曾经可以工作,但是在代码发生变化后意外停止工作的东西。
  • 单元测试有助于创建干净且格式良好的接口。它可以帮助避免单元之间不必要的依赖。可测试性设计也是一个好的可用性设计,也就是说,如果一段代码可以轻松地安装到测试夹具上,那么它通常也可以轻松地集成到系统的生产代码中。
  • 单元测试使开发更快。

特别是这个列表中的最后一项似乎是矛盾的,需要一点解释。单元测试有助于开发更快地进行——怎么可能呢?这似乎不符合逻辑。

毫无疑问:编写单元测试意味着努力。首先也是最重要的,管理者只是看到了这种努力,并不理解为什么开发人员应该为测试投入时间。尤其是在项目的初始阶段,单元测试对开发速度的积极作用可能看不到。在项目的早期阶段,当系统的复杂性相对较低并且大多数事情都运行良好时,编写单元测试起初似乎只是花费精力。但是时代在变…

当系统变得越来越大(+ 100,000 LOC),复杂性增加,理解和验证系统就变得越来越困难(还记得我在第一章描述过的软件熵吗)。通常,当不同团队中的许多开发人员在一个巨大的系统上工作时,他们每天都要面对其他开发人员编写的代码。如果没有单元测试,这可能会成为一项非常令人沮丧的工作。我相信每个人都知道那些愚蠢的、无休止的调试会话,在单步模式下遍历代码,同时一次又一次地分析变量的值。…这是对时间的巨大浪费!而且会大大降低开发速度。

特别是在开发的中后期,以及产品交付后的维护阶段,好的单元测试展现了它们的积极作用。单元测试节省的时间最多的时候是在编写测试后的几个月或几年,这时单元或它的 API 需要改变或扩展。

如果测试覆盖率很高,那么无论一段由开发人员编辑的代码是由他自己写的还是由另一个开发人员写的,都是无关紧要的。好的单元测试可以帮助开发人员快速理解另一个人写的一段代码,即使它是三年前写的。如果一个测试失败了,它会准确地显示出一些行为被破坏的地方。开发人员可以相信,如果所有测试都通过了,一切仍然正常工作。冗长而恼人的调试会话变得罕见,调试器主要用于快速找到失败测试的原因,如果这个原因不明显的话。这很好,因为这样工作很有趣。这是激励,它导致更快更好的结果。开发人员将对代码库有更大的信心,并对它感到满意。不断变化的需求或新功能需求?没问题,因为他们能够快速、经常、高质量地运送新产品。

Unit Test Frameworks

有几种不同的单元测试框架可用于 C++ 开发,例如,CppUnit,Boost。测试,可爱,谷歌测试,还有更多。

原则上,所有这些框架都遵循所谓的 xUnit 的基本设计,这是几个单元测试框架的统称,这些框架的结构和功能都来自 Smalltalk 的 SUnit。除了本章的内容并不局限于特定的单元测试框架,而且因为它的内容适用于一般的单元测试,所以对所有可用框架的全面和详细的比较超出了本书的范围。此外,选择合适的框架取决于许多因素。例如,如果你认为用最少的工作量快速添加新的测试非常重要,那么这可能是某些框架的淘汰标准。

QA 呢?

一个开发人员可能会有这样的态度:“为什么我要测试我的软件?我们有测试人员和质量保证部门,这是他们的工作。”

基本问题是:软件质量是质量保证部门唯一关心的事情吗?

简单明了的答案是:不!我以前说过,现在我再说一遍。尽管你的公司可能有一个单独的 QA 团队来测试软件,但是开发团队的目标应该是让 QA 没有发现任何错误。—罗伯特·c·马丁,干净的编码者[马丁 11]

将一个已知包含 bug 的软件交给 QA 是非常不专业的。专业开发人员从不将系统质量的责任推给其他部门。相反,专业的软件工匠与 QA 人员建立了富有成效的合作关系。他们应该密切合作,相辅相成。

当然,交付 100%无缺陷的软件是一个非常雄心勃勃的目标。QA 时不时会发现不对劲的地方。这很好。质量保证是我们的第二道安全网。他们检查以前的质量保证措施是否充分和有效。

从我们的错误中,我们可以学习并变得更好。专业开发人员通过修复 QA 发现的错误,并通过编写自动化单元测试来在未来捕捉这些错误,从而立即弥补这些质量缺陷。然后他们应该仔细思考这个问题:“以上帝的名义,我们怎么会忽略了这个问题?”回顾的结果应该作为改进开发过程的输入。

良好单元测试的规则

我见过很多非常无用的单元测试。单元测试应该给你的项目增加价值。为了实现这个目标,应该遵循一些基本的规则,我将在本节中描述这些规则。

测试代码质量

对产品代码的同样高质量的要求必须对单元测试代码有效。我会更进一步:理想情况下,生产和测试代码之间不应该有判断上的区别——两者是平等的。如果我们说一方面有生产代码,另一方面有测试代码,那么我们就把那些不可分割的东西分开了。不要那样做!分两类考虑生产和测试代码,为在项目后期忽略测试打下了基础。

单元测试命名

如果一个单元测试失败了,开发人员想立即知道:

  • 单位名称是什么;谁的测试失败了?
  • 测试了什么,测试的环境是什么(测试场景)?
  • 预期的测试结果是什么,失败测试的实际测试结果是什么?

因此,单元测试的表达性和描述性命名非常重要。我的建议是为所有测试建立命名标准。

首先,以这样一种方式命名单元测试模块(取决于单元测试框架,它们被称为测试装具或测试夹具)是一个好的实践,以便被测试的单元可以容易地从它派生出来。显然,它们应该有一个类似于<Unit_under_Test>Test的名字,占位符<Unit_under_Test>必须由测试对象的名字代替。例如,如果你的被测系统(SUT)是单元Money,那么对应的连接到该单元并包含所有单元测试用例的测试夹具应该被命名为MoneyTest(见图 2-3 )。

A429836_1_En_2_Fig3_HTML.jpg

图 2-3。

The system under test (SUT) and its Test Context

除此之外,单元测试必须有表达性和描述性的名称。如果单元测试有或多或少无意义的名字,如testConstructor()test4391()sumTest(),这是没有帮助的。这里有两个给它们取个好名字的建议。

对于可以在不同上下文中使用的通用、多用途类,一个表达性名称可以包含以下部分:

  • 测试场景的前提条件,即执行测试之前 SUT 的状态。
  • 被测单元的被测部分,通常是被测过程、函数或方法(API)的名称。
  • 预期的测试结果。

这就产生了一个单元测试过程/方法的命名模板,如下所示:

<PreconditionAndStateOfUnitUnderTest>_<TestedPartOfAPI>_<ExpectedBehavior>

这里有几个例子:

void CustomerCacheTest::cacheIsEmpty_addElement_sizeIsOne();

void CustomerCacheTest::cacheContainsOneElement_removeElement_sizeIsZero();

void ComplexNumberCalculatorTest::givenTwoComplexNumbers_add_Works();

void MoneyTest:: givenTwoMoneyObjectsWithDifferentBalance_theInequalityComparison_Works();

void MoneyTest::createMoneyObjectWithParameter_getBalanceAsString_returnsCorrectString();

void InvoiceTest::invoiceIsReadyForAccounting_getInvoiceDate_returnsToday();
Listing 2-1.Some examples for

good and expressive unit test names

构建表达性单元测试名称的另一种可能的方法是在名称中体现特定的需求。这些名称通常反映了应用程序域的要求。例如,它们来源于涉众的需求。

void UserAccountTest::creatingNewAccountWithExistingEmailAddressThrowsException();

void ChessEngineTest::aPawnCanNotMoveBackwards();

void ChessEngineTest::aCastlingIsNotAllowedIfInvolvedKingHasBeenMovedBefore();

void ChessEngineTest::aCastlingIsNotAllowedIfInvolvedRookHasBeenMovedBefore();

void HeaterControlTest::ifWaterTemperatureIsGreaterThan92DegTurnHeaterOff();

void BookInventoryTest::aBookThatIsInTheInventoryCanBeBorrowedByAuthorizedPeople();

void BookInventoryTest::aBookThatIsAlreadyBorrowedCanNotBeBorrowedTwice();
Listing 2-2.Some more examples

of unit test names that verify domain-specific requirements

当您阅读这些测试方法名称时,很明显,即使这里没有显示测试的实现和测试方法,也可以很容易地从中获得许多有用的信息。如果这样的测试会失败,这也是一个很大的优势。几乎所有的单元测试框架都在标准输出(stdout)中写入失败测试的名称。因此,极大地方便了错误定位。

单元测试独立性

每个单元测试必须独立于所有其他单元测试。如果测试必须以特定的顺序执行,这将是致命的,因为一个测试是基于前一个测试的结果。永远不要编写其结果是后续测试先决条件的单元测试。不要让测试中的单元处于被改变的状态,这是下面测试的先决条件。

主要问题可能是由全局状态引起的,例如,在测试单元中使用单例或静态成员。单例不仅增加了软件单元之间的耦合。他们还经常持有一个规避单元测试独立性的全局状态。例如,如果某个全局状态是成功测试的先决条件,但是先前的测试已经改变了该全局状态,那么这可能会导致严重的问题。

尤其是在遗留系统中,遗留系统中经常充斥着单例,这就引出了一个问题:我如何才能摆脱对这些单例的所有令人讨厌的依赖,并使我的代码更易于测试?嗯,这是我在第六章的依赖注入一节中讨论的一个重要问题。

Dealing with Legacy Systems

如果你面对所谓的遗留系统,并且在试图添加单元测试时面临许多困难,我推荐 Michael C. Feathers 的书《有效地使用遗留代码》。Feathers 的书包含了许多使用大型的、未经测试的遗留代码库的策略。它还包括 24 种打破依赖的技术目录。这些策略和技术超出了本书的范围。

每个测试一个断言

我知道这是一个有争议的话题,但我会试着解释为什么我认为这很重要。我的建议是限制单元测试只使用一个断言,就像这样:

void MoneyTest::givenTwoMoneyObjectsWithDifferentBalance_theInequalityComparison_Works() {
  const Money m1(-4000.0);
  const Money m2(2000.0);
  ASSERT_TRUE(m1 != m2);
}
Listing 2-3.A unit test that checks the not-equal-operator of a Money class

有人可能会说,我们还可以检查其他比较操作符(例如,Money::operator==())在这个单元测试中是否工作正常。通过简单地添加更多的断言,很容易做到这一点,如下所示:

void MoneyTest::givenTwoMoneyObjectsWithDifferentBalance_testAllComparisonOperators() {
  const Money m1(-4000.0);
  const Money m2(2000.0);
  ASSERT_TRUE(m1 != m2);
  ASSERT_FALSE(m1 == m2);
  ASSERT_TRUE(m1 < m2);
  ASSERT_FALSE(m1 > m2);
  // ...more assertions here...
}
Listing 2-4.Question: Is it really a good idea to check all comparison operators in one unit test?

我认为这种方法的问题是显而易见的:

  • 如果一个测试由于多种原因而失败,开发人员很难快速找到错误的原因。最重要的是,失败的早期断言掩盖了额外的错误,也就是说,它隐藏了后续的断言,因为测试的执行被停止了。
  • 正如在单元测试命名一节中已经解释过的,我们应该以一种精确和有表现力的方式来命名一个测试。有了多重断言,一个单元测试实际上测试了很多东西(顺便说一下,这违反了单一责任原则;见第六章),很难为它找到一个好名字。上面的…testAllComparisonOperators()不够精确。

单元测试环境的独立初始化

这条规则有点类似于单元测试独立性。当一个干净实现的测试完成时,所有与该测试相关的状态必须消失。更具体地说:当运行所有单元测试时,每个测试都必须是应用程序的一个孤立的部分实例化。每个测试都必须完全独立地建立和初始化它所需要的环境。这同样适用于测试执行后的清理。

排除 Getters 和 Setters

不要为一个类的普通 getters 和 setters 编写单元测试,就像这样:

void Customer::setForename(const std::string& forename) {
  this->forename = forename;
}

std::string Customer::getForename() const {
  return forename;
}

Listing 2-5.A simple setter and getter

你真的认为这样简单的方法会出错吗?这些成员函数非常简单,为它们编写单元测试是愚蠢的。此外,通常的 getters 和 setters 由其他更重要的单元测试隐式测试。

注意,我刚刚写了没有必要测试通常和简单的 getters 和 setters。有时候,getters 和 setters 并不那么简单。根据我们将在后面讨论的信息隐藏原理(参见第三章中的信息隐藏一节),如果一个 getter 简单而愚蠢,或者如果它必须做出复杂的事情来确定它的返回值,那么它应该为客户端隐藏。因此,为 getter 或 setter 编写显式测试有时会很有用。

排除第三方代码

不要为第三方代码写测试!我们不必验证库或框架是否如预期的那样工作。例如,我们可以问心无愧地假设 C++ 标准库中无数次使用过的成员函数std::vector::push_back()工作正常。相反,我们可以期待第三方代码自带单元测试。在您的项目中不使用没有自己的单元测试并且质量可疑的库或框架可能是一个明智的架构决策。

排除外部系统

对于外部系统,第三方代码也是如此。不要为你要开发的系统环境中的系统编写测试,因为这不在你的职责范围内。例如,如果您的财务软件使用现有的、通过互联网连接的外部货币转换系统,您就不应该测试它。除了此类系统无法提供明确的答案(货币之间的换算系数每分钟都在变化)以及此类系统可能因网络问题而无法到达之外,我们不对外部系统负责。

我的建议是模仿(见本章后面的测试替身(假对象)一节)这些东西,并测试你的代码,而不是他们的。

我们用数据库做什么?

如今,许多 IT 系统都包含(关系)数据库。他们需要将大量的对象或数据保存到长期存储中,以便这些对象或数据可以以一种舒适的方式被查询,并且在系统关闭后仍然存在。

一个重要的问题是:在单元测试期间,我们应该如何处理数据库?在这个问题上,我的第一条也是最重要的一条建议是:如果没有数据库也能进行测试,那就不要用数据库!——杰勒德·梅萨罗什,xUnit Patterns

在单元测试期间,数据库可能会导致各种各样的、有时是微妙的问题。例如,如果许多单元测试使用同一个数据库,那么该数据库往往会成为一个大型的中央存储,这些测试必须为了不同的目的而共享该存储。这种共享可能会对我在本章前面讨论的单元测试的独立性产生负面影响。很难保证每个单元测试所需的先决条件。一个单元测试的执行可能会通过常用的数据库对其他测试产生不必要的副作用。

另一个问题是数据库基本上很慢。它们比访问本地计算机内存要慢得多。与数据库交互的单元测试比完全在内存中运行的测试要慢得多。假设您有几百个单元测试,每个测试平均需要 500 ms 的额外时间,这是由数据库查询引起的。总的来说,所有的测试都比没有数据库的测试多花几分钟时间。

我的建议是模拟数据库(参见本章后面关于测试双精度/模拟对象的部分),并且只在内存中执行所有单元测试。不要担心:数据库,如果它存在的话,将会涉及到集成和系统测试层面。

不要将测试代码和生产代码混在一起

有时开发人员会想到用测试代码来装备他们的产品代码。例如,一个类可能包含在测试期间以如下方式处理对协作类的依赖的代码:

#include <memory>

#include "DataAccessObject.h"

#include "CustomerDAO.h"

#include "FakeDAOForTest.h"

using DataAccessObjectPtr = std::unique_ptr<DataAccessObject>;

class Customer {

public:

  Customer() {}
  explicit Customer(bool testMode) : inTestMode(testMode) {}

  void save() {
    DataAccessObjectPtr dataAccessObject = getDataAccessObject();
    // ...use dataAccessObject to save this customer...
  };

  // ...

private:

  DataAccessObjectPtr getDataAccessObject() const {
    if (inTestMode) {
      return std::make_unique<FakeDAOForTest>();
    } else {
      return std::make_unique<CustomerDAO>();
    }
  }
  // ...more operations here...

  bool inTestMode{ false };
  // ...more attributes here...
};

Listing 2-6.One possible solution to deal with a dependency during test

DataAccessObject是具体道的抽象基类,在这里是CustomerDAOFakeDAOForTest。最后一个是所谓的伪对象,它只不过是一个测试替身(参见本章后面关于测试替身(伪对象)的部分)。它旨在取代真正的 DAO,因为我们不想测试它,并且我们不想在测试期间保存客户(记住我关于数据库的建议)。使用两个 DAO 中的哪一个由布尔数据成员inTestMode控制。

这个代码可以工作,但是这个解决方案有几个缺点。

首先,我们的生产代码充斥着测试代码。虽然乍看起来并不引人注目,但它会增加复杂性并降低可读性。我们需要一个额外的成员来区分我们系统的测试模式和生产使用。这个布尔成员与客户无关,更不用说与我们系统的域有关。很容易想象,在我们的系统中,许多类都需要这样的成员。

此外,我们的类Customer依赖于CustomerDAOFakeDAOForTest。您可以在源代码顶部的包含列表中看到它。这意味着测试假人FakeDAOForTest也是生产环境中系统的一部分。希望在生产中永远不会调用 test double 的代码,而是编译、链接和部署它。

当然,有更好的方法来处理这些依赖性,并使产品代码远离测试代码。例如,我们可以将特定的 DAO 作为引用参数注入到Customer::save()中。

class DataAccessObject;

class Customer {

public:

  void save(DataAccessObject& dataAccessObject) {
    // ...use dataAccessObject to save this customer...
  }

  // ...
};

Listing 2-7.Avoiding dependencies to test code (1)

或者,这可以在构造类型Customer的实例时完成。在这种情况下,我们必须将对 DAO 的引用作为类的属性。此外,我们必须通过编译器抑制默认构造函数的自动生成,因为我们不希望任何Customer的用户可以创建它的不正确初始化的实例。

class DataAccessObject;

class Customer {

public:

  Customer() = delete;
  Customer(DataAccessObject& dataAccessObject) : dataAccessObject(dataAccessObject) {}
  void save() {
    // ...use member dataAccessObject to save this customer...
  }

  // ...

private:

  DataAccessObject& dataAccessObject;
  // ...
};

Listing 2-8.Avoiding dependencies

to test code (2)

Deleted Functions [C++11]

在 C++ 中,如果没有声明自己的类型,编译器会自动为该类型生成所谓的特殊成员函数(默认构造函数、复制构造函数、复制赋值运算符和析构函数)。从 C++11 开始,move 构造函数和 move 赋值操作符扩展了这个特殊成员函数列表。C++11(及更高版本)提供了一种简单的声明式方法来禁止自动创建任何特殊的成员函数,以及普通的成员函数和非成员函数:您可以删除它们。例如,您可以通过以下方式防止创建默认构造函数:

class Clazz {

public:

  Clazz() = delete;
};

另一个例子:你可以删除操作符new来防止类在堆上被动态分配:

class Clazz {

public:

  void* operator new(std::size_t) = delete;
};

第三种可能是特定的 DAO 是由Customer知道的工厂创建的(参见第九章中关于设计模式的工厂一节)。如果系统在测试环境中运行,可以从外部配置这个工厂来创建所需的 DAO 类型。无论您选择这些可能的解决方案中的哪一个,Customer都没有测试代码。在Customer中没有对特定道的依赖。

测试必须快速运行

在大型项目中,总有一天你会达到拥有成千上万个单元测试的地步。这在软件质量方面是非常棒的。但一个尴尬的副作用可能是,人们会在签入源代码库之前停止运行这些测试,因为这需要太长时间。

很容易想象运行测试所花费的时间和团队的生产力之间有很强的相关性。如果运行所有的单元测试需要 15 分钟,1/2 小时,或者更长时间,开发人员会被阻止工作,浪费时间等待测试结果。即使每个单元测试的执行平均“只”需要半秒钟,执行 1000 个测试也需要 8 分钟以上。这意味着每天执行整个测试套件 10 次将导致总共大约 1.5 小时的等待时间。因此,开发人员运行测试的频率会降低。

我的建议是:测试必须快速运行!单元测试应该为开发人员建立一个快速的反馈回路。一个大型项目的所有单元测试的执行时间不应该超过 3 分钟,甚至更短。为了在开发期间更快地执行本地测试(< =几秒钟),测试框架应该提供一种简单的方法来暂时关闭不相关的测试组。

不用说,在自动化构建系统中,每次在构建最终产品之前,所有的测试都必须无一例外地连续执行。如果构建系统上的一个或多个测试失败,开发团队应该会立即收到通知。例如,这可以通过电子邮件或在显著位置的光学可视化(例如,由于墙上的平面屏幕,或由构建系统控制的“交通灯”)的帮助来完成。即使只有一个测试失败,在任何情况下你都不应该发布和运输产品!

测试替身(假物体)

如果在测试执行过程中被测试的单元完全独立于协作者,也就是说,被测试的单元不使用其他单元或外部系统,那么单元测试应该只被称为“单元测试”。例如,虽然在集成测试期间数据库的参与是不重要的和必需的,因为这是集成测试的目的,但是在真实的单元测试期间对数据库的访问(例如,查询)是被禁止的(参见第节和我们如何处理数据库?本章前面)。因此,要测试的单元对其他模块或外部系统的依赖性应该由所谓的测试替身来代替,也称为假对象或模型。

为了以一种优雅的方式使用这样的测试替身,被测单元的松耦合是要努力争取的(参见“要有原则”一章中的松耦合一节)。例如,一个抽象(例如,一个纯抽象类形式的接口)可以在对一个测试不需要的合作者进行访问时被引入,如图 2-4 所示。

A429836_1_En_2_Fig4_HTML.jpg

图 2-4。

An interface makes it easy to replace X with a Test Double XMock

让我们假设您想要开发一个使用外部 web 服务进行当前货币兑换的应用程序。在单元测试期间,您不能自然地使用这个外部服务,因为它每秒都提供不同的转换因子。而且通过互联网查询服务,基本上很慢,可能会失败。并且不可能模拟临界情况。因此,在单元测试期间,您必须用一个测试 double 替换真实的货币转换。

首先,我们必须在代码中引入一个变化点,在这里我们可以用一个 test double 替换与货币转换服务通信的模块。这可以在接口的帮助下完成,接口在 C++ 中是一个抽象类,只有纯虚拟成员函数。

class CurrencyConverter {

public:

  virtual ∼CurrencyConverter() { }
  virtual long double getConversionFactor() const = 0;
};
Listing 2-9.An abstract interface for currency converters

通过互联网对货币兑换服务的访问被封装在一个实现CurrencyConverter interface的类中。

class RealtimeCurrencyConversionService : public CurrencyConverter {

public:

  virtual long double getConversionFactor() const override;
  // ...more members here that are required to access the service...
};
Listing 2-10.The class that accesses the realtime currency conversion service

出于测试目的,存在第二个实现:Test Double CurrencyConversionServiceMock。该类的对象将返回一个已定义且可预测的转换因子,因为它是单元测试所必需的。此外,该类的对象还提供了从外部设置转换因子的能力,例如,模拟临界情况。

class CurrencyConversionServiceMock : public CurrencyConverter {

public:

  virtual long double getConversionFactor() const override {
    return conversionFactor;
  }

  void setConversionFactor(const long double value) {
    conversionFactor = value;
  }

private:

  long double conversionFactor{0.5};
};

Listing 2-11.The Test Double

在产品代码中使用货币转换器的地方,现在使用接口来访问服务。由于这种抽象,客户端代码在运行时使用哪种实现是完全透明的——是真实的货币转换器还是它的测试 Double。

#include <memory>

class CurrencyConverter;

class UserOfConversionService {

public:

  UserOfConversionService() = delete;
  UserOfConversionService(const std::shared_ptr<CurrencyConverter>& conversionService);
  void doSomething();
  // More of the public class interface follows here...

private:

  std::shared_ptr<CurrencyConverter> conversionService;
  //...internal implementation...
};

Listing 2-12.The header of the class that uses the service

UserOfConversionService::UserOfConversionService   (const std::shared_ptr<CurrencyConverter>& conversionService) :
  conversionService(conversionService) { }

void UserOfConversionService::doSomething() {
  long double conversionFactor = conversionService->getConversionFactor();
  // ...
}

Listing 2-13.An excerpt from the implementation file

在对类UserOfConversionService的单元测试中,测试用例现在能够通过初始化构造函数传递模拟对象。另一方面,在软件的正常操作中,真正的服务可以通过构造函数传递。这种技术被称为依赖注入的设计模式,在“设计模式”一章的同名章节中有详细讨论。

std::shared_ptr<CurrencyConverter> serviceToUse = std::make_shared<name of the desired class here */>();
UserOfConversionService user(serviceToUse);
// The instance of UserOfConversionService is ready for use...
user.doSomething();
Listing 2-14.An example how UserOfConversionService gets its required CurrencyConverter object

Footnotes 1

NASA 国家空间科学数据中心(NSSDC):水手 1 号, http://nssdc.gsfc.nasa.gov/nmc/spacecraftDisplay.do?id=MARIN1 ,检索 2014-04-28。

三、要有原则

我建议学生们把更多的注意力放在基本的想法上,而不是最新的技术上。这些技术在他们毕业前就会过时。基本思想永远不会过时。—戴维·l·帕纳斯

在这一章中,我介绍了设计良好和制作精良的软件的最重要和最基本的原则。这些原则的特别之处在于,它们不依赖于特定的编程范式或编程语言。其中一些甚至不是专门针对软件开发的。例如,所讨论的 KISS 原则可能与生活的许多领域相关:一般来说,尽可能简化生活中的一切并不是一个坏主意——不仅仅是软件开发。

也就是说,你不应该把下面的原则学了一遍就忘了。这些建议是给你内在化的。这些原则非常重要,理想情况下,它们应该成为每个开发人员的第二天性。我在本书后面讨论的许多更具体的原则都源于下面的基本原则。

什么是原则?

在这本书里,你会发现更好的 C++ 代码和设计良好的软件的各种原则。但是一般来说什么是原则呢?

许多人都有指导他们一生的原则。例如,如果你因为几个原因反对吃肉,那将是一个原则。如果你想保护你的孩子,你就给他一些原则,引导他自己做出正确的决定,例如“小心,不要和陌生人说话!”记住这个原则,孩子就能在某些特定的情况下推断出正确的行为。

原则是一种指导你的规则、信念或想法。原则通常与价值观或价值体系直接相关。例如,我们不需要被告知同类相食是错误的,因为人类对于生命有一种与生俱来的价值。作为进一步的例子,敏捷宣言[Beck01]包含了十二条指导项目团队实施敏捷项目的原则。

原则不是不可改变的法律。他们不是被刻在石头上的。在编程中,故意违反原则有时是必要的。如果你有非常充分的理由违反原则,那就去做,但是要非常小心!应该是个例外。

下面的一些基本原则,在本书后面的不同地方,会被重新讨论和深化。

一切都应该尽可能简单,但不能更简单。—阿尔伯特·爱因斯坦,理论物理学家,1879 - 1955 年

KISS 是“保持简单,愚蠢”或“保持简单,愚蠢”的缩写(好吧,我知道,这个缩写还有其他意思,但这两个是最常见的)。在极限编程(XP)中,这个原则由一个名为“做最简单的工作”(DTSTTCPW)的实践来代表。KISS 原则声明简单性应该是软件开发的主要目标,并且应该避免不必要的复杂性。

我认为 KISS 是开发人员在开发软件时通常会忘记的原则之一。软件开发人员倾向于以某种复杂的方式编写代码,让事情变得更加复杂。我知道,我们都是技术高超、积极性很高的开发人员,我们知道关于设计和架构模式、框架、技术、工具以及其他很酷很有趣的东西的一切。制作酷软件不是我们朝九晚五的工作——这是我们的使命,我们通过工作来获得成就感。

但是你必须记住,任何软件系统都有一个内在的复杂性,这个复杂性本身就具有挑战性。毫无疑问,复杂的问题往往需要复杂的代码。固有的复杂性无法降低。由于系统要满足的需求,这种复杂性就在那里。但在这种内在的复杂性上增加不必要的、自制的复杂性将是致命的。因此,明智的做法是不要因为可以就使用语言的每一个花哨功能或很酷的设计模式。另一方面,不要过分强调简单。如果在一个开关盒中有十个决定是必要的,那就是它的方式。

尽可能保持你的代码简单!当然,如果有关于灵活性和可扩展性的高优先级质量需求,您必须增加复杂性来满足这些需求。例如,当需求需要时,你可以使用众所周知的策略模式(参见第九章关于设计模式)在你的代码中引入一个灵活的变化点。但是要小心,只增加使事情变得简单的复杂性。

For programmers, focusing on simplicity may be one of the most difficult things. This is a lifelong learning experience. —— Adrian Bolboaca (@ adibalb), on April 3rd, 2014, on Twitter.

亚吉

总是在你真正需要的时候实施,而不是在你预见到你需要的时候。-罗恩·杰弗里斯,你不会需要它的!【杰弗里斯 98】

这个原则与之前讨论的 KISS 原则紧密相关。YAGNI 是“你不会需要它的”的首字母缩写有时它被翻译成“你不需要它!”YAGNI 是对投机泛化和过度工程的宣战。它声明您不应该编写目前不需要但将来可能需要的代码。

可能每个开发人员在日常工作中都知道这种诱人的冲动:“也许我们以后会用到它…”或者“我们会需要…”不,你不会需要它的!在任何情况下,你都应该抵制生产某种东西以备后用。你可能根本不需要它。但是如果你实现了那些不必要的东西,你就浪费了你的时间,代码变得比它应该变得更复杂了!当然,你也违反了接吻原则。更糟糕的后果可能是,这些未来的代码片段充满错误,并导致严重的问题!

我的建议是:相信重构的力量,不要在你知道它们实际上是必要的之前就开始构建。

干燥的

复制粘贴是一个设计错误。—戴维·l·帕纳斯

虽然这个原则是最重要的原则之一,但我很确定它经常被无意或有意地违反。DRY 是“不要重复自己!”并指出我们应该避免重复,因为重复是邪恶的。有时这个原则也被称为“一次且仅一次”(OAOO)。

复制非常危险的原因是显而易见的:当一个部分被改变时,它的副本必须相应地改变。而且不要抱太大希望。变化肯定会发生。我觉得没有必要提任何抄袭的作品迟早会被遗忘,我们可以和 bug 打个招呼。

好了,就这样——没什么要说的了?等等,还有一些东西,我们需要更深入。

在他们杰出的著作《务实的程序员[Hunt99]》中,迪夫·托马斯和安迪·亨特指出,应用 DRY 原则意味着我们必须确保“每项知识在系统中必须有一个单一的、明确的、权威的表示。”值得注意的是,戴夫和安迪没有明确提到代码,但他们谈到了知识。一个系统的知识远不止是它的代码。例如,DRY 原则也适用于文档、项目和测试计划,或者系统的配置数据。干影响一切!也许你可以想象,严格遵守这个原则并不像乍看起来那么容易。

信息隐蔽

信息隐藏是软件开发中一个众所周知的基本原则。著名的 David L. Parnas 在 1972 年写的开创性论文“关于将系统分解成模块的标准”[Parnas72]中首次记载了这一点。

该原则规定,调用另一段代码的一段代码不应该知道另一段代码的内部情况。这使得更改被调用代码的内部部分成为可能,而不必被迫相应地更改调用代码。

David L. Parnas 将信息隐藏描述为将系统分解为模块的基本原则。Parnas 认为,系统模块化应该关注隐藏困难的设计决策或可能改变的设计决策。软件单元(例如,类或组件)暴露给它的环境的内部越少,单元的实现和它的客户之间的耦合就越少。因此,软件单元内部实现的变化不会传播到它的环境中。

信息隐藏有许多优点:

  • 模块变化后果的限制
  • 如果需要修复错误,对其他模块的影响最小
  • 显著提高了模块的可重用性
  • 更好的模块可测试性

信息隐藏经常与封装混淆,但并不相同。我知道这两个术语在许多著名的书中被当作同义词使用,但我不同意。信息隐藏是一个帮助开发者找到好的模块的设计原则。该原则在多个抽象层次上起作用,并展现其积极效果,尤其是在大型系统中。

封装通常是一种依赖于编程语言的技术,用于限制对模块内部的访问。例如,在 C++ 中,你可以在类成员列表前加上关键字private,以确保它们不能从类外被访问。但是正因为我们使用了这种访问控制的安全措施,我们离自动隐藏信息还很远。封装有助于信息隐藏,但不能保证信息隐藏。

下面的代码示例显示了一个具有较差信息隐藏的封装类:

class AutomaticDoor {

public:

  enum class State {
    closed = 1,
    opening,
    open,
    closing
  };

private:

  State state;
  // ...more attributes here...

public:

  State getState() const;
  // ...more member functions here...
};

Listing 3-1.A class for automatic door steering

(excerpt)

这不是信息隐藏,因为该类的部分内部实现暴露在环境中,即使该类看起来封装得很好。注意getState返回值的类型。使用此类的客户端需要枚举类State,如下例所示:

#include "AutomaticDoor.h"

int main() {
  AutomaticDoor automaticDoor;
  AutomaticDoor::State doorsState = automaticDoor.getState();
  if (doorsState == AutomaticDoor::State::closed) {
    // do something...
  }
  return 0;
}

Listing 3-2.An example how AutomaticDoor

must be used to query the door’s current state

Enumeration Class (Struct) [C++11]

在 C++11 中,枚举类型也有了创新。为了向下兼容早期的 C++ 标准,仍然有著名的带有关键字enum的枚举。从 C++11 开始,也有了枚举类和枚举结构。

这些旧 C++ 枚举的一个问题是,它们将其枚举文字导出到周围的命名空间,从而导致名称冲突,如下例所示:

const std::string bear;
// ...and elsewhere in the same namespace...

enum Animal { dog, deer, cat, bird, bear }; // error: 'bear' redeclared as different kind of symbol

此外,旧的 C++ 枚举隐式转换为int,当不期望或不想进行这种转换时,会导致微妙的错误:

enum Animal { dog, deer, cat, bird, bear };
Animal animal = dog;

int aNumber = animal; // Implicit conversion: works

当使用枚举类(也称为“新枚举”或“强枚举”)时,这些问题不再存在它们的枚举文字对于枚举来说是本地的,它们的值不会隐式地转换为其他类型(比如转换为另一个枚举或一个int)。

const std::string bear;
// ...and elsewhere in the same namespace...

enum class Animal { dog, deer, cat, bird, bear }; // No conflict with the string named 'bear'
Animal animal = Animal::dog;

int aNumber = animal; // Compiler error!

对于现代 C++ 程序,强烈建议使用枚举类而不是普通的旧枚举,因为这样会使代码更安全。因为枚举类也是类,所以它们可以被前向声明。

如果AutomaticDoor的内部实现必须改变,枚举类State从类中移除,会发生什么?显而易见,它将对客户端的代码产生重大影响。这将导致所有使用成员函数AutomaticDoor::getState()的地方都发生变化。

下面是一个具有良好信息隐藏的封装AutomaticDoor:

class AutomaticDoor {

public:

  bool isClosed() const;
  bool isOpening() const;
  bool isOpen() const;
  bool isClosing() const;
  // ...more operations here...

private:

  enum class State {
    closed = 1,
    opening,
    open,
    closing
  };

  State state;
  // ...more attributes here...
};

Listing 3-3.A better designed class for automatic door steering

#include "AutomaticDoor.h"

int main() {
  AutomaticDoor automaticDoor;
  if (automaticDoor.isClosed()) {
    // do something...
  }
  return 0;
}

Listing 3-4.An example how elegant class AutomaticDoor can be used after it was changed

现在改变AutomaticDoor的内部结构容易多了。客户端代码不再依赖于类的内部部分。现在您可以移除枚举State并用另一种实现替换它,而该类的任何用户都不会注意到这一点。

强大的凝聚力

软件开发中的一个普遍建议是,任何软件实体(同义词:模块、组件、单元、类、函数……)都应该具有强(或高)内聚性。总的来说,当模块完成定义明确的工作时,内聚性就很强。

为了更深入地探究这个原理,让我们从图 3-1 开始,看两个内聚性较弱的例子。

A429836_1_En_3_Fig1_HTML.jpg

图 3-1。

MyModule has too many responsibilities, and this leads to many dependencies from and to other modules

在这个任意系统模块化的例子中,业务领域的三个不同方面被放在一个模块中。方面 A、B 和 C 没有任何共同点,或者几乎没有共同点,但是这三个方面都放在MyModule中。查看模块的代码可以发现,A、B 和 C 的函数是对不同的、完全独立的数据块进行操作的。

现在看一下图中所有的虚线箭头。每一个都是依赖。这种箭头尾部的元素需要箭头头部的元素才能实现。在这种情况下,想要使用由 A、B 或 C 提供的服务的系统的任何其他模块将使自己依赖于整个模块MyModule。这种设计的主要缺点是显而易见的:它将导致太多的依赖性,并且可维护性会下降。

为了增加内聚性,A、B 和 C 的方面应该相互分离,并移动到它们自己的模块中(图 3-2 )。

A429836_1_En_3_Fig2_HTML.jpg

图 3-2。

High cohesion : The previously mixed aspects A, B, and C have been separated into discrete modules

现在很容易看出,这些模块中的每一个都比我们以前的MyModule有更少的依赖性。很明显,A、B 和 C 彼此之间没有直接关系。唯一依赖于所有三个模块 A、B 和 C 的模块是名为Module 1的模块。

另一种形式的弱内聚被称为 Shot Gun 反模式。我想众所周知,猎枪是一种能发射大量小球状弹丸的武器。这种武器通常散布很广。在软件开发中,这个比喻用来表达某个领域方面,或者单个逻辑思想,是高度分散的,分布在许多模块中。图 3-3 描绘了这样一种情况。

A429836_1_En_3_Fig3_HTML.jpg

图 3-3。

The Aspect A was scattered over five modules

即使有这种形式的弱内聚力,也会产生许多不利的依赖性。方面 A 的分布式片段必须紧密合作。这意味着实现方面 A 的子集的每个模块必须至少与包含方面 A 的另一个子集的另一个模块交互。这导致了设计中大量的交叉依赖。在最坏的情况下,它会导致循环依赖,比如模块 1 和 3 之间,或者模块 6 和 7 之间。这又一次对可维护性和可扩展性产生了负面影响。当然这种设计的可测试性非常差。

这种设计将导致所谓的猎枪手术。关于方面 A 的某种类型的改变导致对许多模块进行许多小的改变。那确实不好,应该避免。我们必须通过将相同逻辑方面的所有代码片段整合到一个单一的内聚模块中来解决这个问题。

还有一些其他的原则——例如,面向对象设计的单一责任原则(SRP )(见第六章)——可以培养高内聚。高内聚通常与松散耦合相关,反之亦然。

松耦合

考虑下面这个小例子:

class Lamp {

public:

  void on() {
    //...
  }

  void off() {
    //...
  }
};

class Switch {

private:

  Lamp& lamp;
  bool state {false};

public:

  Switch(Lamp& lamp) : lamp(lamp) { }

  void toggle() {
    if (state) {
      state = false;
      lamp.off();
    } else {
      state = true;
      lamp.on();
    }
  }
};

Listing 3-5.A switch that can power on and off a lamp

基本上,这段代码是可行的。您可以首先创建一个类Lamp的实例。然后在实例化类Switch时通过引用传递。用 UML 可视化,这个小例子看起来如图 3-4 所示。

A429836_1_En_3_Fig4_HTML.jpg

图 3-4。

A class diagram of Switch and Lamp

这个设计有什么问题?

问题是我们的Switch包含了对具体类Lamp的直接引用。换句话说:开关知道有灯。

也许你会争辩说:“好吧,但这就是开关的目的。它必须打开和关闭灯。”我会说:是的,如果这是交换机应该做的唯一一件事,那么这种设计可能就足够了。但是请去 DIY 商店看看你能在那里买到的开关。他们知道灯的存在吗?

还有你怎么看待这个设计的可测性?因为单元测试需要开关,所以开关可以独立测试吗?不,这是不可能的。当开关不仅要打开灯,还要打开风扇或电动卷帘时,我们该怎么办?

在上面的例子中,开关和灯紧密耦合。

在软件开发中,应该寻求模块之间的松散耦合(也称为低耦合或弱耦合)。这意味着您应该构建一个系统,其中的每个模块都很少或根本不知道其他独立模块的定义,或者利用这些知识。

软件开发中松耦合的关键是接口。一个接口声明了一个类的公共可访问的行为特征,而不需要提交该类的特定实现。接口就像一个契约。实现接口的类被承诺履行契约,也就是说,这些类必须为接口的方法签名提供实现。

在 C++ 中,接口是使用抽象类实现的,就像这样:

class Switchable {

public:

  virtual void on() = 0;
  virtual void off() = 0;
};
Listing 3-6.The Switchable interface

Switch不再包含对Lamp的引用。相反,它包含了对我们的新接口类Switchable的引用。

class Switch {

private:

  Switchable& switchable;
  bool state {false};

public:

  Switch(Switchable& switchable) : switchable(switchable) {}

  void toggle() {
    if (state) {
      state = false;
      switchable.off();
    } else {
      state = true;
      switchable.on();
    }
  }
};

Listing 3-7.The modified Switch class, where Lamp is gone

Lamp类实现了我们的新接口。

class Lamp : public Switchable {

public:

  void on() override {
    // ...
  }

  void off() override {
    // ...
  }
};

Listing 3-8.Class ‘Lamp’ implements the ‘Switchable’ interface

用 UML 表达,我们的新设计看起来如图 3-5 所示。

A429836_1_En_3_Fig5_HTML.jpg

图 3-5。

Loosely coupled Switch and Lamp via an interface

这种设计的优点是显而易见的。完全独立于受其控制的具体类。此外,Switch可以通过提供一个实现Switchable接口的测试替身来独立测试。你想控制风扇而不是灯?没问题:这个设计可以扩展。创建一个类Fan或者其他表示实现接口Switchable的电气设备的类,如图 3-6 所示。

A429836_1_En_3_Fig6_HTML.jpg

图 3-6。

Via an interface, a Switch is able to control different classes for electrical devices

关注松散耦合可以为系统的各个模块提供高度的自主性。这个原则可以在不同的层次上有效:既可以在最小的模块上有效,也可以在大型组件的系统架构层次上有效。高内聚促进了松散耦合,因为具有明确定义的职责的模块通常依赖于较少的合作者。

小心优化

过早的优化是编程中所有罪恶(或者至少是大部分罪恶)的根源。—唐纳德·e·克努特,美国计算机科学家【克努特 74】

我见过开发人员开始浪费时间的优化,只是对开销有模糊的概念,但并不真正知道性能损失在哪里。他们经常篡改个别指令;或者试图优化小的局部循环,以挤出哪怕是最后一滴性能。作为一个脚注,我说的这些程序员中有一个就是我。

这些活动的成功通常是微不足道的。预期的性能优势通常不会出现。最终这只是浪费了宝贵的时间。相反,所谓的优化代码的可理解性和可维护性通常会受到严重影响。特别糟糕的是:有时在这样的优化措施中,甚至会出现微妙的错误。我的建议是:只要没有明确的性能需求需要满足,就不要去做优化。

我们代码的可理解性和可维护性应该是我们的首要目标。正如我在“但是调用时间开销!”在第四章中,编译器现在非常擅长优化代码。每当你想优化某样东西的时候,想想 YAGNI。

只有当利益相关者明确要求的明确的性能需求没有得到满足时,你才应该采取行动。但是你应该首先仔细分析性能在哪里丢失了。不要仅凭直觉就做任何优化。例如,您可以使用一个分析器来找出瓶颈在哪里。使用这种工具后,开发人员通常会惊讶地发现,性能在一个与最初假设的位置完全不同的位置丢失了。

Note

分析器是一种动态程序分析工具。它测量函数调用的频率和持续时间等指标。收集的剖析信息可用于帮助程序优化。

最小惊讶原则

最小惊讶原则(PLA 解放军),也被称为最小惊讶原则(POLS),是用户界面设计和人类工程学中众所周知的。该原则指出,用户不应该对用户界面的意外响应感到惊讶。用户不应被出现或消失的控件、令人困惑的错误消息、对已建立的按键序列的异常反应(记住:Ctrl + C是在 Windows 操作系统上复制应用程序的事实标准,而不是退出程序)或其他意外行为所迷惑。

这个原理也可以很好的移植到软件开发中的 API 设计上。调用一个函数不应该用意想不到的行为或神秘的副作用让调用者感到惊讶。一个函数应该完全按照它的函数名所暗示的那样去做(参见第四章中关于“函数命名”的章节)。例如,在一个类的实例上调用 getter 不应该修改该对象的内部状态。

童子军规则

这个原则是关于你和你的行为的。它是这样写的:永远让露营地比你发现它的时候更干净。

童子军很有原则。他们的一个原则是,一旦他们发现了这样的不好的事情,他们应该立即清理环境中的垃圾或污染。作为负责任的软件工匠,我们应该将这个原则应用到我们的日常工作中。每当我们在一段代码中发现需要改进的地方,或者有不好的代码味道时,我们应该立即修复它。这段代码的原作者是谁并不重要。

这种行为的优点是我们不断地防止我们的代码崩溃。如果我们都这样做,代码就不会腐烂。软件熵增长的趋势很难控制我们的系统。这种改善不一定很大。这可能是一个非常小的清理,例如:

  • 重命名命名不当的类、变量、函数或方法(参见第四章中的“良好名称和函数命名”一节)。
  • 将一个大函数的内部分解成更小的部分(参见第四章中的“让它们变小”一节)。
  • 通过使被注释的代码段不言自明来删除注释(参见第四章中的“避免注释”一节)。
  • 清理一个复杂而令人困惑的 if-else-compound。
  • 删除一小段重复的代码(参见本章中关于 DRY 原理的部分)。

由于这些改进大部分是代码重构,如第二章所述,由良好的单元测试组成的稳固的安全网是必不可少的。没有适当的单元测试,你不能确定你没有破坏某些东西。

除了良好的单元测试覆盖率,我们还需要团队中的特殊文化:集体代码所有权。

集体代码所有权意味着我们应该真正像一个团体一样工作。任何时候,每个团队成员都可以对任何一段代码进行修改或扩展。不应该有“这是彼得的代码,那是弗雷德的模块”这样的态度。我不碰他们!”别人能接手我们写的代码,应该算是很高的价值了。在一个真正的团队中,任何人都不应该害怕清理代码或添加新功能,或者必须获得许可。有了集体代码所有权的文化,童子军规则将会运行良好。

四、干净的 C++ 基础

正如我在这本书的介绍中已经解释过的(见第一章),很多 C++ 代码并不干净。在许多项目中,软件熵占据了上风。即使您正在处理一个正在进行的开发项目,例如,一个正在维护的软件,代码库的大部分通常是非常旧的。代码看起来就像上个世纪写的一样。这并不奇怪,因为大部分代码是在上个世纪写的!有很多项目的生命周期很长,它们的根源都在 90 年代甚至 80 年代。此外,许多程序员只是从遗留项目中复制代码片段,并修改它们来完成工作。

一些程序员将语言视为众多工具中的一种。他们看不到改进的理由,因为他们胡乱拼凑的东西不知何故会起作用。不应该是那样的,因为它会很快导致软件熵的增加,项目会比你想象的更快地变得一团糟。

在这一章中,我描述了 clean C++ 的基本知识。这些有时是通用的东西,通常与编程语言无关。例如,在所有编程语言中,起一个好名字是必不可少的。其他几个方面,比如常量正确性、智能指针的使用,或者 move 语义的巨大优势,都是 C++ 特有的。

但在我讨论具体话题之前,我想指出一条一般性的建议:

如果您还没有这样做,现在就开始使用 C++11(或更高版本)吧!

随着 2011 年新标准的出现,C++ 在许多方面都得到了改进,C++11 的一些特性,以及后续标准 C++14 和 C++17 的一些特性都非常有用,不容忽视。这不仅仅是性能的问题。这种语言无疑变得更加容易使用,甚至变得更加强大。C++11 不仅能让你的代码更短、更清晰、更易读:它还能提高你的生产率。此外,这种语言标准及其后继标准的特性使您能够编写更加正确和异常安全的代码。

但是现在让我们一步一步地探索干净而现代的 C++ 的关键元素…

好名字

程序必须是为人们阅读而写的,并且只是附带地为机器执行而写。——哈尔·阿伯尔森和杰拉德·让伊·萨斯曼,1984 年

下面这段源代码摘自众所周知的 Apache open office 3 . 4 . 1 版,这是一个开源的办公软件套件。Apache OpenOffice 有着悠久的历史,可以追溯到 1984 年。它源自甲骨文 OpenOffice.org(OOo),是早期 StarOffice 的开源版本。2011 年,甲骨文停止了 OpenOffice.org 的开发,解雇了所有开发人员,并将代码和商标捐献给了阿帕奇软件基金会。所以,请宽容一点,牢记阿帕奇软件基金会继承了一个近 30 年的古兽和一笔庞大的技术债务。

// Building the info struct for single elements
SbxInfo* ProcessWrapper::GetInfo( short nIdx )
{
    Methods* p = &pMethods[ nIdx ];
    // Wenn mal eine Hilfedatei zur Verfuegung steht:
    // SbxInfo* pResultInfo = new SbxInfo( Hilfedateiname, p->nHelpId );
    SbxInfo* pResultInfo = new SbxInfo;
    short nPar = p->nArgs & _ARGSMASK;
    for( short i = 0; i < nPar; i++ )
    {
        p++;
        String aMethodName( p->pName, RTL_TEXTENCODING_ASCII_US );
        sal_uInt16 nInfoFlags = ( p->nArgs >> 8 ) & 0x03;
        if( p->nArgs & _OPT )
            nInfoFlags |= SBX_OPTIONAL;
        pResultInfo->AddParam( aMethodName, p->eType, nInfoFlags );
    }
    return pResultInfo;
}
Listing 4-1.An excerpt from Apache’s OpenOffice 3.4.1 source code

我有一个简单的问题要问你:这个函数是做什么的?

乍一看似乎很容易给出答案,因为代码片段很小(不到 20 LOC),缩进也还可以。但实际上,不可能一目了然地说出这个函数到底是做什么的,原因不仅仅在于你可能不知道的领域。

这一小段代码有许多不好的味道(例如,注释掉的代码、德语注释、类似于0x03的魔法文字等等)。)但一个主要问题是糟糕的命名。这个函数的名字GetInfo()非常抽象,最多给我们一个这个函数实际做什么的模糊概念。此外,名称空间名称ProcessWrapper也不是很有帮助。也许您可以使用这个函数来检索正在运行的进程的信息。嗯,难道RetrieveProcessInformation()不是一个更好的名字吗?

在分析了该函数的实现之后,您还会注意到这个名字具有误导性,因为GetInfo()不像您可能怀疑的那样只是一个简单的 getter。还有一些用new操作符创建的东西。也许你也注意到了函数上方的注释,它谈到了构建,而不仅仅是获取。换句话说,调用站点将接收在堆上分配的资源,并且必须管理它。为了强调这个事实,像CreateProcessInformation()这样的名字不是更好吗?

接下来看看函数的参数和返回值。什么是SbxInfo?什么是nIdx?也许参数nIdx包含一个用于访问数据结构中的元素的值(即索引),但这只是一种猜测。其实我们也不是很清楚。

开发人员阅读源代码的次数要比编译器翻译源代码的次数多得多。因此,源代码应该是可读的,好的名称是增加其可读性的关键因素。如果您与多人一起处理一个项目,好的命名是必不可少的,这样您和您的团队成员可以快速理解您的代码。即使你必须在几周或几个月后编辑或阅读你自己写的一段代码,好的类名、方法名和变量名也会帮助你回忆起你的意图。

所以,这是我的基本建议:

源代码文件、命名空间、类、模板、函数、参数、变量和常量应该有有意义和有表现力的名称。

当我设计软件或写代码时,我会花很多时间考虑名字。我相信现在是考虑好名字的好时机,即使有时并不容易,需要 5 分钟或更长时间。我很少能马上为一件事物找到一个合适的名字。所以我经常重命名,有了好的编辑器或者有重构能力的集成开发环境(IDE)就很容易了。

如果为一个变量、函数或类找到一个合适的名称似乎很困难或几乎不可能,这可能表明其他地方可能出错了。可能存在设计问题,您应该找到并解决命名问题的根本原因。

这里有一些找到好名字的建议。

名称应该是不言自明的

我致力于自我记录代码的概念。自文档化代码是不需要注释来解释其目的的代码(也参见下面关于注释和如何避免注释的部分)。自文档化代码要求其名称空间、类、变量、常数和函数有自解释的名称。

使用简单但描述性和自我解释的名称。

unsigned int num;

bool flag;
std::vector<Customer> list;
Product data;
Listing 4-2.Some examples of bad names

多变的命名惯例经常会变成一场宗教战争,但是我非常确定大家都同意numflaglistdata是非常糟糕的名字。什么是data?一切都是data。这个名字绝对没有语义。这就像你将你的货物和动产包装在移动的盒子里,而不是在上面写下它们真正包含的东西,例如,“炊具”,你会在每个纸箱上都写下“东西”这个词。在新房子里,当纸箱到达时,这些信息是完全无用的。

下面是一个例子,说明如何更好地命名前面代码示例中的四个变量:

unsigned int numberOfArticles;

bool isChanged;
std::vector<Customer> customers;
Product orderedProduct;
Listing 4-3.Some examples of good names

人们现在可以说名字越长越好。考虑以下示例:

unsigned int totalNumberOfCustomerEntriesWithMangledAddressInformation;
Listing 4-4.A very exhaustive variable name

毫无疑问,这个名字极具表现力。即使不知道这些代码来自哪里,读者也很清楚这个变量的用途。但是,这样的名字也有问题。例如,你不容易记住这么长的名字。而且它们很难打字。如果在表达式中使用如此冗长的名称,代码的可读性甚至会受到影响:

totalNumberOfCustomerEntriesWithMangledAddressInformation =
  amountOfCustomerEntriesWithIncompleteOrMissingZipCode +
  amountOfCustomerEntriesWithoutCityInformation +
  amountOfCustomerEntriesWithoutStreetInformation;
Listing 4-5.A naming chaos

, caused by too verbose names

当试图使我们的代码干净时,过长和冗长的名字是不合适的或不可取的。如果使用变量的上下文是清楚的,那么更短和更少描述的名字是可能的。例如,如果变量是一个类的成员(属性),类名通常为变量提供足够的上下文:

class CustomerRepository {

private:

  unsigned int numberOfMangledEntries;
  // ...
};
Listing 4-6.The class’s name provides enough context information for the attribute

使用域中的名称

你可能已经听说过领域驱动设计(DDD)。术语“领域驱动设计”是由 Eric Evans 在他 2004 年的同名著作中提出的。DDD 是复杂的面向对象软件开发中的一种方法,主要关注核心领域和领域逻辑。换句话说,DDD 试图通过将业务领域的事物和概念映射到代码中,使你的软件成为现实系统的模型。例如,如果要开发的软件应该支持汽车租赁中的业务流程,那么汽车租赁的事物和概念(例如,租赁汽车、拼车、承租人、租赁期、租赁确认、会计等。)应该可以在这个软件的设计中发现。另一方面,如果软件是在航空航天工业中开发的,航空航天领域应该在其中得到反映。

这种方法的优点是显而易见的:首先,使用领域术语有助于开发人员和其他利益相关者之间的交流。DDD 帮助软件开发团队在公司的业务和 IT 涉众之间创建一个公共模型,团队可以用它来交流业务需求、数据实体和过程模型。

领域驱动设计的详细介绍超出了本书的范围。然而,以一种可以重新发现应用程序领域中的元素和概念的方式来命名组件、类和函数基本上总是一个非常好的主意。这使得我们能够尽可能自然地交流软件设计。它将使代码更容易被任何参与解决问题的人理解,例如,测试人员或业务专家。

以上面提到的汽车租赁为例。负责为某个客户预订汽车的用例的类可能如下:

class ReserveCarUseCaseController {

public:

  Customer identifyCustomer(const UniqueIdentifier& customerId);
  CarList getListOfAvailableCars(const Station& atStation, const RentalPeriod& desiredRentalPeriod) const;
  ConfirmationOfReservation reserveCar(const UniqueIdentifier& carId, const RentalPeriod& rentalPeriod) const;

private:

  Customer& inquiringCustomer;
};

Listing 4-7.The interface of a use case controller class to reserve a car

现在看一下所有用于类、方法、参数和返回类型的名称。它们代表了汽车租赁领域的典型事物。如果你从头到尾阅读这些方法,这些是租车所需的各个步骤。这是 C++ 代码,但是具有领域知识的非技术利益相关者也很有可能理解它。

在适当的抽象层次上选择名称

为了控制当今软件系统的复杂性,这些系统通常被分层分解。软件系统的层次分解意味着整个问题被分割成更小的部分,分别作为子任务,直到开发人员确信他们能够管理这些更小的部分。进行这种分解有不同的方法和标准。前一节中提到的领域驱动的设计,以及面向对象的分析和设计(OOAD)是这种分解的两种方法,在这两种方法中创建组件和类的基本标准是业务领域。

通过这样的分解,软件模块在不同的抽象层次上被创建:从大型组件或子系统开始,到非常小的构建块,如类。处于较高抽象级别的构件所完成的任务应该通过下一个较低抽象级别的构件的交互来完成。

这种方法引入的抽象层次也对命名有影响。每当我们在层次结构中更深入一步,元素的名称就变得更加具体。

想象一个网店。在顶层,可能存在一个大型组件,它的单一职责是创建发票。该组件可以有一个简短的描述性名称,如Billing。通常,这个组件由更小的组件或类组成。例如,这些较小的模块之一可以负责折扣的计算。另一个模块可以负责创建发票行项目。因此,这些模块的好名字应该是DiscountCalculatorLineItemFactory。如果我们现在深入分解层次,组件、类以及函数或方法的标识符变得越来越具体、冗长,因此也越来越长。例如,类中最深层次的一个小方法可以有一个非常详细和冗长的名字,比如calculateReducedValueAddedTax()

选择名字时避免重复

选择一个提供清晰上下文的类名或其他名称,并将其作为构建成员变量名称的一部分是多余的,例如,如下所示:

#include <string>

class Movie {

private:

  std::string movieTitle;
  // ...
};

Listing 4-8.Don’t repeat the class’s name in its attributes

别这样!这是对 DRY 原则的一个微小的违反。而是将其命名为Title。成员变量在类Movie的名称空间中,所以很清楚谁的标题是指:电影的标题!

这是冗余的另一个例子:

#include <string>

class Movie {
  // ...

private:

  std::string stringTitle;
};

Listing 4-9.Don’t include the attribute’s type in its name

它是一部电影的片名,所以很明显它是一个字符串而不是整数!不要在其名称中包含变量或常数的类型。

避免隐晦的缩写

为变量或常量选择名称时,使用完整的单词而不是晦涩的缩写。原因很明显:晦涩的缩写会大大降低代码的可读性。此外,当开发人员谈论他们的代码时,变量名应该易于发音。

还记得 Open Office 代码片段中第 8 行名为nPar的变量吗?它的意思既不清楚,也不能很好地发音。

这里还有几个该做和不该做的例子:

std::size_t idx;           // Bad!
std::size_t index;         // Good; might be sufficient in some cases
std::size_t customerIndex; // To be preferred, especially in situations where
                           // several objects are indexed

Car ctw;       // Bad!
Car carToWash; // Good

Polygon ply1;         // Bad!
Polygon firstPolygon; // Good

unsigned int nBottles;       // Bad!

unsigned int bottleAmount;   // Better

unsigned int bottlesPerHour; // Ah, the variable holds a work value,
                             // and not an absolute number. Excellent!

const double GOE = 9.80665; // Bad!

const double gravityOfEarth = 9.80665; // More expressive, but misleading. The constant is
                                       // not a gravitation, which would be a force in physics.

const double gravitationalAccelerationOnEarth = 9.80665; // Good.

constexpr Acceleration gravitationalAccelerationOnEarth = 9.80665_ms2; // Wow!

Listing 4-10.Some examples for good and bad names

看最后一行,我已经用“哇!”这看起来很方便,因为这是科学家们熟悉的符号。这看起来就像在学校教物理一样。是的,这在 C++ 中确实是可能的,正如你将在第五章中关于类型丰富编程的下一节中所学到的。

避免匈牙利符号和前缀

你知道查尔斯·西蒙尼吗?查尔斯·西蒙尼是匈牙利裔美国计算机软件专家,20 世纪 80 年代在微软担任首席架构师。也许你在不同的背景下记得他的名字。查尔斯·西蒙尼是一名太空游客,已经两次进入太空,其中一次是去国际空间站。

但是他也发展了一种在计算机软件中命名变量的符号约定,命名为匈牙利符号,这种符号在微软内部被广泛使用,后来也被其他软件制造商使用。

当使用匈牙利表示法时,变量的类型,有时还有范围,被用作该变量的命名前缀。这里有几个例子:

bool fEnabled;       // f = a boolean flag

int nCounter;        // n = number type (int, short, unsigned, ...)

char* pszName;       // psz = a pointer to a zero-terminated string
std::string strName; // str = a C++ stdlib string

int m_nCounter;      // The prefix 'm_' marks that it is a member variable,
                     // i.e. it has class scope.

char* g_pszNotice;   // That's a global(!) variable. Believe me, I've seen
                     // such a thing.

int dRange;          // d = double-precision floating point. In this case it's
                     // a stone-cold lie!
Listing 4-11.Some examples for Hungarian notation with explanations

我对 21 世纪的建议是:

不要使用匈牙利符号,或任何其他基于前缀的符号,在名称中对变量的类型进行编码!

匈牙利符号在弱类型语言(如 c)中有潜在的帮助。在开发人员使用简单的编辑器进行编程,而不是具有“智能感知”功能的 ide 时,它可能是有用的

现代和复杂的开发工具今天很好地支持了开发者,并且显示了变量的类型和范围。没有更好的理由在名称中对变量的类型进行编码。远非如此,这样的前缀会妨碍代码的可读性。

最坏的情况是,在开发过程中,变量的类型在没有修改其名称前缀的情况下发生了改变。换句话说:前缀往往会变成谎言,正如你在上面的例子中看到的最后一个变量。那真的很糟糕!

另一个问题是,在支持多态性的面向对象语言中,前缀不容易指定,或者前缀甚至可能令人费解。哪个匈牙利前缀适合可以是整数也可以是双精度的多态变量?idXdiX?如何为一个实例化的 C++ 模板确定一个合适的、明确无误的前缀?

顺便说一句,即使是微软所谓的通用命名约定也强调不要使用匈牙利符号。

避免出于不同目的使用相同的名称

一旦你为任何种类的软件实体(例如,一个类或者组件),一个函数,或者一个变量引入了一个有意义的和表达性的名字,你应该注意它的名字永远不会被用于任何其他的目的。

我认为很明显,出于不同的目的使用相同的名称会令人困惑,并且会误导代码的读者。别这样。关于这个话题,我只能说这么多。

评论

真理只能在一个地方找到:代码。—罗伯特·c·马丁,干净代码[Martin09]

你还记得你作为一名专业软件开发人员的开端吗?你还记得当年你们公司的编码标准吗?也许你还年轻,从事商业的时间还不长,但是老一点的人会证实,这些标准中的大部分都包含一条规则,即适当的专业代码必须总是被适当地注释。这条规则的绝对可理解的推理是,任何其他开发人员或新的团队成员都可以很容易地理解代码的意图。

乍一看,这条规则似乎是个好主意。因此,在许多公司中,代码被广泛地注释。在一些项目中,代码和注释之间的比例几乎是 50:50。不幸的是,这不是一个好主意。相反:这个规则绝对是个坏主意!它曾经是,而且在几个方面是完全错误的,因为在大多数情况下,注释是一种代码味道。当需要解释和澄清时,评论是必要的。这通常意味着开发人员无法编写简单明了的代码。

请不要误解:评论有一些合理的用例。在某些情况下,注释可能会有所帮助。在这一节的最后,我将介绍一些相当罕见的案例。但是对于任何其他情况,这条规则应该适用,这也是下一节的标题:“让代码讲故事!”

让代码讲述一个故事

想象一下在电影院看一部电影,只有用图片下面的文字描述来解释个别场景,这才是可以理解的。这部电影肯定不会成功。相反,它会被批评家们批评得体无完肤。没有人会看这么糟糕的电影。因此,好电影非常成功,因为它们主要是通过画面和演员的对话来讲述一个扣人心弦的故事。

讲故事在很多领域都是一个成功的概念,不仅仅是在电影制作中。当你考虑构建一个伟大的软件产品时,你应该像向世界讲述一个伟大而迷人的故事一样去思考它。像 Scrum 这样的敏捷项目管理框架使用被称为“用户故事”的东西作为从用户的角度捕捉需求的方法,这并不奇怪。正如我在一个关于偏好特定域名的章节中所解释的,你应该用利益相关者自己的语言和他们交流。

所以,我的建议是:

代码应该讲述一个故事,并且不言自明。尽可能避免评论。

评论不是字幕。每当你想在代码中写一个注释,因为你想解释一些东西的时候,你应该考虑如何更好地写代码,这样它是不言自明的,注释变得多余。像 C++ 这样的现代编程语言具备了编写清晰而富于表现力的代码所必需的一切。优秀的程序员利用这种表现力来讲述故事。任何傻瓜都能写出计算机能理解的代码。优秀的程序员编写人类能够理解的代码。

马丁·福勒,1999 年

不要评论显而易见的事情

我们再一次来看一小段被广泛评论的典型源代码。

customerIndex++;                                        // Increment index
Customer* customer = getCustomerByIndex(customerIndex); // Retrieve the customer at the
                                                        // given index
CustomerAccount* account = customer->getAccount();      // Retrieve the customer's account
account->setLoyaltyDiscountInPercent(discount);         // Grant a 10% discount
Listing 4-12.Are these comments useful?

请不要侮辱读者的智力!很明显这些评论完全没用。代码本身很大程度上是不言自明的。它们不仅没有增加任何新的或相关的信息。更糟糕的是,这些无用的注释是代码的一种复制。它们违反了我们在第三章讨论过的干燥原则。

也许你注意到了另一个细节。看一下最后一行。注释字面上说的是 10%的折扣,但是在代码中有一个名为discount的变量或常量被传递给函数或方法setLoyaltyDiscountInPercent()。这里发生了什么事?一个合理的怀疑是,这个评论已经变成了一个谎言,因为代码被修改了,但是评论没有被改编。那真的不好,误导。

不要用注释禁用代码

有时注释被用来禁用一堆不应该被编译器翻译的代码。一些开发人员对这种做法经常给出的理由是,人们可能会在以后再次使用这段代码。他们认为,“也许有一天…我们会再次需要它。”

// This function is no longer used (John Doe, 2013-10-25):
/*
double calcDisplacement(double t) {
  const double goe = 9.81; // gravity of earth
  double d = 0.5 * goe * pow(t, 2); // calculation of distance
  return d;
}
*/
Listing 4-13.An example for commented-out code

注释掉的代码的一个主要问题是,它增加了混乱,却没有真正的好处。想象一下,上面例子中被禁用的函数不是唯一的一个,而是代码被注释掉的许多地方中的一个。代码很快就会变得一团糟,被注释掉的代码片段会增加很多干扰,影响可读性。此外,注释掉的代码片段没有质量保证,也就是说,它们没有被编译器翻译、测试和维护。我的建议是:

除非是为了快速试验,否则不要使用注释来禁用代码。有版本控制系统!

如果不再使用代码,只需将其删除。放手吧。如果有必要,你有一个“时间机器”来取回它:你的版本控制系统。但是,往往事实证明,这种情况非常罕见。只需看一下开发人员在上面的例子中添加的时间戳。这段代码已经很老了。再次需要它的可能性有多大?

为了在开发过程中快速尝试一些东西,例如,在寻找一个 bug 的原因时,暂时注释掉一段代码当然是有帮助的。但是必须确保这种修改后的代码不会在版本控制系统中签入。

不要写块状注释

像下面这样的评论可以在很多项目中找到。

#ifndef _STUFF_H_
#define _STUFF_H_

// -------------------------------------
// stuff.h: the interface of class Stuff
// John Doe, created: 2007-09-21
// -------------------------------------

class Stuff {

public:

  // ----------------
  // Public interface
  // ----------------

  // ...

protected:

  // -------------
  // Overrideables
  // -------------

  // ...

private:

  // ------------------------
  // Private member functions
  // ------------------------

  // ...

  // ------------------
  // Private attributes
  // ------------------

  // ...

};

#endif

Listing 4-14.An example of block comments

这些类型的评论(我不是指我用来掩盖不相关部分的那些)被称为“块评论”,或者“横幅”它们通常用于将内容摘要放在源代码文件的顶部。或者它们用于标记代码中的特殊位置。例如,他们引入了一个代码段,在这里可以找到一个类的所有私有成员函数。

这类评论大多是纯粹的乱七八糟,应该马上删除。

很少有例外,这样的评论是有益的。在一些罕见的情况下,一组特殊类别的函数可以聚集在这样一个注释下。但是你不应该使用由连字符(-)、斜线(/)、数字符号(#)或星号(*)组成的混乱的字符串来包围它。像下面这样的评论绝对足以介绍这样一个地区:

private:

  // Event handlers:
  void onUndoButtonClick();
  void onRedoButtonClick();
  void onCopyButtonClick();
  // ...
Listing 4-15.Sometimes useful: a comment to introduce a category of functions

在一些项目中,编码标准说在任何源代码文件的顶部带有版权和许可文本的大标题是强制性的。它们可能看起来像这样:

/**************************************************************
 *
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 *
 *************************************************************/
Listing 4-16.The license header

in any source code file of Apache OpenOffice 3.4.1

首先,我想说一些关于版权的基本知识。你不需要添加关于版权的评论,或者做任何其他事情,就可以拥有你作品的版权。根据伯尔尼保护文学和艺术作品公约(Wipo1886)(简称伯尔尼公约),这种评论没有法律意义。

有时需要这样的评论。在美国于 1989 年签署《伯尔尼公约》之前,如果你想在美国执行你的版权,这种版权声明是强制性的。但那是过去的事了。如今,这些评论已不再需要。

我的建议是干脆省略它们。它们只是笨重无用的包袱。然而,如果你想,或者甚至需要在你的项目中提供版权和许可信息,那么你最好把它们写在单独的文件中,比如license.txtcopyright.txt。如果软件许可证要求在所有情况下许可证信息都必须包含在每个源代码文件的头区域,那么如果您的 ide 有一个所谓的折叠编辑器,您可以隐藏这些注释。

不要用注释来代替版本控制

有时——这是非常糟糕的——横幅注释被用于变更日志,如下例所示。

// ############################################################################
// Change log:
// 2016-06-14 (John Smith) Change method rebuildProductList to fix bug #275
// 2015-11-07 (Bob Jones) Extracted four methods to new class ProductListSorter
// 2015-09-23 (Ninja Dev) Fixed the most stupid bug ever in a very smart way
// ############################################################################
Listing 4-17.Managing the change history in the source code file

别这样!跟踪项目中每个文件的更改历史是版本控制系统的主要任务之一。例如,如果您正在使用 Git,您可以使用git log -- [filename]来获取文件的更改历史。编写上述注释的程序员很可能是那些在提交时总是将签入注释框留空的人。

注释有用的罕见情况

当然,并不是所有的源代码注释基本上都是无用的、错误的或者不好的。有些情况下,注释很重要,甚至是不可或缺的。

在少数非常特殊的情况下,可能会发生这样的情况,即使您对所有变量和函数都使用了完美的名称,您的代码的某些部分仍然需要一些进一步的解释来支持读者。例如,如果一段代码具有高度的内在复杂性,以至于不具备深厚专业知识的每个人都不容易理解,则注释是合理的。例如,复杂的数学算法或公式就是这种情况。或者软件系统处理非日常(商业)领域,即不是每个人都容易理解的应用领域,例如,实验物理学、自然现象的复杂模拟或雄心勃勃的加密方法。在这种情况下,一些写得很好的解释事情的评论是非常有价值的。

写一次评论的另一个好理由是,在这种情况下,你必须故意偏离一个好的设计原则。例如,DRY 原则(参见第三章)当然在大多数情况下都是有效的,但是可能有一些非常罕见的情况,例如,您必须故意复制一段代码,以满足关于性能的雄心勃勃的质量要求。这证明了解释你为什么违反了原则的评论是正确的;否则你的队友可能无法理解你的决定。

挑战是这样的:好的有意义的评论很难写。这可能比写代码更难。正如不是每个开发团队成员都擅长设计用户界面一样,也不是每个人都擅长写作。技术写作是一种通常有专家的技能。

因此,这里有一些写评论的建议,由于上述原因,这些建议是不可避免的:

  • 确保你的评论增加了代码的价值。这个上下文中的值意味着注释为其他人(通常是其他开发人员)添加了重要的信息,这些信息从代码本身是看不到的。
  • 总是解释为什么,而不是如何。从代码本身来看,一段代码是如何工作的应该非常清楚,对变量和函数进行有意义的命名是实现这一目标的关键。仅使用注释来解释某段代码存在的原因。例如,您可以提供选择特定算法或方法的理由。
  • 尽量简短,有表现力。喜欢简短的评论,最好是单句,避免冗长的文字。永远记住,评论也需要维护。事实上,保持简短的评论比冗长的解释要容易得多。

Tip

在带有语法颜色的集成开发环境(IDE)中,注释的颜色通常预先配置为绿色或蓝绿色。你应该把这个颜色改成红色!源代码中的注释应该是特别的,应该引起开发人员的注意。

从源代码生成文档

注释的一种特殊形式是可以由文档生成器提取的注释。这种工具的一个例子是 Doxygen ( http://doxygen.org ),它在 C++ 世界中广泛使用,并在 GNU 通用公共许可证(GPLv2)下发布。这种工具解析带注释的 C++ 源代码,并且可以创建可读和可打印文档(例如,PDF)形式的文档,或者可以用浏览器查看的一组互连的 web 文档(HTML)。结合可视化工具,Doxygen 甚至可以生成类图,包括依赖图和调用图。因此,Doxygen 也可以用于代码分析。

为了让这样的工具产生有意义的文档,必须用具体的注释对源代码进行深入的注释。下面是一个不太好的 Doxygen 风格注释的例子:

//! Objects of this class represent a customer account in our system.

class CustomerAccount {
  // ...

  //! Grant a loyalty discount.
  //! @param discount is the discount value in percent.
  void grantLoyaltyDiscount(unsigned short discount);

  // ...
};

Listing 4-18.A class annotated with documentation comments for Doxygen

什么事?类别CustomerAccount的对象代表客户账户。真的吗?!和grantLoyaltyDiscount授予忠诚度折扣?废话!

但是说真的,伙计们!对我来说,这种形式的文档是有利有弊的。

一方面,注释可能非常有用,特别是带有这种注释的库或框架的公共接口(API ),并从中生成文档。特别是如果软件的客户是未知的(公共可用库和框架的典型情况),如果他们想在他们的项目中使用该软件,这样的文档会非常有帮助。

另一方面,这样的注释给你的代码增加了大量的噪音。代码和注释行的比例可以很快达到 50:50。从上面的例子可以看出,这样的评论也倾向于解释显而易见的事情(记住本章中的“不要评论显而易见的事情”)。最后,有史以来最好的文档——“可执行文档”——是一组精心制作的单元测试(参见第二章中关于单元测试的部分和第八章中关于测试驱动开发的部分),它确切地展示了如何使用库的 API。

反正我对这个话题没有定论。如果你想或者必须不惜一切代价用 Doxygen 风格的注释来注释你的软件组件的公共 API,那么,看在上帝的份上,去做吧。如果做得好,会很有帮助。我强烈建议你只关注你的公共 API 头!对于软件的所有其他部分,例如,内部使用的模块或私有函数,我建议不要给它们配备 Doxygen 注释。

如果使用应用领域的术语和解释,上面的例子可以得到显著的改进。

//! Each customer must have an account, so bookings can be made. The account
//! is also necessary for the creation of monthly invoices.
//! @ingroup entities
//! @ingroup accounting

class CustomerAccount {
  // ...

  //! Regular customers occasionally receive a regular discount on their
  //! purchases.
  void grantDiscount(const PercentageValue& discount);

  // ...
};

Listing 4-19.A class annotated with comments from a business perspective for Doxygen

也许你已经注意到,我已经不再用 Dogygen 的@param标签来评论这个方法的参数了。相反,我将它的类型从无意义的unsigned short改为名为PercentageValue的自定义类型的常量引用。因此,该参数已经变得不言自明。为什么这是一个比任何注释都好得多的方法,你可以在第五章的中阅读关于类型丰富编程的章节。

下面是源代码中 Doxygen 风格注释的一些最终提示:

  • 不要使用 Doxygen 的@file [<name>]标签在文件本身的某个地方写文件名。一方面,这是没有用的,因为 Dogygen 无论如何都会自动读取文件名。另一方面,它违反了干原则(见第三章)。这是多余的信息,如果您必须重命名文件,您必须记住也要重命名@file标记。
  • 不要手动编辑@version@author,@date标签,因为你的版本控制系统可以比任何应该手动编辑它们的开发者更好地管理和跟踪这些信息。如果这样的管理信息在所有情况下都应该出现在源代码文件中,那么这些标签应该由版本控制系统自动填充。在所有其他情况下,我会完全没有他们。
  • 不要使用@bug@todo标签。相反,您应该立即修复错误,或者使用问题跟踪软件来记录错误,以便以后进行故障排除,并分别管理开放点。
  • 强烈建议使用@mainpage标签提供一个描述性的项目主页(最好是在一个单独的头文件中),因为这样的主页可以作为一个入门指南,并帮助目前不熟悉手头项目的开发人员进行定位。
  • 我不会使用@example标签来提供包含如何使用 API 的源代码示例的注释块。正如已经提到的,这样的注释给代码增加了很多噪音。相反,我会提供一套精心制作的单元测试(参见关于单元测试的第二章和关于测试驱动开发的第八章),因为这些是最好的使用示例——可执行的示例!此外,单元测试总是正确的和最新的,因为当 API 改变时它们必须被调整(否则测试将失败)。另一方面,带有用法示例的注释可能会在没有人注意到的情况下出错。
  • 一旦一个项目发展到一个特定的规模,建议在 Dogygen 的分组机制(标签:@defgroup <name>@addtogroup <name>@ingroup <name>)的帮助下,将某些类别的软件单元集中起来。例如,当您想要表达某些软件单元属于更高抽象层次上的内聚模块(例如,组件或子系统)的事实时,这是非常有用的。这种机制还允许将某些类别的类组合在一起,例如所有实体、所有适配器(参见第九章中的适配器模式)或所有对象工厂(参见第九章中的工厂模式)。例如,前面代码示例中的类CustomerAccount位于实体组(包含所有业务对象的组)中,但它也是会计组件的一部分。

功能

功能(同义词:方法、过程、服务、操作)是任何软件系统的核心。它们代表代码行之上的第一个组织单位。写得好的函数大大提高了程序的可读性和可维护性。出于这个原因,它们应该精心制作。在这一节中,我给出了编写好函数的几个重要线索。

然而,在我解释我认为对精心制作的功能很重要的东西之前,让我们再次检查一个令人生畏的例子,取自 Apache 的 OpenOffice 3.4.1。

1780  sal_Bool BasicFrame::QueryFileName(String& rName, FileType nFileType, sal_Bool bSave )
1781  {
1782      NewFileDialog aDlg( this, bSave ? WinBits( WB_SAVEAS ) :
1783                          WinBits( WB_OPEN ) );
1784      aDlg.SetText( String( SttResId( bSave ? IDS_SAVEDLG : IDS_LOADDLG ) ) );
1785
1786      if ( nFileType & FT_RESULT_FILE )
1787      {
1788        aDlg.SetDefaultExt( String( SttResId( IDS_RESFILE ) ) );
1789        aDlg.AddFilter( String( SttResId( IDS_RESFILTER ) ),
1790              String( SttResId( IDS_RESFILE ) ) );
1791          aDlg.AddFilter( String( SttResId( IDS_TXTFILTER ) ),
1792              String( SttResId( IDS_TXTFILE ) ) );
1793          aDlg.SetCurFilter( SttResId( IDS_RESFILTER ) );
1794      }
1795
1796      if ( nFileType & FT_BASIC_SOURCE )
1797      {
1798          aDlg.SetDefaultExt( String( SttResId( IDS_NONAMEFILE ) ) );
1799          aDlg.AddFilter( String( SttResId( IDS_BASFILTER ) ),
1800              String( SttResId( IDS_NONAMEFILE ) ) );
1801          aDlg.AddFilter( String( SttResId( IDS_INCFILTER ) ),
1802              String( SttResId( IDS_INCFILE ) ) );
1803          aDlg.SetCurFilter( SttResId( IDS_BASFILTER ) );
1804      }
1805
1806      if ( nFileType & FT_BASIC_LIBRARY )
1807      {
1808          aDlg.SetDefaultExt( String( SttResId( IDS_LIBFILE ) ) );
1809          aDlg.AddFilter( String( SttResId( IDS_LIBFILTER ) ),
1810              String( SttResId( IDS_LIBFILE ) ) );
1811          aDlg.SetCurFilter( SttResId( IDS_LIBFILTER ) );
1812      }
1813
1814      Config aConf(Config::GetConfigName( Config::GetDefDirectory(),
1815          CUniString("testtool") ));
1816      aConf.SetGroup( "Misc" );
1817      ByteString aCurrentProfile = aConf.ReadKey( "CurrentProfile", "Path" );
1818      aConf.SetGroup( aCurrentProfile );
1819      ByteString aFilter( aConf.ReadKey( "LastFilterName") );
1820      if ( aFilter.Len() )

1821          aDlg.SetCurFilter( String( aFilter, RTL_TEXTENCODING_UTF8 ) );
1822      else

1823          aDlg.SetCurFilter( String( SttResId( IDS_BASFILTER ) ) );
1824
1825      aDlg.FilterSelect(); // Selects the last used path
1826  //  if ( bSave )
1827      if ( rName.Len() > 0 )
1828          aDlg.SetPath( rName );
1829
1830      if( aDlg.Execute() )
1831      {
1832          rName = aDlg.GetPath();
1833  /*      rExtension = aDlg.GetCurrentFilter();
1834          var i:integer;
1835          for ( i = 0 ; i < aDlg.GetFilterCount() ; i++ )
1836              if ( rExtension == aDlg.GetFilterName( i ) )
1837                  rExtension = aDlg.GetFilterType( i );
1838  */
1839          return sal_True;
1840      } else return sal_False;
1841  }
Listing 4-20.Another excerpt from Apache's OpenOffice 3.4.1 source code

问题:第一次看到名为QueryFileName()的成员函数时,你期待的是什么?

你希望打开一个文件选择对话框吗(记得在第三章中讨论的最小惊讶原则)?可能不会,但这正是这里所做的。很明显,用户被要求与应用程序进行一些交互,因此这个成员函数更好的名字应该是AskUserForFilename()

但这还不够。如果你仔细观察第一行,你会发现有一个布尔参数bSave用来区分打开文件的对话框和保存文件的对话框。你预料到了吗?函数名中的术语Query…如何与事实相匹配?因此,这个成员函数更好的名字可能是AskUserForFilenameToOpenOrSave()

下面几行处理函数的参数nFileType。显然,有三种不同的文件类型。参数nFileType被一个叫做FT_RESULT_FILEFT_BASIC_SOURCEFT_BASIC_LIBRARY的东西屏蔽了。根据这种按位 AND 运算的结果,文件对话框会有不同的配置,例如设置过滤器。正如布尔参数bSave之前所做的那样,三个if语句引入了替代路径。这增加了函数的圈复杂度。

Cyclomatic Complexity

美籍数学家托马斯·j·麦凯布于 1976 年开发了度量圈复杂度的定量软件。

该度量是通过一段源代码(例如,一个函数)的线性独立路径的数量的直接计数。如果一个函数不包含if -或switch-语句,也不包含for -或while-循环,则只有一条路径通过该函数,其圈复杂度为 1。如果该函数包含一个代表单个决策点的if语句,则有两条路径通过该函数,圈复杂度为 2。

如果圈复杂度很高,受影响的代码通常更难理解、测试和修改,因此容易出现错误。

这三个if提出了另一个问题:这个函数是进行这种配置的合适地方吗?肯定不是!这不属于这里。

以下行(从 1814 开始)正在访问附加的配置数据。无法准确确定,但看起来好像上次使用的文件筛选器(“LastFilterName”)是从包含配置数据的源(配置文件或 Windows 注册表)加载的。尤其令人困惑的是,在前面三个if-块(aDlg.SetCurFilter(...))中设置的已经定义的过滤器将总是在这个位置被覆盖(见第 1820-1823 行)。那么,在之前的三个if-街区设置这个滤镜有什么意义呢?

结束前不久,参考参数rName开始发挥作用。等等……请问什么名字?!大概是文件名吧,没错,但是为什么不命名为filename以排除一切怀疑的可能呢?还有为什么文件名不是这个函数的返回值?(为什么您应该避免所谓的输出参数是本章稍后讨论的主题。)

似乎这还不够糟糕,该函数还包含注释掉的代码。

这个函数只有大约 50 行,但是它有很多不好的代码味道。函数太长,圈复杂度很高,混合了不同的关注点,有很多参数,并且包含死代码。函数名QueryFileName()不具体,可能会引起误解。谁被询问?一个数据库?AskUserForFilename()会好很多,因为它强调与用户的互动。大部分代码难以阅读和理解。nFileType & FT_BASIC_LIBRARY是什么意思?

但是关键的一点是,这个函数要执行的任务(文件名选择)证明了一个自己的类是正确的,因为作为应用程序 UI 一部分的类BasicFrame绝对不负责这些事情。

够了。让我们来看看一个软件设计师在设计好的功能时需要考虑什么。

一件事,不能再多了!

一个函数必须有一个非常精确定义的任务,这个任务应该用它重要的名字来表示。在他杰出的著作《干净的代码》中,美籍软件开发人员 Robert C. Martin 将它表述如下:

The function should do one thing. They should do it well. This is the only thing they should do. —Robert C. Martin, clean code [Martin09]

你现在可能会问:但是我怎么知道一个函数做了太多的事情?以下是一些可能的迹象:

  1. 这个函数很大,也就是说,它包含许多行代码(参见下面关于小函数的部分)。
  2. 您试图为函数找到一个有意义、有表现力的名称,准确描述其用途,但您无法避免使用连词,如“and”或“or”,来构建名称。(另请参见以下关于名称的章节之一。)
  3. 使用空行将函数体垂直分成代表后续步骤的组。通常这些群组还会被引入类似标题的评论。
  4. 圈复杂度很高。该函数包含许多“if”、“else”或“switch-case”语句。
  5. 该函数有许多参数(参见本章后面关于参数和返回值的部分),尤其是一个或多个类型为bool的标志参数。

让他们变小

关于函数的一个中心问题是:函数的最大长度应该是多少?

对于函数的长度,有许多经验法则和启发法。例如,有些人说函数应该垂直显示在屏幕上。好吧,乍一看,这似乎是一个不错的规则。如果一个功能适合屏幕,开发者就不需要滚动。另一方面,我的屏幕高度真的应该决定一个功能的最大尺寸吗?屏幕高度不尽相同。所以,我个人认为这不是一个好的规则。以下是我对这个话题的建议:

函数应该非常小。理想情况下 4-5 行,最多 12-15 行,但不能更多。

恐慌!我已经可以听到抗议声:“许多微小的功能?你说真的吗?!"

是的,我是认真的。正如 Robert C. Martin 在他的书《干净的代码》[Martin09]中所写的:函数应该很小,而且应该更小。

大型函数通常具有很高的复杂性。开发人员通常不能一眼看出这样的函数是做什么的。如果一个功能太大,它通常会有太多的职责(见上一节),而且不能做一件事情。功能越大,越难理解和维护。这种函数通常包含许多嵌套的决策(ifelseswitch)和循环。这也被称为高圈复杂度。

当然,和任何规则一样,很少有合理的例外。例如,一个包含一个大的switch语句的函数,如果读起来非常简洁明了,可能是可以接受的。您可以在一个函数中有一个 400 行的switch语句(在电信系统中,有时需要处理不同类型的输入数据),这完全没问题。

“但是通话时间开销很大!”

人们现在可能会提出异议,许多小函数降低了程序的执行速度。他们可能会争辩说任何函数调用都是昂贵的。

让我解释一下为什么我认为这些恐惧在大多数情况下是没有根据的。

是的,有时候 C++ 编译器不擅长优化,CPU 也相对较慢。在神话传播的时候,C++ 通常比 C 慢。这种神话是由不太懂语言的人传播的。而且时代变了。

现在的现代 C++ 编译器非常擅长优化。例如,它们可以执行多种局部和全局加速优化。它们可以将许多 C++ 结构(如循环或条件语句)简化为功能相似的高效机器码序列。如果这些函数基本上可以内联的话,它们现在足够智能来自动内联这些函数(…当然,有时这是不可能的)。

甚至链接器也能够执行优化。例如,微软的 Visual-Studio 编译器/链接器提供了一个称为整体程序优化的功能,它允许编译器和链接器使用程序中所有模块的信息来执行全局优化。使用 Visual Studio 的另一项功能(称为分析导向优化),编译器使用从。exe 或。dll 文件。

即使我们不想使用编译器的优化选项,当我们考虑一个函数调用时,我们在谈论什么呢?

英特尔酷睿 i7 2600K CPU 能够在 3.4 GHz 的时钟速度下每秒执行 128,300 百万条指令(MIPS)。女士们,先生们,当我们谈论函数调用时,我们谈论的是几纳秒!光在一纳秒(0.000000001 秒)内传播大约 30 厘米。与计算机上的其他操作相比,如缓存外的内存访问或硬盘访问,函数调用要快得多。

开发人员应该把宝贵的时间花在真正的性能问题上,这些问题通常源于糟糕的架构和设计。只有在非常特殊的情况下,才需要担心函数调用开销。

函数命名

一般来说,可以说变量和常量的命名规则尽可能适用于函数和方法。函数名应该清晰、有表现力、不言自明。你不必阅读函数体就能知道它做什么。因为函数定义了程序的行为,所以它们的名字中通常有一个动词。一些特殊的函数被用来提供关于一个状态的信息。他们的名字通常以“是……”或“有……”开头

函数的名字应该以动词开头。谓词,即关于一个对象的可能为真或为假的语句,应该以“是”或“有”开头。

以下是一些表达性方法名称的示例:

void CustomerAccount::grantDiscount(DiscountValue discount);

void Subject::attachObserver(const Observer& observer);

void Subject::notifyAllObservers() const;

int Bottling::getTotalAmountOfFilledBottles() const;

bool AutomaticDoor::isOpen() const;

bool CardReader::isEnabled() const;

bool DoubleLinkedList::hasMoreElements() const;
Listing 4-21.Just a few examples of expressive and self-explanatory names for member functions

使用透露意图的名字

看一下下面的代码行,当然,这只是一个大程序的一小段摘录:

std::string head = html.substr(startOfHeader, lengthOfHeader);

这行代码原则上看起来不错。有一个名为html的 C++ 字符串(头文件<string>),显然包含了一段 HTML(超文本标记语言)。当这一行被执行时,一个子串html的副本被检索并分配给一个名为head的新字符串。子字符串由两个参数定义:一个参数设置子字符串的起始索引,另一个参数定义子字符串中包含的字符数。

好了,我已经详细解释了如何从一段 HTML 中提取标题。让我向您展示相同代码的另一个版本:

std::string ReportRenderer::extractHtmlHeader(const std::string& html) {
  return html.substr(startOfHeader, lengthOfHeader);
}

// ...

std::string head = extractHtmlHeader(html);

Listing 4-22.After introducing an intention-revealing name the code is better understandable.

你能看到这样一个小小的改变能给你的代码带来多少清晰吗?我们已经引入了一个小的成员函数,通过它的语义名称来解释它的意图。在原来可以找到字符串操作的地方,我们用新函数的调用代替了对std::string::substr()的直接调用。

函数的名字应该表达它的意图/目的,而不是解释它是如何工作的。

工作是如何完成的,这就是你应该从函数体的代码中看到的。不要在函数名中解释 How。相反,从商业的角度来表达功能的目的。

另外,我们还有一个优势。如何从 HTML 页面中提取标题的部分功能已经被准隔离,现在可以更容易地替换,而不必在调用函数的地方摸索。

参数和返回值

在我们详细讨论了函数名之后,对于好的干净的函数来说,还有一个重要的方面:函数的参数和返回值。这两者都有助于客户更好地理解和使用函数或方法。

参数数量

一个函数(同义词:方法、操作)最多应该有多少个实参(又名参数)?

在干净的代码中,我们发现了下面的建议:

The ideal parameter number of the function is nil. Next is one (one yuan), followed by two (two yuan). Three parameters (triples) should be avoided as much as possible. More than three (polygons) need very special reasons-they should not be used in any case. —Robert C. Martin, clean code [Martin09]

这个建议非常有趣,因为 Martin 建议理想的函数应该没有参数。这有点奇怪,因为一个纯数学意义上的函数(y = f(x))总是至少有一个自变量(参见关于函数式编程的章节)。这意味着一个“没有参数的函数”通常会有某种副作用。

注意,Martin 在他的书中使用了用 Java 编写的代码示例,所以当他谈到函数时,他实际上指的是类的方法。我们不得不考虑对象的方法还有一个额外的隐式“参数”:thisthis指针代表执行的上下文。在this的帮助下,成员函数可以访问其类的属性,读取或操作它们。换句话说:从成员函数的角度来看,类的属性只是全局变量。因此,马丁法则似乎是一个合适的指导方针,但我认为它主要适用于面向对象的设计。

但是为什么太多的争论是不好的呢?

首先,函数的参数列表中的每个参数都可能导致一个依赖,除了标准内置类型的参数,如intdouble。如果在函数的参数列表中使用复杂类型(例如,类),则代码依赖于该类型。必须包含包含所用类型的头文件。

此外,每个参数都必须在函数内部的某个地方进行处理(否则,该参数就是不必要的,应该立即删除)。三个参数可以导致一个相对复杂的函数,正如我们从 Apache 的 OpenOffice 的成员函数BasicFrame::QueryFileName()的例子中看到的。

在过程编程中,有时很难不超过三个参数。例如,在 C 语言中,你会经常看到带有更多参数的函数。一个令人望而却步的例子是毫无希望的过时的 Windows Win32-API。

HWND CreateWindowEx
(
  DWORD dwExStyle,
  LPCTSTR lpClassName,
  LPCTSTR lpWindowName,
  DWORD dwStyle,
  int x,
  int y,
  int nWidth,
  int nHeight,
  HWND hWndParent,
  HMENU hMenu,
  HINSTANCE hInstance,
  LPVOID lpParam
);
Listing 4-23.The Win32 CreateWindowEx function
to create windows

显然,这个丑陋的代码来自古代。我很确定,如果它被设计成现在的样子,Windows API 看起来就不会像现在这样了。不是没有原因的,有无数的框架,如微软基础类(MFC),Qt ( https://www.qt.io ,或 wxWidgets ( https://www.wxwidgets.org ),包装了这种令人毛骨悚然的界面,并提供了更简单和更面向对象的方式来创建图形用户界面(UI)。

并且几乎没有减少参数数量的可能性。您可以将xynWidthnHeight组合成一个名为Rectangle的新结构,但是仍然有九个参数。更糟糕的是,这个函数的一些参数是指向其他复杂结构的指针,而这些复杂结构是由许多属性组成的。

在好的面向对象设计中,通常不需要这么长的参数列表。但是 C++ 并不是一门纯面向对象的语言,比如 Java 或者 C#。在 Java 中,所有的东西都必须嵌入到一个类中,这有时会导致大量的代码。在 C++ 中,这不是必需的。在 C++ 中,允许实现独立的函数,也就是说,不是类成员的函数。这很好。

所以我对这个话题的建议是:

实函数应该有尽可能少的参数。一个参数是理想数字。一个类的成员函数(方法)通常没有参数。通常这些函数操纵对象的内部状态,或者用来从对象中查询一些东西。

避免标志参数

标志参数是一种告诉函数根据其值执行不同操作的参数。标志参数大多是类型bool,有时甚至是枚举。

Invoice Billing::createInvoice(const BookingItems& items, const bool withDetails) {
  if (withDetails) {
    //...
  } else {
    //...
  }
}
Listing 4-24.A flag argument to control the level of detail of an invoice

标志参数的基本问题是通过函数引入了两条(有时甚至更多)路径。这种参数通常在函数内部的某个地方用ifswitch/case语句进行评估。它用于决定是否采取某个动作。这意味着函数没有完全正确地做一件事,正如它应该做的那样(参见本章前面的“仅此一件事”一节)。这是一种弱衔接的情况(见第三章),违反了单一责任原则(见第六章关于对象定位)。

如果你在代码的某个地方看到函数调用,如果不详细分析函数Billing::createInvoice(),你就不知道truefalse到底是什么意思:

Billing billing;
Invoice invoice = billing.createInvoice(bookingItems, true);
Listing 4-25.Baffling: What does the ‘true’ in the argument list mean?

我的建议是你应该避免旗帜性的争论。如果对执行一个动作的关注没有从它的配置中分离出来,这种类型的争论总是必要的。

一种解决方案是提供单独的、命名良好的函数:

Invoice Billing::createSimpleInvoice(const BookingItems& items) {
  //...
}

Invoice Billing::createInvoiceWithDetails(const BookingItems& items) {
  Invoice invoice = createSimpleInvoice(items);
  //...add details to the invoice...
}

Listing 4-26.Easier to comprehend: two member functions with intention-revealing names

另一个解决方案是计费的专业化层次结构:

class Billing {

public:

  virtual Invoice createInvoice(const BookingItems& items) = 0;
  // ...
};

class SimpleBilling : public Billing {

public:

  virtual Invoice createInvoice(const BookingItems& items) override;
  // ...
};

class DetailedBilling : public Billing {

public:
  virtual Invoice createInvoice(const BookingItems& items) override;
  // ...

private:

  SimpleBilling simpleBilling;
};

Listing 4-27.Different levels of details for invoices, realized the object-oriented way

类型为SimpleBilling的私有成员变量在类DetailedBilling中是必需的,以便能够首先执行简单的发票创建,而不需要代码复制,然后将细节添加到发票中。

Override Specifier [C++11]

从 C++11 开始,可以明确指定虚函数应该覆盖基类虚函数。为此,引入了override标识符。

如果override紧接在成员函数声明之后出现,编译器将检查该函数是否为虚函数,并且正在从基类中重写虚函数。因此,当开发人员仅仅认为他们已经覆盖了一个虚拟函数,但实际上他们已经更改/添加了一个新函数时,例如,由于打字错误,他们可以避免出现微妙的错误。

避免输出参数

输出参数,有时也称为结果参数,是用于函数返回值的函数参数。

经常提到的使用输出参数的好处之一是使用它们的函数可以一次传回多个值。下面是一个典型的例子:

bool ScriptInterpreter::executeCommand(const std::string& name,
                        const std::vector<std::string>& arguments,
                        Result& result);

这个类ScriptInterpreter的成员函数不仅返回一个bool。第三个参数是对类型为Result的对象的非常数引用,它代表函数的实际结果。布尔返回值用于确定解释器是否成功执行了命令。此成员函数的典型调用可能如下所示:

ScriptInterpreter interpreter;
// Many other preparations...
Result result;

if (interpreter.executeCommand(commandName, argumentList, result)) {
  // Continue normally...
} else {
  // Handle failed execution of command...
}

我的简单建议是:

不惜一切代价避免输出参数。

输出参数不直观,会导致混乱。调用者有时不容易发现一个被传递的对象是否被当作一个输出参数,并且可能被函数改变。

此外,输出参数使表达式的简单组合变得复杂。如果函数只有一个返回值,它们可以很容易地与链接的函数调用相互连接。相反,如果函数有多个输出参数,开发人员就不得不准备和处理所有保存结果值的变量。因此,调用这些函数的代码会很快变得一团糟。

特别是如果应该培养不变性并且必须减少副作用,那么输出参数绝对是一个可怕的想法。不出所料,仍然无法将一个不可变的对象(见第九章)作为输出参数传递。

如果一个方法应该返回一些东西给它的调用者,让方法返回它作为方法返回值。如果该方法必须返回多个值,请重新设计它,以返回保存这些值的对象的单个实例。或者,可以使用std::tuple(见侧栏)或std::pair

std::tuple AND std::make_tuple [C++11]

从 C++11 开始,有时有用的类模板就可用了,它可以保存固定大小的不同种类值的集合。对象:std::tuple。在标题<tuple>中定义如下:

template< class... Types >

class tuple;

它是一个所谓的可变模板,也就是说,它是一个可以接受可变数量的模板参数的模板。例如,如果您必须将不同类型的几个不同值保存为一个对象,您可以编写以下代码:

using Customer = std::tuple<std::string, std::string, std::string, Money, unsigned int>;
// ...
Customer aCustomer = std::make_tuple("Stephan", "Roth", "Bad Schwartau",
  outstandingBalance, timeForPaymentInDays);

std::make_tuple创建元组对象,从参数类型中推导出目标类型。使用auto关键字,你可以让编译器从初始化器中推断出aCustomer的类型:

auto aCustomer = std::make_tuple("Stephan", "Roth", "Bad Schwartau",
  outstandingBalance, timeForPaymentInDays);

不幸的是,只能通过索引来访问std::tuple实例的单个元素。例如,要从客户处检索城市,您必须编写以下代码:

auto city = std::get<2>(aCustomer);

这是违反直觉的,会降低代码的可读性。

我的建议是只在特殊情况下使用std::tuple类模板。它只应该用来临时组合那些无论如何都不属于一起的东西。一旦数据(属性、对象)必须放在一起,因为它们的内聚性很高,这通常证明了为这一堆数据引入显式类型是正确的:类!

如果你还必须基本区分成功和失败,那么你可以使用所谓的特例对象模式(参见第九章关于设计模式)来返回一个代表无效结果的对象。

不要传递或返回 0 (NULL,nullptr)

The Billion Dollar Mistake

查尔斯·安东尼·理查德·霍尔爵士,通常被称为东尼·霍尔或 C. A. R .霍尔,是英国著名的计算机科学家。他主要以快速排序算法而闻名。1965 年,东尼·霍尔与瑞士计算机科学家尼克劳斯·沃斯一起进一步开发了程序语言 ALGOL。他在编程语言 ALGOL W 中引入了空引用,ALGOL W 是 PASCAL 的前身。

40 多年后,东尼·霍尔后悔了这个决定。在伦敦 QCon 2009 会议的一次演讲中,他说引入空引用可能是一个历史性的十亿美元的错误。他认为,在过去的几个世纪中,空引用已经造成了如此多的问题,其成本可能约为 10 亿美元。

在 C++ 中,指针可以指向NULL或者0。具体来说,这意味着指针指向的内存地址0\. NULL只是一个宏定义:

#define NULL    0

从 C++11 开始,该语言提供了新的关键字nullptr,其类型为std::nullptr_t

有时我会看到这样的函数:

Customer* findCustomerByName(const std::string& name) const {
  // Code that searches the customer by name...
  // ...and if the customer could not be found:
  return nullptr; // ...or NULL;
}

接收NULLnullptr(从这里开始,为了简单起见,我在下面的文本中只使用nullptr)作为一个函数的返回值可能会令人困惑。打电话的人该怎么处理?这是什么意思?在上面的例子中,具有给定名称的客户可能不存在。但这也意味着可能出现了严重错误。一个nullptr可以意味着失败,可以意味着成功,几乎可以意味着任何事情。

我的建议是:

如果函数或方法的结果不可避免地要返回一个常规指针,不要返回nullptr

换句话说:如果你被迫返回一个常规指针作为函数的结果(我们将在后面看到可能有更好的选择),确保你返回的指针总是指向一个有效的地址。以下是我认为这很重要的原因。

为什么不应该从函数返回nullptr的主要理由是,你将决定做什么的责任转移给了调用者。他们必须检查一下。他们不得不面对它。如果函数可能返回nullptr,,这将导致许多空检查,如下所示:

Customer* customer = findCustomerByName("Stephan");

if (customer != nullptr) {
  OrderedProducts* orderedProducts = customer->getAllOrderedProducts();
  if (orderedProducts != nullptr) {
    // Do something with orderedProducts...
  } else {
    // And what should we do here?
  }
} else {
  // And what should we do here?
}

许多空检查降低了代码的可读性,增加了代码的复杂性。还有一个可见的问题直接把我们引向下一点。

如果一个函数可以返回一个有效的指针或nullptr,它会引入一个替代的流程,需要由调用者继续执行。这应该会导致一个合理和明智的反应。这有时很成问题。当我们指向Customer的指针不是指向一个有效的实例,而是指向nullptr时,我们程序中正确、直观的反应是什么?程序应该用一个消息中止正在运行的操作吗?在这种情况下,是否有某种类型的程序延续是强制性的要求?这些问题有时无法很好地回答。经验表明,对于涉众来说,描述他们的软件的所有所谓的快乐日案例通常是相对容易的,这些案例是正常操作期间的正面案例。描述软件在异常、错误和特殊情况下的预期行为要困难得多。

最糟糕的后果可能是这样的:如果任何空检查被遗忘,这可能导致严重的运行时错误。取消对空指针的引用将导致分段错误,并且您的应用程序会崩溃。

在 C++ 中,还有另一个问题需要考虑:对象所有权。

对于函数的调用者来说,在使用后如何处理指针所指向的资源是模糊的。它的主人是谁?是否需要删除对象?如果是:如何处置资源?对象必须用delete删除吗,因为它在函数内部的某个地方用new操作符分配了?还是对资源对象的所有权进行了不同的管理,以至于禁止了一个delete,会导致未定义的行为(参见第五章中的“不允许未定义的行为”一节)?它甚至可能是一种必须以非常特殊的方式处理的操作系统资源吗?

根据信息隐藏原则(参见第三章)这应该与调用者无关,但事实上我们已经将资源的责任强加给他了。如果调用者没有正确处理指针,就会导致严重的错误,例如,内存泄漏、重复删除、未定义的行为,有时还会导致安全漏洞。

避免常规指针的策略

更喜欢在堆栈上而不是堆上构造简单的对象

创建新对象最简单的方法就是在堆栈上创建它,就像这样:

#include "Customer.h"
// ...
Customer customer;

在上面的例子中,类Customer(在头文件Customer.h中定义)的一个实例在堆栈上被创建。创建实例的代码行通常可以在函数或方法体的某个地方找到。这意味着,如果函数或方法超出范围,实例会自动销毁,这发生在我们分别从函数或方法返回时。

目前为止,一切顺利。但是,如果在函数或方法中创建的对象必须返回给调用者,我们该怎么办呢?

在旧式的 C++ 中,这种挑战通常以这样一种方式来处理,即在堆上创建对象(使用操作符new),然后从函数返回,作为指向该分配资源的指针。

Customer* createDefaultCustomer() {
  Customer* customer = new Customer();
  // Do something more with customer, e.g. configuring it, and at the end...
  return customer;
}

这种方法的可理解的原因是,如果我们正在处理一个大的对象,可以通过这种方式避免昂贵的副本构造。但是我们已经在上一节讨论了这种解决方案的缺点。例如,如果返回的指针是nullptr,调用者应该做什么?此外,函数的调用者被迫负责资源管理(例如,以正确的方式删除返回的指针)。

好消息:从 C++11 开始,我们可以简单地将大型对象作为值返回,而不用担心代价高昂的副本构造。

Customer createDefaultCustomer() {
  Customer customer;
  // Do something with customer, and at the end...
  return customer;
}

在这种情况下,我们不再需要担心资源管理的原因是所谓的移动语义,从 C++11 开始就支持这种语义。简单地说,移动语义的概念允许资源从一个对象“移动”到另一个对象,而不是复制它们。术语“移动”在这个上下文中意味着对象的内部数据被从旧的源对象中移除并被放置到新的对象中。它是将数据的所有权从一个对象转移到另一个对象,并且这可以极快地执行(C++11 move 语义将在下一章 5 中详细讨论)。

在 C++11 中,所有的标准库容器类都被扩展到支持移动语义。这不仅使它们非常有效,而且也更容易处理。例如,要以非常高效的方式从函数中返回一个包含字符串的大型向量,您可以像下面的示例所示那样进行操作:

#include <vector>

#include <string>

using StringVector = std::vector<std::string>;

const StringVector::size_type AMOUNT_OF_STRINGS = 10000;

StringVector createLargeVectorOfStrings() {
  StringVector theVector(AMOUNT_OF_STRINGS, "Test");
  return theVector; // Guaranteed no copy construction here!
}

Listing 4-28.Since C++11, a locally instantiated and large object can be easily returned by value

利用移动语义是摆脱大量正则指针的一个非常好的方法。但是我们可以做得更多…

在函数的参数列表中,使用(const)引用而不是指针

而不是写…

void function(Type* argument);

…您应该使用 C++ 引用,就像这样:

void function(Type& argument);

对参数使用引用而不是指针的主要优点是不需要检查引用是否是nullptr。原因很简单,引用从来不是"NULL."(好吧,我知道有一些微妙的可能性,你仍然可以以一个空引用结束,但这些都预示着一个非常愚蠢或业余的编程风格。)

另一个好处是,你不需要借助解引用操作符(*)来解引用函数内部的任何东西。这将导致更干净的代码。引用可以在函数内部使用,因为它是在堆栈上本地创建的。当然,如果您不想有任何副作用,您应该使它成为一个 const 引用(参见即将到来的关于 Const 正确性的部分)。

如果不可避免地要处理指向资源的指针,那么就使用智能指针

如果因为必须在堆上强制创建资源而不可避免地使用指针,您应该立即包装它,并利用所谓的 RAII 习惯用法(资源获取是初始化)。这意味着你应该为它使用一个智能指针。由于智能指针和 RAII 习惯用法在现代 C++ 中扮演着重要的角色,因此在第五章中有一个关于这个主题的专门章节。

如果一个 API 返回一个原始指针…

…,那么,我们就有了一个“视情况而定”的问题

指针经常从或多或少不受我们控制的 API 中返回。典型的例子是第三方库。

幸运的是,我们面对的是一个设计良好的 API,它提供了创建资源的工厂方法,还提供了将资源交还给库进行安全和适当处理的方法,我们赢了。在这种情况下,我们可以再次利用 RAII 习惯用法(资源获取是初始化;参见第五章。我们可以创建一个定制的智能指针来包装常规指针。解除分配程序可以按照第三方库的预期处理托管资源。

一致性的力量

在 C++ 中,Const 正确性是一种更好、更安全代码的强大方法。使用const可以省去很多麻烦和调试时间,因为违反const会导致编译时错误。而作为一种副作用,const的使用也可以支持编译器应用它的一些优化算法。这意味着正确使用这个限定符也是稍微提高程序执行性能的一种简单方法。

不幸的是,大量使用const的好处被许多开发者低估了。我的建议是:

注意 const 的正确性。尽可能多地使用const,并且总是选择变量或对象的适当声明作为可变或不可变的。

一般来说,C++ 中的关键字const防止对象被程序变异。但是const可以用在不同的上下文中。关键字有很多面。

它最简单的用法是将变量定义为常数:

const long double PI = 3.141592653589794;

另一个用途是防止传递给函数的参数发生变异。因为有几种变体,所以经常会导致混淆。以下是一些例子:

  1. 指针car指向类型Car的常量对象,即Car对象(“指针对象”)不能被修改。
  2. 指针car是一个类型为Car的常量指针,也就是说,你可以修改Car对象,但是你不能修改指针(例如,给它分配一个Car的新实例)。
  3. 在这种情况下,指针和指针对象(Car对象)都不能被修改。
  4. 参数message通过 const 引用传递给函数,即被引用的字符串变量不允许在函数内部改变。
  5. 这只是 const 引用参数的另一种表示法。它在功能上等同于第 4 行(…顺便说一下,我更喜欢第 4 行)。
unsigned int determineWeightOfCar(Car const* car); // 1

void lacquerCar(Car* const car); // 2

unsigned int determineWeightOfCar(Car const* const car); // 3

void printMessage(const std::string& message); // 4

void printMessage(std::string const& message); // 5

Tip

以正确的方式阅读const限定符有一个简单的经验法则。如果你从右向左读,那么任何出现的const限定词都会修改它左边的东西。例外:如果左边什么都没有,例如,在一个声明的开头,那么const把它右边的东西修改了。

const关键字的另一个用途是将一个类的(非静态)成员函数声明为const,如第 5 行的例子所示:

#include <string>

class Car {

public:

  std::string getRegistrationCode() const;
  void setRegistrationCode(const std::string& registrationCode);
  // ...

private:

  std::string _registrationCode;
  // ...
};

与第 6 行的 setter 相反,第 5 行的成员函数getRegistrationCode不能修改类Car的成员变量。下面的getRegistrationCode实现将导致编译器错误,因为该函数试图给_registrationCode分配一个新的字符串:

std::string Car::getRegistrationCode() {
  std::string toBeReturned = registrationCode;
  registrationCode = "foo"; // Compile-time error!
  return toBeReturned;
}

关于 C++ 项目中的旧 C 风格

如果你研究一下相对较新的 C++ 程序(例如,在 GitHub 或 Sourceforge 上),你会惊讶于这些所谓的“新”程序中有多少仍然包含无数行旧的 C 代码。嗯,C 仍然是 C++ 语言的一个子集。这意味着 C 的语言元素仍然可用。不幸的是,在编写干净、安全和现代的代码时,许多这些旧的 C 结构都有严重的缺陷。显然还有更好的选择。

因此,一个基本的建议是,只要有更好的 C++ 替代方案,就不要使用那些旧的、容易出错的 C 结构。这些可能性有很多。如今,在现代 C++ 中,你几乎可以完全不用 C 编程。

比起旧的 C 风格的字符,更喜欢 C++ 字符串和流*

所谓的 C++ 字符串是 C++ 标准库的一部分,属于类型std::stringstd::wstring(都在头文件<string>中定义)。事实上,两者都是类模板std::basic_string<T>上的类型定义,并且是这样定义的:

typedef basic_string<char> string;

typedef basic_string<wchar_t> wstring;

要创建这样的字符串,必须实例化这两个模板之一的对象,例如,使用它们的初始化构造函数:

std::string name("Stephan");

与此相比,所谓的 C 风格字符串只是一个以所谓的零终止符(有时也称为空终止符)结尾的字符数组(类型charwchar_t)。零终止符是一个特殊字符(\0,ASCII 码 0),用来表示字符串的结束。C 风格的字符串可以这样定义:

char name[] = "Stephan";

在这种情况下,零终止符会自动添加到字符串的末尾,即字符串的长度为 8 个字符。重要的一点是,我们必须记住,我们仍然在处理一组字符。这意味着,例如,它有一个固定的大小。您可以使用 index 运算符更改数组的内容,但是不能在数组末尾添加更多的字符。如果结尾的零终止符被意外覆盖,这可能会导致各种问题。

字符数组通常在指向第一个元素的指针的帮助下使用,例如,当它作为函数参数传递时:

char* pointerToName = name;

void function(char* pointerToCharacterArray) {
  //...
}

然而,在许多 C++ 程序以及教科书中,C-string 仍然被频繁使用。现在的 C++ 有什么好的理由使用 C 风格的字符串吗?

是的,有些情况下你仍然可以使用 C 风格的字符串。我将介绍一些例外情况。但是对于现代 C++ 程序中应用的大量字符串,它们应该使用 C++ 字符串来实现。与旧的 C 风格字符串相比,类型分别为std::stringstd::wstring的对象提供了许多优势:

  • C++ 字符串对象自己管理它们的内存,所以你可以很容易地复制、创建和销毁它们。这意味着它们将您从管理字符串数据的生命周期中解放出来,使用 C 风格的字符数组时,这可能是一项棘手且令人生畏的任务。
  • 它们是可变的。可以通过各种方式轻松操作字符串:添加字符串或单个字符、连接字符串、替换字符串的一部分等。
  • C++ 字符串提供了一个方便的迭代器接口。与所有其他标准库容器类型一样,std::stringstd::wstring分别允许您迭代它们的元素(即,它们的字符)。这也意味着在标题<algorithm>中定义的所有合适的算法都可以应用于字符串。
  • C++ 字符串与 C++ I/O 流(例如,ostreamstringstreamfstream等)完美地协同工作。)以便您可以轻松利用所有这些有用的流工具。
  • 从 C++11 开始,标准库广泛使用 move 语义。许多算法和容器现在都是移动优化的。这也适用于 C++ 字符串。例如,std::string的一个实例通常可以简单地作为函数的返回值返回。以前仍然需要使用指针或引用的方法来有效地从函数返回大型字符串对象,也就是说,不需要昂贵的字符串数据复制,现在已经不再需要了。

总之,可以提出以下建议:

除了少数例外,现代 C++ 程序中的字符串应该用来自标准库的 C++ 字符串来表示。

好吧,但是证明使用旧的 C 风格字符串是正确的少数例外是什么呢?

一方面,有字符串常量,即不可变的字符串。如果你只是需要一个固定字符的固定数组,那么std::string提供的优势很小。例如,您可以这样定义一个字符串常量:

const char* const PUBLISHER = "Apress Media LLC";

在这种情况下,既不能改变所指向的值,也不能修改指针本身(参见关于常量正确性的部分)。

使用 C 字符串的另一个原因是与 C 风格 API 各自的库兼容。许多第三方库通常具有低级接口,以确保向后兼容性,并保持其使用范围尽可能广泛。在这样的 API 中,字符串通常被认为是 C 风格的字符串。然而,即使在这种情况下,C 风格字符串的使用也应该局限于这个接口的处理。除了使用这样的 API 进行数据交换,只要有可能,就应该使用更加舒适的 C++ 字符串。

避免使用 printf()、sprintf()、gets()等。

printf(),它是 C 库的一部分,用于执行输入/输出操作(在头文件<cstdio>中定义),将格式化数据打印到标准输出(stdout)。一些开发人员仍然在他们的 C++ 代码中使用大量的printf来进行跟踪/记录。他们经常争辩说printf是…不…它一定比 C++ I/O 流快得多,因为整个 C++ 开销都不见了。

首先,无论如何,I/O 都是一个瓶颈,不管你使用的是printf()还是std::cout。在标准输出上写任何东西通常都很慢,比程序中的大多数其他操作都慢。在某些情况下,std::cout可能比printf()稍慢,但是相对于 I/O 操作的总成本来说,这几微秒通常可以忽略不计。在这一点上,我还想提醒大家小心(过早的)优化(记得第三章中的“小心优化”一节)。

其次,printf()从根本上说是类型不安全的,因此容易出错。该函数需要一系列非类型化参数,这些参数与填充了格式说明符的 C 字符串相关,这是第一个参数。不能安全使用的函数永远不要使用,因为这会导致细微的错误、未定义的行为(参见第五章中关于未定义行为的章节)和安全漏洞。

std::to_String() [C++11]

在现代 C++ 程序中,不要使用 C 函数sprintf()(头文件<cstdio>)进行转换。从 C++11 开始,所有数值类型的变量都可以通过安全方便的std::to_string()std::to_wstring()函数转换成 C++ 字符串,这两个函数在头文件<string>中定义。例如,可以通过以下方式将带符号整数转换为包含值的文本表示的std::string:

int value { 42 };
std::string valueAsString = std::to_string(value);

std::to_string()std::to_wstring()分别适用于所有整型或浮点型,如intlonglong longunsigned intfloatdouble等。但是这个简单的转换助手的一个主要缺点是在某些情况下不准确。

double d { 1e-9 };
std::cout << std::to_string(d) << "\n"; // Caution! Output: 0.000000

此外,没有配置功能来控制to_string()如何格式化输出字符串,例如,小数位数。这意味着这个函数实际上只能在真实程序中很小程度上使用。如果需要更精准、定制化的转换,就得自己提供。除了使用sprintf(),你还可以利用字符串流(头文件<sstream>)和头文件<iomanip>中定义的 I/O 操作器的配置功能,如下例所示:

#include <iomanip>

#include <sstream>

#include <string>

std::string convertDoubleToString(const long double valueToConvert, const int precision) {
  std::stringstream stream { };
  stream << std::fixed << std::setprecision(precision) << valueToConvert;
  return stream.str();
}

第三,与printf不同,C++ I/O 流允许通过提供一个定制的插入操作符(operator<<)来轻松地对复杂对象进行流处理。假设我们有一个类Invoice(在名为Invoice.h的头文件中定义),如下所示:

01  #ifndef INVOICE_H_
02  #define INVOICE_H_
03
04  #include <chrono>
05  #include <memory>
06  #include <ostream>
07  #include <string>
08  #include <vector>
09
10  #include "Customer.h"
11  #include "InvoiceLineItem.h"
12  #include "Money.h"
13  #include "UniqueIdentifier.h"
14
15  using InvoiceLineItemPtr = std::shared_ptr<InvoiceLineItem>;
16  using InvoiceLineItems = std::vector<InvoiceLineItemPtr>;
17
18  using InvoiceRecipient = Customer;
19  using InvoiceRecipientPtr = std::shared_ptr<InvoiceRecipient>;
20
21  using DateTime = std::chrono::system_clock::time_point;
22
23  class Invoice {
24  public:

25    explicit Invoice(const UniqueIdentifier& invoiceNumber);
26    Invoice() = delete;
27    void setRecipient(const InvoiceRecipientPtr& recipient);
28    void setDateTimeOfInvoicing(const DateTime& dateTimeOfInvoicing);
29    Money getSum() const;
30    Money getSumWithoutTax() const;
31    void addLineItem(const InvoiceLineItemPtr& lineItem);
32    // ...possibly more member functions here...
33
34  private:

35    friend std::ostream& operator<<(std::ostream& outstream, const Invoice& invoice);
36    std::string getDateTimeOfInvoicingAsString() const;
37
38    UniqueIdentifier invoiceNumber;
39    DateTime dateTimeOfInvoicing;
40    InvoiceRecipientPtr recipient;
41    InvoiceLineItems invoiceLineItems;
42  };
43  // ...
Listing 4-29.An excerpt from file Invoice.h with line numbers

该类依赖于一个发票接受者(在本例中,它是在标题Customer.h中定义的Customer的别名);见第 18 行),并使用代表发票号的标识符(类型UniqueIdentifier),保证该发票号在所有发票号中是唯一的。此外,发票使用了一种可以表示金额的数据类型(参见第九章关于设计模式的“货币类”一节),以及对另一种表示单个发票行项目的数据类型的依赖。后者用于使用std::vector管理发票内的发票项目列表(分别参见第 16 行和第 41 行)。为了表示开具发票的时间,我们使用了 Chrono 库中的数据类型time_point(在头文件<chrono>中定义),这种数据类型从 C++11 开始就可用了。

现在让我们进一步想象,我们希望将整个发票及其所有数据流式传输到标准输出。如果我们可以写一些像…这样的东西,不是很简单和方便吗

std::cout << instanceOfInvoice;

嗯,用 C++ 这是可能的。输出流的插入操作符(<<)可以为任何类重载。我们只需在头部的类声明中添加一个operator<<函数。让这个函数成为类的朋友是很重要的(见第 35 行),因为它将在不创建对象的情况下被调用。

43  // ...
44  std::ostream& operator<<(std::ostream& outstream, const Invoice& invoice) {
45    outstream << "Invoice No.: " << invoice.invoiceNumber << "\n";
46    outstream << "Recipient: " << *(invoice.recipient) << "\n";
47    outstream << "Date/time: " << invoice.getDateTimeOfInvoicingAsString() << "\n";
48    outstream << "Items:" << "\n";
49    for (const auto& item : invoice.invoiceLineItems) {
50      outstream << "    " << *item << "\n";
51    }
52    outstream << "Amount invoiced: " << invoice.getSum() << std::endl;
53    return outstream;
54  }
55  // ...
Listing 4-30.The insertion operator for class Invoice

Invoice的所有结构组件都被写入函数内部的输出流中。这是可能的,因为类UniqueIdentifierInvoiceRecipientInvoiceLineItem对于输出流也有它们自己的插入操作符函数(这里没有显示)。为了打印 vector 中的所有行项目,使用了一个基于 C++11 范围的 for 循环。为了获得发票日期的文本表示,我们使用一个名为getDateTimeOfInvoicingAsString()的内部助手方法,它返回一个格式良好的日期/时间字符串。

所以,我对现代 C++ 程序的建议是:

避免使用printf(),以及其他不安全的 C 函数,如sprintf()puts()等。

比起简单的 C 风格数组,更喜欢标准的库容器

不要使用 C 风格的数组,应该使用从 C++11 开始可用的std::array<TYPE, N>模板(header <array>)。std::array<TYPE, N>的实例是固定大小的序列容器,和普通的 C 风格数组一样有效。

C 风格数组的问题与 C 风格字符串的问题大致相同(参见上一节)。c 数组是不好的,因为它们是作为指向第一个元素的原始指针传递的。这可能很危险,因为没有绑定检查来保护该数组的用户访问不存在的元素。用std::array构建的数组更安全,因为它们不会衰减为指针(参见本章前面的“避免常规指针的策略”一节)。

使用std::array的一个优点是它知道自己的大小(元素的数量)。使用数组时,数组的大小是经常需要的重要信息。普通的 C 风格数组不知道自己的大小。因此,数组的大小通常必须作为附加信息来处理,例如,在附加变量中。例如,大小必须作为附加参数传递给函数调用,如下例所示。

const std::size_t arraySize = 10;
MyArrayType array[arraySize];

void function(MyArrayType const* array, const std::size_t arraySize) {
  // ...
}

严格地说,在这种情况下数组和它的大小并没有形成一个内聚的单元(参见第三章中关于强内聚的部分)。此外,从上一节关于参数和返回值的内容中,我们已经知道函数参数的数量应该尽可能少。

相反,std::array的实例携带它们的大小,任何实例都可以被查询。因此,函数或方法的参数列表不需要关于数组大小的额外参数:

#include <array>

using MyTypeArray = std::array<MyType, 10>;

void function(const MyTypeArray& array) {
  const std::size_t arraySize = array.size();
  //...
}

std::array另一个值得注意的优点是它有一个兼容 STL 的接口。类模板提供了公共成员函数,所以它看起来像标准库中的其他容器。例如,一个数组的用户可以使用std::array::begin()std::array::end()分别得到一个指向序列开始和结束的迭代器。这也意味着标题<algorithm>中的算法可以应用到数组中(参见下一章中关于算法的章节)。

#include <array>

#include <algorithm>

using MyTypeArray = std::array<MyType, 10>;
MyTypeArray array;

void doSomethingWithEachElement(const MyType& element) {
  // ...
}

std::for_each(std::cbegin(array), std::cend(array), doSomethingWithEachElement);

Non-Member std::begin( ) and std::end( ) [C++11/14]

每个 C++ 标准库容器分别有一个begin()``cbegin()和一个end()``cend()成员函数来检索容器的迭代器和常量迭代器。

C++11 为此引入了免费的非成员函数:std::begin(<container>)std::end(<container>).在 C++14 中,添加了仍然缺失的函数std::cbegin(<container>)std::cend(<container>)std::rbegin(<container>)std::rend(<container>)。现在建议不要使用成员函数,而是使用这些非成员函数(都在 header <iterator>中定义)来获取容器的迭代器和常量迭代器,如下所示:

#include <vector>

std::vector<AnyType> aVector;

auto iter = std::begin(aVector); // ...instead of 'auto iter = aVector.begin();'

原因是这些免费函数允许更灵活和通用的编程风格。例如,许多用户定义的容器没有begin()end()成员函数,这使得它们无法用于标准库算法(参见第五章中关于算法的部分)或任何其他需要迭代器的用户定义的模板函数。检索迭代器的非成员函数是可扩展的,因为它们可以为任何类型的序列重载,包括旧的 C 风格的数组。换句话说:非 STL 兼容的(自定义)容器可以用迭代器功能进行改进。

例如,假设您必须处理一个 C 风格的整数数组,如下所示:

int fibonacci[] = { 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144 };

这种类型的数组现在可以用一个标准的符合库的迭代器接口来改进。对于 C 风格的数组,标准库中已经提供了这样的函数,所以您不必自己编程。它们看起来或多或少像这样:

template <typename Type, std::size_t size>
Type* begin(Type (&element)[size]) {
    return &element[0];
}

template <typename Type, std::size_t size>
Type* end(Type (&element)[size]) {
    return &element[0] + size;
}

要将数组的所有元素插入到输出流中,例如,要在标准输出中打印它们,我们现在可以编写:

int main() {
  for (auto it = begin(fibonacci); it != end(fibonacci); ++it) {
    std::cout << *it << ", ";
  }
  std::cout << std::endl;
  return 0;
}

为定制容器类型或旧的 C 风格数组提供重载的begin()end()函数,可以将所有标准库算法应用于这些类型。

此外,std::array可以在成员函数std::array::at(size_type n)的帮助下访问包括绑定检查在内的元素。如果给定的索引超出界限,就会抛出类型为std::out_of_bounds的异常。

使用 C++ 强制转换代替旧的 C 风格强制转换

在一个错误的印象出现之前,我想首先提出一个重要的警告:

Warning

类型转换基本上是不好的,应该尽可能避免!它们是一个可信的迹象,表明一定存在设计问题,尽管只是相对微小的问题。

但是,如果在某种情况下无法避免类型强制转换,那么在任何情况下都不应该使用 C 样式强制转换:

double d { 3.1415 };

int i = (int)d;

在这种情况下,double 被降级为整数。这种显式转换伴随着精度的损失,因为浮点数的小数位被丢弃了。C 风格类型的显式转换是这样说的:“编写这行代码的程序员知道后果。”

这当然比隐式类型转换要好。然而,您应该使用 C++ 强制转换进行显式类型转换,而不是使用旧的 C 风格强制转换,如下所示:

int i = static_cast<int>(d);

对这个建议的简单解释是:C++ 风格的强制转换是由编译器在编译时检查的!c 风格的类型转换不是这样检查的,因此它们可能会在运行时失败,这可能会导致严重的错误或应用程序崩溃。例如,一个临时使用的 C-style 强制转换可能会导致堆栈损坏,如下例所示。

int32_t i { 200 };                  // Reserves and uses 4 byte memory
int64_t* pointerToI = (int64_t*)&i; // Pointer points to 8 byte

*pointerToI = 9223372036854775807;  // Can cause run-time error through stack corruption

显然,在这种情况下,可以将一个 64 位的值写入一个只有 32 位大小的内存区域。问题是编译器无法将我们的注意力吸引到这段具有潜在危险的代码上。编译器翻译这段代码,即使使用非常保守的设置(g++ -std=c++17 -pedantic -pedantic-errors -Wall -Wextra -Werror -Wconversion),也没有抱怨。这可能会在程序执行过程中导致非常隐蔽的错误。

现在让我们看看如果我们在第二行使用 C++ static_cast而不是旧的和糟糕的 C 风格类型转换会发生什么:

int64_t* pointerToI = static_cast<int64_t*>(&i); // Pointer points to 8 byte

编译器现在能够发现有问题的转换,并报告相应的错误消息:

error: invalid static_cast from type 'int32_t* {aka int*}' to type 'int64_t* {aka long int*}'

您应该使用 C++ 强制转换而不是旧的 C 风格强制转换的另一个原因是,在程序中很难发现 C 风格的强制转换。它们既不容易被开发人员发现,也不能使用普通编辑器或文字处理器方便地搜索。相比之下,搜索诸如static_cast<>const_cast<>dynamic_cast<>这样的术语非常容易。

一目了然,以下是关于现代设计良好的 C++ 程序的类型转换的所有建议:

  1. 在任何情况下都要尽量避免类型转换(强制转换)。相反,请尝试消除迫使您使用转换的底层设计错误。
  2. 如果显式类型转换不可避免,请只使用 C++ 风格的强制转换(static_cast<>const_cast<>),因为这些强制转换会被编译器检查。永远不要使用旧的和不好的 C 风格造型。
  3. 注意dynamic_cast<>也不应该被使用,因为它被认为是糟糕的设计。对一个dynamic_cast<>的需求是一个可靠的指示,表明专门化层次结构中有问题(这个主题将在关于面向对象的第六章中更深入)。
  4. 在任何情况下,都不要使用reinterpret_cast<>。这种类型转换标志着不安全、不可移植和依赖于实现的强制转换。它又长又不方便的名字是一个广泛的提示,让你思考你目前正在做什么。

避免宏

也许 C 语言最重要的继承之一就是宏。宏是一段可以通过名称识别的代码。如果所谓的预处理器在编译时在程序的源代码中找到一个宏的名称,该名称将被其相关的代码片段替换。

一种类型的宏是类似对象的宏,通常用于为数字常量提供符号名称,如下例所示。

#define BUFFER_SIZE 1024

#define PI 3.14159265358979
Listing 4-31.Two examples of object-like macros

宏的其他典型示例如下:

#define MIN(a,b) (((a)<(b))?(a):(b))

#define MAX(a,b) (((a)>(b))?(a):(b))
Listing 4-32.Two examples of function-like macros

分别。MAX比较两个值并分别返回较小和较大的值。这种宏被称为类函数宏。虽然这些宏看起来很像函数,但它们不是。C 预处理器只是用相关的代码片段替换名称(实际上,这是一个文本查找和替换操作)。

宏有潜在的危险。它们通常表现得不像预期的那样,并且可能具有不希望的副作用。例如,让我们假设您已经定义了这样一个宏:

#define DANGEROUS 1024+1024

在你代码的某个地方,你写了这个:

int value = DANGEROUS * 2;

可能有人期望变量value包含 4096,但实际上应该是 3072。记住数学运算的顺序,它告诉我们从左到右,除法和乘法应该先发生。

另一个因使用宏而产生意外副作用的例子是以如下方式使用“MAX ”:

int maximum = MAX(12, value++);

预处理器将生成以下内容:

int maximum = (((12)>(value++))?(12):(value++));

现在很容易看出,value上的后增量操作将执行两次。这肯定不是编写上述代码的开发人员的意图。

不要再用宏了!至少从 C++11 开始,它们几乎已经过时了。除了极少数例外,宏不再是必需的,也不应该在现代 C++ 程序中使用。也许引入所谓的反射(即程序可以在运行时检查、自省和修改自己的结构和行为的能力)作为未来 C++ 标准的一部分,可以帮助完全摆脱宏。但是在时机到来之前,宏目前仍然需要用于一些特殊的目的,例如,当使用单元测试或日志框架时。

不要使用类似对象的宏,而是使用常量表达式来定义常量:

constexpr int HARMLESS = 1024 + 1024;

代替类似函数的宏,简单地使用真正的函数,例如在标题<algorithm>中定义的函数模板std::minstd::max(参见下一章中关于<algorithm>标题的部分):

#include <algorithm>
// ...

int maximum = std::max(12, value++);

五、现代 C++ 的高级概念

在第 3 和 4 章中,我们讨论了为干净现代的 C++ 代码建立坚实基础的基本原则和实践。记住这些原则和规则,开发人员可以提高软件项目的内部 C++ 代码质量,因此,通常可以显著提高其外部质量。代码变得更容易理解,更容易维护,更容易扩展,更不容易出现错误,这对任何软件设计师来说都是一种更好的生活,因为使用这样一个可靠的代码库会更有趣。在第二章中,我们还了解到,最重要的是,一套维护良好、制作精良的单元测试可以进一步提高软件的质量和开发效率。

但是我们能做得更好吗?当然可以。

正如我在这本书的介绍中已经解释过的,在过去的几年中,优秀的老恐龙 C++ 经历了一些相当大的改进。语言标准 C++11(ISO/IEC 14882:2011 的缩写),以及随后的标准 C++14(它只是 c++ 11 的一个小扩展),以及最新版本的 C++17(它已经在 2017 年 6 月进入了最终的 ISO 投票过程),已经在已经有些尘土飞扬的编程语言中形成了一个现代、灵活、高效的开发工具。通过这些标准引入的一些新概念,如移动语义,实际上是一种范式转变。

我已经在前几章中使用了这些 C++ 标准的一些特性,并在侧栏中做了大部分解释。现在是时候更深入地研究其中的一些,并探索它们如何支持我们编写非常可靠和现代的 C++ 代码。当然,在这里不可能完全讨论较新的 C++ 标准的所有语言特性。这将远远超出本书的范围,且不说这一点已被许多其他书籍所涵盖。因此,我选择了几个我认为非常支持编写干净的 C++ 代码的主题。

管理资源

管理资源是软件开发人员的基本工作。大量各种各样的资源必须定期分配、使用,并在使用后归还。其中包括以下内容:

  • 内存(在堆栈上或堆上);
  • 访问硬盘或其他媒体上的文件(读/写)所需的文件句柄;
  • 网络连接(例如,到服务器、数据库等。);
  • 线程、锁、定时器和事务;
  • 其他操作系统资源,如 Windows 操作系统上的 GDI 句柄。(缩写 GDI 代表图形设备接口。GDI 是微软 Windows 的核心操作系统组件,负责表示图形对象。)

正确处理资源可能是一项棘手的任务。考虑以下示例:

void doSomething() {
  ResourceType* resource = new ResourceType();
  try {
    // ...do something with resource...
    resource->foo();
  } catch (...) {
    delete resource;
    throw;
  }
  delete resource;
}
Listing 5-1.Dealing with a resource that was allocated on the heap

这里有什么问题?也许你已经注意到了两个相同的陈述。包罗万象的异常处理机制在我们的程序中至少引入了两条可能的路径。这也意味着我们必须确保在两个地方释放资源。在正常情况下,这种无所不包的异常处理程序是不受欢迎的。但是在这种情况下,我们除了在这里捕获所有可能发生的异常之外没有其他机会,因为我们必须首先释放资源,然后再抛出异常对象,在其他地方处理它(例如,在函数的调用位置)。

在这个简化的例子中,我们只有两条路径。在真实的程序中,可以存在更多的执行路径。一个delete被遗忘的概率要高得多。任何遗忘的delete都会导致危险的资源泄漏。

Warning

不要低估资源泄漏!资源泄漏是一个严重的问题,特别是对于长寿命的进程,以及快速分配许多资源而在使用后不释放它们的进程。如果一个操作系统缺乏资源,这可能会导致严重的系统状态。此外,资源泄漏可能是一个安全问题,因为攻击者可以利用它们进行拒绝服务攻击。

对于上面的小示例,最简单的解决方案是在堆栈上分配资源,而不是在堆上分配:

void doSomething() {
  ResourceType resource;

  // ...do something with resource...
  resource.foo();

}

Listing 5-2.Much easier: Dealing with a resource on the stack

通过这一更改,资源在任何情况下都会被安全删除。但有时不可能分配堆栈上的所有内容,正如我们在第四章中的“不要传递或返回 0 (NULL,nullptr)”一节中已经讨论过的。文件句柄、操作系统资源等等呢??

核心问题是:我们如何保证分配的资源总是被释放?

资源获取是初始化(RAII)

资源获取是初始化(RAII)是一种习惯用法(见第九章关于习惯用法),可以帮助以安全的方式处理资源。这个习惯用法也被称为构造器获取、析构器释放(骨干)和基于范围的资源管理(SBRM)。

RAII 通过构造函数和相应的析构函数来利用类的对称性。我们可以在类的构造函数中分配资源,也可以在析构函数中释放它。如果我们创建这样的类作为模板,它可以用于不同类型的资源。

template <typename RESTYPE>

class ScopedResource final {

public:
  ScopedResource() { managedResource = new RESTYPE(); }
  ∼ScopedResource() { delete managedResource; }

  RESTYPE* operator->() const { return managedResource; }

private:
  RESTYPE* managedResource;
};

Listing 5-3.A very simple class template that can manage several types

of resources

现在我们可以如下使用类模板ScopedResource:

#include "ScopedResource.h"

#include "ResourceType.h"

void doSomething() {

  ScopedResource<ResourceType> resource;

  try {
    // ...do something with resource...
    resource->foo();
  } catch (...) {
    throw;
  }
}

Listing 5-4.Using ScopedResource to manage an instance

of ResourceType

很容易看出,不需要newdelete。如果resource超出范围,这可能发生在这个方法的不同点,类型ResourceType的包装实例通过ScopedResource的析构函数被自动删除。

但是通常没有必要重新发明轮子和实现这样的包装器,它也被称为智能指针。

智能指针

从 C++11 开始,标准库提供了不同的、高效的智能指针实现,以方便使用。这些指针在被引入 C++ 标准之前,已经在著名的 Boost library 项目中开发了很长一段时间,可以认为是非常简单的。智能指针降低了内存泄漏的可能性。此外,它们被设计成线程安全的。

本节提供了一个简要概述。

具有 std::unique_ptr 的唯一所有权

类模板std:: unique_ptr<T>(在头文件<memory>中定义)管理一个指向T类型对象的指针。顾名思义,这种智能指针提供了唯一的所有权,即一个对象一次只能由std::unique_ptr<T>的一个实例拥有,这是std::shared_ptr<T>的主要区别,下面会解释。这也意味着不允许复制构造和复制赋值。

它的用法非常简单:

#include <memory>

class ResourceType {
  //...
};

//...
std::unique_ptr<ResourceType> resource1 { std::make_unique<ResourceType>() };
// ... or shorter with type deduction ...

auto resource2 { std::make_unique<ResourceType>() };

在这个构造之后,resource可以像指向ResourceType实例的常规指针一样使用。(std::make_unique<T>在下文“避免新增和删除”一节中解释)。例如,您可以使用*->操作符进行解引用:

resource->foo();

当然,如果resource超出了范围,包含的类型ResourceType的实例将被安全释放。但最棒的是,resource可以很容易地放进集装箱,例如,装在std::vector里:

#include "ResourceType.h"

#include <memory>

#include <vector>

using ResourceTypePtr = std::unique_ptr<ResourceType>;

using ResourceVector = std::vector<ResourceTypePtr>;

//...

ResourceTypePtr resource { std::make_unique<ResourceType>() };
ResourceVector aCollectionOfResources;
aCollectionOfResources.push_back(std::move(resource));
// IMPORTANT: At this point, the instance of 'resource' is empty!

请注意,我们确保std::vector::push_back()分别调用了std::unique_ptr<T>的 move 构造函数 move 赋值操作符(见下一章关于 move 语义的部分)。因此,resource不再管理一个对象,并被标记为空。

Caution

不要再在你的代码中使用std::auto_ptr<T>!随着 C++11 标准的发布,std::auto_ptr<T>已经被标记为“不推荐使用”,应该不再使用。在最新的标准 C++17 中,这个智能指针类模板现在终于从语言中删除了!这个智能指针的实现不支持右值引用和移动语义(参见本章后面关于移动语义的部分),并且不能存储在标准库容器中。std::unique_ptr<T>是合适的替代者。

如前所述,不允许复制构造std:: unique_ptr<T>。然而,托管资源的独占所有权可以通过使用移动语义(我们将在后面的章节中详细讨论移动语义)以如下方式转移到std::unique_ptr<T>的另一个实例:

std::unique_ptr<ResourceType> pointer1 = std::make_unique<ResourceType>();
std::unique_ptr<ResourceType> pointer2; // pointer2 owns nothing yet

pointer2 = std::move(pointer1); // Now pointer1 is empty, pointer2 is the new owner

与 std::shared_ptr 共享所有权

类模板std::shared_ptr<T>(在头文件<memory>中定义)的实例可以获得T类型资源的所有权,并且可以与std::shared_ptr<T>的其他实例共享该所有权。换句话说,类型T的单个实例的所有权,以及删除它的责任,可以由许多共享所有者接管。

提供类似简单有限的垃圾收集器功能。智能指针的实现有一个引用计数器,它监视拥有共享对象的指针实例的数量。如果指针的最后一个实例被销毁,它将释放托管资源。

图 5-1 显示了一个 UML 对象图,描述了一个运行系统中的情况,其中三个实例(client1client2client3)使用三个智能指针实例共享同一个资源(:Resource)。

A429836_1_En_5_Fig1_HTML.jpg

图 5-1。

An object diagram depicting how three clients are sharing one resource through smart pointers

与之前讨论的std::unique_ptr<T>相比,std::shared_ptr<T>当然可以像预期的那样进行复制构建。但是您可以通过使用std::move<T>来强制移动被管理的资源:

std::shared_ptr<ResourceType> pointer1 = std::make_shared<ResourceType>();
std::shared_ptr<ResourceType> pointer2;

pointer2 = std::move(pointer1); // The reference count does not get modified, pointer1 is empty

在这种情况下,引用计数器不会被修改,但是在移动后使用变量pointer1时必须小心,因为它是空的,也就是说,它保存了一个nullptr。移动语义和实用函数std::move<T>将在后面的章节中讨论。

没有所有权,但使用 std::weak_ptr 进行安全访问

有时,有必要拥有一个非所有指针,指向一个或多个共享指针所拥有的资源。一开始你可能会说,“好吧,但是有什么问题呢?我只需通过调用get()的成员函数,随时从std::shared_ptr<T>的实例中获取原始指针。

std::shared_ptr<ResourceType> resource = std::make_shared<ResourceType>();
// ...
ResourceType* rawPointerToResource = resource.get();
Listing 5-5.Retrieving the regular pointer from an instance of std::shared_ptr<T>

小心脚下!这可能很危险。如果std::shared_ptr<ResourceType>的最后一个实例在你程序的某个地方被销毁,而这个原始指针仍然在某个地方被使用,会发生什么?原始指针将指向无人区,使用它会导致严重的问题(记得我在前一章中对未定义行为的警告)。您绝对没有机会确定原始指针是指向资源的有效地址,还是指向内存中的任意位置。

如果你需要一个指向资源的指针而没有所有权,你应该使用std::weak_ptr<T>(在头文件<memory>中定义),这对资源的生命周期没有影响。std::weak_ptr<T>仅仅“观察”被管理的资源,并且可以询问它是否有效。

01  #include <memory>
02
03  void doSomething(const std::weak_ptr<ResourceType>& weakResource) {
04    if (! weakResource.expired()) {
05      // Now we know that weakResource contains a pointer to a valid object
06      std::shared_ptr<ResourceType> sharedResource = weakResource.lock();
07      // Use sharedResource...
08    }
09  }
10
11  int main() {
12    auto sharedResource(std::make_shared<ResourceType>());
13    std::weak_ptr<ResourceType> weakResource(sharedResource);
14
15    doSomething(weakResource);
16    sharedResource.reset(); // Deletes the managed instance of ResourceType
17    doSomething(weakResource);
18
19    return 0;
20  }
Listing 5-6.Using std::weak_ptr<T> to deal with resources that are not owned

正如您在上面代码示例的第 4 行所看到的,如果弱指针对象管理一个有效的资源,我们可以询问它。这是通过调用其成员函数expired()来完成的。std::weak_ptr<T>不提供解引用操作符,像*,或者->。如果我们想使用这个资源,我们首先必须调用lock()函数(见第 6 行)来从中获得一个共享指针对象。

你现在可能会问自己,这种智能指针类型的用例是什么。为什么它是必要的,因为我也可以在任何需要资源的地方使用std::shared_ptr<T>

首先,通过std::shared_ptr<T>std::weak_ptr<T>,你能够在软件设计中区分资源的所有者和资源的使用者。并不是每一个只为某个特定的和有时间限制的任务而需要资源的软件单元都想成为它的所有者。正如我们在上面的例子中的函数doSomething()中看到的,有时仅仅在有限的时间内将弱指针“提升”为强指针就足够了。

一个很好的例子就是对象缓存,为了提高性能,它将最近访问过的对象在内存中保存一段时间。缓存中的对象保存在std::shared_ptr<T>实例中,以及最后使用的时间戳。一种垃圾收集器进程定期运行,它扫描缓存并决定销毁那些在定义的时间跨度内没有使用的对象。

在那些使用缓存对象的地方,std::weak_ptr<T>的实例被用来保存指向这些对象的不拥有的指针。如果那些std::weak_ptr<T>实例的expired()成员函数返回 true,垃圾收集器进程已经从缓存中清除了对象。在另一种情况下,std::weak_ptr<T>::lock()函数可用于从中检索一个std::shared_ptr<T>。现在,即使垃圾收集器进程处于活动状态,也可以安全地使用该对象。要么该过程评估std::shared_ptr<T>的使用计数器,并确定该对象当前至少有一个用户在缓存之外。结果,对象的寿命被延长。或者进程从缓存中删除对象,这不会影响其用户。

另一个例子是处理循环依赖。例如,如果你有一个类A需要一个指向另一个类B的指针,反之亦然,你将会以一个循环依赖结束。如果您使用std::shared_ptr<T>来指向相应的另一个类,如下面的代码示例所示,您可能会导致内存泄漏。这样做的原因是在各自的共享指针实例中的使用计数器永远不会倒计数到 0。因此,这些对象永远不会被删除。

#include <memory>

class B; // Forward declaration

class A {

public:
  void setB(std::shared_ptr<B>& pointerToB) {
    myPointerToB = pointerToB;
  }

private:
  std::shared_ptr<B> myPointerToB;
};

class B {

public:
  void setA(std::shared_ptr<A>& pointerToA) {
    myPointerToA = pointerToA;
  }

private:
  std::shared_ptr<A> myPointerToA;
};

int main() {
  { // Curly braces build a scope
    auto pointerToA = std::make_shared<A>();
    auto pointerToB = std::make_shared<B>();
    pointerToA->setB(pointerToB);
    pointerToB->setA(pointerToA);
  }
  // At this point, respectively one instance of A and B is "lost in space" (memory leak)

  return 0;
}

Listing 5-7.The problem with circular dependencies caused through a thoughtless use of std::shared_ptr<T>

如果类中的std::shared_ptr<T>成员变量被指向相应的其他类的不拥有的弱指针(std::weak_ptr<T>)替换,那么内存泄漏的问题就解决了。

class B; // Forward declaration

class A {

public:
  void setB(std::shared_ptr<B>& pointerToB) {
    myPointerToB = pointerToB;
  }

private:
  std::weak_ptr<B> myPointerToB;
};

class B {

public:
  void setA(std::shared_ptr<A>& pointerToA) {
    myPointerToA = pointerToA;
  }

private:
  std::weak_ptr<A> myPointerToA;
};
// ...

Listing 5-8.Circular dependencies implemented in the right way with std::weak_ptr<T>

基本上,循环依赖在应用程序代码中是糟糕的设计,应该尽可能避免。在低级库中可能会有一些例外,循环依赖不会导致严重的问题。但是除此之外,你应该遵循在第六章的专门章节中讨论的非循环依赖原则。

避免显式新建和删除

在现代 C++ 程序中,当编写应用程序代码时,应该避免显式调用newdelete。为什么呢?简单而简短的解释是:newdelete增加了复杂性。

更详细的回答是这样的:每次当不可避免地调用newdelete时,人们都必须处理一个例外的、非违约的情况,一个需要特殊处理的情况。为了理解这些异常情况是什么,我们来看看默认情况——任何 C++ 开发人员都应该努力争取的情况。

通过以下措施可以避免new和/或delete的显式调用:

  • 尽可能在堆栈上使用分配。堆栈上的分配既简单又安全(记住在第三章中讨论的 KISS 原则)。不可能泄漏堆栈上分配的任何内存。一旦超出范围,资源就会被破坏。您甚至可以通过值从函数中返回对象,从而将其内容传递给调用函数。
  • 要在堆上分配资源,请使用“make functions”使用std::make_unique<T>std::make_shared<T>来实例化资源,并立即将它包装到一个管理资源的管理器对象(一个智能指针)中。
  • 在适当的地方使用容器(标准库、Boost 或其他)。容器管理其元素的存储空间。相反,在自行开发数据结构和序列的情况下,您不得不自己实施整个存储管理,这可能是一项复杂且容易出错的任务。
  • 为来自需要特定内存管理的专有第三方库的资源提供包装器(见下一节)。

管理专有资源

正如在本节关于资源管理的介绍中已经提到的,有时需要使用默认的newdelete操作符来管理那些没有在堆上分配或释放的其他资源。这种资源的示例是来自文件系统的打开文件、动态加载的模块(例如,Windows 操作系统上的动态链接库(DLL))或图形用户界面的平台特定对象(例如,窗口、按钮、文本输入字段等)。).

通常这些类型的资源是通过一个叫做句柄的东西来管理的。句柄是对操作系统资源的抽象且唯一的引用。在 Windows 上,数据类型HANDLE用于定义这样的句柄。事实上,该数据类型在 header WinNT.h中定义如下,headerWinNT.h是一个 C 风格的头文件,它定义了各种 Win32 API 宏和类型:

typedef void *HANDLE;

例如,如果您想要访问具有某个进程 ID 的正在运行的 Windows 进程,您可以使用 Win32 API 函数OpenProcess()检索该进程的句柄。

#include <windows.h>
// ...

const DWORD processId = 4711;
HANDLE processHandle = OpenProcess(PROCESS_ALL_ACCESS, FALSE, processId);

使用完手柄后,必须使用CloseHandle()功能关闭手柄:

BOOL success = CloseHandle(processHandle);

因此,我们有类似于运算符new及其对应运算符delete的对称性。因此,也应该有可能利用 RAII 习惯用法,并为此类资源使用智能指针。首先,我们只需要用一个调用CloseHandle()的自定义删除器来替换默认删除器(调用delete):

#include <windows.h> // Windows API declarations

class Win32HandleCloser {

public:
  void operator()(HANDLE handle) const {
    if (handle != INVALID_HANDLE_VALUE) {
      CloseHandle(handle);
    }
  }
};

小心点!如果您现在通过编写如下代码来定义类型别名,std::shared_ptr<T>将管理类型为void**的东西,因为HANDLE已经被定义为一个void指针:

using Win32SharedHandle = std::shared_ptr<HANDLE>; // Caution!

因此,Win32 HANDLE的智能指针必须定义如下:

using Win32SharedHandle = std::shared_ptr<void>;

using Win32WeakHandle = std::weak_ptr<void>;

Note

C++ 中不允许定义一个std::unique_ptr<void>!这是因为std::shared_ptr<T>实现了类型擦除,而std::unique_ptr<T>没有。如果一个类支持类型擦除,这意味着它可以存储任意类型的对象,并正确地销毁它们。

如果您想要使用共享句柄,您必须注意在构造期间将自定义删除器Win32HandleCloser的实例作为参数传递:

const DWORD processId = 4711;
Win32SharedHandle processHandle { OpenProcess(PROCESS_ALL_ACCESS, FALSE, processId),
  Win32HandleCloser() };

我们喜欢移动它

如果有人问我 C++11 的哪一个特性可能对现在和将来如何编写现代 C++ 程序有最深远的影响,我会明确地提名 move semantics。我已经在第四章中简要讨论了 C++ move 语义,在关于避免常规指针的策略的章节中。但是我认为它们非常重要,所以我想在这里深化这个语言特性。

什么是移动语义?

在许多以前的案例中,旧的 C++ 语言强迫我们使用复制构造函数,我们实际上并不想创建一个对象的深层副本。相反,我们只是想“移动对象的有效载荷”一个对象的有效载荷只不过是该对象携带的嵌入数据,所以只不过是其他对象,或者像int这样的原始类型的成员变量。

例如,以前我们不得不复制对象而不是移动它的情况如下:

  • 将本地对象实例作为函数或方法的返回值返回。在 C++11 之前,为了防止这些情况下的复制构造,经常使用指针。
  • 将对象插入到std::vector或其他容器中。
  • std::swap<T>模板功能的实现。

在前面提到的许多情况下,没有必要保持源对象不变,也就是说,创建一个深层的,并且在运行时效率方面通常很昂贵的副本,以便源对象保持可用。

C++11 引入了一个语言特性,使得移动一个对象的嵌入数据成为一个头等操作。除了复制构造函数和复制赋值操作符,该类的开发者现在可以实现移动构造函数和移动赋值操作符(我们将在后面看到为什么他实际上不应该这样做!).移动操作通常非常有效。与真正的复制操作相反,源对象的数据只是移交给目标对象,操作的参数(源对象)被置于一种“空”或初始状态。

下面的例子展示了一个任意的类,它显式地实现了两种类型的语义:复制构造函数(第 6 行)和赋值操作符(第 8 行),以及移动构造函数(第 7 行)和赋值操作符(第 9 行)。

01  #include <string>
02
03  class Clazz {
04  public:
05    Clazz() noexcept;                         // Default constructor
06    Clazz(const Clazz& other);                // Copy constructor
07    Clazz(Clazz&& other) noexcept;            // Move constructor
08    Clazz& operator=(const Clazz& other);     // Copy assignment operator
09    Clazz& operator=(Clazz&& other) noexcept; // Move assignment operator
10    virtual ∼Clazz() noexcept;                // Destructor
11
12  private:
13    // ...
14  };
Listing 5-9.An example class that explicitely declares special member functions for copy and move

正如我们将在后面的“零的规则”一节中看到的,不显式声明和定义这样的构造函数和赋值操作符应该是任何 C++ 开发人员的主要目标。

移动语义与右值引用密切相关(见下一节)。当一个类的构造函数或者赋值操作符接受一个右值引用作为参数时,它们分别被称为“移动构造函数”和“移动赋值操作符”。右值引用通过双&运算符(&&)标记。为了更好的区分,带有单个&符号(&)的普通引用现在也被称为左值引用。

那些左值和右值的问题

所谓的左值和右值是历史术语(从 C 语言继承而来),因为左值通常出现在赋值表达式的左侧,而右值通常出现在赋值表达式的右侧。在我看来,对 lvalue 更好的解释是,它是一个定位值。这清楚地表明左值表示占据存储器中的位置的对象(即,它具有可访问和可识别的存储器地址)。

相反,右值是表达式中所有非左值的对象。它是一个临时对象或其子对象。因此,不可能给右值赋值。

尽管这些定义来自旧的 C 世界,并且 C++11 仍然引入了更多的类别(xvalue、glvalue 和 prvalue)来支持 move 语义,但是它们对于日常使用来说还是相当不错的。

左值表达式的最简单形式是变量声明:

Type var1;

表达式var1是一个类型为Type的左值。以下声明也表示左值:

Type* pointer;
Type& reference;
Type& function();

左值可以是赋值操作的左操作数,就像本例中的整数变量theAnswerToAllQuestions:

int theAnswerToAllQuestions = 42;

此外,将内存地址分配给指针表明指针是左值:

Type* pointerToVar1 = &var1;

文字“42”是一个右值。它并不代表内存中可识别的位置,所以不可能给它赋值(当然,右值也会占用堆栈上数据段的内存,但是这个内存是临时分配的,在赋值操作完成后会立即释放):

int number = 23; // Works, because 'number' is an lvalue
42 = number; // Compiler error: lvalue required as left operand of assignment

你不相信上面通用例子中第三行的function()是左值?是啊!您可以编写以下(毫无疑问,有些奇怪)代码,编译器会毫无怨言地编译它:

int theAnswerToAllQuestions = 42;

int& function() {
  return theAnswerToAllQuestions;
}

int main() {
  function() = 23; // Works!
  return 0;
}

右值引用

如上所述,C++11 移动语义与右值引用密切相关。这些右值引用现在使得对右值的内存位置进行寻址成为可能。在下面的例子中,临时内存被分配给一个右值引用,从而使它成为“永久的”您甚至可以检索一个指向这个位置的指针,并使用这个指针操纵由右值引用所引用的内存。

int&& rvalueReference = 25 + 17;

int* pointerToRvalueReference = &rvalueReference;
*pointerToRvalueReference = 23;

通过引入右值引用,这些引用当然也可以作为参数出现在函数或方法中。表 5-1 显示了可能性。

表 5-1。

Different function respectively method signatures, and their allowed parameter kinds

| 函数/方法签名 | 允许的参数种类 | | --- | --- | | `void function(Type param) void X::method(Type param)` | 左值和右值都可以作为参数传递。 | | `void function(Type& param) void function(const Type& param) void X::method(Type& param) void X::method(const Type& param)` | 只有左值可以作为参数传递。 | | `void function(Type&& param) void X::method(Type&& param)` | 只有右值可以作为参数传递。 |

表 5-2 显示了函数或方法的返回类型的情况,以及函数/方法的返回语句允许的内容:

表 5-2。

Possible kinds of return types of functions respectively parameters

| 函数/方法签名 | return 语句返回的可能数据类型 | | --- | --- | | `int function() int X::method()` | `[const] int`、`[const] int&`或`[const] int&&.` | | `int& function() int& X::method()` | 非常数`int`或`int&.` | | `int&& function() int&& X::method()` | 文字(例如,`return 42`)或一个对象的右值引用(通过`std::move()`获得),该对象的生存期比函数各自的方法范围长。 |

当然,尽管右值引用可以用于任何函数或方法中的参数,但它们的应用领域注定是在移动构造函数和移动赋值操作符中。

#include <utility> // std::move<T>

class Clazz {

public:
  Clazz() = default;

  Clazz(const Clazz& other) {
    // Classical copy construction for lvalues
  }

  Clazz(Clazz&& other) noexcept {
    // Move constructor for rvalues: moves content from 'other' to this
  }
  Clazz& operator=(const Clazz& other) {
    // Classical copy assignment for lvalues
    return *this;
  }

  Clazz& operator=(Clazz&& other) noexcept {
    // Move assignment for rvalues: moves content from 'other' to this
    return *this;
  }
  // ...
};

int main() {
  Clazz anObject;
  Clazz anotherObject1(anObject);            // Calls copy constructor
  Clazz anotherObject2(std::move(anObject)); // Calls move constructor
  anObject = anotherObject1;                 // Calls copy assignment operator
  anotherObject2 = std::move(anObject);      // Calls move assignment operator
  return 0;
}

Listing 5-10.A class that explicitely defines both copy- and move semantics

不要强制到处移动

也许你已经注意到在上面的代码示例中使用了帮助函数std::move<T>()(在 header <utility>中定义)来强制编译器使用 move 语义。

首先,这个小助手功能的名字有误导性。std::move<T>()不会移动任何东西。这或多或少是一种生成对类型为T的对象的右值引用的强制转换。

在大多数情况下,没有必要这样做。在正常情况下,构造函数或赋值运算符的复制和移动版本之间的选择是在编译时通过重载决策自动完成的。编译器确定它面对的是左值还是右值,然后相应地选择最合适的构造函数或赋值运算符。C++ 标准库的容器类也考虑了由移动操作保证的异常安全级别(我们将在“预防胜于治疗”一节中更详细地讨论这个主题)。

请特别注意这一点——不要像这样编写代码:

#include <string>

#include <utility>

#include <vector>

using StringVector = std::vector<std::string>;

StringVector createVectorOfStrings() {
  StringVector result;
  // ...do something that the vector is filled with many strings...
  return std::move(result); // Bad and unnecessary, just write "return result;"!
}

Listing 5-11.An improper use of std::move()

在 return 语句中使用std:: move<T>()不仅完全没有必要,因为编译器已经知道该变量是要从函数中移出的候选变量(从 C++11 开始,所有标准库容器以及标准库的许多其他类都支持 move 语义,如std::string)。更糟糕的影响可能是它会干扰 RVO(返回值优化),也称为复制省略,现在几乎所有编译器都在执行。RVO 独立副本省略允许编译器在从函数或方法返回值时优化掉代价高昂的副本构造。

永远记住第三章中的重要原则:小心优化!不要到处用std::move<T>()语句搞乱你的代码,只是因为你认为你可以用代码的优化来比你的编译器更聪明。你不是!到处都有这些std::move<T>(),你的代码的可读性将会受到影响,并且你的编译器可能无法正确地执行它的优化策略。

零的规则

作为一个有经验的 C++ 开发人员,你可能已经知道了三法则和五法则。三原则[Koenig01]最初由 Marshall Cline 在 1991 年提出,它指出如果一个类显式地定义了一个析构函数,那么它应该总是定义一个复制构造函数和一个复制赋值操作符。随着 C++11 的出现,这个规则被扩展成了五规则,因为语言中增加了移动构造函数和移动赋值操作符,而且如果一个类定义了析构函数,这两个特殊的成员函数也必须被定义。

“三法则”和“五法则”在 C++ 类设计中一直是很好的建议,而当开发人员没有考虑它们时,可能会出现微妙的错误,正如下面故意编写的糟糕代码示例所示。

#include <cstring>

class MyString {

public:
  explicit MyString(const std::size_t sizeOfString) : data { new char[sizeOfString] } { }
  MyString(const char* const charArray, const std::size_t sizeOfArray) {
    data = new char[sizeOfArray];
    strcpy(data, charArray);
  }
  virtual ∼MyString() { delete[] data; };

  char& operator[](const std::size_t index) {
    return data[index];
  }
  const char& operator[](const std::size_t index) const {
    return data[index];
  }
  // ...

private:
  char* data;
};

Listing 5-12.An improper implementation of a string class

这确实是一个非常业余的实现的 string 类,有一些缺陷,例如,没有一个nullptr被传递到初始化构造函数中,并且完全忽略了字符串通常可以增长和收缩的事实。当然,现在没有人必须自己实现一个 string 类,从而重新发明轮子。有了std::string,C++ 标准库中有了一个防弹的字符串类。然而,根据上面的例子,很容易证明为什么遵守五原则是重要的。

为了安全地释放由初始化构造函数为内部字符串表示分配的内存,必须定义一个显式析构函数,并且必须实现它来实现这一点。然而,在上面的类中,违反了五的规则,并且缺少显式的复制/移动构造函数,以及复制/移动赋值操作符。

现在,让我们假设我们以下面的方式使用类MyString:

int main() {
  MyString aString("Test", 4);
  MyString anotherString { aString }; // Uh oh! :-(
  return 0;
}

由于我们的MyString类没有显式定义复制或移动构造函数,编译器会合成这些特殊的成员函数,即编译器会分别生成一个默认的复制构造函数和一个默认的移动构造函数。这些默认实现只创建源对象成员变量的平面副本。在我们的例子中,存储在字符指针data中的地址值被复制,但不是这个指针指向的内存区域。

这意味着:在自动生成的默认复制构造函数被调用来创建anotherString之后,MyString的两个实例共享相同的数据,这在图 5-2 所示的调试器的变量视图中很容易看到。

A429836_1_En_5_Fig2_HTML.jpg

图 5-2。

Both character pointers are pointing to the same memory address

如果字符串对象被破坏,这将导致内部数据的双重删除,从而可能导致严重的问题,如分段错误或未定义的行为。

在正常情况下,没有理由为类定义显式析构函数。每次当你被迫定义一个析构函数时,这都是一个值得注意的异常,因为它表明你需要在一个对象的生命周期结束时用资源做一些特殊的事情,这需要相当大的努力。通常需要一个重要的析构函数来释放资源,例如,堆上的内存。因此,您还需要定义显式的复制/移动构造函数和复制/移动赋值操作符,以便在复制或移动时正确处理这些资源。这就是五法则的含义。

有不同的方法来处理上述问题。例如,我们可以提供显式的复制/移动构造函数以及复制/移动赋值操作符来正确处理分配的内存,例如,通过创建指针所指向的内存区域的深层副本。另一种方法是禁止复制和移动,并防止编译器生成这些函数的默认版本。从 C++11 开始,可以通过删除这些特殊的成员函数来做到这一点,这样任何被删除函数的使用都是不良形式的,也就是说,程序将无法编译。

class MyString {

public:
  explicit MyString(const std::size_t sizeOfString) : data { new char[sizeOfString] } { }
  MyString(const char* const charArray, const int sizeOfArray) {
    data = new char[sizeOfArray];
    strcpy(data, charArray);
  }
  virtual ∼MyString() { delete[] data; };
  MyString(const MyString&) = delete;
  MyString& operator=(const MyString&) = delete;
  // ...
};
Listing 5-13.A modified MyString class that explicitely deletes copy constructor and copy assignment operator

问题是,通过删除特殊的成员函数,该类现在的使用范围非常有限。例如,MyString现在不能用在std::vector中,因为std::vector要求其元素类型T是可复制赋值和可复制构造的。

好了,现在是时候选择一种不同的方法和不同的思维方式了。我们要做的是去掉释放已分配资源的析构函数。如果成功了,根据规则五,也没有必要显式地提供其他特殊的成员函数。所以我们开始吧:

#include <vector>

class MyString {

public:
  explicit MyString(const std::size_t sizeOfString) {
    data.resize(sizeOfString, ’ ’);
  }

  MyString(const char* const charArray, const int sizeOfArray) : MyString(sizeOfArray) {
    if (charArray != nullptr) {
      for (int index = 0; index < sizeOfArray; index++) {
        data[index] = charArray[index];
      }
    }
  }

  char& operator[](const std::size_t index) {
    return data[index];
  }

  const char& operator[](const std::size_t index) const {
    return data[index];
  }
  // ...

private:
  std::vector<char> data;
};

Listing 5-14.Replacing the char-pointer by a vector of char makes an explicit destructor superfluous

再一次备注:我知道这是一个不切实际的业余实现一个自制的字符串,现在已经没有必要了,但这只是为了演示。

现在发生了什么变化?我们已经用元素类型为charstd::vector替换了类型为char*的私有成员。因此,我们不再需要一个显式的析构函数,因为如果一个类型为MyString的对象被销毁了,我们就什么都不用做了。不需要释放任何资源。因此,编译器生成的特殊成员函数,如复制/移动构造函数或复制/移动赋值操作符,在使用时会自动执行正确的操作,我们不必显式定义它们。这是个好消息,因为我们遵循了接吻原则(见第三章)。

这就引出了零的法则!零规则是 R. Martinho Fernandes 在 2012 年的一篇博客文章中提出的。ISO 标准委员会成员、HSR 技术学院 IFS 软件研究所所长 Peter Sommerlad 教授在一次关于会议 C++ 2013 [Sommerlad13]的会议演讲中也提出了该规则。规则是这样说的:

编写类时,既不需要声明/定义析构函数,也不需要声明/定义复制/移动构造函数或复制/移动赋值运算符。使用 C++ 智能指针和标准库类和容器来管理资源。

换句话说,零规则表明,您的类应该以这样一种方式设计,即编译器生成的用于复制、移动和销毁的成员函数自动做正确的事情。这使得你的类更容易理解(总是想起第三章中的 KISS 原则),更不容易出错,更易于维护。其背后的原则是:用更少的代码做更多的事情。

编译器是你的同事

正如我已经在其他地方写过的,C++11 语言标准的出现已经从根本上改变了现代和干净的 C++ 程序的设计方式。程序员在编写现代 C++ 代码时使用的风格、模式和习惯用法与以前完全不同。除了新的 C++ 标准提供了许多有用的新特性来编写可维护、可理解、高效和可测试的 C++ 代码这一事实之外,还有一些东西也发生了变化:编译器的角色!

以前,编译器只是一种工具,用来将源代码翻译成计算机可执行的机器指令(目标代码);但是现在它越来越成为在不同层面上支持开发者的工具。现在使用 C++ 编译器的三个指导原则如下:

  • 所有能在编译时完成的事情也应该在编译时完成。
  • 所有可以在编译时检查的东西也应该在编译时检查。
  • 编译器所能知道的关于程序的一切也应该由编译器来决定。

在前面的章节中,我们已经在某些地方体验了编译器是如何支持我们的。例如,在关于移动语义的章节中,我们已经看到现代 C++ 编译器现在能够执行多种复杂的优化(例如,复制省略),我们不必再关心这些。在接下来的几节中,我将向您展示编译器如何支持我们开发人员,并使许多事情变得更加简单。

自动类型推导

还记得 C++11 之前的 C++ 关键字auto的含义吗?我很确定这可能是这门语言中最不为人知和使用最少的关键字。也许你还记得 C++98 或 C++03 中的auto是一个所谓的存储类说明符,它被用来定义一个局部变量具有“自动持续时间”,也就是说,变量在定义时被创建,当它所属的块退出时被销毁。从 C++11 开始,所有变量默认都有自动持续时间,除非另有说明。因此,auto以前的语义变得没用了,这个关键词有了全新的含义。

如今,auto用于自动类型推导,有时也叫类型推断。如果它用作变量的类型说明符,则它指定正在声明的变量的类型将从其初始值设定项中自动推导(或推断)出来,如以下示例所示:

auto theAnswerToAllQuestions = 42;

auto iter = begin(myMap);

const auto gravitationalAccelerationOnEarth = 9.80665;

constexpr auto sum = 10 + 20 + 12;

auto strings = { "The", "big", "brown", "fox", "jumps", "over", "the", "lazy", "dog" };

auto numberOfStrings = strings.size();

Argument Dependent Name Lookup (ADL)

参数相关(名称)查找(简称:ADL),也称为 Koenig Lookup(以美国计算机科学家安德鲁·克尼格的名字命名),是一种编译器技术,它根据在调用位置传递给函数的参数类型来查找非限定的函数名(即没有前缀命名空间限定符的函数名)。

假设您有一个std::map<K, T>(在标题<map>中定义),如下所示:

#include <map>

#include <string>
std::map<unsigned int, std::string> words;

由于 ADL,如果使用begin()end()函数从容器中检索迭代器,就没有必要指定名称空间std。你可以简单地写:

auto wordIterator = begin(words);

编译器不仅会查看局部范围,还会查看包含参数类型的名称空间(在本例中是map<T>的名称空间,也就是std)。因此,在上面的例子中,编译器为名称空间std中的地图找到了一个合适的begin()函数。

在某些情况下,您需要显式定义名称空间,例如,如果您想对一个简单的 C 风格数组使用std::begin()std::end()

乍一看,使用auto而不是具体类型似乎是一个方便的特性。开发人员不再被迫记住类型的名称。他们简单地写autoconst autoauto&(对于引用)或const auto&(对于常量引用),编译器做剩下的事情,因为它知道赋值的类型。自动类型推导当然也可以与constexpr结合使用(参见关于编译时计算的章节)。

请不要害怕尽可能多地使用auto(或分别使用auto&``const auto&)。代码仍然是静态类型的,变量的类型也有明确的定义。例如,上例中的变量strings的类型是std::initializer_list<const char*>,变量numberOfStrings的类型是std::initializer_list<const char*>::size_type

Std::Initializer_List [C++11]

在以前(C++11 以前),如果我们想用文字初始化一个标准的库容器,我们必须做以下事情:

std::vector<int> integerSequence;
integerSequence.push_back(14);
integerSequence.push_back(33);
integerSequence.push_back(69);
// ...and so on...

从 C++11 开始,我们可以简单地这样做:

std::vector<int> integerSequence { 14, 33, 69, 104, 222, 534 };

原因是std::vector<T>有一个重载的构造函数,它接受一个所谓的初始化列表作为参数。初始化列表是一个类型为std::initializer_list<T>的对象(在头文件<initializer_list>中定义)。

当您使用用一对花括号括起来的逗号分隔的文字列表(所谓的 bracked-init-list)时,类型std::initializer_list<T>的实例会自动构造。您可以为自己的类配备可以接受初始化列表的构造函数,如下例所示:

#include <string>

#include <vector>

using WordList = std::vector<std::string>;

class LexicalRepository {

public:
  explicit LexicalRepository(const std::initializer_list<const char*>& words) {
    wordList.insert(begin(wordList), begin(words), end(words));
  }
  // ...

private:
  WordList wordList;
};

int main() {
  LexicalRepository repo { "The", "big", "brown", "fox", "jumps", "over", "the", "lazy", "dog" };
  // ...
  return 0;
}

注意:这个初始化列表不应该和它的成员初始化列表混淆!

从 C++14 开始,也支持函数的自动返回类型推导。当一个返回类型有一个难以记忆或难以理解的名称时,这尤其有用,在将复杂的非标准数据类型作为返回类型处理时,经常会出现这种情况。

auto function() {
  std::vector<std::map<std::pair<int, double>, int>> returnValue;
  // ...fill ’returnValue’ with data...
  return returnValue;
}

我们到现在还没有讨论 lambda 函数(它们将在第七章中详细讨论),但是 C++11 和更高版本允许你在命名变量中存储 lambda 表达式:

auto square = [](int x) { return x * x; };

也许你现在想知道:在第四章,作者告诉我们一个富有表现力的好的命名对于代码的可读性是很重要的,应该是每个专业程序员的主要目标。同一个作者现在提倡使用关键字auto,这使得仅仅通过阅读代码来快速识别变量的类型变得更加困难。这不是矛盾吗?

我的明确回答是:不,恰恰相反!除了少数例外,auto可以提高代码的可读性。让我们看看变量赋值的以下两种替代方案:

// 1st version: without auto
std::shared_ptr<controller::CreateMonthlyInvoicesController> createMonthlyInvoicesController =
  std::make_shared<controller::CreateMonthlyInvoicesController>();

// 2nd version: with auto:

auto createMonthlyInvoicesController =
  std::make_shared<controller::CreateMonthlyInvoicesController>();

Listing 5-15.Which one of the following two versions would you prefer?

在我看来,使用auto的版本更容易阅读。没有必要显式地重复类型,因为从它的初始化式中可以很清楚地看出createMonthlyInvoicesController将是什么类型。顺便说一下,重复显式类型也是对 DRY 原则的一种违反(参见第三章)。如果你想到上面这个名为square的 lambda 表达式,它的类型是一个唯一的、未命名的非联合类类型,那么如何显式定义这样的类型呢?

所以,我的建议是:

如果不影响代码的意图,尽可能使用 auto!

编译期间的计算

高性能计算(HPC)的爱好者,以及嵌入式软件的开发人员和喜欢使用静态常量表来分离数据和代码的程序员,都希望在编译时尽可能多地进行计算。这样做的原因很容易理解:所有可以在编译时计算或评估的东西不一定要在运行时计算或评估。换句话说:在编译时尽可能多的计算是提高程序运行效率的唾手可得的成果。这种优势有时伴随着一个缺点,那就是编译代码所花费的时间或多或少会增加。

从 C++11 开始,就有了constexpr(常量表达式)说明符来定义在编译时评估一个函数或变量的值是可能的。随后的标准 C++14 取消了以前对constexpr的一些严格限制。例如,一个由constexpr指定的函数只允许有一个return语句。这个限制从 C++14 开始就取消了。

一个最简单的例子是,变量值是在编译时通过算术运算从文字中计算出来的,如下所示:

constexpr int theAnswerToAllQuestions = 10 + 20 + 12;

变量theAnswerToAllQuestions也是一个常量,就好像已经用const声明了一样;因此,您不能在运行时操作它:

int main() {
  // ...
  theAnswerToAllQuestions = 23; // Compiler error: assignment of read-only variable!
  return 0;
}

还有constexpr功能:

constexpr int multiply(const int multiplier, const int multiplicand) {
  return multiplier * multiplicand;
}

这种函数可以在编译时调用,但在运行时也像普通函数一样使用非常数参数。这对于借助单元测试来测试那些功能的原因来说已经是必要的了(参见第二章)。

constexpr int theAnswerToAllQuestions = multiply(7, 6);

不出所料,也可以递归调用constexpr指定的函数,如下面计算阶乘的函数示例所示。

01  #include <iostream>
02
03  constexpr unsigned long long factorial(const unsigned short n) {
04    return n > 1 ? n * factorial(n - 1) : 1;
05  }
06
07  int main() {
08    unsigned short number = 6;
09    auto result1 = factorial(number);
10    constexpr auto result2 = factorial(10);
11
12    std::cout << "result1: " << result1 << ", result2: " << result2 << std::endl;
13    return 0;
14  }
Listing 5-16.Calculating the factorial of a non-negative integer ’n’ at compile time

前面的例子已经在 C++11 下运行了。factorial()函数只包含一条语句,在constexpr函数中从一开始就允许递归。main()函数包含对factorial()函数的两次调用。仔细看看这两个函数调用是值得的。

第 9 行的第一个调用使用变量number作为函数参数n的变量,其结果被赋给一个非常数变量result1。第 10 行的第二个函数调用使用一个数字文本作为参数,它的结果被赋给一个带有constexpr说明符的变量。这两个函数调用在运行时的区别可以在反汇编的目标代码中最清楚地看到。图 5-3 显示了 Eclipse CDT 反汇编窗口中关键点的目标代码。

A429836_1_En_5_Fig3_HTML.jpg

图 5-3。

The disassembled object code

第 9 行的第一个函数调用产生了五条机器指令。第四条指令(callq)是在内存地址0x5555555549bd跳转到函数factorial()。换句话说,很明显这个函数是在运行时调用的。相比之下,我们看到第 10 行对factorial()的第二次调用只产生了一条简单的机器指令。movq指令将四字从源操作数复制到目标操作数。在运行时没有昂贵的函数调用。factorial(10的结果,十六进制的是0x375f00,十进制的分别是 3,628,800,已经在编译时计算出来,并且像目标代码中的常量一样可用。

正如我之前所写的,从 C++14 开始,C++11 中对contexpr指定函数的一些限制已经被废除了。例如,一个constexpr指定的函数现在可以有不止一个return语句,它可以有像if - else这样的条件——分支,“文字”类型的局部变量,或者循环。基本上,几乎所有的 C++ 语句都是允许的,如果它们没有预先假定或者要求一些只有在运行时环境中才可用的东西,例如,在堆上分配内存,或者抛出异常。

可变模板

我认为constexpr也可以用在模板中就不那么奇怪了,如下例所示。

template <typename T>

constexpr T pi = T(3.1415926535897932384626433L);
Listing 5-17.A variable template for mathematical constant pi

这就是所谓的变量模板,通过对宏使用#define来代替古老的常量定义,这是一个很好且灵活的选择(参见第四章中的“避免宏”一节)。根据模板实例化过程中的使用上下文,数学常数 pi 被分为floatdoublelong double

template <typename T>

constexpr T computeCircumference(const T radius) {
  return 2 * radius * pi<T>;
}

int main() {
  const long double radius { 10.0L };
  constexpr long double circumference = computeCircumference(radius);
  std::cout << circumference << std::endl;
  return 0;
}

Listing 5-18.Calculating a circles circumference at compile time using variable template ’pi’

最后但同样重要的是,你也可以在编译时在计算中使用类。你可以为类定义constexpr构造函数和成员函数。

#include <iostream>

#include <cmath>

class Rectangle {

public:
  constexpr Rectangle() = delete;
  constexpr Rectangle(const double width, const double height) :
    width { width }, height { height } { }
  constexpr double getWidth() const { return width; }
  constexpr double getHeight() const { return height; }
  constexpr double getArea() const { return width * height; }
  constexpr double getLengthOfDiagonal() const {
    return std::sqrt(std::pow(width, 2.0) + std::pow(height, 2.0));
  }

private:
  double width;
  double height;
};

int main() {
  constexpr Rectangle americanFootballPlayingField { 48.76, 110.0 };
  constexpr double area = americanFootballPlayingField.getArea();
  constexpr double diagonal = americanFootballPlayingField.getLengthOfDiagonal();

  std::cout << "The area of an American Football playing field is " <<
      area << "m² and the length of its diagonal is " << diagonal <<
      "m." << std::endl;
  return 0;
}

Listing 5-19.Rectangle is a constexpr class

另外,在编译时和运行时都可以使用类。然而,与普通类相反,它不允许定义虚拟成员函数(编译时没有多态性),并且constexpr类不能有显式定义的析构函数。

Note

上述代码示例可能无法在某些 C++ 编译器上编译。按照今天的标准,C++ 标准并没有将 numerics 库(头文件<cmath>)中常见的数学函数指定为constexpr,像std::sqrt()std::pow()。编译器实现可以自由地做这件事,但这不是强制性的。

然而,从干净代码的角度来看,应该如何判断编译时的这些计算呢?把constexpr加到任何可能拥有它的事物上,基本上是个好主意吗?

嗯,我的看法是constexpr并没有降低代码的可读性。说明符总是在变量和常量定义之前,分别在函数或方法声明之前。因此,它不会打扰太多。另一方面,如果我明确知道某个东西永远不会在编译时被求值,我也应该放弃这个说明符。

不允许未定义的行为

在 C++(以及其他一些编程语言)中,语言规范没有定义任何可能情况下的行为。在某些地方,规范说某个操作的行为在某些情况下是未定义的。在这种情况下,您无法预测会发生什么,因为程序的行为取决于编译器的实现、底层操作系统或特殊的优化开关。那真的很糟糕!该程序可能会崩溃或悄悄地产生不正确的结果。

下面是一个未定义行为的例子,一个智能指针的错误使用:

const std::size_t NUMBER_OF_STRINGS { 100 };
std::shared_ptr<std::string> arrayOfStrings(new std::string[NUMBER_OF_STRINGS]>);

假设这个std::shared_ptr<T>对象是最后一个指向字符串数组资源的对象,在某个地方超出了作用域,会发生什么?

答:std::shared_ptr<T>的析构函数减少了共享所有者的数量,计数器变为零。结果,由智能指针管理的资源(std::string的数组)通过调用它的析构函数被销毁。但是它会做错,因为当你使用new[]分配托管资源时,你需要调用数组形式delete[],而不是delete,来释放资源,而std::shared_ptr<T>的默认删除器使用delete

delete而不是delete[]删除数组会导致未定义的行为。没有具体说明会发生什么。也许这会导致内存泄漏,但这只是猜测。

Caution

避免未定义的行为!这是一个严重的错误,最终程序会无声无息地出错。

让智能指针正确删除字符串数组有几种解决方案。例如,你可以提供一个自定义删除器作为类似函数的对象(也称为“仿函数”,参见第七章):

template< typename Type >

struct CustomArrayDeleter
{
  void operator()(Type const* pointer)
  {
    delete [] pointer;
  }
};

现在,您可以使用自己的删除器,如下所示:

const std::size_t NUMBER_OF_STRINGS { 100 };
std::shared_ptr<std::string> arrayOfStrings(new std::string[NUMBER_OF_STRINGS], CustomArrayDeleter<std::string>());

在 C++11 中,头文件<memory>中定义了一个默认的数组类型删除器:

const std::size_t NUMBER_OF_STRINGS { 100 };
std::shared_ptr<std::string> arrayOfStrings(new std::string[NUMBER_OF_STRINGS],
  std::default_delete<std::string[]>());

当然,根据要满足的需求,应该考虑使用std::vector是否总是实现“事物数组”的最佳解决方案

类型丰富的编程

不要相信名字。信任类型。类型不会说谎。类型是你的朋友!——马里奥·富斯科(@mariofusco),2016 年 4 月 13 日,在推特上

1999 年 9 月 23 日,经过 10 个月的太阳系第四行星之旅,美国宇航局失去了火星气候轨道器 I,一个机器人太空探测器。当飞船进入轨道插入时,位于科罗拉多州的洛克希德·马丁航天公司的推进团队和位于帕萨迪纳(加利福尼亚州)的美国宇航局任务导航团队之间的重要数据传输失败。这个错误把飞船推到了离火星大气层太近的地方,在那里它立即燃烧了。

A429836_1_En_5_Fig4_HTML.jpg

图 5-4。

Artist's rendering of the Mars Climate Orbiter (Author: NASA/JPL/Corby Waste; License: Public Domain)

数据传输失败的原因是美国宇航局任务导航团队使用国际单位制(SI),而洛克希德·马丁公司的导航软件使用英制单位(英制测量系统)。任务导航小组使用的软件已经发送了磅-力-秒(lbf s)的值,但是轨道飞行器的导航软件期望的值是牛顿-秒(N s)。美国航天局的总财务损失为 3.28 亿美元。大约 200 名优秀的航天器工程师毕生的工作在几秒钟内毁于一旦。

这个失败不是一个简单软件错误的典型例子。这两个系统本身可能都运转正常。但是它揭示了软件开发中一个有趣的方面。看来两个工程团队之间的沟通和协调问题是这次失败的主要原因。显而易见:既没有对两个子系统进行联合系统测试,也没有正确设计两个子系统之间的接口。人们有时会犯错误。这里的问题不是错误,而是美国宇航局系统工程的失败,以及我们在检测错误的过程中的检查和平衡。这就是我们失去太空船的原因。——爱德华·魏勒博士,美国航天局负责空间科学的副署长[JPL99]

事实上,我对火星气候轨道器的系统软件一无所知。但是根据故障的检查报告,我了解到一个软件产生的结果使用“英制”单位,而另一个使用这些结果的软件希望它们使用公制单位。

我想每个人都知道 C++ 成员函数声明,看起来像下面的类:

class SpacecraftTrajectoryControl {

public:
  void applyMomentumToSpacecraftBody(const double impulseValue);
};

double代表什么?名为applyMomentumToSpacecraftBody的成员函数所期望的值是什么单位?它是以牛顿(N)、牛顿-秒(N s)、磅-力-秒(lbf s)还是其他任何单位测量的值?事实上我们并不知道。double可以是任何东西。当然,它是一种类型,但不是语义类型。也许它已经被记录在某个地方,或者我们可以给这个参数一个更有意义、更详细的名字,比如impulseValueInNewtonSeconds,这总比什么都没有好。但是,即使最好的文档或参数名也不能保证这个类的客户端将一个不正确的单元值传递给这个成员函数。

我们能做得更好吗?我们当然可以。

我们真正想要的是正确定义一个接口,并且有丰富的语义,就像这样:

class SpacecraftTrajectoryControl {

public:
  void applyMomentumToSpacecraftBody(const Momentum& impulseValue);
};

在力学中,动量是用牛顿-秒(Ns)来度量的。一牛顿秒(1 Ns)是一牛顿(在国际单位制中是 1 千克米/秒 2)作用于一个物体(物理物体)一秒的力。

要使用类似于Momentum的类型来代替非特定的浮点类型double,我们必须首先引入该类型。在第一步中,我们定义了一个模板,它可以用来表示基于 MKS 单位制的物理量。缩写 MKS 代表米(长度)、千克(质量)和秒(时间)。这三个基本单位可以用来表示任何给定的物理测量。

template <int M, int K, int S>

struct MksUnit {
  enum { metre = M, kilogram = K, second = S};
};
Listing 5-20.A class template to represent MKS units

此外,我们需要一个表示值的类模板:

template <typename MksUnit>

class Value {

private:
  long double magnitude{ 0.0 };

public:
  explicit Value(const long double magnitude) : magnitude(magnitude) {}
  long double getMagnitude() const {
    return magnitude;
  }
};

Listing 5-21.A class template to represent values of MKS units

接下来,我们可以使用这两个类模板来定义具体物理量的类型别名。以下是一些例子:

using DimensionlessQuantity = Value<MksUnit<0, 0, 0>>;

using Length = Value<MksUnit<1, 0, 0>>;

using Area = Value<MksUnit<2, 0, 0>>;

using Volume = Value<MksUnit<3, 0, 0>>;

using Mass = Value<MksUnit<0, 1, 0>>;

using Time = Value<MksUnit<0, 0, 1>>;

using Speed = Value<MksUnit<1, 0, -1>>;

using Acceleration = Value<MksUnit<1, 0, -2>>;

using Frequency = Value<MksUnit<0, 0, -1>>;

using Force = Value<MksUnit<1, 1, -2>>;

using Pressure = Value<MksUnit<-1, 1, -2>>;
// ... etc. ...

现在也可以定义Momentum,这是我们的applyMomentumToSpacecraftBody成员函数所需的参数类型:

using Momentum = Value<MksUnit<1, 1, -1>>;

在我们引入类型别名Momentum之后,下面的代码将无法编译,因为没有合适的构造函数将double转换为Value<MksUnit<1,1,-1>>:

SpacecraftTrajectoryControl control;

const double someValue = 13.75;
control.applyMomentumToSpacecraftBody(someValue); // Compile-time error!

即使是下一个例子也会导致编译时错误,因为类型为Force的变量不能像Momentum一样使用,并且必须防止这些不同维度之间的隐式转换:

SpacecraftTrajectoryControl control;
Force force { 13.75 };
control.applyMomentumToSpacecraftBody(force); // Compile-time error!

但是这个会工作得很好:

SpacecraftTrajectoryControl control;
Momentum momentum { 13.75 };
control.applyMomentumToSpacecraftBody(momentum);

这些单位也可用于常数的定义。为此,我们需要稍微修改一下类模板Value。我们将关键字constexpr(参见第四章中的“编译期间的计算”一节)添加到初始化构造函数和getMagnitude()成员函数中。这不仅允许我们创建不必在运行时初始化的编译时常量Value。正如我们将在后面看到的,我们现在也能够在编译时用我们的物理值进行计算。

template <typename MksUnit>

class Value {

public:
  constexpr explicit Value(const long double magnitude) noexcept : magnitude { magnitude } {}
  constexpr long double getMagnitude() const noexcept {
    return magnitude;
  }

private:

  long double magnitude { 0.0 };
};

此后,不同物理单位的常数可以定义如下:

constexpr Acceleration gravitationalAccelerationOnEarth { 9.80665 };

constexpr Pressure standardPressureOnSeaLevel { 1013.25 };

constexpr Speed speedOfLight { 299792458.0 };

constexpr Frequency concertPitchA { 440.0 };

constexpr Mass neutronMass { 1.6749286e-27 };

此外,如果实现了必要的操作符,单元之间的计算是可能的。例如,这些是加法、减法、乘法和除法运算符模板,用于对不同 MKS 单位的两个值执行不同的计算:

template <int M, int K, int S>

constexpr Value<MksUnit<M, K, S>> operator+
  (const Value<MksUnit<M, K, S>>& lhs, const Value<MksUnit<M, K, S>>& rhs) noexcept {
  return Value<MksUnit<M, K, S>>(lhs.getMagnitude() + rhs.getMagnitude());
}

template <int M, int K, int S>

constexpr Value<MksUnit<M, K, S>> operator-
  (const Value<MksUnit<M, K, S>>& lhs, const Value<MksUnit<M, K, S>>& rhs) noexcept {
  return Value<MksUnit<M, K, S>>(lhs.getMagnitude() - rhs.getMagnitude());
}

template <int M1, int K1, int S1, int M2, int K2, int S2>

constexpr Value<MksUnit<M1 + M2, K1 + K2, S1 + S2>> operator*
  (const Value<MksUnit<M1, K1, S1>>& lhs, const Value<MksUnit<M2, K2, S2>>& rhs) noexcept {
  return Value<MksUnit<M1 + M2, K1 + K2, S1 + S2>>(lhs.getMagnitude() * rhs.getMagnitude());
}

template <int M1, int K1, int S1, int M2, int K2, int S2>

constexpr Value<MksUnit<M1 - M2, K1 - K2, S1 - S2>> operator/
  (const Value<MksUnit<M1, K1, S1>>& lhs, const Value<MksUnit<M2, K2, S2>>& rhs) noexcept {
  return Value<MksUnit<M1 - M2, K1 - K2, S1 - S2>>(lhs.getMagnitude() / rhs.getMagnitude());
}

现在你将能够写出这样的东西:

constexpr Momentum impulseValueForCourseCorrection = Force { 30.0 } * Time { 3.0 };
SpacecraftTrajectoryControl control;
control.applyMomentumToSpacecraftBody(impulseValueForCourseCorrection);

与将两个无意义的double相乘并将其结果赋给另一个无意义的double相比,这显然是一个显著的进步。很有表现力。这也更安全,因为你不能将乘法的结果赋给不同于Momentum类型的变量。

最好的部分是:类型安全在编译时得到了保证!在运行时没有开销,因为符合 C++11(和更高版本)的编译器可以执行所有必要的类型兼容性检查。

让我们更进一步。如果我们可以像下面这样写,不是很方便和直观吗?

constexpr Acceleration gravitationalAccelerationOnEarth = 9.80665_ms2;

甚至这在现代 C++ 中也是可能的。从 C++11 开始,我们可以通过为文字定义特殊函数(所谓的文字运算符)来为文字提供自定义后缀:

constexpr Force operator"" _N(long double magnitude) {
  return Force(magnitude);
}

constexpr Acceleration operator"" _ms2(long double magnitude) {
  return Acceleration(magnitude);
}

constexpr Time operator"" _s(long double magnitude) {
  return Time(magnitude);
}

constexpr Momentum operator"" _Ns(long double magnitude) {
  return Momentum(magnitude);
}

// ...more literal operators here...

User-Defined Literals [C++11]

基本上,文字是编译时常量,其值在源文件中指定。从 C++11 开始,开发人员可以通过为文字定义用户定义的后缀来生成用户定义类型的对象。例如,如果一个常量应该用 U.S.-$ 145.67 来初始化,这可以通过编写下面的表达式来完成:

constexpr Money amount = 145.67_USD;

在这种情况下,“_USD”是代表金额的浮点文字的用户定义后缀。为了使用这种用户定义的文字,必须定义一个称为文字运算符的函数:

constexpr Money operator"" _USD (const long double amount) {
  return Money(amount);
}

一旦我们为我们的物理单位定义了用户定义的文字,我们就可以按以下方式使用它们:

Force force = 30.0_N;
Time time = 3.0_s;
Momentum momentum = force * time;

这个符号不仅为物理学家和其他科学家所熟悉。甚至更安全。使用类型丰富的编程和用户定义的文字,您可以避免将表示秒值的文字赋给类型为Force的变量。

Force force1 = 3.0; // Compile-time error!
Force force2 = 3.0_s; // Compile-time error!
Force force3 = 3.0_N; // Works!

当然,也可以将用户定义的文字与自动类型推导和/或常量表达式一起使用:

auto force = 3.0_N;

constexpr auto acceleration = 100.0_ms2;

那很方便也很优雅,不是吗?所以,这是我对公共界面设计的建议:

创建强类型的接口(API)。

换句话说:你应该尽量避免在公共接口或者 API 中使用通用的、低级的内置类型,比如intdouble,或者最糟糕的是void*。这种非语义类型在某些情况下是危险的,因为它们可以表示任何东西。

Tip

已经有一些基于模板的库提供了物理量的类型,包括所有的 SI 单位。一个众所周知的例子是 Boost。单位(从版本 1.36.0 开始是 Boost 的一部分;见 http://www.boost.org )。

了解你的库

你听说过“不是这里发明的”(NIH)综合症吗?这是一种组织上的反模式。NIH 综合症是一个贬义词,指的是许多开发组织中的一种立场,描述了对现有知识或基于其来源的屡试不爽的解决方案的忽视。这是一种“重新发明轮子”的形式,也就是说,重新实现某个地方已经有相当高质量的东西(一个库或一个框架)。这种态度背后的理由通常是相信内部开发在几个方面肯定更好。它们经常被错误地认为比现有的和完善的解决方案更便宜、更安全、更灵活、更可控。

事实上,只有少数公司能够成功开发出与市场上已经存在的解决方案真正等效甚至更好的替代方案。通常,这种开发的巨大努力并不能证明低收益的合理性。与已经存在多年的现有成熟解决方案相比,自行开发的库或框架在质量上明显更差,这种情况并不少见。

在过去的几十年里,C++ 环境中出现了许多优秀的库和框架。这些解决方案有机会在很长一段时间内变得成熟,并且已经在成千上万个项目中成功使用。没有必要重新发明轮子。优秀的软件工匠应该知道这些库。不需要知道这些库和它们的 API 的每一个细节。不过,知道某些应用领域已经有了经过实践检验的解决方案是件好事,这些解决方案值得一看,以便为您的软件开发项目缩小选择范围。

利用

如果你想提高你组织中的代码质量,用一个目标代替你所有的编码指南:没有原始循环!Adobe 首席软件架构师 Sean Parent,在 CppCon 2013 上

摆弄元素集合是编程中的日常活动。不管我们处理的是测量数据的集合、电子邮件、字符串、数据库中的记录还是其他元素,软件都必须对它们进行过滤、排序、删除、操作等等。

在许多程序中,我们可以找到“原始循环”(例如,手工制作的for-循环或while-循环),用于访问容器或序列中的一些或所有元素,以便用它们做一些事情。一个简单的例子是以这种方式颠倒存储在std::vector中的整数的顺序:

#include <vector>

std::vector<int> integers { 2, 5, 8, 22, 45, 67, 99 };

// ...somewhere in the program:
std::size_t leftIndex = 0;
std::size_t rightIndex = integers.size() - 1;

while (leftIndex < rightIndex) {
  int buffer = integers[rightIndex];
  integers[rightIndex] = integers[leftIndex];
  integers[leftIndex] = buffer;
  ++leftIndex;
  --rightIndex;
}

基本上这段代码是可行的。但是它有几个缺点。很难立即看出这段代码在做什么(事实上,while 循环中的前三行可以由头文件<utility>中的std::swap替换)。此外,以这种方式编写代码非常繁琐,而且容易出错。想象一下,出于任何原因,我们违反了向量的边界,并试图访问超出范围的位置上的元素。与成员函数std::vector::at()不同,std::vector::operator[]不会引发std::out_of_range异常。这将导致未定义的行为。

C++ 标准库提供了 100 多种有用的算法,可应用于容器或序列,用于搜索、计数和操作元素。它们被收集在标题<algorithm>中。

例如,要颠倒任何类型的标准库容器中的元素顺序,例如,在一个std::vector中,我们可以简单地使用std::reverse:

#include <algorithm>

#include <vector>

std::vector<int> integers = { 2, 5, 8, 22, 45, 67, 99 };
// ...somewhere in the program:
std::reverse(std::begin(integers), std::end(integers));
// The content of ’integers’ is now: 99, 67, 45, 22, 8, 5, 2

与我们以前自己编写的解决方案不同,这段代码不仅更紧凑、更不容易出错,而且更易于阅读。由于std::reverse是一个函数模板(像所有其他算法一样),它普遍适用于所有标准库序列容器、关联容器、无序关联容器、std::string以及原始数组(顺便说一下,在现代 C++ 程序中不应该再使用原始数组;参见第四章中的“比起简单的 C 风格数组,更喜欢标准的库容器”一节。

#include <algorithm>

#include <string>

// Works, but primitive arrays should not be used in a modern C++ program

int integers[] = { 2, 5, 8, 22, 45, 67, 99 };
std::reverse(std::begin(integers), std::end(integers));

std::string text { "The big brown fox jumps over the lazy dog!" };
std::reverse(std::begin(text), std::end(text));
// Content of ’text’ is now: "!god yzal eht revo spmuj xof nworb gib ehT"

Listing 5-22.Applying std::reverse to a C-style array and a string

当然,反向算法也可以应用于容器或序列的子范围:

std::string text { "The big brown fox jumps over the lazy dog!" };
std::reverse(std::begin(text) + 13, std::end(text) - 9);
// Content of ’text’ is now: "The big brown eht revo spmuj xof lazy dog!"
Listing 5-23.Only a sub-area of the string is reversed

自 C++17 以来,算法的并行化更加容易

你的免费午餐很快就会结束。—赫伯·萨特[萨特 05]

上面这段话是写给全世界的软件开发人员的,摘自当时的 ISO C++ 标准化委员会成员 Herb Sutter 在 2005 年发表的一篇文章。当时,处理器的时钟频率不再逐年增加。换句话说,串行处理速度已经达到了物理极限。相反,处理器越来越多地配备了更多的内核。处理器架构的这种发展导致了一个严重的后果:开发人员不再能够利用时钟速率不断提高的处理器性能 Herb 谈到的“免费午餐”——但他们将被迫开发大规模多线程程序,作为更好地利用现代多核处理器的一种方式。因此,开发人员和软件架构师现在需要在他们的软件架构和设计中考虑并行化。

在 C++11 出现之前,C++ 标准只支持单线程编程,你必须使用第三方库(例如 Boost。线程)或编译器扩展(例如,开放多处理(OpenMP))来并行化您的程序。从 C++11 开始,所谓的线程支持库可用于支持多线程和并行编程。这个标准库的扩展引入了线程、互斥、条件变量和未来。

并行化一段代码需要很好的问题知识,因此必须在软件设计中加以考虑。否则,可能会出现由竞争条件导致的难以调试的细微错误。特别是对于标准库的算法,这些算法通常必须在装满大量对象的容器上运行,为了利用当今的现代多核处理器,应该为开发人员简化并行化。

从 C++17 开始,标准库的部分内容已经根据 C++ 并行扩展技术规范(ISO/IEC TS 19570:2015)进行了重新设计,简称为并行 TS (TS =技术规范)。换句话说,在 C++17 中,这些扩展成为了主流 ISO C++ 标准的一部分。他们的主要目标是将开发人员从复杂的任务中解放出来,去摆弄线程支持库中的那些低级语言特性,比如std::threadstd::mutex等。

事实上,这意味着 69 个众所周知的算法被重载,现在也可以在一个或多个版本中使用,接受一个额外的并行化模板参数ExecutionPolicy(见侧栏)。这些算法中的一些例如是std::for_eachstd::transformstd::copy_ifstd::sort。此外,还增加了七种新算法,它们也可以并行化,如std::reducestd::exclusive_scanstd::transform_reduce。这些新算法在函数式编程中特别有用,这也是我将在第七章中讨论它们的原因。

Execution Policies [C++17]

header <algorithm>中的大部分算法模板已经过载,现在也有了可并行化版本。例如,除了已经存在的函数std::find的模板之外,还定义了另一个版本,它使用一个额外的模板参数来指定执行策略:

// Standard (single-threaded) version:
template< class InputIt, class T >
InputIt find( InputIt first, InputIt last, const T& value );
// Additional version with user-definable execution policy (since C++17):
template< class ExecutionPolicy, class ForwardIt, class T >
ForwardIt find(ExecutionPolicy&& policy, ForwardIt first, ForwardIt last, const T& value);

模板参数ExecutionPolicy可用的三个标准策略标签是:

  • std::execution::seq–一种执行策略类型,定义并行算法的执行可以是顺序的。因此,这与您使用不带执行策略的算法模板函数的单线程标准版本或多或少是一样的。
  • std::execution::par–一种执行策略类型,定义并行算法的执行可以并行化。它允许实现在多线程上执行算法。重要提示:并行算法不会自动防止关键数据竞争或死锁。您有责任确保在执行该函数时不会出现数据争用情况。
  • std::execution::par_unseq–一种执行策略类型,定义并行算法的执行可以被矢量化和并行化。矢量化利用了现代 CPU 的 SIMD(单指令、多数据)命令集。SIMD 意味着处理器可以同时在多个数据点上执行相同的操作。

当然,用几个元素并行排序一个小向量是绝对没有意义的。线程管理的开销将远远高于性能提升。因此,也可以在运行时动态选择执行策略,例如,通过考虑向量的大小。不幸的是,C++17 标准还没有接受动态执行策略。现在正计划用于即将到来的 C++20 标准。

对所有可用算法的全面讨论超出了本书的范围。但是在简短介绍了 header <algorithm>和 C++17 并行化的新可能性之后,让我们来看几个可以用算法做什么的例子。

集装箱的分类和输出

下面的例子使用了来自标题<algorithm> : std::sortstd::for_each的两个模板。在内部,std::sort正在使用快速排序算法。默认情况下,std::sort内部的比较是通过元素的operator<函数来执行的。这意味着如果你想对你自己的一个类的实例序列进行排序,你必须确保operator<在那个类型上被正确实现。

#include <algorithm>

#include <iostream>

#include <string>

#include <vector>

void printCommaSeparated(const std::string& text) {
  std::cout << text << ", ";
}

int main() {
  std::vector<std::string> names = { "Peter", "Harry", "Julia", "Marc", "Antonio", "Glenn" };
  std::sort(std::begin(names), std::end(names));
  std::for_each(std::begin(names), std::end(names), printCommaSeparated);
  return 0;
}

Listing 5-24.Sorting a vector of strings and printing them on stdout

比较两个序列

以下示例使用std::equal比较两个字符串序列。

#include <algorithm>

#include <iostream>

#include <string>

#include <vector>

int main() {
  const std::vector<std::string> names1 { "Peter", "Harry", "Julia", "Marc", "Antonio",
    "Glenn" };
  const std::vector<std::string> names2 { "Peter", "Harry", "Julia", "John", "Antonio",
    "Glenn" };

  const bool isEqual = std::equal(std::begin(names1), std::end(names1), std::begin(names2), std::end(names2));

  if (isEqual) {
    std::cout << "The contents of both sequences are equal.\n";
  } else {
    std::cout << "The contents of both sequences differ.\n";
  }
  return 0;
}

Listing 5-25.Comparing two sequences of strings

默认情况下,std::equal使用operator==比较元素。但你可以随心所欲地定义“平等”。标准比较可以由自定义比较操作代替:

#include <algorithm>

#include <iostream>

#include <string>

#include <vector>

bool compareFirstThreeCharactersOnly(const std::string& string1,
                                     const std::string& string2) {
  return (string1.compare(0, 3, string2, 0, 3) == 0);
}

int main() {
  const std::vector<std::string> names1 { "Peter", "Harry", "Julia", "Marc", "Antonio",
    "Glenn" };
  const std::vector<std::string> names2 { "Peter", "Harold", "Julia", "Maria", "Antonio",
    "Glenn" };

  const bool isEqual = std::equal(std::begin(names1), std::end(names1), std::begin(names2),
    std::end(names2), compareFirstThreeCharactersOnly);

  if (isEqual) {
    std::cout << "The first three characters of all strings in both sequences are equal.\n";
  } else {
    std::cout << "The first three characters of all strings in both sequences differ.\n";
  }
  return 0;
}

Listing 5-26.Comparing two sequences of strings using a custom predicate function

如果比较函数compareFirstThreeCharactersOnly()不需要可重用性,上面进行比较的行也可以使用 lambda 表达式实现(我们在第七章中更详细地讨论 lamda 表达式),如下所示:

  // Compare just the first three characters of every string to ascertain equalness:
  const bool isEqual =
    std::equal(std::begin(names1), std::end(names1), std::begin(names2), std::end(names2),
    [](const auto& string1, const auto& string2) {
      return (string1.compare(0, 3, string2, 0, 3) == 0);
    });

这种替代方案可能看起来更紧凑,但它不一定有助于代码的可读性。显式函数compareFirstThreeCharactersOnly()有一个语义名称,它非常清楚地表达了比较的内容(而不是方式;参见第四章中的“使用透露意图的名称”一节。从带有 lambda 表达式的版本中,不一定一眼就能看出具体比较的是什么。永远记住,代码的可读性应该是我们的首要目标之一。同样,永远记住源代码注释基本上是一种代码味道,不适合解释难以阅读的代码(记住第四章中关于注释的部分)。

利用 Boost

著名的 Boost 库( www .我就不能泛泛介绍了。助推。org ,在 Boost 软件许可下分发,版本 1.0)此处。库(其实就是库的库)太大太强大了,详细讨论已经超出了本书的范围。此外,还有许多关于 Boost 的好书和教程。

但是我认为了解这个库及其内容是非常重要的。C++ 开发人员在日常工作中面临的许多问题和挑战都可以通过 Boost 的库得到很好的解决。

除此之外,Boost 是几个库的“孵化器”,如果它们有一定的成熟度,它们有时会被接受成为 C++ 语言标准的一部分。请注意:这并不一定意味着它们完全兼容!例如,std::thread(从 C++11 开始成为标准的一部分)部分等同于 Boost。线程,但也有一些不同之处。例如,Boost 实现支持线程取消,而 C++11 线程不支持。另一方面,C++11 支持std::async,Boost 不支持。

从我的角度来看,从 Boost 中了解这些库是值得的,并且要记住当你有一个合适的问题可以被它们适当地解决时。

您应该了解的更多库

除了标准的库容器、<algorithm>和 Boost 之外,在编写代码时,您可能会考虑到更多的库。这里有一个当然不完整的库列表,当你遇到某个合适的问题时,值得一看:

  • 日期和时间工具(<chrono>):从 C++11 开始,该语言提供了一组类型来表示时钟、时间点和持续时间。例如,您可以借助std::chrono::duration来表示时间间隔。有了std::chrono::system_clock,一个全系统的实时时钟就可用了。从 C++11 开始,只要包含<chrono>头文件,就可以使用这个库。
  • 正则表达式库(<regex>):从 C++11 开始,可以使用正则表达式库来执行字符串内的模式匹配。还支持基于正则表达式替换字符串中的文本。从 C++11 开始,只要包含<regex>头文件,就可以使用这个库。
  • 文件系统库(<filesystem>):从 C++17 开始,文件系统库已经成为标准的一部分。在它成为主线 C++ 标准的一部分之前,它一直是一个技术规范(ISO/IEC TS 18822:2015)。独立于操作系统的库提供了各种工具,用于在文件系统及其组件上执行操作。在<filesystem>的帮助下,你可以创建目录、复制文件、遍历目录条目、获取文件大小等。从 C++17 开始,只要包含<filesystem>头文件,你就可以使用这个库。

Tip

如果您目前仍未按照最新的 C++17 标准工作,请使用 Boost。文件系统可能是一种替代方案。

  • Range-v3:由 ISO C++ 标准化委员会成员 Eric Niebler 编写的 C++11/14/17 范围库。Range-v3 是一个只有头文件的库,它简化了对 C++ 标准库的容器或来自其他库(如 Boost)的容器的处理。在这个库的帮助下,你可以在各种情况下摆脱有时有点棘手的迭代器杂耍。例如,不写std::sort(std::begin(container), std::end(container)),你可以简单地写ranges::sort(container)。GitHub 上有 Range-v3,网址: https://github.com/ericniebler/range-v3 。文档可以在这里找到: https://ericniebler.github.io/range-v3/
  • concurrent Data Structures(lib CDs):由 Max Khizhinsky 编写的一个大部分只有头文件的 C++ 模板库,为并行高性能计算提供了无锁算法和并发数据结构实现。这个库是用 C++11 编写的,并在 BSD 许可下发布。libcds 及其文档可以在 SourceForge 上找到,网址: http://libcds.sourceforge.net

正确的异常和错误处理

也许你已经听说过跨领域关注这个术语。这个表达包括所有那些难以通过模块化概念解决的事情,因此需要软件架构和设计的特殊处理。这些典型的跨领域问题之一是安全性。如果你必须在你的软件系统中考虑数据安全和访问限制,因为这是某些质量要求所要求的,这是一个贯穿整个系统的敏感话题。你必须在几乎所有地方,在几乎每一个组件中处理它。

另一个横切关注点是事务处理。特别是在使用数据库的软件应用程序中,你必须确保一个所谓的事务,它是一系列连贯的单个操作,必须作为一个完整的单元成功或失败;它永远不会只是部分完整。

另一个例子是,日志也是一个跨领域的问题。在软件系统中,通常到处都需要日志记录。有时,特定于领域的高效代码中充斥着日志语句,这不利于代码的可读性和可理解性。

如果软件架构没有考虑这些横切关注点,这可能导致不一致的解决方案。例如,两个不同的日志框架可以用在同一个项目中,因为在同一个系统上工作的两个开发团队决定选择不同的框架。

异常和错误处理是另一个横切关注点。处理需要特殊响应和处理的错误和不可预测的异常在每个软件系统中都是强制性的。当然,系统范围的错误处理策略应该是统一和一致的。因此,负责软件架构的人必须在项目的早期设计和开发错误处理策略,这一点非常重要。

那么,指导我们开发一个好的错误处理策略的原则是什么呢?什么时候抛出异常是合理的?我如何处理抛出的异常?出于什么目的不应该使用异常?有哪些选择?

下面几节介绍了一些规则、指南和原则,帮助 C++ 程序员设计和实现良好的错误处理策略。

预防胜于治疗

处理错误和异常的一个非常好的基本策略是尽量避免它们。原因是显而易见的:所有不可能发生的事情都不需要治疗。

也许你现在会说:“嗯,这是老生常谈。当然,避免错误或异常要好得多,但有时无法避免它们。”你说得对,乍一看这听起来很平庸。是的,尤其是在使用第三方库、访问数据库或访问外部系统时,不可预见的事情可能会发生。但是对于你自己的代码,也就是那些你可以按照自己的意愿设计的东西,你可以采取适当的措施来尽量避免异常。

David Abrahams,一位美国程序员,前 ISO C++ 标准化委员会成员,Boost C++ 库的创始成员,创建了对异常安全的理解,并在 1998 年的一篇论文[Abrahams98]中提出。本文中制定的一套契约准则,也称为“亚伯拉罕保证”,对 C++ 标准库的设计以及该库如何处理异常产生了重大影响。但是这些指导方针不仅仅与低级别的库实现者相关。在更高抽象层次上编写应用程序代码的软件开发人员也可以考虑它们。

异常安全是界面设计的一部分。接口(API)不仅仅由函数签名组成,也就是说,函数的参数和返回类型。此外,调用函数时可能抛出的异常是其接口的一部分。此外,还有三个方面必须考虑:

  • 前提条件:前提条件是在调用函数或类的方法之前必须始终为真的条件。如果违反了一个前提条件,就不能保证函数调用会导致预期的结果:函数调用可能成功,也可能失败,可能导致不必要的副作用,或者显示未定义的行为。
  • 不变量:不变量是在函数或方法执行过程中必须始终为真的条件。换句话说,这是一个在函数执行开始和结束时都成立的条件。面向对象中不变量的一种特殊形式是类不变量。如果违反了这样的不变量,那么在方法调用之后,该类的对象(实例)将处于不正确和不一致的状态。
  • 后置条件:后置条件是在函数或方法执行后必须立即为真的条件。如果违反了后置条件,则一定是在函数或方法的执行过程中发生了错误。

异常安全背后的思想是,函数,或者一个类及其方法,给它们的客户一种承诺,或者保证,关于不变量,后置条件,以及关于可能抛出或者不抛出的异常。有四个级别的异常安全。在接下来的小节中,我将按照安全性递增的顺序简要讨论它们。

没有例外-安全

有了这种最低级别的异常安全——从字面上看,没有异常安全——绝对没有保证。任何发生的异常都会带来灾难性的后果。例如,违反了被调用函数或方法的不变量和后置条件,并且您的代码的一部分(例如,一个对象)可能处于损坏状态。

我认为,毫无疑问,您编写的代码永远不会提供这种不充分的异常安全级别!就当没有“没有例外——安全”这回事吧。仅此而已;关于那件事没什么好说的了。

基本例外-安全

基本的异常安全保证是任何一段代码至少应该提供的保证。这也是相对较少的实现工作就可以达到的异常安全级别。该级别保证了以下几点:

  • 如果在函数或方法调用过程中抛出异常,就可以确保没有资源泄漏!这种保证包括内存资源以及其他资源。这可以通过应用 RAII 模式来实现(参见关于 RAII 和智能指针的部分)。
  • 如果在函数或方法调用过程中抛出异常,所有不变量都会被保留。
  • 如果在函数或方法调用过程中抛出异常,之后将不会有数据或内存损坏,并且所有对象都处于健康和一致的状态。但是,不能保证数据内容与调用函数或方法之前的内容相同。

严格的规则是这样的:

设计您的代码,尤其是您的类,使它们至少保证基本的异常安全。这应该始终是默认的异常安全级别!

要知道 C++ 标准库期望所有用户类型总是至少给出基本的异常保证,这一点很重要。

强异常-安全性

强异常安全保证了基本异常安全级别所保证的一切,但还保证了在出现异常的情况下,数据内容完全恢复到调用函数或方法之前的状态。换句话说,有了这个异常安全级别,我们就可以像在数据库的事务处理中一样获得提交或回滚语义。

很容易理解,这种异常安全级别会导致更高的实现工作量,并且在运行时会很昂贵。这种额外努力的一个例子是所谓的复制和交换习惯用法,它必须用于确保复制分配的强大异常安全性。

在没有任何好的理由的情况下,给你的整个代码配备强大的异常安全会违反基斯和 YAGNI 原则(见第三章)。因此,这方面的指导方针如下:

只有在绝对必要的情况下,才为您的代码发布强异常安全保证。

当然,如果必须满足某些关于数据完整性和数据正确性的质量要求,您必须提供回滚机制,这种机制通过强大的异常安全性来保证。

无投掷保证

这是最高的异常安全级别,也称为故障透明性。简单地说,这个级别意味着作为一个函数或方法的调用者,你不必担心异常。函数或方法调用将会成功。永远!它永远不会抛出异常,因为一切都在内部得到了妥善处理。永远不会违反不变量和后置条件。

这是异常安全的全方位无忧包,但有时很难甚至不可能实现,尤其是在 C++ 中。例如,如果您在函数中使用任何类型的动态内存分配,如操作符new,无论是直接还是间接(例如,通过std::make_shared<T>),在遇到异常后,您绝对没有机会以成功处理的函数结束。

在这些情况下,不抛出保证要么是绝对强制的,要么至少是明确建议的:

  • 类的析构函数必须保证在任何情况下都不抛出!原因是,在其他情况下,在遇到异常后堆栈展开时也会调用析构函数。如果在堆栈展开期间出现另一个异常,那将是致命的,因为程序会立即终止。因此,析构函数中处理分配的资源并试图关闭它们的任何操作,比如打开的文件或堆中分配的内存,都不能抛出。
  • 移动操作(移动构造函数和移动赋值操作符;参见前面关于移动语义的部分)应该保证无抛出。如果一个移动操作抛出一个异常,那么这个移动没有发生的可能性非常高。因此,应该不惜一切代价避免移动操作的实现通过可能引发异常的资源分配技术来分配资源。此外,为打算与 C++ 标准库容器一起使用的类型提供无抛出保证也很重要。如果容器中元素类型的移动构造函数没有给出无抛出保证(也就是说,移动构造函数没有用noexcept说明符声明,参见下面的侧栏),那么容器将更喜欢使用复制操作而不是移动操作。
  • 默认构造函数最好是无抛出的。基本上,在构造函数中抛出异常是不可取的,但这是处理构造函数失败的最好方法。一个“半构造的对象”很有可能违反不变量。处于破坏状态的对象违反了它的类不变量,这是无用和危险的。因此,当异常不可避免时,在默认构造函数中抛出异常是无可非议的。然而,尽量避免它是一个好的设计策略。默认构造函数应该简单。如果一个默认的构造函数可以抛出,它可能做了太多复杂的事情。因此,在设计类时,应该尽量避免默认构造函数中的异常。
  • 交换函数必须保证在任何情况下都不抛出!一个专业实现的swap()函数不应该使用可能引发异常的内存分配技术来分配任何资源(例如内存)。如果swap()可以抛出,那将是致命的,因为它可能以不一致的状态结束。编写异常安全operator=()的最佳方式是使用非抛出swap()函数来实现它。

Noexcept Specifier and Operator [C++11]

在 C++11 之前,有一个可以出现在函数声明中的关键字throw。它用于在一个逗号分隔的列表中列出函数可能直接或间接抛出的所有异常类型,称为动态异常规范。从 C++11 开始就不推荐使用throw(exceptionType, exceptionType, ...)了,现在已经从 C++17 的标准中删除了!什么仍然可用,但也被标记为不推荐使用,因为 C++11 是没有异常类型列表的throw()说明符。它的语义现在与noexcept(true)说明符相同。

函数签名中的noexcept说明符声明该函数不能抛出任何异常。这同样适用于noexcept(true),,它只是noexcept的同义词。相反,用noexcept(false)声明的函数有可能抛出,也就是说,它可能抛出异常。以下是一些例子:

void nonThrowingFunction() noexcept;

void anotherNonThrowingFunction() noexcept(true);

void aPotentiallyThrowingFunction() noexcept(false);

使用noexcept有两个很好的理由:首先,函数或方法可能抛出(或不抛出)的异常是函数接口的一部分。它是关于语义的,帮助阅读代码的开发人员知道什么可能会发生,什么不会发生。noexcept告诉开发者可以在自己的非抛出函数中放心使用这个函数。因此,noexcept的出现有点类似于const

其次,它可以被编译器用于优化。noexcept潜在地允许编译器编译该函数,而不增加先前被移除的throw(...)所需要的运行时开销,即当抛出未列出的异常时调用std::unexpected()所必需的目标代码。

对于模板实现者,还有一个noexcept操作符,它执行编译时检查,如果表达式被声明为不抛出任何异常,则返回true:

constexpr auto isNotThrowing = noexcept(nonThrowingFunction());

注意:同样constexpr函数(参见“编译时的计算”一节)在运行时求值时会抛出,所以你可能还需要noexcept来处理其中的一些。

例外就是例外——真的!

在第四章中,我们在“不要传递或返回 0 (NULL,nullptr)”一节中讨论了不应该将nullptr作为函数的返回值返回。作为一个代码示例,我们有一个小函数,它应该根据客户的名字执行查找,如果找不到这个客户,当然不会有结果。现在有人可能会想出一个主意,我们可以为一个未找到的客户抛出一个异常,如下面的代码示例所示。

#include "Customer.h"

#include <string>

#include <exception>

class CustomerNotFoundException : public std::exception {
  virtual const char* what() const noexcept override {
    return "Customer not found!";
  }
};

// ...

Customer CustomerService::findCustomerByName(const std::string& name) const noexcept(false) {
  // Code that searches the customer by name...
  // ...and if the customer could not be found:
  throw CustomerNotFoundException();
}

现在让我们来看看这个函数的调用位置:

  Customer customer;
  try {
    customer = findCustomerByName("Non-existing name");
  } catch (const CustomerNotFoundException& ex) {
    // ...
  }
  // ...

乍一看,这似乎是一个可行的解决方案。如果我们必须避免从函数中返回nullptr,我们可以抛出一个CustomerNotFoundException来代替。在调用站点,借助于try-catch构造,我们现在能够区分好的情况和坏的情况。

事实上,这是一个非常糟糕的解决方案!仅仅因为客户的名字不存在而找不到客户绝对不是特例。这些都是正常情况下会发生的事情。上面例子中所做的是对异常的滥用。异常不是用来控制正常程序流的。例外应该留给真正例外的人!

“真正的卓越”是什么意思?这意味着你对此无能为力,你也无法真正处理这个异常。例如,让我们假设您面临一个std::bad_alloc异常,这意味着内存分配失败。现在节目应该怎么继续?这个问题的根本原因是什么?底层硬件系统是否缺少内存?那我们就有一个非常严重的问题了!有没有什么有意义的方法可以从这个严重的异常中恢复过来,恢复程序的执行?我们还能对程序继续运行,就像什么都没发生一样负责吗?

这些问题不容易回答。也许这个问题的真正触发器是一个悬空指针,在我们遇到std::bad_alloc异常之前,它已经被不熟练地使用了数百万条指令。所有这些都很少能在例外的时候重现。

以下是我的建议:

仅在非常特殊的情况下抛出异常。不要滥用异常来控制正常的程序流程。

也许你现在会问自己:“嗯,分别用nullptrNULL作为返回值是不好的,异常也是不可取的……我现在该怎么办?”在第九章关于设计模式的“特例对象(空对象)”一节中,我将提出一个可行的解决方案来以适当的方式处理这些情况。

如果你恢复不了,就赶快离开

如果遇到无法恢复的异常,最好的方法通常是记录该异常(如果可能的话),或者生成一个崩溃转储文件供以后分析,并立即终止程序。快速终止可能是最佳反应的一个很好的例子是内存分配失败。如果一个系统缺少内存,那么在你的程序中你应该怎么做?

这种针对一些关键异常和错误的严格处理策略背后的原则被称为“死程序不会说谎”,在《实用程序员》[Hunt99]一书中有所描述。

最糟糕的事情莫过于在出现严重错误后,若无其事地继续工作,例如,产生成千上万的错误预订,或者第一百次将电梯从地下室送到顶楼再送回来。相反,在太多间接损害发生之前离开。

定义用户特定的异常类型

虽然在 C++ 中你可以抛出任何你想抛出的东西,比如一个int或者一个const char*,但是我不建议你这么做。异常由其类型捕获;因此,为某些特定领域的异常创建定制的异常类是一个非常好的主意。正如我在第四章中已经解释过的,一个好的命名对于代码的可读性和可维护性是至关重要的,异常类型也应该有一个好的名字。对设计“正常”程序代码有效的其他原则当然也对异常类型有效(我们将在关于面向对象的章节 6 中详细讨论这些原则)。

要提供您自己的异常类型,您可以简单地创建您自己的类,并从std::exception(在头文件<stdexcept>中定义)派生它:

#include <stdexcept>

class MyCustomException : public std::exception {
  virtual const char* what() const noexcept override {
    return "Provide some details about what was going wrong here!";
  }
};

通过覆盖从std::exception继承的虚拟what()成员函数,我们可以向调用者提供一些关于哪里出错的信息。此外,从std::exception派生出我们自己的异常类将使它可以被一个通用的 catch-clause 捕获(顺便说一下,这应该被认为是捕获异常的最后一种可能性),就像这样:

#include <iostream>

// ...

try {
  doSomethingThatThrows();
} catch (const std::exception& ex) {
  std::cerr << ex.what() << std::endl;
}

基本上,异常类应该有一个简单的设计,但是如果您想提供关于异常原因的更多细节,您也可以编写更复杂的类,如下所示:

class DivisionByZeroException : public std::exception {

public:
  DivisionByZeroException() = delete;
  explicit DivisionByZeroException(const int dividend) {
    buildErrorMessage(dividend);
  }

  virtual const char* what() const noexcept override {
    return errorMessage.c_str();
  }

private:
  void buildErrorMessage(const int dividend) {
    errorMessage = "A division with dividend = ";
    errorMessage += std::to0_string(dividend);
    errorMessage += ", and divisor = 0, is not allowed (Division by Zero)!";
  }

  std::string errorMessage;
};

Listing 5-27.A custom exception class for divisions by zero

请注意,由于它的实现,私有成员函数buildErrorMessage()只能保证强异常安全,即由于使用了std::string::operator+=()可能会抛出!因此,初始化构造函数也不能保证不抛出。这就是为什么异常类通常应该有一个非常简单的设计。

下面是我们的DivisionByZeroException类的一个小用法示例:

int divide(const int dividend, const int divisor) {
  if (divisor == 0) {
    throw DivisionByZeroException(dividend);
  }
  return dividend / divisor;
}

int main() {
  try {
    divide(10, 0);
  } catch (const DivisionByZeroException& ex) {
    std::cerr << ex.what() << std::endl;
    return 1;
  }
  return 0;
}

按值抛出,按常数引用捕捉

有时我看到异常对象在new的帮助下被分配到堆上,并作为指针抛出,就像这个例子:

try

{
  CFile f(_T("M_Cause_File.dat"), CFile::modeWrite);
  // If "M_Cause_File.dat" does not exist, the constructor of CFile throws an exception
  // this way: throw new CFileException()
}

catch(CFileException* e)
{
  if( e->m_cause == CFileException::fileNotFound)
    TRACE(_T("ERROR: File not found\n"));
  e->Delete();
}

也许您已经认识到了这种 C++ 编码风格:以这种方式抛出和捕捉异常可以在优秀的老 MFC(微软基础类)库中找到。重要的是,不要忘记在 catch-clause 的末尾调用Delete()成员函数;否则你可以说“你好!”内存泄漏。

嗯,用new抛出异常并将它们作为指针捕获在 C++ 中是可能的,但这是糟糕的设计。不要这样做!如果忘记删除异常对象,将会导致内存泄漏。总是通过值抛出异常对象,并通过常量引用捕获它们,这在前面的所有例子中都可以看到。

注意 Catch-从句的正确顺序

如果在一个try块后提供了不止一个catch-子句,例如为了区分不同类型的异常,注意正确的顺序是很重要的。Catch-条款按照出现的顺序进行评估。这意味着更具体的异常类型的catch-子句必须放在前面。在下面的例子中,异常类DivisionByZeroExceptionCommunicationInterruptedException都是从std::exception派生的。

try {
  doSomethingThatCanThrowSeveralExceptions();
} catch (const DivisionByZeroException& ex) {
  // ...
} catch (const CommunicationInterruptedException& ex) {
  // ...
} catch (const std::exception& ex) {
  // Handle all other exceptions here that are derived from std::exception
} catch (...) {
  // The rest...
}
Listing 5-28.The more specific exceptions must be handled first

我认为原因是显而易见的:让我们假设一般std::exceptioncatch-子句是第一个子句,会发生什么?下面那些更具体的永远不会有机会,因为它们被更一般的“隐藏”了。因此,开发人员必须注意将它们按正确的顺序排列。

六、面向对象

面向对象(OO)的历史根源可以在 20 世纪 50 年代末找到。挪威计算机科学家克利斯登·奈加特和奥利·约翰·达尔在军事研究所挪威国防研究所(NDRE)为挪威第一个核反应堆的开发和建造进行了模拟计算。在开发模拟程序时,两位科学家注意到,用于该任务的过程编程语言不太适合要解决的问题的复杂性。达尔和尼加德感到需要在这些语言中找到合适的可能性来抽象和再现现实世界的结构、概念和过程。

1960 年,尼加德搬到了两年前在奥斯陆建立的挪威计算中心(NCC)。三年后,奥利·约翰·达尔也加入了 NCC。在这个私人的、独立的、非盈利的研究基金会,两位科学家为一种——从今天的观点来看——面向对象编程语言开发了第一个想法和概念。尼加德和达尔在寻找一种适用于所有领域的语言,而不是专门针对某些应用领域的语言,例如,用于数值计算和线性代数的 Fortran 或 COBOL,它是专门为商业用途设计的。

他们研究活动的结果最终是编程语言 Sim ula-67,它是过程编程语言 ALGOL 60 的扩展。新语言引入了类、子类、对象、实例变量、虚方法,甚至垃圾收集器。Simula-67 被认为是第一种面向对象的编程语言,并影响了以下许多其他编程语言,例如,由 Alan Kay 及其团队在 20 世纪 70 年代初设计的完全面向对象的编程语言 Smalltalk。

当丹麦计算机科学家比雅尼·斯特劳斯特鲁普于 1970 年末在剑桥大学完成他的博士论文《分布式计算机系统中的通信和控制》时,他使用了 Simula-67,并发现它非常有用,但对于实际应用来说太慢了。因此,他开始寻找将 Simula-67 数据抽象的面向对象概念与低级编程语言的高效率相结合的可能性。当时最有效的编程语言是 C 语言,它是由美国计算机科学家丹尼斯·里奇于 20 世纪 70 年代早期在贝尔电话实验室开发的。Stroustrup 于 1979 年加入贝尔电话实验室的计算机科学研究中心,他开始在 C 语言中加入面向对象的特性,如类、继承、强类型检查和许多其他东西,并将其命名为“带类的 C”1983 年,该语言的名称改为 C++,这是 Stroustrups 的合伙人 Rick Mascitti 创造的一个词,其中++ 的灵感来自于该语言的后增量运算符。

在接下来的几十年里,面向对象成为了主导的编程范式。

面向对象的思维

有一点非常重要,我们需要牢记在心。仅仅因为市场上有几种支持面向对象概念的编程语言,就绝对不能保证使用这些语言的开发人员会自动产生面向对象的软件设计。尤其是那些长期使用过程语言的开发人员,在向这种编程范式过渡时经常会遇到困难。面向对象不是一个容易理解的概念。它要求开发者以一种新的方式看待世界。

艾伦·库尔蒂斯·凯博士在 20 世纪 70 年代早期和 PARC 施乐公司的一些同事一起开发了面向对象的编程语言 Smalltalk,他被誉为“面向对象”一词的创始人之一在通过电子邮件与德国大学讲师迪平的讨论中。来自柏林自由大学的 Stefan Ram 从 2003 年开始,Kay 为他解释了什么是面向对象:

I think that objects are like biological cells and/or a single computer on the network, which can only communicate with messages (so messaging appeared from the very beginning-it took some time to understand how to use messaging effectively in programming languages). (......) OOP to me only means messaging, local reservation, protecting and hiding state processes, and extreme late binding of everything. —Dr. Alan Curtis Kay, American computer scientist, July 23, 2003 [ram 03]

生物细胞可以被定义为所有生物体的最小结构和功能单位。它们通常被称为“生命的基石”艾伦·凯认为软件就像生物学家看待复杂的活的有机体一样。艾伦·凯的这种视角应该不会令人惊讶,因为他拥有数学和分子生物学学士学位。

艾伦·凯的细胞就是我们在 OO 中所说的对象。一个对象可以被认为是一个有结构和行为的“东西”。生物细胞有一层膜包裹着它。这也适用于面向对象的对象。对象应该被很好地封装,并通过定义良好的接口提供服务。

此外,Alan Kay 强调“消息传递”对他来说在面向对象中起着核心作用。然而,他并没有确切地解释他这么说的意思。在一个对象上调用一个名为foo()的方法是否等同于向那个对象发送一个名为“foo”的消息?或者 Alan Kay 有消息传递基础设施的想法,比如 CORBA(公共对象请求代理体系结构)和类似的技术?Kay 博士也是一名数学家,所以他也可以指一个著名的消息传递数学模型,名为 Actor 模型,在并发计算中非常流行。

在任何情况下,不管 Alan Kay 在谈到消息传递时有什么想法,我认为这种观点很有趣,并且大体上适用于在抽象层次上解释面向对象程序的典型结构。但是凯先生的解释肯定不足以回答下列重要问题:

  • 我如何找到并形成“细胞”(物体)?
  • 我如何设计这些单元的公共可用接口?
  • 我如何管理谁可以与谁通信(依赖)?

面向对象(OO)主要是一种思维方式,而不是所用语言的问题。它也可能被滥用和误用。

我见过许多用 C++ 或纯面向对象语言(如 Java)编写的程序,其中使用了类,但这些类只构成了包装过程程序的大型名称空间。或者略带讽刺地表达:显然,类似 Fortran 的程序可以用几乎任何编程语言编写。另一方面,每一个已经内化了面向对象思想的开发人员将能够用面向对象的设计开发软件,即使是用像 ANSI-C、汇编程序这样的语言,或者使用 shell 脚本。

抽象——掌握复杂性的关键

面向对象背后的基本思想是我们在软件中从我们领域的相关部分建模事物和概念。因此,我们将自己仅仅局限于那些必须在我们的软件系统中表现出来以满足涉众需求的东西,也称为需求。抽象是以适当的方式对这些事物和概念建模的最重要的工具。我们不想模仿整个真实世界。我们只需要从现实世界中摘录,减少到与实现系统用例相关的细节。

例如,如果我们想在一个书店系统中代表一个顾客,那么这个顾客的血型是很有可能的,而且是绝对没有意义的。另一方面,对于医学领域的软件系统来说,人的血型可能是一个重要的细节。

对我来说,面向对象是关于数据抽象、责任、模块化,以及分而治之。如果我必须把它归结为,我会说 OO 是关于对复杂性的掌握。我用一个小例子来解释一下。

以汽车为例。汽车是由几个部分组成的,例如,车身、发动机、齿轮、车轮、座椅等。这些部分本身也由更小的部分组成。以汽车的发动机为例(让我们假设它是内燃机,而不是电动机)。发动机由气缸体、汽油点火泵、传动轴、凸轮轴、活塞、发动机控制单元(ECU)、冷却液子系统等组成。冷却液子系统同样由热交换器、冷却液泵、冷却液储罐、风扇、恒温器和加热器芯组成。汽车的分解理论上可以延续到最小的螺丝钉。每个确定的子系统或部分都有明确定义的职责。但是,只有所有的零件组合在一起,并以正确的方式组装,才能制造出一辆能提供司机所期望的服务的汽车。

复杂的软件系统可以用同样的方式来考虑。它们可以分层分解成粗粒度到细粒度的模块。这有助于应对系统的复杂性,提供更多的灵活性,并培养可重用性、可维护性和可测试性。进行这种分解的指导原则主要如下:

  • 信息隐藏(参见第三章中的同名章节),
  • 凝聚力强(见第三章中的同名章节),
  • 松散耦合(参见第三章中的同名章节),以及
  • 单一责任原则(SRP 请参阅本章后面的同名部分)。

优秀课堂设计的原则

在面向对象语言中,形成前面描述的模块的广泛和众所周知的机制是类的概念。类被认为是封装的软件模块,它将结构特征(同义词:属性、数据成员、字段)和行为特征(同义词:成员函数、方法、操作)结合成一个内聚单元。

在像 C++ 这样具有面向对象功能的编程语言中,类是高于函数的下一个更高级的结构化概念。它们通常被描述为对象的蓝图(同义词:实例)。这足以成为进一步研究类的概念的理由。在这一章中,我给出了一些用 C++ 设计和编写好类的重要线索。

保持小班教学

在我作为软件开发人员的职业生涯中,我见过很多非常大的班级。成千上万行代码并不罕见。经过仔细观察,我注意到这些大型类通常只被用作或多或少过程化程序的名称空间,这些程序的开发人员通常不理解面向对象。

我认为这种大班的问题是显而易见的。如果类包含几千行代码,那么它们很难理解,并且它们的可维护性和可测试性通常很差,更不用说可重用性了。根据几项研究,大班通常包含更多的缺陷。

The God Class Anti-Pattern

在许多系统中,有非常大的类,有许多属性和几百个操作。这些类的名称通常以“…控制器”、“管理器”或“…助手”结尾开发人员经常争辩说,系统中的某个地方必须有一个中心实例来拉动字符串并协调一切。这种思维方式的结果是这样的巨型类,它们的内聚性非常差(参见第三章中关于强内聚性的部分)。他们就像一个便利店,提供五颜六色的商品。

这种类被称为 God 类,Go d 对象,有时也称为 Blob(Blob 是 1958 年的一部美国恐怖/科幻电影,讲述了一种外星变形虫吃掉一个村庄的居民)。这就是所谓的反模式,被认为是糟糕设计的同义词。一个神类是一个不可驯服的野兽,难以维护,难以理解,不可测试,容易出错,并且对其他类有很大的依赖性。在系统的生命周期中,这样的类越来越大。这让问题变得更糟。

什么被证明是函数大小的好规则(参见第四章中的“让它们小一点”),这似乎也是类大小的好建议:类应该小!

如果小尺寸是类设计的一个目标,那么接下来的问题是:多小?

对于函数,我已经在第章第 4 中给出了一些代码行。难道没有可能为被认为是好的或合适的类定义一些行吗?

在 ThoughtWorks 选集[ThoughtWorks08]中,Jeff Bay 提交了一篇题为“对象健美操:今天更好的软件设计的 9 个步骤”的文章,建议一个类的代码不要超过 50 行。

对于许多开发人员来说,大约 50 行的上限似乎是不可能的。他们似乎对阶级的产生有一种无法解释的抵触情绪。他们经常这样争论:“不超过 50 行?但这将导致大量的小类,只有很少的成员和函数。”然后他们肯定会变出一个不能简化到如此小规模的班级的例子。

我确信那些开发商完全错了。我很确定每一个软件系统都可以被分解成如此小的基本构件。

是的,如果班级很小,你会有更多的班级。但那是 OO!在面向对象的软件开发中,类是一种同样自然的语言元素,如函数或变量。换句话说:不要害怕创建小班。小班更容易使用、理解和测试。

尽管如此,这引出了一个基本问题:代码行数上限的定义基本上是正确的吗?我认为代码行数(LOC)是一个有用的指标。太多的 LOC 是一种气味。你可以仔细看看 50 行以上的课。但是并不一定是多行代码总是有问题。一个更好的标准是一个类的责任量。

单一责任原则

单一责任原则(SRP)声明每个软件单元——包括组件、类和功能——应该只有一个单一的和明确定义的责任。

SRP 基于我在第三章中讨论过的内聚性的一般原则。如果一个类有明确的职责,通常它的凝聚力也很强。

但是责任到底是什么?在文学作品中,我们经常可以找到这样的解释:改变阶级必须只有一个原因。一个经常提到的例子是,当类由于系统不同方面的新的或改变的需求而需要改变时,这个规则被违反了。

这些方面可以是例如设备驱动程序和 UI。如果必须改变同一个类,或者因为设备驱动程序的接口已经改变,或者必须实现关于图形用户界面的新需求,那么这个类显然有太多的责任。

另一类方面与系统的领域有关。如果必须改变同一个类,或者因为有关于客户管理的新要求,或者有关于发票的新要求,那么这个类有太多的责任。

遵循 SRP 的类通常很小,并且很少依赖。它们很清楚,容易理解,也很容易测试。

责任是比一个类的代码行数更好的标准。可以有 100 行、200 行甚至 500 行的类,如果这些类不违反单一责任原则,这是完全可以的。尽管如此,高锁定计数可能是一个指标。这是一条提示:“你应该看看这些课程!也许一切都很好,但也许他们这么大,是因为他们的责任太多了。”

开闭原则(OCP)

所有系统在其生命周期内都会发生变化。当开发预期比第一个版本持续时间更长的系统时,必须记住这一点。—Ivar Jacobson,瑞典计算机科学家,1992 年

对于任何类型的软件单元,尤其是对于类设计,另一个重要的指导原则是开闭原则(OCP)。它陈述了软件实体(模块、类、函数等)。)应该对扩展开放,但对修改关闭。

一个简单的事实是,软件系统会随着时间的推移而发展。必须不断满足新的需求,现有的需求必须根据客户需求或技术进步进行更改。这些扩展不仅应该以优雅的方式进行,而且应该尽可能地省力。它们应该特别地以这样一种方式制作,即不需要改变现有的代码。如果任何新的需求会导致软件现有的和经过良好测试的部分发生一连串的变化和调整,那将是致命的。

在面向对象中支持这一原则的一种方式是继承的概念。通过继承,可以在不修改类的情况下向类中添加新的功能。此外,有许多面向对象的设计模式正在培育 OCP,比如 Strategy,或者 Decorator(参见第九章关于设计模式)。

在第三章中关于松耦合的部分,我们已经讨论了一个非常支持 OCP 的设计(见图 3-6 )。这里,我们通过一个接口将开关和灯解耦。通过这一步,设计不会被修改,但可以愉快地扩展。我们可以很容易地添加更多可切换的设备,并且我们不需要触摸类别开关、灯和可切换的接口。你可以很容易地想象,这种设计的另一个优点是现在很容易提供一个测试替身(例如,一个模拟对象)用于测试目的(参见第二章中关于测试替身(模拟对象)的部分)。

利斯科夫替代原理

里斯科夫替代原理基本上是说,你不能通过给一只狗增加四条假腿来创造一只章鱼。——马里奥·富斯科(@mariofusco),2013 年 9 月 15 日,在推特上

初看起来,面向对象的继承和多态的关键概念似乎相对简单。继承是一个分类学概念,应该用来构建类型的专门化层次结构,也就是说,子类型是从更一般的类型派生出来的。多态性通常意味着提供一个单一的接口作为对不同类型对象的访问可能性。

目前为止,一切顺利。但是有时你会遇到这样的情况,子类型并不真的想要适合类型层次结构。让我们讨论一个经常用来说明问题的非常流行的例子。

正方形和长方形的困境

假设我们正在开发一个类库,其中包含用于在画布上绘图的基本形状类型,例如,CircleRectangleTriangleTextLabel。可视化为 UML 类图,这个库可能看起来如图 6-1 所示。

A429836_1_En_6_Fig1_HTML.jpg

图 6-1。

A class library of diff erent shapes

抽象基类Shape具有对于所有特定形状都相同的属性和操作。例如,对于所有形状来说,它们如何在画布上从一个位置移动到另一个位置是相同的。然而,Shape不知道如何显示(同义词:绘制)或隐藏(同义词:删除)特定的形状。因此,这些操作是抽象的,也就是说,它们不能(完全)在Shape中实现。

在 C++ 中,抽象类Shape(以及Shape所需的类Point)的实现可能如下所示:

class Point final {

public:
  Point() : x { 5 }, y { 5 } { }
  Point(const unsigned int initialX, const unsigned int initialY) :
    x { initialX }, y { initialY } { }
  void setCoordinates(const unsigned int newX, const unsigned int newY) {
    x = newX;
    y = newY;
  }
  // ...more member functions here...

private:
  unsigned int x;
  unsigned int y;
};

class Shape {

public:
  Shape() : isVisible { false } { }
  virtual ∼Shape() = default;
  void moveTo(const Point& newCenterPoint) {
    hide();
    centerPoint = newCenterPoint;
    show();
  }
  virtual void show() = 0;
  virtual void hide() = 0;
  // ...

private:
  Point centerPoint;
  bool isVisible;
};

void Shape::show() {
  isVisible = true;
}

void Shape::hide() {
  isVisible = false;
}

Listing 6-1.This is what the two classes Point and Shape look like

Final Specifier [C++11]

从 C++11 开始就有的final说明符有两种用法。

一方面,您可以使用此说明符来避免在派生类中重写单个虚拟成员函数,如下例所示:

class AbstractBaseClass {

public:
  virtual void doSomething() = 0;
};

class Derived1 : public AbstractBaseClass {

public:
  virtual void doSomething() final {
    //...
  }
};

class Derived2 : public Derived1 {

public:
  virtual void doSomething() override { // Causes a compiler error!
    //...
  }
};

另外,你也可以将一个完整的类标记为final,就像我们形状库中的类Point一样。这确保了开发人员不能将这样的类用作继承的基类。

class NotDerivable final {
  // ...
};

在 Shapes 库中的所有具体类中,我们可以对一个类进行示例性的研究,这个类就是Rectangle:

class Rectangle : public Shape {

public:
  Rectangle() : width { 2 }, height { 1 } { }
  Rectangle(const unsigned int initialWidth, const unsigned int initialHeight) :
    width { initialWidth }, height { initialHeight } { }

  virtual void show() override {
    Shape::show();
    // ...code to show a rectangle here...
  }

  virtual void hide() override {
    Shape::hide();
    // ...code to hide a rectangle here...
  }

  void setWidth(const unsigned int newWidth) {
    width = newWidth;
  }

  void setHeight(const unsigned int newHeight) {
    height = newHeight;
  }

  void setEdges(const unsigned int newWidth, const unsigned int newHeight) {
    width = newWidth;
    height = newHeight;
  }

  unsigned long long getArea() const {
    return static_cast<unsigned long long>(width) * height;
  }
  // ...

private:
  unsigned int width;
  unsigned int height;
};

Listing 6-2.The important 

parts of class Rectangle

客户端代码希望以相似的方式使用所有形状,不管是哪种特定的实例(RectangleCircle等)。)它面临着。例如,所有 sha pes 应该一次性显示在画布上,这可以使用以下代码实现:

#include "Shapes.h" // Circle, Rectangle, etc.

#include <memory>

#include <vector>

using ShapePtr = std::shared_ptr<Shape>;

using ShapeCollection = std::vector<ShapePtr>;

void showAllShapes(const ShapeCollection& shapes) {
  for (auto& shape : shapes) {
    shape->show();
  }
}

int main() {

  ShapeCollection shapes;
  shapes.push_back(std::make_shared<Circle>());
  shapes.push_back(std::make_shared<Rectangle>());
  shapes.push_back(std::make_shared<TextLabel>());
  // ...etc...

  showAllShapes(shapes);
  return 0;
}

现在让我们假设用户为我们的库制定了一个新的需求:他们想要一个正方形!

大概每个人都会马上想起自己小学的几何课。那时你的老师可能也说过,正方形是一种特殊的矩形,它有四条等长的边和四个等角(90 度角)。因此,第一个显而易见的解决方案似乎是我们从Rectangle派生出一个新的类Square,如图 6-2 所示。

A429836_1_En_6_Fig2_HTML.jpg

图 6-2。

Deriving a Square from class Rectangle – a good idea?

乍一看,这似乎是一个可行的解决方案。Square继承了Rectangle的接口和实现。这有助于避免代码重复(参见我们在第三章中讨论的 DRY 原则),因为Square可以很容易地重用在Rectangle中实现的行为。

一个正方形只需要满足一个额外的简单需求,这个需求在上面的 UML 图中显示为类Square : {width = height}中的一个约束。这个约束意味着Square类型的实例在任何情况下都保证它的边都是一样长的。

因此,我们首先通过从我们的Rectangle派生来实现我们的Square:

class Square : public Rectangle {

public:
  //...
};

但其实并不是一个好的解决办法!

请注意,Square继承了Rectangle的所有操作。这意味着我们可以用一个Square实例做以下事情:

Square square;
square.setHeight(10);    // Err...changing only the height of a square?!
square.setEdges(10, 20); // 

Uh oh!

首先,Square的用户会非常困惑,它提供了一个带有两个参数的设置器(记住第三章中的最小惊讶原则)。他们认为:为什么有两个参数?哪个参数用于设置所有边的长度?我必须把两个参数设为相同的值吗?如果我不这么做会怎么样?

当我们执行以下操作时,情况会更加戏剧性:

std::unique_ptr<Rectangle> rectangle = std::make_unique<Square>();
// ...and somewhere else in the code...
rectangle->setEdges(10, 20);

在这种情况下,客户端代码使用有意义的 setter。矩形的两条边都可以独立操作。这并不奇怪;这正是期望。然而,结果可能是怪异的。在这样的调用之后,Square类型的实例实际上不再是正方形,因为它有两个不同的边长。所以我们再次违反了最小惊讶原则,更糟糕的是:违反了Square的类不变量。

然而,现在有人可能会争辩说,我们可以在类Rectangle中将setEdges()setWidth()setHe、声明为virtual,并使用替代实现覆盖类Square中的这些成员函数,这在未经请求使用的情况下会引发异常。此外,我们在类Square中提供了一个新的成员函数setEdge(),如下所示:

#include <stdexcept>
// ...

class IllegalOperationCall : public std::logic_error
{

public:

explicit IllegalOperationCall(const std::string& message) : logic_error(message) { }

virtual ∼IllegalOperationCall() { }
};

class Square : public Rectangle {

public:
Square() : Rectangle { 5, 5 } { }

explicit Square(const unsigned int edgeLength) : Rectangle { edgeLength, edgeLength } { }

virtual void setEdges([[maybe_unused]] const unsigned int newWidth,
                      [[maybe_unused]] const unsigned int newHeight) override {
  throw IllegalOperationCall { ILLEGAL_OPERATION_MSG };
}

virtual void setWidth([[maybe_unused]] const unsigned int newWidth) override {
  throw IllegalOperationCall { ILLEGAL_OPERATION_MSG };
}

virtual void setHeight([[maybe_unused]] const unsigned int newHeight) override {
  throw IllegalOperationCall { ILLEGAL_OPERATION_MSG };
}

void setEdge(const unsigned int length) {
  Rectangle::setEdges(length, length);
}

private:

static const constexpr char* const ILLEGAL_OPERATION_MSG { "Unsolicited call of a prohibited "
    "operation on an instance of class Square!" };
};

Listing 6-3.A really bad implementation of Square that tries to “erase” unwanted inherited features

嗯,我认为这显然是一个非常糟糕的设计。它违反了面向对象的基本原则,即派生类不能删除基类的继承属性。这绝对不是解决我们问题的办法。首先,如果我们想使用一个Square的实例作为一个Rectangle,那么新的 setter setEdge()将是不可见的。此外,所有其他的 setters 在被使用时都会抛出一个异常——这真是糟糕透了!它破坏了面向对象。

那么,这里的根本问题是什么?为什么从一个Rectangle中派生出一个类Square会引起这么多的困难?

解释是这样的:从Rectangle派生Square违背了面向对象软件设计中的一个重要原则——利斯科夫替代原则(LSP)!

美国计算机科学家芭芭拉·利斯科夫(Barbara Liskov)是麻省理工学院(MIT)的学院教授,Jeannette Wing 是卡内基梅隆大学的计算机科学教授,直到 2013 年,他们在 1994 年的一篇论文中阐述了该原理如下:

Let q(x) be the provable property of object x of type T. Then q(y) should be a provable attribute of object y of type S, where S is a subtype of T. —barbarbara lis kov,Jeanette Wing [Liskov94]

嗯,这不一定是日常使用的定义。罗伯特·c·马丁(Robert C. Martin)在 1996 年的一篇文章中把这个原则表述如下:

Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it. -Robert Martin [Martin 96]

事实上,这意味着:派生类型必须完全可以替换它们的基本类型。在我们的例子中,这是不可能的。类型为Square的实例不能替换为Rectangle。原因在于约束{width = height}(所谓的类不变量)将由Square强制执行,但是Rectangle不能满足该约束。

Liskov 替换原则分别为类型和类层次结构规定了以下规则:

  • 基类的前提条件(参见第五章关于前提条件的“预防胜于治疗”一节)不能在派生类中得到加强。
  • 基类的后置条件(参见第五章中的“预防胜于治疗”一节)不能在派生类中被削弱。
  • 基类的所有不变量都不能通过派生的子类来改变或违反。
  • 历史约束(也称为“历史规则”):对象的(内部)状态只能通过在它们的公共接口(封装)上的方法调用来改变。由于派生类可以引入基类中不存在的新属性和方法,这些方法的引入可以允许基类中不允许的派生类的对象的状态改变。所谓的历史约束禁止这样做。例如,如果基类被设计成一个不可变对象的蓝图(见第九章关于不可变类),派生类不应该在新引入的成员函数的帮助下使这个不变性属性无效。

上图(图 6-2 )类图中泛化关系的解释(SquareRectangle之间的箭头)经常翻译成“……是一个……”:Square是一个Rectangle。但这可能会产生误导。在数学中,可以说正方形是一种特殊的矩形,但在编程中却不是!

为了处理这个问题,客户必须知道他正在处理的是哪种特定的类型。一些开发人员现在可能会说,“没问题,这可以通过使用运行时类型信息(RTTI)来完成。”

Run-Time Type Information (RTTI)

术语运行时类型信息(有时也称为运行时类型标识)表示在运行时访问对象数据类型信息的 C++ 机制。RTTI 背后的一般概念叫做类型自省,在其他编程语言中也可以使用,比如 Java。

在 C++ 中,typeid运算符(在头文件<typeinfo>中定义)和dynamic_cast<T>(参见第四章中关于 C++ 强制转换的章节)属于 RTTI。例如,要在运行时确定对象的类,您可以编写:

const std::type_info& typeInformationAboutObject = typeid(instance);

类型std::type_info(也在 header <typeinfo>中定义)的 const 引用现在保存关于对象的类的信息,例如,类名。从 C++11 开始,哈希代码也是可用的(std::type_info::hash_code()),它对于引用相同类型的std::type_info对象是相同的。

重要的是要知道 RTTI 只适用于多态的类,也就是说,至少有一个直接或通过继承的虚函数的类。此外,在某些编译器上可以打开或关闭 RTTI。例如,当使用 gcc (GNU 编译器集合)时,可以通过使用-fno- rtti选项禁用 RTTI。

using ShapePtr = std::shared_ptr<Shape>;

using ShapeCollection = std::vector<ShapePtr>;
//...

void resizeAllShapes(const ShapeCollection& shapes) {
  try {
    for (const auto& shape : shapes) {
      const auto rawPointerToShape = shape.get();
      if (typeid(*rawPointerToShape) == typeid(Rectangle)) {
        Rectangle* rectangle = dynamic_cast<Rectangle*>(rawPointerToShape);
        rectangle->setEdges(10, 20);
        // Do more Rectangle-specific things here...
      } else if (typeid(*rawPointerToShape) == typeid(Square)) {
        Square* square = dynamic_cast<Square*>(rawPointerToShape);
        square->setEdge(10);
      } else {
        // ...
      }
    }
  } catch (const std::bad_typeid& ex) {
    // Attempted a typeid of NULL pointer!
  }
}

Listing 6-4.Just another “hack”: Usi

ng RTTI to distinguish between different types of shape during runtime

别这样!这不可能也不应该是合适的解决方案,尤其是在一个干净现代的 C++ 程序中。面向对象的许多好处,比如动态多态,都被抵消了。

Caution

每当你被迫在你的程序中使用 RTTI 来区分不同的类型时,它就是一种明显的“设计气味”,也就是说,一个糟糕的面向对象软件设计的明显标志!

此外,我们的代码会被糟糕的if-else结构严重污染,可读性也会下降。似乎这还不够,try-catch构造还表明可能会出错。

但是我们能做什么呢?

首先,我们应该再仔细看看正方形到底是什么。

从纯数学的观点来看,正方形可以看作是等边长的矩形。目前为止,一切顺利。但是这个定义不能直接转移到面向对象的类型层次结构中。正方形不是矩形的子类型!

相反,正方形仅仅是矩形的一种特殊状态。如果一个矩形有相同的边长,这仅仅是矩形的一种状态,那么我们通常用我们的自然语言给这样一个特殊的矩形一个特殊的名字:然后我们谈论一个正方形!

这意味着我们只需要向我们的类Rectangle添加一个 inspector 方法来查询它的状态,允许我们放弃一个显式的类Square。根据 KISS 原则(参见第三章),该解决方案可能完全足以满足新的要求。此外,我们可以为客户提供一个方便的设置方法,用于相等地设置两个边的长度。

class Rectangle : public Shape {

public:
  // ...
  void setEdgesToEqualLength(const unsigned int newLength) {
    setEdges(newLength, newLength);
  }

  bool isSquare() const {
    return width == height;
  }
  //...
};

Listing 6-5.A simple solution without an explicit class Square

偏爱合成而非遗传

但是,如果明确的类 s Square是不折不扣地必需的,例如,因为有人需要它,我们能做什么呢?嗯,如果是这样的话,那么我们永远不应该从Rectangle继承,而是从Shape类继承,如图 6-3 所示。为了不违反 DRY 原则,我们将使用类Rectangle的一个实例作为Square的内部实现。

A429836_1_En_6_Fig3_HTML.jpg

图 6-3。

The Square uses and delegates to an embedded instance of Rectangle

用源代码表示,这个类Square的实现看起来像这样:

class Square : public Shape {

public:
  Square() {
    impl.setEdges(5, 5);
  }

  explicit Square(const unsigned int edgeLength) {
    impl.setEdges(edgeLength, edgeLength);
  }

  void setEdge

(const unsigned int length) {
    impl.setEdges(length, length);
  }

  virtual void moveTo(const Point& newCenterPoint) override {
    impl.moveTo(newCenterPoint);
  }

  virtual void show() override {
    impl.show();
  }

  virtual void hide() override {
    impl.hide();
  }

  unsigned lomg longgetArea() const {
    return impl.getArea();
  }

private:
  Rectangle impl;
};

Listing 6-6.The Square delegates all method calls to an embedded instance of Rectangle

也许你已经注意到了moveTo()方法也被覆盖了。为此,moveTo()方法也必须在Shape类中成为虚拟的。我们必须覆盖它,因为从Shape继承的moveTo()在基类ShapecenterPoint上操作,而不是在使用的Rectangle的嵌入式实例上操作。这是这个解决方案的一个小缺点:从基类Shape继承的一些部分闲置着。

显然,使用这种解决方案,我们将失去将Square的实例分配给Rectangle的可能性:

std::unique_ptr<Rectangle> rectangle = std::make_unique<Square>(); // Compiler error!

这种解决面向对象中继承问题的方法背后的原理被称为“优先组合优先继承”(FCoI),有时也被称为“优先委托优先继承”。对于功能的重用,面向对象编程基本上有两种选择:继承(“白盒重用”)和组合或委托(“黑盒重用”)。有时候,更好的方法是将另一个类型视为黑盒,也就是说,只通过其明确定义的公共接口来使用它,而不是从该类型派生一个子类型。通过组合/委托的重用比通过继承的重用促进了类之间的松散耦合。

接口隔离原则(ISP)

我们已经知道接口是促进类之间松散耦合的一种方式。在前面关于开闭原则的章节中,我们已经看到接口是在代码中拥有扩展和变化点的一种方式。一个接口就像一个契约:类可以通过这个契约请求服务,而服务可以由履行这个契约的其他类提供。

但是,当这些契约变得太广泛时,也就是说,如果一个接口变得太宽或太“胖”,会出现什么问题呢?用一个例子可以很好地说明其后果。假设我们有以下接口:

class Bird {

public:
  virtual ∼Bird() = default;
  virtual void fly() = 0;
  virtual void eat() = 0;
  virtual void run() = 0;
  virtual void tweet() = 0;
};
Listing 6-7.An interface for Birds

这个接口是由几个具体的 birds 实现的,比如由一个Sparrow

class Sparrow : public Bird {

public:
  virtual void fly() override {
    //...
  }
  virtual void eat() override {
    //...
  }
  virtual void run() override {
    //...
  }
  virtual void tweet() override {
    //...
  }
};
Listing 6-8.The class Sparrow overrides and implements all pure virtual member functions of Bird

目前为止,一切顺利。现在假设我们有另一个具体的Bird : a Penguin

class Penguin : public Bird {

public:
  virtual void fly() override {
    // ???
  }
  //...
};
Listing 6-9.The class Penguin

虽然企鹅肯定是鸟,但它不会飞。虽然我们的接口相对较小,因为它只声明了四个简单的成员函数,但是这些声明的服务显然不能由每一种鸟提供。

接口分离原则(ISP)声明接口不应该被实现类不需要的成员函数所膨胀,或者这些类不能以有意义的方式实现。在我们上面的例子中,类Penguin不能为Bird::fly()提供有意义的实现,但是Penguin被强制覆盖那个成员函数。

接口分离原则认为,我们应该将一个“胖接口”分离成更小的、高度内聚的接口。由此产生的小接口也称为角色接口。

class Lifeform {

public:
  virtual void eat() = 0;
  virtual void move() = 0;
};

class Flyable {

public:
  virtual void fly() = 0;
};

class Audible {

public:
  virtual void makeSound() = 0;
};

Listing 6-10.The three role interfaces as a better alternative to the broad Bird interface

这些小角色接口现在可以非常灵活地组合。这意味着实现类只需要为那些声明的成员函数提供有意义的功能,它们能够以合理的方式实现这些功能。

class Sparrow : public Lifeform, public Flyable, public Audible {
  //...
};

class Penguin : public Lifeform, public Audible {
  //...
};

Listing 6-11.The classes Sparrow and Penguin respectively implement the relevant interfaces

非循环依赖原则

有时需要两个类互相“了解”。例如,让我们假设我们正在开发一个网上商店。为了能够实现某些用例,在这个网上商店中代表客户的类必须知道它的相关帐户。对于其他用例,帐户必须能够访问其所有者,即客户。

在 UML 中,这种相互关系如图 6-4 所示。

A429836_1_En_6_Fig4_HTML.jpg

图 6-4。

The association relationships between class Customer and class Account

这就是所谓的循环依赖。这两个阶层直接或间接地相互依赖。在这种情况下,只有两个类。涉及几个软件单元时也会出现循环依赖。

让我们看看图 6-4 所示循环依赖是如何在 C++ 中实现的。

在 C++ 中肯定行不通的是:

#ifndef CUSTOMER_H_

#define CUSTOMER_H_

#include "Account.h"

class Customer {
// ...

private:
  Account customerAccount;
};

#endif

Listing 6-12.The content of file Customer.h

#ifndef ACCOUNT_H_

#define ACCOUNT_H_

#include "Customer.h"

class Account {

private:
  Customer owner;
};

#endif

Listing 6-13.The content of file Account.h

我认为这个问题在这里是显而易见的。只要有人使用类Account,或者类Customer,他就会在编译时引发连锁反应。比如账户拥有一个客户的实例谁拥有一个账户的实例谁拥有一个客户的实例,等等等等……由于 C++ 编译器严格的处理顺序,上述实现会导致编译器错误。

例如,通过将引用或指针与前向声明结合使用,可以避免这些编译器错误。前向声明是对标识符(例如,类型,如类)的声明,而不定义该标识符的完整结构。因此,这种类型有时也被称为不完整类型。因此,它们只能用于指针或引用,而不能用于实例成员变量,因为编译器不知道它的大小。

#ifndef CUSTOMER_H_

#define CUSTOMER_H_

class Account;

class Customer {

public:
  // ...
  void setAccount(Account* account) {
    customerAccount = account;
  }
  // ...

private:
  Account* customerAccount;
};

#endif

Listing 6-14.The modified Customer with a forward-declared Account

ifndef ACCOUNT_H_

#define ACCOUNT_H_

class Customer;

class Account {

public:
  //...
  void setOwner(Customer* customer) {
    owner = customer;
  }
  //...

private:
  Customer* owner;
};

#endif

Listing 6-15.The modified Account with a forward-declared Customer

手放在心上:你对这个解决方案有没有感到有点不舒服?如果是的话,那是有充分理由的!编译器错误消失了,但是这个“修复”产生了一种不好的直觉。让我们看看这两个类是如何使用的:

#include "Account.h"

#include "Customer.h"
// ...
  Account* account = new Account { };
  Customer* customer = new Customer { };
  account->setOwner(customer);
  customer->setAccount(account);
// ...
Listing 6-16.Creating the instances of Customer and Account, and wiring them circularly together

我敢肯定,一个严重的问题是显而易见的:例如,如果 Account 的实例将被删除,但 Customer 的实例仍然存在,会发生什么情况?那么,Customer 的实例将包含一个悬空指针,即一个指向无人区的指针!使用或取消引用这样的指针会导致严重的问题,比如未定义的行为或应用程序崩溃。

前向声明对于某些事情非常有用,但是使用它们来处理循环依赖是一个非常糟糕的做法。这是一个令人毛骨悚然的变通方法,它应该隐藏一个基本的设计问题。

问题在于循环依赖本身。这是糟糕的设计。客户和账户这两个类别不能分开。因此,它们不能彼此独立使用,也不能彼此独立测试。这使得单元测试变得相当困难。

如果出现图 6-5 中描述的情况,问题会变得更加严重。

A429836_1_En_6_Fig5_HTML.jpg

图 6-5。

The impact of circular dependencies between classes in different components

我们的类CustomerAccount分别位于不同的组件中。也许在每个组件中有更多的类,但是这两个类有一个循环依赖。其结果是,这种循环依赖对架构级别也有负面影响。类级的循环依赖导致组件级的循环依赖。CustomerManagementAccounting是紧耦合的(记住第三章中关于松耦合的部分),不能单独(重复)使用。当然,独立元件测试也不再可能。架构层次上的模块化实际上已经被降低到荒谬的程度。

非循环依赖原则声明组件或类的依赖图应该没有循环。循环依赖是紧密耦合的一种糟糕形式,应该不惜一切代价避免。

别担心!打破循环依赖总是可能的,下一节将分别展示如何避免打破它们。

从属倒置原则

在上一节中,我们了解到循环依赖是不好的,在任何情况下都应该避免。和许多其他有不必要的依赖的问题一样,接口的概念(在 C++ 中,接口是用抽象类模拟的)是我们处理类似前一种情况的朋友。

因此,我们的目标应该是打破循环依赖,同时不丧失 class Customer 访问 Account 的必要可能性,反之亦然。

第一步是我们不再允许两个类中的一个直接访问另一个类。相反,我们只允许通过接口进行这样的访问。基本上,从两个类(CustomerAccount)中的哪一个提取接口并不重要。我决定从Customer中提取一个名为Owner的接口。例如,Owner接口只声明了一个纯虚拟成员函数,这个函数必须被实现这个接口的类覆盖。

#ifndef OWNER_H_

#define OWNER_H_

#include <memory>

#include <string>

class Owner {

public:
  virtual ∼Owner() = default;
  virtual std::string getName() const = 0;
};

using OwnerPtr = std::shared_ptr<Owner>;

#endif

Listing 6-17.An exemplary implementation of the new interface Owner

(File: Owner.h)

#ifndef CUSTOMER_H_

#define CUSTOMER_H_

#include "Owner.h"

#include "Account.h"

class Customer : public Owner {

public:
  void setAccount(AccountPtr account) {
    customerAccount = account;
  }

  virtual std::string getName() const override {
    // return the Customer's name here...
  }
  // ...

private:
  AccountPtr customerAccount;
  // ...
};

using CustomerPtr = std::shared_ptr<Customer>;

#endif

Listing 6-18.The class Customer that implements i

nterface Owner (File: Customer.h)

从上面显示的 Customer 类的源代码中可以很容易地看出,Customer 仍然知道它的帐户。但是,当我们现在看一看更改后的 class Account 实现时,就不再依赖于客户了:

#ifndef ACCOUNT_H_

#define ACCOUNT_H_

#include "Owner.h"

class Account {

public:
  void setOwner(OwnerPtr owner) {
    this->owner = owner;
  }
  //...

private:
  OwnerPtr owner;
};

using AccountPtr = std::shared_ptr<Account>;

#endif

Listing 6-19.The changed implementation of 

class Account (File: Account.h)

作为一个 UML 类图,在类层次上改变的设计如图 6-6 所示。

A429836_1_En_6_Fig6_HTML.jpg

图 6-6。

The introduction of the interface has eliminated the circular dependency on class level

太棒了!通过重新设计的第一步,我们现在已经实现了在类级别上不再有循环依赖。现在,类Account对类Customer一无所知。但是当我们爬上图 6-7 所示的组件层时,情况会是怎样的呢?

A429836_1_En_6_Fig7_HTML.jpg

图 6-7。

The circular dependency between the components is still there

不幸的是,组件之间的循环依赖还没有被打破。这两个关联关系仍然是从一个组件中的一个元素到另一个组件中的一个元素。然而,实现这个目标的步骤非常简单:我们只需要将接口Owner重新定位到其他组件中,如图 6-8 所示。

A429836_1_En_6_Fig8_HTML.jpg

图 6-8。

Relocating the interface also fixes the circular dependency problem on an architecture level

太好了!现在组件之间的循环依赖已经消失了。Accounting组件不再依赖于CustomerManagement,因此模块化的质量得到了显著提高。此外,Accounting组件现在可以独立测试。

事实上,两个组件之间的不良依赖并没有真正消除。相反,通过引入接口Owner,我们甚至在类级别上获得了更多的依赖。我们真正做的是颠倒依赖性。

依赖倒置原则(DIP)是一种面向对象的设计原则,用于分离软件模块。该原则指出,面向对象设计的基础不是具体软件模块的特殊属性。相反,它们的公共特性应该被整合到一个共享的抽象中(例如,一个接口)。罗伯特·c·马丁(Robert c . Martin)a . k .“鲍勃大叔”将原则表述如下:

A. advanced modules should not depend on low-level modules. Both should rely on abstraction. B. Abstraction should not depend on details. Details should depend on abstraction. -Robert Martin [Martin 03]

Note

此引用中的术语“高级模块”和“低级模块”可能会引起误解。它们不一定指它们在分层架构中的概念位置。在这种特定情况下,高级模块是需要来自另一个模块(所谓的低级模块)的外部服务的软件模块。高级模块是调用动作的模块,低级模块是执行动作的模块。在一些情况下,这两类模块也可以位于软件架构的不同级别(例如,层),或者如在我们的示例中位于不同的组件中。

依赖倒置的原则是被认为是好的面向对象设计的基础。它通过抽象(例如接口)来定义所提供和所需的外部服务,从而促进了可重用软件模块的开发。一致地应用于我们上面讨论的情况,我们也必须相应地重新设计CustomerAccount之间的直接依赖,如图 6-9 所示。

A429836_1_En_6_Fig9_HTML.jpg

图 6-9。

Dependency Inversion Principle applied

两个组件中的类都完全依赖于抽象。因此,对于Accounting组件的客户端来说,哪个类需要Owner接口或提供Account接口已经不再重要了(还记得第三章中关于信息隐藏的部分)——我已经通过引入一个名为AnyClass的类暗示了这种情况,这个类实现了Account并使用了Owner

例如,如果我们现在必须更改或替换Customer类,例如,因为我们想要将Accounting安装到测试夹具上进行组件测试,那么不需要更改AnyClass类来实现它。这也适用于相反的情况。

依赖倒置原则允许软件开发人员有目的地设计模块之间的依赖关系,即定义依赖关系指向哪个方向。你想反转组件之间的依赖关系,即Accounting应该依赖CustomerManagement?没问题:只需将两个接口从Accounting重新定位到CustomerManagement,依赖性就会改变。降低代码的可维护性和可测试性的不良依赖可以被优雅地重新设计和减少。

不要和陌生人说话(德米特里定律)

你还记得我在本章前面谈到的那辆车吗?我把这辆车描述为几个部分的组合,例如,车身、发动机、齿轮等等。我已经解释过,这些部分又可以由部分组成,而部分本身又可以由几个部分组成,等等。这就导致了一辆车的分层自上而下的分解。当然,一辆车可以有一个想要使用它的司机。

可视化为 UML 类图,汽车分解的摘录可以如图 6-10 所示。

A429836_1_En_6_Fig10_HTML.jpg

图 6-10。

The hierarchical decomposition of a simple car

根据第五章讨论的单一责任原则,一切都很好,因为每个单一类都有明确定义的责任。

现在我们假设司机想驾驶汽车。这可以在类Driver中实现如下:

class Driver {

public:
// ...
  void drive(Car& car) const {
    Engine& engine = car.getEngine();
    FuelPump& fuelPump = engine.getFuelPump();
    fuelPump.pump();
    Ignition& ignition = engine.getIgnition();
    ignition.powerUp();
    Starter& starter = engine.getStarter();
    starter.revolve();
  }
// ...
};
Listing 6-20.An excerpt from the implementation of class Driver

这里有什么问题?嗯,作为一名汽车司机,你会期望你必须直接接触你的汽车发动机,打开燃油泵,打开点火系统,并让起动机旋转吗?我甚至更进一步:如果你只是想驾驶你的汽车,你甚至对你的汽车由这些部件组成的事实感兴趣吗?!

我很确定你的明确答案会是:不!

现在让我们看一下图 6-11 ,它描绘了 UML 类图中的相关部分,看看这个实现对设计有什么影响。

A429836_1_En_6_Fig11_HTML.jpg

图 6-11。

The bad dep endencies of class Driver

从上图中可以很容易地看出,类Driver有许多令人尴尬的依赖关系。Driver并不仅仅依赖于Engine。这个类与Engine的某些部分也有一些依赖关系。很容易想象这有一些不利的后果。

例如,如果内燃机被电力传动系统取代,会发生什么?电力驱动没有燃油泵、点火系统和起动机。因此,结果将是类驱动程序的实现必须进行调整。这违反了开闭原则(见前面一节)。此外,所有将CarEngine的内部暴露给环境的公共 getters 都违反了信息隐藏原则(参见第三章)。

本质上,上述软件设计违反了德米特里定律(LoD),也称为最少知识原则。得墨忒耳定律可以被认为是一个原则,它说的是“不要和陌生人说话”,或者“只和你的近邻说话。”这个原则表明你应该进行害羞编程,目标是控制面向对象设计中的通信结构。

德米特里定律假设了以下规则:

  • 成员函数可以直接调用自己类范围内的其他成员函数。
  • 允许成员函数直接调用其类范围内的成员变量上的成员函数。
  • 如果成员函数有参数,则允许该成员函数直接调用这些参数的成员函数。
  • 如果成员函数创建局部对象,则允许该成员函数调用这些局部对象上的成员函数。

如果上述四种成员函数调用中的一种返回的对象在结构上比该类的近邻更远,则禁止对这些对象调用成员函数。

Why This Rule Is Named Law Of Demeter

这个原则的名字可以追溯到关于面向方面软件开发的 Demeter 项目,在那里制定并严格应用了这些规则。Demeter 项目是 20 世纪 80 年代后期的一个研究项目,主要关注于通过自适应编程使软件更易于维护和扩展。德米特里定律是由伊恩·m·霍兰德和卡尔·利伯海尔发现并提出的,他们在那个项目中工作。在希腊神话中,得墨忒耳是宙斯和农业女神的妹妹。

那么,在我们的例子中,现在消除不良依赖的解决方案是什么呢?很简单,我们应该问自己:一个司机到底想做什么?答案很简单:他想发动汽车!

class Driver {

public:
// ...
  void drive(Car& car) const {
    car.start();
  }
// ...
};

汽车用这个启动命令做什么?也很简单:它将这个方法调用委托给它的引擎。

class Car {

public:
// ...
  void start() {
    engine.start();
  }
// ...

private:
  Engine engine;
};

最后但同样重要的是,引擎知道如何通过在它的部件上以正确的顺序调用适当的成员函数来执行启动过程,这些部件在软件设计中是它的近邻。

class Engine {

public:
// ...
  void start() {
    fuelPump.pump();
    ignition.powerUp();
    starter.revolve();
  }
// ...

private:
  FuelPump fuelPump;
  Ignition ignition;
  Starter starter;
};

在图 6-12 中描绘的类图中,可以非常清楚地看到这些变化对面向对象设计的积极影响。

A429836_1_En_6_Fig12_HTML.jpg

图 6-12。

Less dependencies a fter the application of the Law of Demeter

司机对汽车零部件令人讨厌的依赖消失了。相反,驾驶员可以启动汽车,而不用考虑汽车的内部结构。类Driver不再知道有一个Engine,一个FuelPump等等。所有那些糟糕的公共 getter 函数,那些向所有其他类显示汽车内部或引擎的函数,都消失了。这也意味着Engine及其部件的变化只会产生非常局部的影响,不会直接导致整个设计的级联变化。

在设计软件时遵循德米特定律可以显著减少依赖的数量。这导致了松散耦合,并助长了信息隐藏原则和开闭原则。与许多其他原则和规则一样,也可能有一些合理的例外,在这些例外中,开发人员必须出于非常充分的理由偏离这一原则。

避免贫血类

在几个项目中,我见过如下的类:

class Customer {

public:
  void setId(const unsigned int id);
  unsigned int getId() const;
  void setForename(const std::string& forename);
  std::string getForename() const;
  void setSurname(const std::string& surname);
  std::string getSurname() const;
  //...more setters/getters here...

private:
  unsigned int id;
  std::string forename;
  std::string surname;
  // ...more attributes here...
};

Listing 6-21.A class without functionality that serves only as a bucket for a bunch of data

这个域类代表任意软件系统中的客户,不包含任何逻辑。逻辑在不同的地方,甚至是代表Customer专有功能的逻辑,也就是说,只对Customer的属性进行操作。

这样做的程序员使用对象作为一堆数据的袋子。这只是数据结构的过程化编程,与面向对象无关。所有这些设置器/获取器都非常愚蠢,严重违反了信息隐藏原则——实际上我们可以在这里使用一个简单的 C 结构(关键字:struct)。

这样的班级被称为贫血班,应该不惜一切代价避免。它们经常出现在一个反模式的软件设计中,Martin Fowler 称之为贫血域模型。它与面向对象设计的基本思想完全相反,面向对象设计是将数据和与数据一起工作的功能组合成内聚的单元。

只要你不违反德米特定律,你应该把逻辑也插入到(域)类中,如果这个逻辑是在那个类的属性上操作或者只与那个类的近邻合作。

告诉,不要问!

“告诉,不要问”的原则与前面讨论的得墨忒耳定律有一些相似之处。这个原则是对所有这些公共 get 方法的“宣战”,它揭示了一个对象的内部状态。也告诉不要问促进封装,加强信息隐藏(见第三章),但首要原则是关于强内聚力。

让我们来看一个小例子。假设前面例子中的成员函数Engine::start()实现如下:

class Engine {

public:
// ...
  void start() {
    if (! fuelPump.isRunning()) {
      fuelPump.powerUp();
      if (fuelPump.getFuelPressure() < NORMAL_FUEL_PRESSURE) {
        fuelPump.setFuelPressure(NORMAL_FUEL_PRESSURE);
      }
    }
    if (! ignition.isPoweredUp()) {
      ignition.powerUp();
    }
    if (! starter.isRotating()) {
      starter.revolve();
    }
    if (engine.hasStarted()) {
      starter.openClutchToEngine();
      starter.stop();
    }
  }
// ...

private:
  FuelPump fuelPump;
  Ignition ignition;
  Starter starter;
  static const unsigned int NORMAL_FUEL_PRESSURE { 120 };
};
Listing 6-22.A possible, but not recommendable implementation of the Engine::start() member function

显而易见,类Enginestart()方法从其部分查询许多状态,并做出相应的响应。此外,Engine还会检查燃油泵的燃油压力,如果压力过低,则会进行调整。这也意味着Engine必须知道正常燃油压力的值。由于 if 分支众多,圈复杂度很高。

“告诉不要问”原则提醒我们,如果一个对象能够自己做出决定,我们就不应该要求这个对象发布关于其内部状态的信息,并决定在这个对象之外做什么。基本上,这个原则提醒我们,在面向对象中,数据和对这些数据的操作,是要被组合成内聚的单元的。

如果我们将这个原则应用到我们的例子中,Engine::start()方法将只告诉它的部分它们应该做什么:

class Engine {

public:
// ...
  void start() {
    fuelPump.pump();
    ignition.powerUp();
    starter.revolve();
  }
// ...

private:
  FuelPump fuelPump;
  Ignition ignition;
  Starter starter;
};
Listing 6-23.Delegating of stages of the starting procedure to the responsible parts of the engine

零件可以自己决定如何执行该命令,因为他们有相关的知识,例如,FuelPump可以做所有它必须做的事情来增加燃油压力:

class FuelPump {

public:
// ...
  void pump() {
    if (! isRunning) {
      powerUp();
      setNormalFuelPressure();
    }
  }
// ...

private:
  void powerUp() {
    //...
  }

  void setNormalFuelPressure() {
    if (pressure != NORMAL_FUEL_PRESSURE) {
      pressure = NORMAL_FUEL_PRESSURE;
    }
  }

  bool isRunning;
  unsigned int pressure;
  static const unsigned int NORMAL_FUEL_PRESSURE { 120 };
};

Listing 6-24.An excerpt from the FuelPump class

当然,并不是所有的吸气剂天生就是坏的。有时有必要从对象中检索信息,例如,如果该信息应该显示在图形用户界面上。

避免静态类成员

我可以想象很多读者现在在想:静态成员变量和静态成员函数到底有什么问题?

嗯,也许您还记得我在前面关于小班的章节中描述的上帝类反模式。在那里,我已经描述了实用程序类通常会变成如此巨大的“神类”此外,这些实用程序类通常也由许多静态成员函数组成,甚至经常没有例外。对此,一个很容易理解的理由是:我为什么要强迫实用程序类的用户创建它的一个实例?因为这样的类为不同的目的提供了五颜六色的不同功能,顺便说一下,这是弱内聚的标志,我为这些杂乱的东西创建了一个特殊的模式名:垃圾商店反模式。根据在线百科全书维基百科,旧货店是一种零售商店,类似于廉价商店,以低廉的价格提供各种各样的常用商品。

class JunkShop {

public:
  // ...many public utility functions...
  static int oneOfManyUtilityFunctions(int param);
  // ...more public utility functions...
};
Listing 6-25.Excerpt from some utility class

#include "JunkShop.h"

class Client {
  // ...
  void doSomething() {
    // ...
    y = JunkShop::oneOfManyUtilityFunctions(x);
    // ...
  }
};

Listing 6-26.Another class that uses the Utility class

第一个问题是,您的代码变得与这些“垃圾商店”中的所有静态助手函数紧密相连从上面的例子可以很容易地看出,在另一个软件模块的实现中的某个地方使用了来自实用程序类的这种静态函数。因此,没有简单的方法可以用别的方法来替换这个函数调用。但是在单元测试中(见第二章),这正是你想要做的。

此外,静态成员函数培养了过程化编程风格。将它们与静态变量结合使用会使面向对象变得荒谬。借助静态成员变量在类的所有实例中共享相同的状态本质上不是 OOP,因为它打破了封装,因为对象不再完全控制它的状态。

当然,C++ 不是 Java 或者 C# 那样的纯面向对象的编程语言,基本不禁止用 C++ 写过程化代码。但是当你想这样做的时候,你应该对自己诚实,因此使用简单的独立过程,分别是函数、全局变量和名称空间。

我的建议是尽量避免静态成员变量和成员函数。

这个规则的一个例外是类的私有常量,因为它们是只读的,不代表对象的状态。另一个例外是工厂方法,即创建对象实例的静态成员函数,通常是类类型的实例,也作为静态成员函数的命名空间。

七、函数式编程

几年来,编程范式经历了一次复兴,这通常被视为面向对象的一种逆流。这个演讲是关于函数式编程的。

最早的函数式编程语言之一是 LISP(大写的“Lisp”是一种更古老的拼写,因为该语言的名称是“列表处理”的缩写),它是由美国计算机科学家和认知科学家约翰·麦卡锡于 1958 年在麻省理工学院(MIT)设计的。麦卡锡还创造了“人工智能”(AI)这个术语,他使用 Lisp 作为人工智能应用程序的编程语言。Lisp 基于所谓的 Lambda 演算(λ calculus),一种由美国数学家阿隆佐·邱奇在 20 世纪 30 年代提出的正式模型(见下面的侧栏)。

事实上,Lisp 是计算机编程语言的一个家族。Lisp 的各种方言在过去已经出现。例如,每个使用过著名的 Emacs 文本编辑器家族成员(例如 GNU Emacs 或 X Emacs)的人都知道 Emacs Lisp 这种方言,它被用作扩展和自动化的脚本语言。

值得注意的是,在 Lisp 之后开发的函数式编程语言包括:

  • Scheme:一种带有静态绑定的 Lisp 方言,在 20 世纪 70 年代由 MIT 人工智能实验室(AI Lab)开发。
  • 米兰达:第一个被商业支持的纯粹懒惰的函数式语言。
  • Haskell:一种通用的纯函数式编程语言,以美国逻辑学家和数学家哈斯凯尔·布鲁克斯·加里的名字命名。
  • Erlang:由瑞典电信公司 Ericsson 开发,主要致力于构建大规模可伸缩和高可靠性的实时软件系统。
  • F#(读作 F sharp):一种多种编程语言,也是微软的成员。NET 框架。F# 的主要范例是函数式编程,但它也允许开发人员切换到。网络生态系统。
  • Clojure:由 Rich Hickey 创建的 Lisp 编程语言的现代方言。Clojure 是纯功能性的,运行在 Java 虚拟机和公共语言运行时(CLR 微软的运行时环境。NET 框架)。

The Lambda Calculus

很难找到对 Lambda 微积分的无痛介绍。许多关于这个主题的文章都是非常科学的,需要很好的数学和逻辑知识。甚至我也不会试图在这里解释 Lambda 微积分,因为这不是本书的主要重点。但是你可以在网上找到无数的解释;只要问一下你信任的搜索引擎,你就会得到数百个搜索结果。

仅此而已:Lambda 演算可以被认为是可能的最简单和最小的编程语言。它仅由两部分组成:一个单一的函数定义模式和一个单一的转换规则。这两个组件足以为函数式编程语言(如 LISP、Haskell、Clojure 等)的形式描述提供一个通用模型。

到今天为止,函数式编程语言仍然没有像它们的命令式亲戚一样被广泛使用,例如像面向对象的语言,但是它们的传播在增加。例如 JavaScript 和 Scala,它们都是多种语言(也就是说,它们不是纯粹的函数式语言),但是由于它们的函数式编程能力,它们变得越来越流行,尤其是在 web 开发中。

这足以让我们更深入地研究这个主题,探索这种编程风格到底是怎么回事,以及现代 C++ 在这个方向上提供了什么。

什么是函数式编程?

很难为函数式编程(有时缩写为 FP)找到一个普遍接受的定义。通常,人们认为函数式编程是一种编程风格,其中整个程序完全由纯函数构建而成。这立即引出了一个问题:在这种情况下,“纯函数”是什么意思?好吧,我们将在下一节讨论这个问题。然而,基本上这是正确的:函数式编程的基础是数学意义上的函数。程序是由函数的组合、函数的求值和函数链构成的。

就像面向对象一样(见第六章),函数式编程也是一种编程范式。这意味着它是一种思考软件构建的方式。然而,函数式编程范式也经常被所有那些归因于它的积极属性所定义。与其他编程范例(尤其是面向对象)相比,这些被认为是有利的属性如下:

  • 通过避免(全局)共享的可变状态,没有副作用。在纯函数式编程中,函数调用没有任何副作用。纯函数的这一重要性质将在下一节“什么是函数”中详细讨论
  • 不可变的数据和对象。在纯函数式编程中,所有的数据都是不可变的,也就是说,一旦数据结构被创建,就永远不能被改变。相反,如果我们将一个函数应用于一个数据结构,那么就会创建一个新的数据结构,这个新的数据结构或者是旧数据结构的变体。一个令人愉快的结果是,不可变数据具有线程安全的巨大优势。
  • 函数合成和高阶函数。在函数式编程中,函数可以像数据一样对待。你可以在变量中存储一个函数。您可以将一个函数作为参数传递给其他函数。函数可以作为其他函数的结果返回。函数可以很容易地链接起来。换句话说:函数是语言的一等公民。
  • 更好、更容易的并行化。并发基本上很难。软件设计师必须注意多线程环境中的许多事情,而当只有一个执行线程时,她通常不必担心这些事情。在这样的程序中寻找漏洞是非常痛苦的。但是,如果函数的调用从来没有任何副作用,如果没有全局状态,如果我们只处理不可变的数据结构,那么使一个软件并行就容易得多。相反,对于命令式语言,如面向对象的语言,以及其经常可变的状态,您需要锁定和同步机制来保护数据不被几个线程同时访问和操作(参见第九章中的“不变性的力量”一节,了解如何在 C++ 中创建不可变的类或对象)。
  • 容易测试。如果纯函数具有上面提到的所有积极性质,它们也非常容易测试。没有必要在测试用例中考虑全局可变状态或其他副作用。

我们将会看到,用 C++ 中的函数式风格编程不能自动地完全保证所有这些积极的方面。例如,如果我们需要一个不可变的数据类型,我们必须这样设计它,如第九章所解释的。但现在让我们更深入地探讨这个话题,让我们讨论一下中心问题:函数式编程中的函数是什么?

什么是函数?

在软件开发中,我们可以找到许多被命名为“功能”的东西例如,软件应用程序提供给用户的一些功能通常也称为程序的功能。在 C++ 中,类的方法有时被称为成员函数。计算机程序的子程序通常被认为是函数。毫无疑问,这些例子在某种程度上也是“函数”,但不是我们在函数式编程中处理的函数。

当我们谈论函数式编程中的函数时,我们谈论的是真正的数学函数。这意味着我们将函数视为一组输入参数和一组允许的输出参数之间的关系,由此每组输入参数与一组输出参数正好相关。作为一个简单而通用的公式,函数是一个表达式,如图 7-1 所示。

A429836_1_En_7_Fig1_HTML.jpg

图 7-1。

The function f maps x to y

这个简单的公式定义了任何函数的基本模式。它表示 y 的值依赖于,并且仅仅依赖于,x 的值,还有一点很重要,对于相同的 x 值,y 的值也总是相同的!换句话说,函数 f 将 x 的任何可能值映射到 y 的唯一值。在数学和计算机编程中,这也称为引用透明性。

Referential Transparency

函数式编程的一个重要优点是纯函数总是透明的。

术语“参照透明性”起源于分析哲学,这是自 20 世纪初以来发展起来的某些哲学运动的总称。分析哲学基于一种传统,最初主要通过理想语言(形式逻辑)或通过分析日常使用的日常语言来运作。“参照透明”这个术语是由美国哲学家和逻辑学家威拉德·范·奥曼·奎因(1908-2000)提出的。

如果一个函数是引用透明的,这意味着无论何时我们用相同的输入值调用这个函数,我们将总是得到相同的输出。用真正的函数式语言编写的函数,计算一个表达式并返回它的值,除此之外什么也不做。换句话说,我们在理论上能够用函数调用的结果值直接替换它,这种改变不会有任何影响。这使我们能够将功能链接在一起,就像它们是黑盒一样。

参照透明性直接把我们引向纯函数的概念。

纯函数与不纯函数

下面是一个简单的 C++ 纯函数的例子:

double square(const double value) noexcept {
  return value * value;
};
Listing 7-1.A simple example of a pure function in C++

很容易看出,square()的输出值完全取决于传递给函数的参数value,所以用同一个参数value调用square()两次,每次都会产生相同的结果。我们没有副作用,因为如果这个函数的任何调用被完成,它不会留下任何“污垢”来影响对square()的后续调用。这种函数完全独立于外部状态,没有任何副作用,对于相同的输入总是产生相同的输出,具体地说,是指涉透明的,这种函数被称为纯函数。

相反,命令式编程范例(如过程式或面向对象的编程)不提供这种无副作用的保证,如下例所示:

#include <iostream>

class Clazz {

public:
  int functionWithSideEffect(const int value) noexcept {
    return value * value + someKindOfMutualState++;
  }

private:
  int someKindOfMutualState { 0 };
};

int main() {
  Clazz instanceOfClazz { };
  std::cout << instanceOfClazz.functionWithSideEffect(3) << std::endl; // Output: "9"
  std::cout << instanceOfClazz.functionWithSideEffect(3) << std::endl; // Output: "10"
  std::cout << instanceOfClazz.functionWithSideEffect(3) << std::endl; // Output: "11"
  return 0;
}

Listing 7-2.An example demonstrating that member functions of classes can cause side effects

在这种情况下,每次调用名字为Clazz::functionWithSideEffect()的成员函数都会改变类Clazz实例的内部状态。因此,尽管函数参数的给定参数总是相同的,但是每次调用这个成员函数都会返回不同的结果。在使用由过程操作的全局变量的过程编程中,也可以产生类似的效果。即使调用时总是使用相同的参数,也能产生不同输出的函数称为不纯函数。一个函数是不纯函数的另一个明显标志是,在不使用返回值的情况下调用它是有意义的。如果你能做到这一点,这个功能一定有任何副作用。

在单线程执行环境中,全局状态可能会导致一些问题和痛苦。但是现在想象一下,你有一个多线程的执行环境,有几个线程在运行,以不确定的顺序调用函数?在这样的环境中,实例的全局状态或对象范围的状态通常是有问题的,并且可能导致不可预测的行为或细微的错误。

现代 C++ 中的函数式编程

信不信由你,函数式编程一直是 C++ 的一部分!有了这种多种语言,即使是用 C++98,你也总能用函数式风格编程。我可以凭良心断言这一点的原因是,自从 C++ 开始以来,就存在已知的模板元编程(TMP)(顺便说一下,TMP 是一个非常复杂的主题,因此对许多开发人员来说是一个挑战,即使是技术娴熟、经验丰富的开发人员也是如此)。

用 C++ 模板进行函数式编程

许多 C++ 开发人员都知道,模板元编程是一种技术,在编译器将源代码转换为目标代码之前的一个步骤中,编译器使用所谓的模板来生成 C++ 源代码。许多程序员可能没有意识到的事实是,模板元编程是函数式编程,并且它是图灵完备的。

Turing Completeness

术语“图灵全集”以著名的英国计算机科学家、数学家、逻辑学家和密码分析学家艾伦·图灵(1912-1954)的名字命名,通常用于定义什么使一种语言成为“真正的”编程语言。如果你可以用一种编程语言解决任何可能的问题,并且理论上可以用图灵机来计算,那么这种编程语言就是图灵完备的。图灵机是艾伦·图灵发明的一种抽象的理论机器,作为计算的理想化模型。

实际上,没有一个计算机系统是真正的图灵完备的。原因是理想的图灵完备性需要无限的内存和无限的递归,而这是今天的计算机系统所不能提供的。因此,一些系统通过模拟无界存储器来近似图灵完全性,但是受到底层硬件的物理限制的约束。

作为证明,我们将只使用 TMP 计算两个整数的最大公约数(GCD)。两个都不为零的整数的 GCD 是两个给定整数相除的最大正整数。

01  #include <iostream>
02
03  template< unsigned int x, unsigned int y >
04  struct GreatestCommonDivisor {
05    static const unsigned int result = GreatestCommonDivisor< y, x % y >::result;
06  };
07
08  template< unsigned int x >
09  struct GreatestCommonDivisor< x, 0 > {
10    static const unsigned int result = x;
11  };
12
13  int main() {
14    std::cout << "The GCD of 40 and 10 is: " << GreatestCommonDivisor<40u, 10u>::result <<
15      std::endl;
16    std::cout << "The GCD of 366 and 60 is: " << GreatestCommonDivisor<366u, 60u>::result <<
17      std::endl;
18    return 0;
19  }
Listing 7-3.Calculating the greatest common divisor using template metaprogramming

这是我们的程序生成的输出:

The GCD of 40 and 10 is: 10
The GCD of 366 and 60 is: 6

这种在编译时使用模板计算 GCD 的方式的显著之处在于,它是真正的函数式编程。使用的两个类模板完全没有状态。没有可变变量,这意味着一旦变量被初始化,就不能改变它的值。在模板实例化过程中,一个递归过程被启动,当第 9 - 11 行的专门化类模板开始起作用时,这个过程停止。而且,正如上面已经提到的,我们在模板元编程中有图灵完备性,这意味着任何可以想到的计算都可以在编译时使用这种技术来完成。

嗯,模板元编程无疑是一个强大的工具,但也有一些缺点。特别是,如果使用大量的模板元编程,代码的可读性和可理解性会受到严重影响。TMP 的语法和习惯用法很难理解,更不用说当出现问题时那些广泛的、经常是模糊的错误消息了。当然,随着模板元编程的广泛使用,编译时间也会增加。因此,TMP 当然是一种设计和开发泛型库的适当方法(参见 C++ 标准库),但是如果需要这种类型的泛型编程(例如,最小化代码重复),则应该只在现代和精心制作的应用程序代码中使用。

顺便说一下,从 C++11 开始,不再需要在编译时使用模板元编程进行计算。借助常量表达式(constexpr);参见第五章中关于编译时计算的部分)GCD 可以很容易地作为一个普通的递归函数来实现,如下例所示:

constexpr unsigned int greatestCommonDivisor(const unsigned int x,
                                             const unsigned int y) noexcept {
  return y == 0 ? x : greatestCommonDivisor(y, x % y);
}
Listing 7-4.A GCD function using recursion that can be evaluated at compile time

对了,这背后的数学算法叫做欧几里德算法,或者欧几里德算法,以古希腊数学家欧几里德命名。

在 C++17 中,数值算法std::gcd()已经成为 C++ 标准库的一部分(在头文件<numeric>中定义),因此不再需要自己实现它。

#include <iostream>

#include <numeric>

int main() {
  constexpr auto result = std::gcd(40, 10);
  std::cout << "The GCD of 40 and 10 is: " << result << std::endl;
  return 0;
}

Listing 7-5.Using function std::gcd from header <numeric>

类似函数的对象(仿函数)

在 C++ 中,从一开始就有可能定义和使用所谓的类函数对象,简称为函子(Functors)。从技术上讲,函子或多或少只是一个定义括号运算符的类,也就是operator()。在实例化这些类之后,它们就可以像函数一样使用了。

根据operator()是没有参数、一个参数还是两个参数,函子被称为生成器、一元函数或二元函数。我们先来看一个发电机。

发电机

正如“生成器”这个名字所揭示的,这种类型的函子是用来产生某种东西的。

class IncreasingNumberGenerator {

public:
  int operator()() noexcept { return number++; }

private:
  int number { 0 };
};

Listing 7-6.An example of a Generator, a functor that is called with no argument

工作原理相当简单:每调用一次IncreasingNumberGenerator::operator(),就将成员变量number的实际值返回给调用者,然后将这个成员变量的值加 1。以下使用示例在标准输出中打印数字 0 到 2 的序列:

int main() {
  IncreasingNumberGenerator numberGenerator { };
  std::cout << numberGenerator() << std::endl;
  std::cout << numberGenerator() << std::endl;
  std::cout << numberGenerator() << std::endl;
  return 0;
}

还记得我在第五章的算法部分引用的肖恩·帕伦特的话吗:没有原始循环!为了用一定数量的递增值填充std::vector<T>,我们不应该实现自己手工制作的循环。相反,我们可以使用在 header <algorithm>中定义的std::generate,这是一个函数模板,它为特定范围内的每个元素分配一个由给定的生成器对象生成的值。因此,我们可以编写以下简单易读的代码,使用我们的IncreasingNumberGenerator用递增的数字序列填充一个向量:

#include <algorithm>

#include <vector>

using Numbers = std::vector<int>;

int main() {
  const std::size_t AMOUNT_OF_NUMBERS { 100 };
  Numbers numbers(AMOUNT_OF_NUMBERS);
  std::generate(std::begin(numbers), std::end(numbers), IncreasingNumberGenerator());
  // ...now 'numbers' contain values from 0 to 99...
  return 0;
}

Listing 7-7.Filling a vector with an increasing number sequence using std::generate

不难想象,这些函子并不满足纯函数的严格要求。生成器通常有一个可变状态,也就是说,当调用operator()时,这些函子通常会产生一些副作用。在我们的例子中,可变状态由私有成员变量IncreasingNumberGenerator::number表示,它在每次调用括号操作符后递增。

Tip

<numeric>已经包含了一个函数模板std::iota(),它以编程语言 APL 中的函数符号⍳ (Iota)命名,它不是一个生成器函子,但它可以用来以一种优雅的方式用一个升序的值序列填充一个容器。

Generator 类型的类似函数的对象的另一个例子是下面的随机数生成器仿函数模板。该函子封装了初始化和使用伪随机数发生器(PRNG)所必需的所有东西,该伪随机数发生器基于所谓的 Mersenne Twister 算法(在标题<random>中定义)。

#include <random>

template <typename NUMTYPE>

class RandomNumberGenerator {

public:
  RandomNumberGenerator() {
    mersenneTwisterEngine.seed(randomDevice());
  }

  NUMTYPE operator()() {
    return distribution(mersenneTwisterEngine);
  }

private:
  std::random_device randomDevice;
  std::uniform_int_distribution<NUMTYPE> distribution;
  std::mt19937_64 mersenneTwisterEngine;
};

Listing 7-8.A generator functor class template, encapsulating a pseudorandom number generator

这就是仿函数RandomNumberGenerator的用法:

#include "RandomGenerator.h"

#include <algorithm>

#include <functional>

#include <iostream>

#include <vector>

using Numbers = std::vector<short>;

const std::size_t AMOUNT_OF_NUMBERS { 100 };

Numbers createVectorFilledWithRandomNumbers() {
  RandomNumberGenerator<short> randomNumberGenerator { };
  Numbers randomNumbers(AMOUNT_OF_NUMBERS);
  std::generate(begin(randomNumbers), end(randomNumbers), std::ref(randomNumberGenerator));
  return randomNumbers;
}

void printNumbersOnStdOut(const Numbers& randomNumbers) {
  for (const auto& number : randomNumbers) {
    std::cout << number << std::endl;
  }
}

int main() {
  Numbers randomNumbers = createVectorFilledWithRandomNumbers();
  printNumbersOnStdOut(randomNumbers);
  return 0;
}

Listing 7-9.Filling a vector with 100 random numbers

一元函数

接下来,让我们看一个一元函数类对象的例子,它是一个仿函数,其 paranthesis 运算符只有一个参数。

class ToSquare {

public:
  constexpr int operator()(const int value) const noexcept { return value * value; }
};
Listing 7-10.An example for a unary functor

顾名思义,这个仿函数对圆括号运算符中传递给它的值进行平方运算。将operator()声明为const,这是一个指示符,表明它的行为像一个纯函数,也就是说,一个调用不会有副作用。这并不一定总是如此,因为,当然,一元函子也可以有私有成员变量,因此也可以有可变状态。

使用ToSquare仿函数,我们现在可以扩展上面的例子,并将其应用于带有升序整数序列的向量。

#include <algorithm>

#include <vector>

using Numbers = std::vector<int>;

int main() {
  const std::size_t AMOUNT_OF_NUMBERS = 100;
  Numbers numbers(AMOUNT_OF_NUMBERS);
  std::generate(std::begin(numbers), std::end(numbers), IncreasingNumberGenerator());
  std::transform(std::begin(numbers), std::end(numbers), std::begin(numbers), ToSquare());
  // ...
  return 0;
}

Listing 7-11.All 100 numbers in a vector are squared

所使用的算法std::transform(在标题<algorithm>中定义)将给定的函数或函数对象应用于一个范围(由前两个参数定义),并将结果存储在另一个范围(由第三个参数定义)。在我们的例子中,两个范围是相同的。

述语

一种特殊的函子是谓词。如果一元函子有一个参数和一个指示某个测试的结果truefalse的布尔返回值,则称为一元谓词,如下例所示:

class IsAnOddNumber {

public:
  constexpr bool operator()(const int value) const noexcept { return (value % 2) != 0; }
};
Listing 7-12.An example for a Predicate

这个谓词现在可以应用于我们的数字序列,使用std::remove_if算法来去除所有奇数。问题是这个算法的名字有误导性。实际上,它并没有移除任何东西。任何与谓词不匹配的元素(在我们的例子中是所有偶数)都被移动到容器的开头,这样要删除的元素就在末尾。然后std::remove_if返回一个迭代器,指向要删除的范围的开始。这个迭代器可以被std::vector::erase()成员函数用来真正从向量中删除不需要的元素。顺便说一下,这种非常有效的技术被称为擦除-删除习惯用法。

#include <algorithm>

#include <vector>

using Numbers = std::vector<int>;

int main() {
  const std::size_t AMOUNT_OF_NUMBERS = 100;
  Numbers numbers(AMOUNT_OF_NUMBERS);
  std::generate(std::begin(numbers), std::end(numbers), IncreasingNumberGenerator());
  std::transform(std::begin(numbers), std::end(numbers), std::begin(numbers), ToSquare());
  numbers.erase(std::remove_if(std::begin(numbers), std::end(numbers), IsAnOddNumber()),
    std::end(numbers));
  // ...
  return 0;
}

Listing 7-13.All odd numbers from the vector are deleted using the Erase-remove idiom

为了能够以更灵活和通用的方式使用仿函数,它通常被实现为类模板。因此,我们可以将我们的一元函子IsAnOddNumber重构为一个类模板,这样它就可以用于所有的整数类型,比如shortintunsigned int等。由于 C++11 语言提供了所谓的类型特征(在头文件<type_traits>中定义),我们可以确保模板只用于整型,如下例所示:

#include <type_traits>

template <typename INTTYPE>

class IsAnOddNumber {

public:
  static_assert(std::is_integral<INTTYPE>::value,
    "IsAnOddNumber requires an integer type for its template parameter INTTYPE!");
  constexpr bool operator()(const INTTYPE value) const noexcept { return (value % 2) != 0; }
};

Listing 7-14.Ensuring that the template parameter is an integral data type

从 C++11 开始,该语言提供了static_assert(),一种在编译时执行的断言检查。在我们的例子中,static_assert()用于在模板实例化期间检查模板参数INTTYPE是否是使用类型特征std::is_integral<T>的整型。main()函数体内使用谓词的位置(erase-remove 构造)现在需要稍微调整一下:

  // ...
  numbers.erase(std::remove_if(std::begin(numbers), std::end(numbers),
    IsAnOddNumber<Numbers::value_type>()), std::end(numbers));
  // ...

如果我们现在无意中使用了带有非整数数据类型的模板,比如double,我们会从编译器那里得到一条引人注目的错误消息:

[...]
../src/Functors.h: In instantiation of ‘class IsAnOddNumber<double>’:
../src/Main.cpp:13:94:   required from here
../src/Functors.h:42:3: error: static assertion failed: IsAnOddNumber requires an integer type for its template parameter INTTYPE!
[...]

Type Traits

模板是泛型编程的基础。C++ 标准库中的容器,还有迭代器和算法,都是使用 C++ 模板概念的非常灵活的泛型编程的杰出例子。但是从技术的角度来看,如果用模板参数实例化一个模板,那么只发生一个简单的文本查找和替换过程。例如,如果一个模板参数被命名为T,那么每次出现的T都会被替换为在模板实例化期间作为模板参数传入的数据类型。

问题是:不是每个数据类型都适合每个模板的实例化。例如,如果您将一个数学运算定义为 C++ 仿函数模板,以便它可以用于不同的数字数据类型(shortintdouble等)。),用std::string实例化这个模板绝对没有意义。

C++ 标准库头文件<type_traits>(从 C++11 开始可用)提供了一个全面的检查集合,用于检索编译时作为模板参数传入的类型信息。换句话说,在类型特征的帮助下,您能够定义模板参数必须满足的编译器可验证的要求。

例如,您可以通过使用类型特征std::is_nothrow_copy_constructible<T>来确保用于模板实例化的类型必须是可复制构造的,并结合无抛出异常安全保证(参见第五章中的“无抛出保证”一节)。

template <typename T>

class Clazz {
  static_assert(std::is_nothrow_copy_constructible<T>::value,
    "The given type for T must be copy-constructible and may not throw!");
  // ...
};

类型特征不仅可以与static_assert()一起使用,以通过错误消息中止编译。例如,它们也可以用于一个叫做 SFINAE(替换失败不是错误)的习语,在第九章中关于习语的章节中会有更详细的讨论。

最后,让我们看看二元函子。

二元函子

如上所述,二元仿函数是一个类似函数的对象,它接受两个参数。如果这种函子对其两个参数进行运算以执行某种计算(例如加法)并返回该运算的结果,则称为二元运算符。如果这样一个函子有一个布尔返回值作为某个测试的结果,如下例所示,它被称为二元谓词。

class IsGreaterOrEqual {

public:
  bool operator()(const auto& value1, const auto& value2) const noexcept {
    return value1 >= value2;
  }
};
Listing 7-15.An example for a binary predicate that compares its two parameters

Note

在 C++11 之前,函子是一个很好的实践,根据它们的参数数量,分别从模板std::unary_functionstd::binary_function中派生出来(两者都在头文件<functional>中定义)。这些模板在 C++11 中被标记为不推荐使用,并且在最新的 C++17 标准中从标准库中删除。

绑定器和函数包装器

随着 2005 年 C++ 技术报告草案 1 (TR 1)的发布,C++ 中函数式编程方面的下一个发展步骤已经完成,这是标准 ISO/IEC TR 19768:2007 C++ 库扩展的通用名称。TR 1 指定了一系列对 C++ 标准库的扩展,其中包括对函数式编程的扩展。这份技术报告是后来的 C++11 标准的库扩展建议,事实上,13 个建议库中的 12 个(稍作修改)也将其纳入了 2011 年发布的新语言标准。

在函数式编程方面,TR 1 引入了两个函数模板std::bindstd::function,它们在库头<functional>中定义。

函数模板std::bind是函数及其参数的绑定包装器。您可以使用一个函数(或函数指针,或仿函数),并将实际值“绑定”到函数的一个或所有参数。换句话说,您可以从现有的函数或仿函数创建新的类似函数的对象。让我们从一个简单的例子开始:

#include <functional>

#include <iostream>

constexpr double multiply(const double multiplicand, const double multiplier) noexcept {
  return multiplicand * multiplier;
}

int main() {
  const auto result1 = multiply(10.0, 5.0);
  auto boundMultiplyFunctor = std::bind(multiply, 10.0, 5.0);
  const auto result2 = boundMultiplyFunctor();

  std::cout << "result1 = " << result1 << ", result2 = " << result2 << std::endl;
  return 0;
}

Listing 7-16.Using std::bind to wrap binary function multiply()

在这个例子中,multiply()函数和两个浮点数文字(10.05.0)一起使用std::bind包装。数字文字表示绑定到两个函数参数multiplicandmultiplier的实际参数。结果,我们得到了一个新的类似函数的对象,它存储在变量boundMultiplyFunctor中。然后可以像普通的函子一样使用括号运算符调用它。

也许你现在会问自己:很好,但我不明白。那有什么用?活页夹功能模板的实际好处是什么?

嗯,std::bind在编程中允许一些被称为部分应用(或部分函数应用)的东西。部分应用是一个过程,其中只有函数参数的子集绑定到值或变量,而其他部分尚未绑定。未绑定的参数由占位符_1_2_3等替换,这些占位符在名称空间std::placeholders中定义。

#include <functional>

#include <iostream>

constexpr double multiply(const double multiplicand, const double multiplier) noexcept {
  return multiplicand * multiplier;
}

int main() {
  using namespace std::placeholders;

  auto multiplyWith10 = std::bind(multiply, _1, 10.0);
  std::cout << "result = " << multiplyWith10(5.0) << std::endl;
  return 0;
}

Listing 7-17.An example of partial function application

在上面的例子中,multiply()函数的第二个参数被绑定到浮点数文字10.0,但是第一个参数被绑定到一个占位符。类函数对象是std::bind()的返回值,存储在变量multiplyWith10中。这个变量现在可以像函数一样使用,但是我们只需要传递一个参数:要乘以10.0的值。

部分函数应用是一种适应技术,它允许我们在各种情况下使用函数或仿函数,在这些情况下,我们需要它们的功能,但我们只能提供一些参数,而不是所有参数。此外,在占位符的帮助下,函数参数的顺序可以适应客户端代码期望的顺序。例如,参数表中的multiplicandmultiplier的位置可以互换,方法是将它们映射到一个新的类似功能的对象,如下所示:

auto multiplyWithExchangedParameterPosition = std::bind(multiply, _2, _1);

在我们使用multiply()函数的情况下,这显然是没有意义的(记住乘法的可交换性),因为新的函数对象将产生与原始multiply()函数完全相同的结果,但是在其他情况下,参数顺序的调整可以提高函数的可用性。部分功能应用是界面适配的工具。

顺便说一下,特别是结合作为返回参数的函数,带有关键字auto的自动类型演绎(参见第五章中的“自动类型演绎”一节)可以提供有价值的服务,因为如果我们考察 GCC 编译器从上面的std::bind()调用返回的是什么,它是下面复杂类型的对象:

std::_Bind_helper<bool0,double (&)(double, double),const _Placeholder<int2> &,const _Placeholder<int1> &>::type

很可怕,不是吗?在源代码中显式地写下这样一个类型不仅有点帮助,而且代码的可读性也会大打折扣。多亏了关键字auto,没有必要明确定义这些类型。但是在那些罕见的情况下,当你必须这么做的时候,类模板std::function就会发挥作用,它是一个通用的多态函数包装器。这个模板可以包装任意的可调用对象(普通函数、仿函数、函数指针等)。),并管理用于存储该对象的内存。例如,要将我们的乘法函数multiply()包装成一个std::function对象,代码如下所示:

std::function<double(double, double)> multiplyFunc = multiply;

auto result = multiplyFunc(10.0, 5.0);

既然我们已经讨论了std::bindstd::function和部分应用的技术,我有一个可能令人失望的消息要告诉你:自从 C++ 11 和 lambda 表达式的引入以来,大多数来自 C++ 标准库的模板材料已经很少需要了。

λ表达式

随着 C++11 的出现,这种语言有了一个值得注意的新特性:lambda 表达式!它们的其他常用术语是 lambda 函数、函数文字或只是 lambda。有时它们也被称为闭包,这实际上是函数式编程中的一个通用术语,顺便提一下,这也不完全正确。

Closure

在命令式编程语言中,我们习惯了这样一个事实:当程序执行离开了定义变量的范围时,变量就不再可用了。例如,如果一个函数完成并返回给它的调用者,那么该函数的所有局部变量都将从调用堆栈中移除并从内存中删除。

另一方面,在函数式编程中,我们可以构建一个闭包,它是一个具有持久局部变量作用域的函数对象。换句话说,闭包允许将一个具有部分或全部局部变量的作用域绑定到一个函数上,只要这个函数存在,这个作用域对象就会一直存在。

在 C++ 中,由于 lambda 导入器中的捕获列表,这样的闭包可以在 lambda 表达式的帮助下创建。闭包不同于 lambda 表达式,就像面向对象中的对象(实例)不同于它的类一样。

lambda 表达式的特殊之处在于,它们通常是内联实现的,也就是说,是在应用时实现的。这有时可以提高代码的可读性,编译器可以更有效地应用它们的优化策略。当然,lambda 函数也可以被视为数据,例如,存储在变量中,或者作为函数参数传递给所谓的高阶函数(参见下一节关于该主题的内容)。

lambda 表达式的基本结构如下所示:

 capture list  -> return_type_declaration { lambda body }

由于这本书不是 C++ 语言入门,所以我在这里不解释 lambda 表达式的所有基础知识。即使您是第一次看到这样的东西,也应该相对清楚返回类型、参数列表和 lambda 主体与普通函数非常相似。乍一看,有两件事似乎不同寻常。例如,lambda 表达式没有像普通函数或类似函数的对象那样的名称。这就是为什么在这种情况下也谈论匿名函数的原因。另一个显眼的是开头的方括号,也叫λ导入器。顾名思义,lambda 导入器标记了 lambda 表达式的开始。此外,介绍者还可选地包含被称为捕获列表的东西。

这个捕获列表之所以如此重要,是因为这里列出了来自外部范围的所有变量,这些变量应该在 lambda 主体内部可用,以及它们应该通过值(复制)还是通过引用来捕获。换句话说,这些是 lambda 表达式的闭包。

一个示例 lambda 表达式定义如下:

[](const double multiplicand, const double multiplier) { return multiplicand * multiplier; }

这是我们的λ乘法函数。介绍者有一个空白的捕获列表,这意味着没有使用来自周围范围的任何内容。此外,在这种情况下,也没有指定返回类型,因为编译器可以很容易地推断出它。

通过将 lambda 表达式赋给一个变量,就创建了一个相应的运行时对象,即所谓的闭包。这实际上是正确的:编译器从 lambda 表达式中生成一个未指定类型的仿函数类,它在运行时被实例化并赋给变量。捕获列表中的捕获被转换成函子对象的构造函数参数和成员变量。lambda 的参数列表中的参数被转换成仿函数的圆括号运算符(operator())的参数。

#include <iostream>

int main() {
  auto multiply = [](const double multiplicand, const double multiplier) {
    return multiplicand * multiplier;
  };
  std::cout << multiply(10.0, 50.0) << std::endl;
  return 0;
}

Listing 7-18.Using the lambda expression to multiply two doubles

然而,整个事情可以做得更短,因为 lambda 表达式可以通过在 lambda 体后面附加带有参数的括号,在它的定义处直接调用。

int main() {
  std::cout <<
    [](const double multiplicand, const double multiplier) {
      return multiplicand * multiplier;
    }(50.0, 10.0) << std::endl;
  return 0;
}
Listing 7-19.Defining and calling of a lambda expression in one go

当然,前面的例子只是为了演示,因为以这种方式使用 lambda 毫无意义。以下示例使用了两个 lambda 表达式。一个由算法std::transform使用,用尖括号将字符串向量quote中的单词封装起来,并将它们存储在另一个名为result的向量中。另一个λ表达式被std::for_each用来在标准输出上输出result的内容。

#include <algorithm>

#include <iostream>

#include <string>

#include <vector>

int main() {
  std::vector<std::string> quote { "That's", "one", "small", "step", "for", "a", "man,", "one",
    "giant", "leap", "for", "mankind." };
  std::vector<std::string> result;

  std::transform(begin(quote), end(quote), back_inserter(result),
    [](const std::string& word) { return "<" + word + ">"; });
  std::for_each(begin(result), end(result),
    [](const std::string& word) { std::cout << word << " "; });

  return 0;
}

Listing 7-20.Putting every single word in a list in angle brackets

这个小程序的输出是:

<That's> <one> <small> <step> <for> <a> <man,> <one> <giant> <leap> <for> <mankind.>

泛型 Lambda 表达式(C++14)

随着 C++14 的发布,lambda 表达式经历了一些进一步的改进。从 C++14 开始,允许使用auto(参见第五章中关于自动类型推导的章节)作为函数的返回类型,或者 lambda。换句话说,编译器将推导出类型。这种 lambda 表达式称为泛型 lambda 表达式。

这里有一个例子:

#include <complex>

#include <iostream>

int main() {
  auto square = [](const auto& value) noexcept { return value * value; };

  const auto result1 = square(12.56);
  const auto result2 = square(25u);
  const auto result3 = square(-6);
  const auto result4 = square(std::complex<double>(4.0, 2.5));

  std::cout << "result1 is " << result1 << "\n";
  std::cout << "result2 is " << result2 << "\n";
  std::cout << "result3 is " << result3 << "\n";
  std::cout << "result4 is " << result4 << std::endl;

  return 0;
}

Listing 7-21.Applying a generic lambda expression on values of different data type

在编译函数时,参数类型和结果类型是根据具体参数(文字)的类型自动派生的(在前面的示例中是doubleunsigned intint和类型为std::complex<T>的复数)。广义 lambdas 在与标准库算法的交互中非常有用,因为它们是普遍适用的。

高阶函数

函数式编程的一个核心概念是所谓的高阶函数。它们是一流功能的附属品。高阶函数是以一个或多个其他函数作为参数的函数,或者它们可以返回一个函数作为结果。在 C++ 中,任何可调用的对象,例如,std::function包装器的实例、函数指针、从 lambda 表达式创建的闭包、手工制作的仿函数以及任何其他实现operator()的对象都可以作为参数传递给高阶函数。

我们可以保持这个介绍相对简短,因为我们已经看到并使用了几个高阶函数。C++ 标准库中的许多算法(参见第五章中关于算法的部分)都是这类函数。根据它们的用途,它们采用一元运算符、一元谓词或二元运算符将其应用于容器或容器中元素的子范围。

当然,尽管 header <algorithm>和 header <numeric>为不同的目的提供了强大的高阶函数的全面选择,您也可以分别实现高阶函数,或者自己实现高阶函数模板,如下例所示:

#include <functional>

#include <iostream>

#include <vector>

template<typename CONTAINERTYPE, typename UNARYFUNCTIONTYPE>

void myForEach(const CONTAINERTYPE& container, UNARYFUNCTIONTYPE unaryFunction) {
  for (const auto& element : container) {
    unaryFunction(element);
  }
}

template<typename CONTAINERTYPE, typename UNARYOPERATIONTYPE>

void myTransform(CONTAINERTYPE& container, UNARYOPERATIONTYPE unaryOperator) {
  for (auto& element : container) {
    element = unaryOperator(element);
  }
}

template<typename NUMBERTYPE>

class ToSquare {

public:
  NUMBERTYPE operator()(const NUMBERTYPE& number) const noexcept {
    return number * number;
  }
};

template<typename TYPE>

void printOnStdOut(const TYPE& thing) {
  std::cout << thing << ", ";
}

int main() {
  std::vector<int> numbers { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
  myTransform(numbers, ToSquare<int>());
  std::function<void(int)> printNumberOnStdOut = printOnStdOut<int>;
  myForEach(numbers, printNumberOnStdOut);
  return 0;
}

Listing 7-22.An example for self-made higher-order functions

在这种情况下,我们自制的两个高阶函数模板myTransform()myForEach()只适用于整个容器,因为与标准库算法不同,它们没有迭代器接口。然而,关键的一点是,开发人员可以提供 C++ 标准库中不存在的自定义高阶函数。

我们现在将更详细地研究这些高阶函数中的三个,因为它们在函数式编程中起着重要的作用。

映射、过滤和减少

每一种严肃的函数式编程语言都必须提供至少三个有用的高阶函数:map、filter 和 reduce(同义词:fold)。即使根据编程语言的不同,它们有时可能会有不同的名称,但您可以在 Haskell、Erlang、Clojure、JavaScript、Scala 和许多其他具有函数式编程功能的语言中找到这种三足鼎立的形式。因此,我们可以理直气壮地宣称,这三个高阶函数形成了一个非常常见的函数式编程设计模式。

因此,这些高阶函数也包含在 C++ 标准库中,您应该不会感到惊讶。也许你也不会惊讶,我们已经使用了其中的一些功能。

让我们依次看看这些函数。

地图

地图可能是三者中最容易理解的。在这个高阶函数的帮助下,我们可以对列表中的每个元素应用一个操作函数。在 C++ 中,这个函数是由标准库算法std::transform(在头文件<algorithm>中定义)提供的,你已经在前面的一些代码示例中看到过了。

过滤器

过滤也很容易。顾名思义,这个高阶函数接受一个谓词(参见本章前面关于谓词的部分)和一个列表,它从列表中删除任何不满足谓词条件的元素。在 C++ 中,这个函数是由标准库算法std::remove_if(在头文件<algorithm>中定义)提供的,你已经在前面的一些代码示例中看到过了。

然而,这里有另一个很好的例子来分别过滤std::remove_if。如果你患有一种叫做“aibohphobia”的疾病,这是一种对回文的非理性恐惧的幽默术语,你应该从单词列表中过滤出回文,如下所示:

#include <algorithm>

#include <iostream>

#include <string>

#include <vector>

class IsPalindrome {

public:
  bool operator()(const std::string& word) const {
    const auto middleOfWord = begin(word) + word.size() / 2;
    return std::equal(begin(word), middleOfWord, rbegin(word));
  }
};

int main() {
  std::vector<std::string> someWords { "dad", "hello", "radar", "vector", "deleveled", "foo",
    "bar", "racecar", "ROTOR", "", "C++", "aibohphobia" };
  someWords.erase(std::remove_if(begin(someWords), end(someWords), IsPalindrome()),
    end(someWords));
  std::for_each(begin(someWords), end(someWords), [](const auto& word) {
    std::cout << word << ",";
  });
  return 0;
}

Listing 7-23.Removing all palindromes from a vector of words

这个程序的输出是:

hello,vector,foo,bar,C++,

减少(折叠)

Reduce(同义词:Fold,Collapse,Aggregate)是三个高阶函数中最强大的,乍一看可能有点难以理解。“分别减少折叠”是一个高阶函数,通过对值列表应用二元运算符来获得单个结果值。在 C++ 中,这个函数由标准库算法std::accumulate(在头文件<numeric>中定义)提供。有人说std::accumulate是标准库中最强大的算法。

从一个简单的例子开始,你可以很容易地得到一个向量中所有整数的和:

#include <numeric>

#include <iostream>

#include <vector>

int main() {
  std::vector<int> numbers { 12, 45, -102, 33, 78, -8, 100, 2017, -110 };

  const int sum = std::accumulate(begin(numbers), end(numbers), 0);
  std::cout << "The sum is: " << sum << std::endl;
  return 0;
}

Listing 7-24.Building the sum of all values in a vector using std::accumulate

这里使用的版本std::accumulate在参数列表中不需要显式的二元运算符。使用这个版本的函数,只需计算所有值的总和。当然,您可以通过 lambda 表达式提供自己的二元运算符,如下例所示:

int main() {
  std::vector<int> numbers { 12, 45, -102, 33, 78, -8, 100, 2017, -110 };

  const int maxValue = std::accumulate(begin(numbers), end(numbers), 0,
    [](const int value1, const int value2) {
    return value1 > value2 ? value1 : value2;
  });
  std::cout << "The highest number is: " << maxValue << std::endl;
  return 0;
}

Listing 7-25.Finding the highest number in a vector using std::accumulate

Left and Right Fold

函数式编程通常区分两种折叠元素列表的方式:左折叠和右折叠。

如果我们将第一个元素与递归组合其余元素的结果相组合,这称为右折叠。相反,如果我们将递归组合除最后一个元素之外的所有元素的结果与最后一个元素组合,则此操作称为左折叠。

例如,如果我们取一列要用+运算符折叠成一个和的值,那么左折叠运算的括号如下:((A + B) + C) + D。相反,对于右折叠,括号应该这样设置:A + (B + (C + D))。在简单的结合+运算的情况下,无论它是由左折叠还是右折叠形成,结果都没有任何区别。但是对于非关联的二元函数,元素组合的顺序可能会影响最终结果的值。

同样在 C++ 中,我们可以区分左折叠和右折叠。如果我们对普通迭代器使用std::accumulate,我们会得到一个左折叠:

std::accumulate(begin, end, init_value, binary_operator)

相反,如果我们将std::accumulate与反向迭代器一起使用,我们会得到一个右折叠:

std::accumulate(rbegin, rend, init_value, binary_operator)

C++17 中的折叠表达式

从 C++17 开始,这种语言获得了一个有趣的新特性,叫做折叠表达式。C++17 fold 表达式被实现为所谓的可变模板(从 C++11 开始可用),即以类型安全的方式接受可变数量的参数的模板。这个任意数量的参数保存在一个所谓的参数包中。

C++17 增加了在二元运算符的帮助下直接减少参数包的可能性,即执行折叠。C++17 fold 表达式的一般语法如下:

( ... operator parampack )                     // left fold
( parampack operator ... )                     // right fold
( initvalue operator ... operator parampack )  // left fold with an init value
( parampack operator ... operator initvalue )  // right fold with an init value

让我们看一个例子,一个带有初始值左文件夹:

#include <iostream>

template<typename... PACK>

int subtractFold(int minuend, PACK... subtrahends) {
  return (minuend - ... - subtrahends);
}

int main() {
  const int result = subtractFold(1000, 55, 12, 333, 1, 12);
  std::cout << "The result is: " << result << std::endl;
  return 0;
}

Listing 7-26.An example for a left fold

注意,由于缺乏operator–的关联性,在这种情况下不能使用右折叠。Fold 表达式支持 32 种运算符,包括逻辑运算符==&&||

下面是另一个测试参数包至少包含一个偶数的示例:

#include <iostream>

template <typename... TYPE>

bool containsEvenValue(const TYPE&... argument) {
  return ((argument % 2 == 0) || ...);
}

int main() {
  const bool result1 = containsEvenValue(10, 7, 11, 9, 33, 14);
  const bool result2 = containsEvenValue(17, 7, 11, 9, 33, 29);

  std::cout << std::boolalpha;
  std::cout << "result1 is " << result1 << "\n";
  std::cout << "result2 is " << result2 << std::endl;
  return 0;
}

Listing 7-27.Checking whether a parameter pack contains an even value

这个程序的输出是:

result1 is true
result2 is false

函数式编程中的干净代码

毫无疑问,函数式编程运动在 C++ 之前就没有停止过,这基本上是好的。许多有用的概念已经被整合到我们有些陈旧的编程语言中。

但是以函数式风格编写的代码并不一定是好的或干净的代码。在过去几年中,函数式编程语言越来越受欢迎,这可能会让您相信函数式代码本身比面向对象代码更易于维护、可读性更好、可测试性更强,并且更不容易出错。但那不是真的!相反,精心编写的功能性代码做着重要的事情,可能很难理解。

例如,我们来看一个简单的折叠操作,它与前面的一个示例非常相似:

// Build the sum of all product prices

const Money sum = std::accumulate(begin(productPrices), end(productPrices), 0.0);

如果你在没有解释源代码注释的情况下阅读这篇文章……这种意图会泄露代码吗?请记住我们在第四章中学到的关于注释的内容:每当你感到有写源代码注释的冲动时,你应该首先思考如何改进代码,使注释变得多余。

所以,我们真正想读或分别写的东西是这样的:

const Money totalPrice = buildSumOfAllPrices(productPrices);

所以,让我们先做一个基本声明:

无论您将使用何种编程风格,优秀软件设计的原则仍然适用!

比起面向对象,你更喜欢函数式编程风格?好吧,但我相信你会同意 KISS、DRY 和 YAGNI(见第三章)也是函数式编程中非常好的原则!你以为在函数式编程中可以忽略单责任原则(见第六章)?算了吧!如果一个函数做不止一件事,它将导致与面向对象类似的问题。我想我不必提及好的和富有表现力的命名(见第四章关于好的名字)对于函数式环境中代码的可理解性和可维护性也是非常重要的。永远记住,开发人员花在阅读代码上的时间比写代码要多得多。

因此,我们可以得出结论,面向对象软件设计者和程序员使用的大多数设计原则也可以被函数式程序员使用。

我个人更喜欢两种编程风格的平衡混合。有许多设计挑战可以使用面向对象的范例完美地解决。多态性是面向对象的一大好处。我可以利用依赖倒置原则(参见第六章中的同名章节),这允许我倒置源代码和运行时依赖。

相反,使用函数式编程风格可以更好地解决复杂的数学计算。如果必须满足高而雄心勃勃的性能和效率要求,这将不可避免地要求某些任务的并行化,那么函数式编程就可以发挥出它的王牌。

不管你是喜欢用面向对象的方式,还是用函数式的方式,或者两者的适当混合来编写软件,你都应该永远记住下面这句话:

Always write the person who maintains your code as a violent psychopath who knows where you live. —John F. Woods, 1991, in comp.lang.c++ newsgroup

的一篇文章中

八、测试驱动开发

水星计划在很短的时间内(半天)反复运行。开发团队对所有变更进行了技术审查,有趣的是,应用了测试优先开发的极限编程实践,在每个微增量之前规划和编写测试。——Craig Larman 和 Victor R. Basili,《迭代和增量开发:简史》。IEEE,2003 年

在“单元测试”一节中(见第二章),我们已经了解到一套好的小型快速测试可以确保我们的代码正确运行。目前为止,一切顺利。但是测试驱动开发(TDD)有什么特别之处,为什么它值得在本书中增加一章呢?

尤其是在最近几年,测试驱动开发的学科越来越受欢迎。T DD 已经成为软件工匠工具箱中的一个重要组成部分。这有点令人惊讶,因为测试优先方法的基本思想并不新鲜。上面提到的水星计划是美国第一个载人航天计划,从 1958 年到 1963 年在美国国家航空航天局的指导下进行。尽管大约 50 年前实践的测试优先的方法肯定不是我们今天所知道的那种 TDD,但是我们可以说这个基本思想在专业软件开发的很早时候就出现了。

但这种方法似乎已经被遗忘了几十年。在无数拥有数十亿行代码的项目中,测试在开发过程结束时被推迟。项目时间表中重要测试的这种右移有时会带来灾难性的后果,这是众所周知的:如果项目的时间越来越短,开发团队通常会首先放弃重要的测试。

随着软件开发中敏捷实践的日益流行,以及 21 世纪初一种叫做极限编程(XP)的新方法的出现,测试驱动开发被重新发现。Kent Beck 写了他著名的书《测试驱动开发:举例子》[Beck02],像 TDD 这样的测试优先方法经历了一次复兴,并成为软件工匠工具箱中日益重要的工具。

在这一章中,我不仅会解释虽然术语“测试”包含在测试驱动的开发中,但它主要不是关于质量保证的。TDD 提供了比简单的代码正确性验证更多的好处。相反,我将解释 TDD 与有时被称为普通旧单元测试(POUT)的区别,随后详细讨论 TDD 的工作流程,并通过一个详细的实际例子展示如何在 C++ 中进行。

普通旧单元测试(POUT)的缺点

毫无疑问,正如我们在第二章中看到的,一套单元测试基本上比没有测试要好得多。但是在许多项目中,单元测试是与要测试的代码的实现并行编写的,有时甚至是在要开发的模块完成之后。图 8-1 所示的活动图将这一过程可视化。

A429836_1_En_8_Fig1_HTML.jpg

图 8-1。

The typical sequence in development with traditional unit testing

这种广泛使用的方法有时也被称为简单旧单元测试(POUT)。基本上 POUT 的意思是软件会“代码优先”开发,不先测试;例如,使用这种方法,单元测试总是在要测试的代码编写完成后编写。对许多开发人员来说,这个顺序似乎是唯一的逻辑顺序。他们认为,要测试某样东西,显然要测试的东西需要以前已经被建造过。在一些开发组织中,这种方法甚至被错误地命名为“测试驱动开发”,这是完全错误的。

就像我说的,简单的老单元测试比没有单元测试要好。尽管如此,这种方法也有一些缺点:

  • 没有必要在事后编写单元测试。一旦一个特性起作用了(…或者看起来起作用了),就没有动力用单元测试来改进代码了。这一点都不好玩,而且对于许多开发人员来说,转移到下一件事情的诱惑太大了。
  • 结果代码可能很难测试。通常,用单元测试来改进现有的代码并不容易,因为人们并不重视原始代码的可测试性。这允许紧密耦合的代码出现。
  • 用改进的单元测试来达到相当高的测试覆盖率并不容易。在代码之后编写单元测试有可能会漏掉一些问题或错误。

作为游戏改变者的测试驱动开发

测试驱动开发(TDD)彻底改变了传统开发。对于还没有接触过 TDD 的开发人员来说,这种方法代表了一种范式的转变。

作为一种所谓的测试优先方法,与 POUT 相反,TDD 不允许在相关的测试被编写之前编写任何产品代码。换句话说:TDD 意味着我们总是在编写相应的产品代码之前编写新特性或功能的测试。这是严格地一步一步完成的:在每个实现的测试之后,只需编写足够的产品代码,测试就能通过。并且只要对于要开发的模块还有未实现的需求,就要这样做。

乍一看,为尚不存在的东西编写单元测试似乎是矛盾的,也有点荒谬。这怎么行?

别担心,很管用。在下一节我们详细讨论了 TDD 背后的过程之后,所有的疑问都有望消除。

TDD 的工作流程

当执行测试驱动开发时,重复运行图 8-2 中描述的步骤,直到满足待开发单元的所有已知需求。

A429836_1_En_8_Fig2_HTML.jpg

图 8-2。

The detailed workflow of TDD as an activity diagram

首先,值得注意的是,被标上“开始”的初始节点之后的第一个动作是,开发者要思考她想做什么。我们在这个动作的上方看到一个接受“需求”的所谓输入引脚这里指的是哪些要求?

首先,软件系统必须满足一些需求。这既适用于顶层业务涉众对整个系统的需求,也适用于较低抽象层的需求,即组件、类和功能的需求,这些需求都是从业务涉众的需求中派生出来的。使用 TDD 和它的测试优先方法,需求被单元测试牢牢地钉住——事实上,在产品代码被编写之前。在我们的单元开发的测试优先方法的例子中,也就是说,在测试金字塔的最低层(参见第二章中的图 2-1 ,当然这里指的是最低层的需求。

接下来,要编写一个测试,由此要设计公共接口(API)。这可能令人惊讶,因为在这个周期的第一次运行中,我们仍然没有编写任何产品代码。那么,如果我们有一张白纸,这里可以设计什么界面呢?

嗯,简单的答案是这样的:那张“空白纸”正是我们现在想要填写的,但来自一个不同于往常的视角。我们现在从待开发单元的未来外部客户的角度出发。我们使用一个小的测试来定义我们想要如何使用被开发的单元。换句话说,这一步应该导致良好可测试的,因此也是良好可用的软件单元。

在我们在测试中编写了适当的行之后,我们当然还必须满足编译器的要求,并提供测试所要求的接口。

然后紧接着下一个惊喜:新编写的单元测试必须(最初)失败。为什么呢?

简单的回答:我们必须确保测试根本不会失败。甚至单元测试本身也可能被错误地实现,例如,无论我们在产品代码中做什么,它总是通过。因此,我们必须确保新编写的测试已准备就绪。

现在,我们正在进入这个小工作流程的高潮:我们编写足够的生产代码——一行也不能多!–新的单元测试(…以及,如果有的话,所有先前存在的测试)通过!并且在这一点上遵守纪律非常重要,不要写超过要求的代码(记住第三章的 KISS 原则)。由开发人员决定在每种情况下什么是合适的。有时一行代码,甚至一条语句就足够了;在其他情况下,你需要调用一个库函数。如果是后者,现在是时候考虑如何集成和使用这个库了,尤其是如何用一个测试替身来代替它(参见第二章中关于测试替身(模拟对象)的部分)。

如果我们现在运行单元测试,并且我们做的一切都是正确的,那么测试将会通过。

现在,我们已经到了这个过程中的一个重要阶段。如果测试现在通过了,在这一步我们总是有 100%的单元测试覆盖率。永远!不仅仅是技术测试覆盖度量意义上的 100%,比如功能覆盖、分支覆盖或者语句覆盖。不,更重要的是,对于已经实现的需求,我们有 100%的单元测试覆盖率!是的,在这一点上,对于要开发的单元,可能仍然有一些或者许多未实现的需求。这是可以的,因为我们将一次又一次地经历 TDD 循环,直到所有的需求都得到满足。但是对于此时已经满足的需求子集,我们有 100%的单元测试覆盖率。

这个事实给了我们巨大的力量!有了这个无缝的单元测试安全网,我们现在能够进行无畏的重构了。代码味道(例如,重复代码)或设计问题现在可以修复。我们不需要害怕破坏功能,因为定期执行的单元测试会给我们即时的反馈。令人高兴的是:如果在重构阶段有一个或多个测试失败,导致失败的代码变化是非常小的。

在重构完成之后,我们现在可以通过继续 TDD 循环来实现另一个尚未实现的需求。如果没有更多的要求,我们准备好了。

图 8-2 描绘了 TDD 循环的许多细节。归结为图 8-3 中描述的三个主要步骤,TDD 循环通常被称为“红-绿-重构”

  • RED:我们编写一个失败的单元测试。
  • 绿色:我们编写了足够的产品代码,新的测试和所有以前编写的测试都可以通过。
  • 重构:从产品代码和单元测试中消除了代码重复和其他代码味道。

A429836_1_En_8_Fig3_HTML.jpg

图 8-3。

The core workflow of TDD

术语红色和绿色是指典型的单元测试框架集成,可用于各种 IDE,其中通过的测试显示为绿色,失败的测试显示为红色。

Uncle Bob’s Three Rules of Tdd

在他的伟大著作《干净的编码者》[Martin11]中,罗伯特·c·马丁(又名鲍勃大叔)建议我们遵循 TDD 的三个规则:

  • 在你写完一个失败的单元测试之前,不允许你写任何产品代码。
  • 不允许你编写超过足以失败的单元测试——不编译就是失败。
  • 不允许您编写超过足以通过当前失败的单元测试的生产代码。

Martin 认为严格遵守这三条规则会迫使开发人员在很短的周期内完成工作。因此,开发人员将永远不会在几秒钟或几分钟之内就感觉到代码是正确的,一切正常。

理论已经讲得够多了,现在我将通过一个小例子来解释一个使用 TDD 的软件的完整开发。

TDD 举例:罗马数字代码 Kata

如今被称为代码形的基本思想首先是由迪夫·托马斯描述的,他是著名的著作《务实的程序员》的两位作者之一。Dave 认为开发人员应该在小型的、与工作无关的代码库上反复练习,这样他们就可以像音乐家一样精通自己的专业。他说,开发人员应该不断地学习和提高自己,为此,他们需要练习来一遍又一遍地应用理论,每次都利用反馈来做得更好。

代码形是编程中的一个小练习,正好满足这个目的。术语“形”是从武术中继承来的。在远东的格斗运动中,他们用形反复练习他们的基本动作。目标是使运动过程尽善尽美。

这种实践被转移到软件开发中。为了提高他们的编程技能,开发人员应该在小练习的帮助下练习他们的技能。Kat 成为软件工艺运动的一个重要方面。它们可以解决开发人员应该具备的不同能力,例如,了解 IDE 的键盘快捷键,学习一种新的编程语言,关注某些设计原则,或者实践 TDD。在互联网上,有几种适合不同目的的目录,例如迪夫·托马斯关于 http://codekata.com 的收藏。

对于 TDD 的第一步,我们使用了一个强调算法的代码 kata:众所周知的罗马数字代码 kata。

TDD Kata: Convert Arabic Numbers To Roman Numerals

罗马人用字母书写数字。例如,他们写“V”代表阿拉伯数字 5。

您的任务是使用测试驱动开发(TDD)方法开发一段代码,将 1 到 3,999 之间的阿拉伯数字翻译成它们各自的罗马表示。

罗马系统中的数字是由拉丁字母的组合来表示的。今天使用的罗马数字基于七个字符:

    1 ⇒ I
    5 ⇒ V
   10 ⇒ X
   50 ⇒ L
  100 ⇒ C
  500 ⇒ D
1,000 ⇒ M

数字是通过将字符组合在一起并将数值相加而形成的。例如,阿拉伯数字 12 用“XII”(10+1+1)表示。数字 2017 在罗马字母中是“MMXVII”。

例外是 4、9、40、90、400 和 900。为了避免这种情况,四个相等的字符必须连续出现,例如,数字 4 不是用“IIII”来表示,而是用“IV”来表示。这就是所谓的减法,即从 V (5 - 1 = 4)中减去前面的字符 I 所代表的数字。再比如“CM”,就是 900 (1,000 - 100)。

顺便说一下:罗马人没有 0 的等价物,而且他们不知道负数。

准备

在我们能够编写我们的第一个测试之前,我们需要做一些准备,并且必须设置测试环境。

作为这个 kata 的单元测试框架,我使用 Google Test ( https://github.com/google/googletest ),一个在新 BSD 许可下发布的独立于平台的 C++ 单元测试框架。当然,任何其他 C++ 单元测试框架也可以用于这个表。

强烈建议使用版本控制系统。除了少数例外,我们将在 TDD 周期的每一次传递之后执行一次对版本控制系统的提交。这有一个很大的好处,那就是我们能够往回走,退回可能是错误的决定。

此外,我们必须考虑如何组织源代码文件。我对这个 kata 的建议是最初只从一个文件开始,这个文件将占用所有未来的单元测试:ArabicToRomanNumeralsConverterTestCase.cpp。由于 TDD 指导我们逐步完成一个软件单元的形成过程,所以有可能在以后决定是否需要额外的文件。

对于基本的函数检查,我们编写一个 main 函数来初始化 Google Test 并运行所有的测试,我们编写一个简单的单元测试(名为PreparationsCompleted),它总是故意失败,如下面的代码示例所示。

#include <gtest/gtest.h>

int main(int argc, char** argv) {
  testing::InitGoogleTest(&argc, argv);
  return RUN_ALL_TESTS();
}

TEST(ArabicToRomanNumeralsConverterTestCase, PreparationsCompleted) {
  GTEST_FAIL();
}

Listing 8-1.The initial content of ArabicToRom

anNumeralsConverterTestCase.cpp

在编译和链接之后,我们执行生成的二进制文件来运行测试。我们的小程序在标准输出(stdout)上的输出应该如下所示:

[==========] Running 1 test from 1 test case.
[----------] Global test environment set-up.
[----------] 1 test from ArabicToRomanNumeralsConverterTestCase
[ RUN      ] ArabicToRomanNumeralsConverterTestCase.PreparationsCompleted
../ ArabicToRomanNumeralsConverterTestCase.cpp:9: Failure
Failed
[  FAILED  ] ArabicToRomanNumeralsConverterTestCase.PreparationsCompleted (0 ms)
[----------] 1 test from ArabicToRomanNumeralsConverterTestCase (2 ms total)

[----------] Global test environment tear-down
[==========] 1 test from 1 test case ran. (16 ms total)
[  PASSED  ] 0 tests.
[  FAILED  ] 1 test, listed below:
[  FAILED  ] ArabicToRomanNumeralsConverterTestCase.PreparationsCompleted

 1 FAILED TEST

Listing 8-2.The output of the test

run

不出所料,测试失败了。stdout 上的输出非常有助于想象哪里出错了。它指定失败测试的名称、文件名、行号以及测试失败的原因。在这种情况下,这是一个由特殊的 Google 测试宏引起的故障。

如果我们现在将宏GTEST_FAIL()与测试中的宏GTEST_SUCCEED()交换,在重新编译之后,测试应该通过:

[==========] Running 1 test from 1 test case.
[----------] Global test environment set-up.
[----------] 1 test from ArabicToRomanNumeralsConverterTestCase
[ RUN      ] ArabicToRomanNumeralsConverterTestCase.PreparationsCompleted
[       OK ] ArabicToRomanNumeralsConverterTestCase.PreparationsCompleted (0 ms)
[----------] 1 test from ArabicToRomanNumeralsConverterTestCase (0 ms total)

[----------] Global test environment tear-down
[==========] 1 test from 1 test case ran. (4 ms total)
[  PASSED  ] 1 test.

Listing 8-3.The output of the suc

cessful test run

这很好,因为现在我们知道一切都准备妥当,我们可以开始我们的 ka ta。

第一次测试

第一步是决定我们想要实现的第一个小需求。然后我们会为它写一个失败的测试。对于我们的例子,我们决定从将一个阿拉伯数字转换成罗马数字开始:我们想将阿拉伯数字 1 转换成“I”

因此,我们采用已经存在的虚拟测试,并将其转换为真实的单元测试,这可以证明这个小需求的实现。因此,我们还必须考虑转换函数的接口应该是什么样子。

TEST(ArabicToRomanNumeralsConverterTestCase, 1_isConvertedTo_I) {
  ASSERT_EQ("I", convertArabicNumberToRomanNumeral(1));
}
Listing 8-4.The first test (irrelevant parts of the source code were omitted)

如您所见,我们已经决定使用一个简单的函数,该函数以一个阿拉伯数字作为参数,以一个字符串作为返回值。

但是代码不能在没有编译器错误的情况下编译,因为函数convertArabicNumberToRomanNumeral()还不存在。让我们记住 Bob 叔叔提出的 TDD 三条规则中的第二条:“不允许编写超过足以失败的单元测试——不编译就是失败。”

这意味着我们现在必须停止编写测试代码,以编写足够的产品代码,使其能够被编译而不出错。因此,我们现在将创建转换函数,我们甚至将该函数直接写入源代码文件,其中也包含测试。当然,我们意识到不能继续这样下去。

#include <gtest/gtest.h>

#include <string>

int main(int argc, char** argv) {
  testing::InitGoogleTest(&argc, argv);
  return RUN_ALL_TESTS();
}

std::string convertArabicNumberToRomanNumeral(const unsigned int arabicNumber) {
  return "";
}

TEST(ArabicToRomanNumeralsConverterTestCase, 1_isConvertedTo_I) {
  ASSERT_EQ("I", convertArabicNumberToRomanNumeral(1));
}

Listing 8-5.The function stub satisfies the compiler

现在代码可以再次编译而不会出错。目前这个函数只返回一个空字符串。

此外,我们现在有了第一个可执行的测试,它必须失败(红色),因为测试期望一个“I”,但是函数返回一个空字符串:

[==========] Running 1 test from 1 test case.
[----------] Global test environment set-up.
[----------] 1 test from ArabicToRomanNumeralsConverterTestCase
[ RUN      ] ArabicToRomanNumeralsConverterTestCase.1_isConvertedTo_I
../ArabicToRomanNumeralsConverterTestCase.cpp:14: Failure
Value of: convertArabicNumberToRomanNumeral(1)
  Actual: ""
Expected: "I"
[  FAILED  ] ArabicToRomanNumeralsConverterTestCase.1_isConvertedTo_I (0 ms)
[----------] 1 test from ArabicToRomanNumeralsConverterTestCase (0 ms total)

[----------] Global test environment tear-down
[==========] 1 test from 1 test case ran. (6 ms total)
[  PASSED  ] 0 tests.
[  FAILED  ] 1 test, listed below:
[  FAILED  ] ArabicToRomanNumeralsConverterTestCase.1_isConvertedTo_I

 1 FAILED TEST

Listing 8-6.The output of Google Test 

after executing the deliberately failing unit test (RED)

好的,这正是我们所期待的。

Note

根据使用的 Google Test 版本,测试框架的输出可能与这里显示的略有不同。

现在我们需要改变函数convertArabicNumberToRomanNumeral()的实现,这样测试就能通过。规则是这样的:做可能有效的最简单的事情。还有什么比从函数中返回一个“I”更容易的呢?

std::string convertArabicNumberToRomanNumeral(const unsigned int arabicNumber) {
  return "I";
}
Listing 8-7.The changed functi

on (irrelevant parts of the source code were omitted)

你可能会说,“等一下!这不是一个将阿拉伯数字转换成罗马数字的算法。那是作弊!”

当然,算法还没有准备好。你必须改变你的想法。TDD 的规则规定,我们应该编写通过当前测试的最简单的代码。这是一个渐进的过程,我们才刚刚开始。

[==========] Running 1 test from 1 test case.
[----------] Global test environment set-up.
[----------] 1 test from ArabicToRomanNumeralsConverterTestCase
[ RUN      ] ArabicToRomanNumeralsConverterTestCase.1_isConvertedTo_I
[       OK ] ArabicToRomanNumeralsConverterTestCase.1_isConvertedTo_I (0 ms)
[----------] 1 test from ArabicToRomanNumeralsConverterTestCase (0 ms total)

[----------] Global test environment tear-down
[==========] 1 test from 1 test case ran. (1 ms total)
[  PASSED  ] 1 test.

太棒了!测试通过了(绿色),我们可以进入重构步骤了。实际上还不需要重构某些东西,所以我们可以继续进行下一次 TDD 循环。但是首先我们必须将我们的更改提交给源代码库。

第二个测试

对于我们的第二个单元测试,我们将取 2,它必须被转换成"II."

TEST(ArabicToRomanNumeralsConverterTestCase, 2_isConvertedTo_II) {
  ASSERT_EQ("II", convertArabicNumberToRomanNumeral(2));
}

不出所料,这个测试一定会失败(红色),因为我们的函数convertArabicN umberToRomanNumeral()总是返回一个“I.”。在我们验证了测试失败之后,我们补充了实现,以便测试能够通过。我们再一次做了最简单可行的事情。

std::string convertArabicNumberToRomanNumeral(const unsigned int arabicNumber) {
  if (arabicNumber == 2) {
    return "II";
  }
  return "I";
}
Listing 8-8.We add some code to pass the new test

两项测试都通过(绿色)。

我们现在应该重构一些东西吗?也许还没有,但是你可能会暗自怀疑我们很快就需要重构了。目前,我们继续进行第三次测试…

第三次测试和之后的整理

不出所料,我们的第三个测试将测试数字 3 的转换:

TEST(ArabicToRomanNumeralsConverterTestCase, 3_isConvertedTo_III) {
  ASSERT_EQ("III", convertArabicNumberToRomanNumeral(3));
}

当然,这个测试会失败(红色)。通过该测试和所有先前测试(绿色)的代码如下所示:

std::string convertArabicNumberToRomanNumeral(const unsigned int arabicNumber) {
  if (arabicNumber == 3) {
    return "III";
  }
  if (arabicNumber == 2) {
    return "II";
  }
  return "I";
}

在第二次测试中,我们已经对新设计有了不好的直觉,这并不是没有根据的。至少现在我们应该对明显的代码重复完全不满意了。很明显,我们不能继续走这条路。无止境的 if 语句序列不是一个解决方案,因为我们最终会得到一个糟糕的设计。重构的时候到了,我们可以无所畏惧地去做,因为 100%的单元测试覆盖率创造了一种舒适的安全感!

如果我们看一下函数convertArabicNumberToRomanNumeral()中的代码,可以看出一种模式。阿拉伯数字就像罗马数字中 I 字符的计数器。换句话说:只要要转换的数字在达到 0 之前可以减 1,就在罗马数字串上加一个“I”。

这可以用一种优雅的方式来完成,使用 while 循环和字符串连接,就像这样:

std::string convertArabicNumberToRomanNumeral(unsigned int arabicNumber) {
  std::string romanNumeral;
  while (arabicNumber >= 1) {
    romanNumeral += "I";
    arabicNumber--;
  }
  return romanNumeral;
}
Listing 8-9.The conversion function after refactoring

看起来不错。我们消除了代码重复,找到了一个紧凑的解决方案。我们还必须从参数arabicNumber中移除const声明,因为我们必须操作函数中的阿拉伯数字。并且仍然通过了三个现有的单元测试。

我们可以进行下一项测试。当然,您也可以继续使用 5,但我决定使用“10-is-X”。我希望十国集团会显示出与 1、2 和 3 类似的模式。当然,阿拉伯数字 5 将在以后处理。

TEST(ArabicToRomanNumeralsConverterTestCase, 10_isConvertedTo_X) {
  ASSERT_EQ("X", convertArabicNumberToRomanNumeral(10));
}
Listing 8-10.The 4th unit test

这个测试失败(红色)不应该让任何人感到惊讶。下面是 Google Test 在 stdout 上写的关于这个新测试的内容:

[ RUN      ] ArabicToRomanNumeralsConverterTestCase.10_isConvertedTo_X
../ArabicToRomanNumeralsConverterTestCase.cpp:31: Failure
Value of: convertArabicNumberToRomanNumeral(10)
  Actual: "IIIIIIIIII"
Expected: "X"
[  FAILED  ] ArabicToRomanNumeralsConverterTestCase.10_isConvertedTo_X (0 ms)

测试失败了,因为 10 不是"IIIIIIIIII,"而是"X."。但是,如果我们看到 Google Test 的输出,我们就可以得到一个想法。也许和我们处理阿拉伯数字 1,2 的方法一样。和 3,也可以用于 10,20 和 30?

停止!嗯,这是可以想象的,但是我们不应该在没有单元测试引导我们找到这样一个解决方案的情况下为未来创造一些东西。如果我们将 20 和 30 的生产代码与 10 的代码一起实现,我们将不再进行测试驱动的工作。所以,我们再做一次可能有效的最简单的事情。

std::string convertArabicNumberToRomanNumeral(unsigned int arabicNumber) {
  if (arabicNumber == 10) {
    return "X";
  } else {
    std::string romanNumeral;
    while (arabicNumber >= 1) {
      romanNumeral += "I";
      arabicNumber--;
    }
    return romanNumeral;
  }
}
Listing 8-11.The conversion function can now also convert 10

好的,测试和所有之前的测试都通过了(绿色)。我们可以逐步为阿拉伯数字 20 添加一个测试,然后为 30 添加一个测试。在我们运行完两种情况下的 TDD 循环后,我们的转换函数如下所示:

std::string convertArabicNumberToRomanNumeral(unsigned int arabicNumber) {
  if (arabicNumber == 10) {
    return "X";
  } else if (arabicNumber == 20) {
    return "XX";
  } else if (arabicNumber == 30) {
    return "XXX";
  } else {
    std::string romanNumeral;
    while (arabicNumber >= 1) {
      romanNumeral += "I";
      arabicNumber--;
    }
    return romanNumeral;
  }
}
Listing 8-12.The result during the 6th TDD-cycle before refactoring

至少现在迫切需要重构。出现的代码有一些不好的味道,比如一些冗余和高圈复杂度。然而,我们的怀疑也得到了证实,对数字 10、20 和 30 的处理遵循着与处理数字 1、2 和 3 相似的模式。让我们来试试:

std::string convertArabicNumberToRomanNumeral(unsigned int arabicNumber) {
  std::string romanNumeral;
  while (arabicNumber >= 10) {
    romanNumeral += "X";
    arabicNumber -= 10;
  }
  while (arabicNumber >= 1) {
    romanNumeral += "I";
    arabicNumber--;
  }
  return romanNumeral;
}
Listing 8-13.After the refactori

ng all if-else-decisions are gone

非常好,所有测试立即通过!看来我们的方向是正确的。

然而,我们必须记住 TDD 循环中重构步骤的目标。在这一部分,你可以读到以下内容:从产品代码和单元测试中消除了代码重复和其他代码味道。

我们应该以批判的眼光看待我们的测试代码。目前看起来是这样的:

TEST(ArabicToRomanNumeralsConverterTestCase, 1_isConvertedTo_I) {
  ASSERT_EQ("I", convertArabicNumberToRomanNumeral(1));
}

TEST(ArabicToRomanNumeralsConverterTestCase, 2_isConvertedTo_II) {
  ASSERT_EQ("II", convertArabicNumberToRomanNumeral(2));
}

TEST(ArabicToRomanNumeralsConverterTestCase, 3_isConvertedTo_III) {
  ASSERT_EQ("III", convertArabicNumberToRomanNumeral(3));
}

TEST(ArabicToRomanNumeralsConverterTestCase, 10_isConvertedTo_X) {
  ASSERT_EQ("X", convertArabicNumberToRomanNumeral(10));
}

TEST(ArabicToRomanNumeralsConverterTestCase, 20_isConvertedTo_XX) {
  ASSERT_EQ("XX", convertArabicNumberToRomanNumeral(20));
}

TEST(ArabicToRomanNumeralsConverterTestCase, 30_isConvertedTo_XXX) {
  ASSERT_EQ("XXX", convertArabicNumberToRomanNumeral(30));
}

Listing 8-14.The emerged unit tests have a 

lot of code duplications

请记住我在第二章写的关于测试代码质量的话:测试代码的质量必须和生产代码的质量一样高。换句话说,我们的测试需要重构,因为它们包含许多重复,应该设计得更优雅。此外,我们希望增加它们的可读性和可维护性。但是我们能做什么呢?

看看上面的六个测试。测试中的验证总是相同的,可以理解为:“断言阿拉伯数字被转换为罗马数字

一个解决方案可以是为此提供一个专用断言(也称为自定义断言或自定义匹配器),可以用与上面句子相同的方式来理解:

assertThat(x).isConvertedToRomanNumeral("string");

带有自定义断言的更复杂的测试

为了实现我们的自定义断言,我们首先编写一个失败的单元测试,但是不同于我们以前编写的单元测试:

TEST(ArabicToRomanNumeralsConverterTestCase, 33_isConvertedTo_XXXIII) {
  assertThat(33).isConvertedToRomanNumeral("XXXII");
}

33 的转换已经起作用的概率非常高。因此,我们通过指定一个故意的错误结果作为期望值(“XXXII”)来强制测试失败(红色)。但是这个新的测试失败也是由于另一个原因:编译器不能编译没有错误的单元测试。名为assertThat的函数还不存在,同样也没有isConvertedToRomanNumeral。永远记住 Robert C. Martin 的 TDD 的第二条规则(见上文):“你不允许编写超过足以失败的单元测试——不编译就是失败。”

所以我们必须首先通过编写自定义断言来满足编译器。这将由两部分组成:

  • 一个免费的assertThat(<parameter>)函数,返回一个自定义断言类的实例。
  • 包含真实断言方法的自定义断言类,验证被测试对象的一个或多个属性。
class RomanNumeralAssert {

public:

  RomanNumeralAssert() = delete;
  explicit RomanNumeralAssert(const unsigned int arabicNumber) :
      arabicNumberToConvert(arabicNumber) { }
  void isConvertedToRomanNumeral(const std::string& expectedRomanNumeral) const {
    ASSERT_EQ(expectedRomanNumeral, convertArabicNumberToRomanNumeral(arabicNumberToConvert));
  }

private:

  const unsigned int arabicNumberToConvert;
};

RomanNumeralAssert assertThat(const unsigned int arabicNumber) {
  RomanNumeralAssert assert { arabicNumber };
  return assert;
}

Listing 8-15.A custom assertion for Roman numerals

Note

除了自由函数assertThat,断言类中还可以使用静态和公共类方法。当您面临名称空间冲突时,这可能是必要的,例如,相同函数名的冲突。当然,在使用类方法时,名称空间的名字必须加在前面:RomanNumeralAssert::assertThat(33).isConvertedToRomanNumeral("XXXIII");

现在可以编译代码而不出错,但是新的测试在执行过程中会像预期的那样失败。

[ RUN      ] ArabicToRomanNumeralsConverterTestCase.33_isConvertedTo_XXXIII
../ArabicToRomanNumeralsConverterTestCase.cpp:30: Failure
Value of: convertArabicNumberToRomanNumeral(arabicNumberToConvert)
  Actual: "XXXIII"
Expected: expectedRomanNumeral
Which is: "XXXII"
[  FAILED  ] ArabicToRomanNumeralsConverterTestCase.33_isConvertedTo_XXXIII (0 ms)
Listing 8-16.An excerpt from the output of Google-Test on stdout

因此,我们需要修改测试,并更正我们期望得到的结果中的罗马数字。

TEST(ArabicToRomanNumeralsConverterTestCase, 33_isConvertedTo_XXXIII) {
  assertThat(33).isConvertedToRomanNumeral("XXXIII");
}
Listing 8-17.Our Custom Asserter allows a more compact spelling of the test code

现在,我们可以将之前的所有测试总结成一个测试。

TEST(ArabicToRomanNumeralsConverterTestCase, 

conversionOfArabicNumbersToRomanNumerals_Works) {
  assertThat(1).isConvertedToRomanNumeral("I");
  assertThat(2).isConvertedToRomanNumeral("II");
  assertThat(3).isConvertedToRomanNumeral("III");
  assertThat(10).isConvertedToRomanNumeral("X");
  assertThat(20).isConvertedToRomanNumeral("XX");
  assertThat(30).isConvertedToRomanNumeral("XXX");
  assertThat(33).isConvertedToRomanNumeral("XXXIII");
}
Listing 8-18.All checks can be elegantly pooled in one test function

现在看一下我们的测试代码:无冗余、干净、易读。我们自作主张的直接性相当优雅。现在添加更多的测试非常容易,因为我们只需为每个新测试编写一行代码。

您可能会抱怨这种重构也有一个小缺点。测试方法的名称现在没有重构之前所有测试方法的名称那么具体(参见第二章中的单元测试名称一节)。我们能容忍这些小缺点吗?我想是的。我们在这里做了一个妥协:这个小缺点被测试的可维护性和可扩展性方面的好处所补偿。

现在我们可以继续 TDD 循环,并为以下三个测试连续实现产品代码:

assertThat(100).isConvertedToRomanNumeral("C");
assertThat(200).isConvertedToRomanNumeral("CC");
assertThat(300).isConvertedToRomanNumeral("CCC");

在三次迭代之后,重构步骤之前的代码将如下所示:

std::string convertArabicNumberToRomanNumeral(unsigned int arabicNumber) {
  std::string romanNumeral;
  if (arabicNumber == 100) {
    romanNumeral = "C";
  } else if (arabicNumber == 200) {
    romanNumeral = "CC";
  } else if (arabicNumber == 300) {
    romanNumeral = "CCC";
  } else {
    while (arabicNumber >= 10) {
      romanNumeral += "X";
      arabicNumber -= 10;
    }
    while (arabicNumber >= 1) {
      romanNumeral += "I";
      arabicNumber--;
    }
  }
  return romanNumeral;
}
Listing 8-19.Our conversion function in the 9th TDD cycle before refactoring

同样的模式出现在 1,2,3 中。以及 10、20 和 30。我们也可以使用类似的循环来处理数百个:

std::string convertArabicNumberToRomanNumeral(unsigned int arabicNumber) {
  std::string romanNumeral;
  while (arabicNumber >= 100) {
    romanNumeral += "C";
    arabicNumber -= 100;
  }
  while (arabicNumber >= 10) {
    romanNumeral += "X";
    arabicNumber -= 10;
  }
  while (arabicNumber >= 1) {
    romanNumeral += "I";
    arabicNumber--;
  }
  return romanNumeral;
}

Listing 8-20.The emerging pattern, as well as which parts of the code are variable and which are identical, is clearly recognizable

又到了大扫除的时候了

在这一点上,我们应该再次对我们的代码进行批判性的审视。如果我们继续这样,代码将包含许多重复的代码,因为三个while-语句看起来非常相似。然而,我们可以通过抽象所有三个while循环中相同的代码部分来利用这些相似性。

重构时间到了!所有三个while-循环中唯一不同的代码部分是阿拉伯数字及其对应的罗马数字。想法是将这些可变部分从循环的其余部分中分离出来。

第一步,我们引入了一个struct,它将阿拉伯数字映射成罗马数字。此外,我们需要该结构的一个数组(这里我们将使用 C++ 标准库中的std::array)。最初,我们将只添加一个元素到数组中,将字母“C”分配给数字 100。

struct ArabicToRomanMapping {
  unsigned int arabicNumber;
  std::string romanNumeral;
};

const std::size_t numberOfMappings = 1;

using ArabicToRomanMappings = std::array<ArabicToRomanMapping, numberOfMappings>;

const ArabicToRomanMappings arabicToRomanMappings = {
  { 100, "C" }
};

Listing 8-21.Introducing an array that holds mappings between Arabic numbers and their Roman equivalent

做好这些准备后,我们修改转换函数中的第一个 while 循环,以验证基本思想是否可行。

std::string convertArabicNumberToRomanNumeral(unsigned int arabicNumber) {
  std::string romanNumeral;
  while (arabicNumber >= arabicToRomanMappings[0].arabicNumber) {
    romanNumeral += arabicToRomanMappings[0].romanNumeral;
    arabicNumber -= arabicToRomanMappings[0].arabicNumber;
  }
  while (arabicNumber >= 10) {
    romanNumeral += "X";
    arabicNumber -= 10;
  }
  while (arabicNumber >= 1) {
    romanNumeral += "I";
    arabicNumber--;
  }
  return romanNumeral;
}
Listing 8-22.Replacing the literals with entries from the new array

所有测试都通过了。因此,我们可以继续用映射“10-is- X,”和“1-is- I”填充数组(不要忘记相应地调整数组大小!).

const std::size_t numberOfMappings { 3 };
// ...

const ArabicToRomanMappings arabicToRomanMappings = { {
  { 100, "C" },
  {  10, "X" },
  {   1, "I" }
} };

std::string convertArabicNumberToRomanNumeral(unsigned int arabicNumber) {
  std::string romanNumeral;
  while (arabicNumber >= arabicToRomanMappings[0].arabicNumber) {
    romanNumeral += arabicToRomanMappings[0].romanNumeral;
    arabicNumber -= arabicToRomanMappings[0].arabicNumber;
  }
  while (arabicNumber >= arabicToRomanMappings[1].arabicNumber) {
    romanNumeral += arabicToRomanMappings[1].romanNumeral;
    arabicNumber -= arabicToRomanMappings[1].arabicNumber;
  }
  while (arabicNumber >= arabicToRomanMappings[2].arabicNumber) {
    romanNumeral += arabicToRomanMappings[2].romanNumeral;
    arabicNumber -= arabicToRomanMappings[2].arabicNumber;
  }
  return romanNumeral;
}

Listing 8-23.Again a pattern emerges: the obvious code redundancy can be eliminated by a loop

同样,所有测试都通过了。太棒了!但是仍然有很多重复的代码,所以我们必须继续我们的重构。好消息是,我们现在可以看到所有三个while-循环的唯一区别只是数组索引。这意味着如果我们遍历数组,我们可以只进行一次while-循环。

std::string convertArabicNumberToRomanNumeral(unsigned int arabicNumber) {
  std::string romanNumeral;
  for (const auto& mapping : arabicToRomanMappings) {
    while (arabicNumber >= mapping.arabicNumber) {
      romanNumeral += mapping.romanNumeral;
      arabicNumber -= mapping.arabicNumber;
    }
  }
  return romanNumeral;
}
Listing 8-24.Through the range based for-loop, the DRY principle is no more violated

所有测试都通过了。哇,太棒了!看一看这段简洁易读的代码。通过将阿拉伯数字添加到数组中,现在可以支持更多的阿拉伯数字到罗马数字的映射。我们将对 1,000 进行测试,必须将其转换为“M.”以下是我们的下一个测试:

assertThat(1000).isConvertedToRomanNumeral("M");

测试不出所料地失败了。通过将“1000-is- M”的另一个元素添加到数组中,新的测试,当然还有所有先前的测试,应该会通过。

const ArabicToRomanMappings arabicToRomanMappings = { {
    { 1000, "M" },
    {  100, "C" },
    {   10, "X" },
    {    1, "I" }
} };

这个小变化之后的一次成功的测试运行证实了我们的假设:它起作用了!这很容易。我们现在可以添加更多的测试,例如 2000 和 3000。甚至 3333 应该立即工作:

assertThat(2000).isConvertedToRomanNumeral("MM");
assertThat(3000).isConvertedToRomanNumeral("MMM");
assertThat(3333).isConvertedToRomanNumeral("MMMCCCXXXIII");

很好。我们的代码甚至可以处理这些情况。但是,还有一些罗马数字尚未实现。例如,必须转换为“V.”的 5

assertThat(5).isConvertedToRomanNumeral("V");

不出所料,这个测试失败了。有趣的问题如下:既然测试通过了,我们应该做什么?也许你可以考虑对这种情况进行特殊处理。但是这真的是一个特例吗,或者我们可以像对待以前的和已经实现的转换一样对待这次转换吗?

可能最简单的方法就是在数组的正确索引处添加一个新元素。嗯,也许值得一试…

const ArabicToRomanMappings arabicToRomanMappings = { {
    { 1000, "M" },
    {  100, "C" },
    {   10, "X" },
    {    5, "V" },
    {    1, "I" }
} };

我们的假设为真:所有测试都通过了!甚至像 6 和 37 这样的阿拉伯数字现在也可以正确地转换成罗马数字。我们通过为这些情况添加断言来验证:

  assertThat(6).isConvertedToRomanNumeral("VI");
//...
  assertThat(37).isConvertedToRomanNumeral("XXXVII");

接近终点线

毫不奇怪,我们可以对“50-is- L”和“500-is- D.”使用基本相同的方法

接下来,我们需要处理所谓的减法记数法的实现,例如,阿拉伯数字 4 必须转换成罗马数字“IV.”。我们如何优雅地实现这些特例呢?

嗯,经过短暂的考虑后,很明显这些情况并没有什么特别的!最后,当然不禁止向数组中添加映射规则,其中字符串包含两个而不是一个字符。例如,我们可以在arabicToRomanMappings数组中添加一个新的“4-is-IV”条目。也许你会说:“那不是黑吗?”不,我不这么认为。它实用而简单,不会让事情变得不必要的复杂。

因此,我们首先添加一个将失败的新测试:

assertThat(4).isConvertedToRomanNumeral("IV");

对于要通过的新测试,我们为 4 添加相应的映射规则(参见数组中的 pe nultimate 条目):

const ArabicToRomanMappings arabicToRomanMappings = { {
    { 1000, "M"  },
    {  500, "D"  },
    {  100, "C"  },
    {   50, "L"  },
    {   10, "X"  },
    {    5, "V"  },
    {    4, "IV" },
    {    1, "I"  }
} };

在我们执行了所有测试并验证它们通过之后,我们可以确定我们的解决方案也适用于 4!因此,我们可以对“9-is-IX”、“40-is-XL”、“90-is-XC”等重复这种模式。模式总是相同的,所以我没有在这里显示最终的源代码(完整代码的最终结果如下所示),但我认为这并不难理解。

搞定了。

有趣的问题是:我们什么时候知道自己完了?我们必须实现的软件已经完成了?我们可以停止运行 TDD 循环?我们真的必须通过单元测试来测试从 1 到 3999 的所有数字才能知道我们完成了吗?

简单的答案是:如果我们代码的所有需求都已经成功实现,并且我们没有找到一个新的单元测试来产生新的产品代码,那么我们就完成了!

这正是我们的 TDD 形现在的情况。我们仍然可以向测试方法中添加更多的断言;每次都可以通过测试,而不需要改变产品代码。这是 TDD 对我们“说话”的方式:“嘿,伙计,你完了!”

结果如下所示:

#include <gtest/gtest.h>

#include <string>

#include <array>

int main(int argc, char** argv) {
  testing::InitGoogleTest(&argc, argv);
  return RUN_ALL_TESTS();
}

struct ArabicToRomanMapping {
  unsigned int arabicNumber;
  std::string romanNumeral;
};

const std::size_t numberOfMappings { 13 };

using ArabicToRomanMappings = std::array<ArabicToRomanMapping, numberOfMappings>;

const ArabicToRomanMappings arabicToRomanMappings = { {
    { 1000, "M"  },
    {  900, "CM" },
    {  500, "D"  },
    {  400, "CD" },
    {  100, "C"  },
    {   90, "XC" },
    {   50, "L"  },
    {   40, "XL" },
    {   10, "X"  },
    {    9, "IX" },
    {    5, "V"  },
    {    4, "IV" },
    {    1, "I"  }
} };

std::string convertArabicNumberToRomanNumeral(unsigned int arabicNumber)

{
  std::string romanNumeral;
  for (const auto& mapping : arabicToRomanMappings) {
    while (arabicNumber >= mapping.arabicNumber) {
      romanNumeral += mapping.romanNumeral;
      arabicNumber -= mapping.arabicNumber;
    }
  }
  return romanNumeral;
}

// Test code starts here...

class RomanNumeralAssert {

public:

  RomanNumeralAssert() = delete;
  explicit RomanNumeralAssert(const unsigned int arabicNumber) :
      arabicNumberToConvert(arabicNumber) { }
  void isConvertedToRomanNumeral(const std::string& expectedRomanNumeral) const {
    ASSERT_EQ(expectedRomanNumeral, convertArabicNumberToRomanNumeral(arabicNumberToConvert));
  }

private:

  const unsigned int arabicNumberToConvert;
};

RomanNumeralAssert assertThat(const unsigned int arabicNumber) {
  return RomanNumeralAssert { arabicNumber };
}

TEST(ArabicToRomanNumeralsConverterTestCase, conversionOfArabicNumbersToRomanNumerals_Works) {
  assertThat(1).isConvertedToRomanNumeral("I");
  assertThat(2).isConvertedToRomanNumeral("II");
  assertThat(3).isConvertedToRomanNumeral("III");
  assertThat(4).isConvertedToRomanNumeral("IV");
  assertThat(5).isConvertedToRomanNumeral("V");
  assertThat(6).isConvertedToRomanNumeral("VI");
  assertThat(9).isConvertedToRomanNumeral("IX");
  assertThat(10).isConvertedToRomanNumeral("X");
  assertThat(20).isConvertedToRomanNumeral("XX");
  assertThat(30).isConvertedToRomanNumeral("XXX");
  assertThat(33).isConvertedToRomanNumeral("XXXIII");
  assertThat(37).isConvertedToRomanNumeral("XXXVII");
  assertThat(50).isConvertedToRomanNumeral("L");
  assertThat(99).isConvertedToRomanNumeral("XCIX");
  assertThat(100).isConvertedToRomanNumeral("C");
  assertThat(200).isConvertedToRomanNumeral("CC");
  assertThat(300).isConvertedToRomanNumeral("CCC");
  assertThat(499).isConvertedToRomanNumeral("CDXCIX");
  assertThat(500).isConvertedToRomanNumeral("D");
  assertThat(1000).isConvertedToRomanNumeral("M");
  assertThat(2000).isConvertedToRomanNumeral("MM");
  assertThat(2017).isConvertedToRomanNumeral("MMXVII");
  assertThat(3000).isConvertedToRomanNumeral("MMM");
  assertThat(3333).isConvertedToRomanNumeral("MMMCCCXXXIII");
  assertThat(3999).isConvertedToRomanNumeral("MMMCMXCIX");
}

Listing 8-25.This version 

has been checked-in at GitHub (URL see below) with the commit message “Done.”

Info

完整的罗马数字 Kata 的源代码,包括它的版本历史,可以在 GitHub 上找到: https://github.com/clean-cpp/book-samples/

等等!然而,仍然有一个非常重要的步骤要做:我们必须将生产代码与测试代码分开。像我们的工作台一样,我们一直在使用文件ArabicToRomanNumeralsConverterTestCase.cpp,但是现在是时候了,软件工匠必须从老虎钳上取下他完成的作品。换句话说,生产代码现在必须被移动到一个不同的、仍待创建的新文件中;但是当然单元测试应该仍然能够测试代码。

在最后的重构步骤中,可以做出一些设计决策。例如,它是保留一个独立的转换函数,还是应该将转换方法和数组包装到一个新的类中?我显然倾向于后者(将代码嵌入到一个类中),因为它是面向对象的设计,并且借助封装更容易隐藏实现细节。

无论产品代码将如何被提供并被集成到它的使用环境中(这取决于目的),我们的无缝单元测试覆盖使得不太可能因此出错。

TDD 的优势

测试驱动开发主要是用于软件组件的增量设计和开发的工具和技术。这就是为什么缩写 TDD 也经常被称为“测试驱动设计”这是一种方式,当然不是唯一的方式,在你写产品代码之前考虑你的需求或者设计。

TDD 的显著优势如下:

  • 如果做得好,TDD 会迫使你在编写软件时迈出一小步。这种方法确保您总是只需要编写几行产品代码,就可以再次达到一切正常的舒适状态。这也意味着您最多只需要几行代码就可以实现一切正常工作。这是与预先产生和改变大量产品代码的传统方法的主要区别,后者伴随着软件有时不能在几小时或几天内没有错误地编译和执行的缺点。
  • TDD 建立了一个非常快速的反馈回路。开发人员必须始终知道他们是否仍然在一个正确的系统上工作。因此,对他们来说,有一个快速的反馈回路,在一瞬间知道一切都正常工作是很重要的。复杂的系统和集成测试,尤其是如果它们仍然是手工执行的,就不能做到这一点,而且太慢了(记住第二章中的测试金字塔)。
  • 首先创建单元测试有助于开发人员真正考虑需要做什么。换句话说,TDD 确保代码不是简单地从大脑被砍进键盘。这很好,因为以这种方式编写的代码通常容易出错,难以阅读,有时甚至是多余的。许多开发人员通常比他们交付优秀工作的真实能力走得更快。从积极的意义上来说,TDD 是一种让开发人员慢下来的方法。不要担心,经理们,你们的开发人员放慢速度是好事,因为当高测试覆盖率显示出它的积极作用时,这将很快得到回报,开发过程中的质量和速度将显著提高。
  • 使用 TDD,无缝规范以可执行代码的形式出现。例如,用办公套件的文本处理程序用自然语言编写的规格说明是不可执行的——它们是“死工件”
  • 开发人员更加自觉和负责地处理依赖关系。如果需要另一个软件组件或者甚至是一个外部系统(例如,一个数据库),那么这种依赖性可以通过一个抽象(接口)来定义,并由一个用于测试的测试副本(也称为模拟对象)来代替。得到的软件模块(例如,类)更小,松散耦合,并且只包含通过测试所必需的代码。
  • 默认情况下,使用 TDD 的新兴产品代码将拥有 100%的单元测试覆盖率。如果 TDD 被正确地执行,那么不应该有一行产品代码不是由先前编写的单元测试激发的。

测试驱动的开发可以成为一个好的和可持续的软件设计的驱动者和推动者。如同许多其他的工具和方法一样,TDD 的实践不能保证一个好的设计。它不是解决设计问题的灵丹妙药。设计决策仍然由开发人员做出,而不是由工具做出。至少,TDD 是一种有用的方法,可以避免被认为是糟糕的设计。许多在日常工作中使用 TDD 的开发人员可以确认,使用这种方法很难产生或容忍糟糕和混乱的代码。

毫无疑问,开发人员已经完成了所有需要的功能:如果所有的单元测试都是绿色的,这意味着单元的所有需求都得到了满足,工作完成了!一个令人愉快的副作用是,它完成的质量很高。

此外,TDD 工作流还驱动着待开发单元的设计,尤其是它的界面。使用 TDD 和测试优先,API 的设计和实现由其测试用例来指导。任何试图为遗留代码编写单元测试的人都知道这有多困难。这些系统通常是“代码优先”构建的许多不方便的依赖和糟糕的 API 设计使这类系统中的测试变得复杂。如果一个软件单元很难被测试,它也很难被重用。换句话说:TDD 给出了一个软件单元可用性的早期反馈,也就是说,这个软件在它计划的执行环境中可以被集成和使用的简单程度。

什么时候我们不应该使用 TDD

最后一个问题是:我们应该使用测试优先的方法开发系统的每一部分代码吗?

我明确的回答是不!

毫无疑问:测试驱动开发是指导软件设计和实现的一个很好的实践。理论上,用这种方式开发软件系统的几乎所有部分都是可能的。作为一种积极的副作用,新兴的代码是 100%默认测试的。

但是项目的某些部分太简单、太小或者不太复杂,以至于不能证明这种方法是正确的。如果你可以快速地编写代码,因为复杂性和风险都很低,那么你当然可以这么做。这种情况的例子是没有功能的纯数据类(顺便说一下,这是一种气味,但出于其他原因;参见第六章中关于贫血类的部分),或者只是将两个模块耦合在一起的简单粘合代码。

此外,对于 TDD,原型 ping 可能是一项非常困难的任务。当你进入一个新的领域,或者你应该在一个没有领域经验的非常创新的环境中开发软件,你有时不确定你要走哪条路才能找到解决方案。在需求非常不稳定和模糊的项目中首先编写单元测试可能是一项极具挑战性的任务。有时候,简单快速地写下第一个基本解决方案,并在后续步骤中借助改进的单元测试来确保其质量可能会更好。

另一个 TDD 帮不上忙的大挑战是获得一个好的架构。TDD 不能取代软件系统的粗粒度结构(子系统、组件等)上的必要反映。如果你面临关于框架、库、技术或架构模式的基本决策,TDD 将不会帮助你。

对于其他任何事情,我强烈推荐 TDD。当您必须用 C++ 开发一个软件单元(如一个类)时,这种方法可以节省大量时间,避免麻烦和错误的开始。对于任何比几行代码更复杂的东西,软件工匠可以像其他开发人员不经过测试就能编写代码一样快地测试代码,如果不是更快的话。—桑德罗·曼库索

Tip

如果你想更深入地研究 C++ 的测试驱动开发,我推荐 Jeff Langr 的优秀著作《测试驱动开发的现代 C++ 编程》[Langr13]。Jeff 的书对 TDD 提供了更深入的见解,并为您提供了在 C++ 中进行 TDD 的挑战和回报的实践课程。

九、设计模式和习惯用法

优秀的工匠可以利用丰富的经验和知识。一旦他们为某个问题找到了一个好的解决方案,他们就会把这个解决方案运用到他们的技能中,以便在将来解决类似的问题。理想情况下,他们将他们的解决方案转换成某种被称为规范形式的东西,并为自己和他人记录下来。

Canonical Form

在这个上下文中,术语“标准形式”描述的是在不失一般性的情况下简化为最简单和最有意义的形式。与设计模式相关,模式的规范形式描述了其最基本的元素:名称、上下文、问题、力量、解决方案、示例、缺点等。

对于软件开发者来说也是如此。有经验的开发人员可以利用大量的示例解决方案来解决软件中经常出现的设计问题。他们与他人分享他们的知识,并使其可重复用于类似的问题。这背后的原则:不要多此一举!

1995 年,一本备受关注并广受好评的书出版了。它的四位作者,即 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides,也称为四人帮(g of ),将设计模式的原则引入了软件开发,并提出了 23 个面向对象设计模式的目录。它的标题是设计模式:可重用面向对象软件的元素[Gamma95],直到今天,它仍被视为软件开发领域最重要的作品之一。

一些人认为 Gamma 等人发明了他们书中描述的所有设计模式。但事实并非如此。设计模式不是发明出来的,而是可以找到的。作者研究了在灵活性、可维护性和可扩展性方面做得很好的软件系统。他们找到了这些积极特征的原因,并以规范的形式描述了它们。

在“四人帮”的书出现后,人们认为在接下来的几年里将会出现大量的图案书。但这并没有发生。事实上,在接下来的几年中,出现了一些关于主题模式的其他重要书籍,如面向模式的软件架构(也称为缩写“POSA”)[Busch 96]或关于架构模式的企业应用架构模式[Fowler02],但是预期的大部分都没有出现。

设计原则与设计模式

在前几章中,我们已经讨论了很多设计原则。但是这些原则和设计模式有什么关系呢?什么更重要?

好吧,让我们假设一下,也许有一天面向对象会变得完全不受欢迎,函数式编程(见第七章)将成为主导的编程范例。原则有吻、干、YAGNI、单一责任原则、开闭原则、信息隐藏等。,然后变得无效,因此毫无价值?明确的答案是否定的!

原则是作为决策基础的基本“真理”或“法则”。因此,在大多数情况下,原则独立于特定的编程范式或技术。例如,接吻原则(见第三章)是一个非常普遍的原则。无论您是使用面向对象还是函数式风格编程,或者使用不同的语言,如 C++、C#、Java 或 Erlang,尝试做一些尽可能简单的事情总是一种值得的态度!

相比之下,设计模式是特定环境下具体设计问题的解决方案。尤其是那些在著名的“四人帮”设计模式书中描述的模式,都与面向对象密切相关。因此,原则更持久,也更重要。你可以自己找到某个编程问题的设计模式,如果你已经内化了原理的话。

Decision-making and mode give people solutions; Principles help them design their own. —— —Eoin Woods' keynote speech at the 2009 IEEE/IFIP Joint Working Conference on Software Architecture (WICSA2009)

一些模式,以及何时使用它们

除了四人帮书中描述的 23 种设计模式,当然还有更多模式。一些模式经常在开发项目中被发现,而另一些或多或少是罕见的或奇特的。以下部分讨论了一些我认为最重要的设计模式。那些解决经常出现的设计问题的人,开发人员至少应该听说过这些问题。

顺便说一句,我们已经在前面的章节中使用了一些设计模式,有些甚至相对激烈,但我们只是没有提到或注意到它。只是一个小小的提示:在“四人帮”的书[Gamma95]中,你可以找到一种被称为……迭代器的设计模式!

在我们继续讨论各个设计模式之前,这里必须指出一个警告:

Warning

不要用设计模式的用法来夸大它!毫无疑问,设计模式很酷,有时甚至令人着迷。但是过度使用它们,特别是如果没有好的理由来证明它,可能会产生灾难性的后果。你的软件设计将遭受无用的过度工程。永远记住《吻》和《YAGNI》(见第三章)。

但是现在让我们来看看几个模式。

依赖注入

依赖注入是敏捷架构的一个关键要素。—沃德·坎宁安,转述自 2004 年太平洋西北软件质量会议(PNSQC)上的“敏捷和传统开发”小组讨论

当然,我用四人帮的名著中没有提到的一个模式来开始关于特定设计模式的部分是有重要原因的。我确信依赖注入是迄今为止最重要的模式,可以帮助软件开发人员显著改进软件设计。这种模式可以被视为游戏规则的改变者。

在我们深入依赖注入之前,我首先要考虑另一种不利于良好软件设计的模式:单例模式!

单例反模式

我很确定你知道名为 Singleton 的设计模式。乍一看,这是一种简单而普遍的模式,不仅仅是在 C++ 领域(我们很快就会看到它所谓的简单可能是欺骗性的)。一些代码库甚至布满了单线。例如,这种模式通常用于所谓的记录器(用于记录目的的对象)、数据库连接、中央用户管理或表示来自物理世界的事物(例如,硬件,如 USB 或打印机接口)。此外,工厂和那些所谓的实用程序类通常是作为单件实现的。后者对他们自己来说是一种代码味道,因为它们是弱内聚的标志(见第三章)。

记者经常问设计模式的作者他们什么时候会修改他们的书并出版新的版本。他们通常的回答是,他们看不出这有什么理由,因为这本书的内容在很大程度上仍然有效。然而,在接受在线杂志 InformIT 采访时,他们给出了一个更详细的答案。下面是整个采访的一小段摘录,它揭示了 Gamma 关于单件的一个有趣的观点(拉里·奥布赖恩是采访者,Erich Gamma 给出了答案):

[......] Larry: How will you reconstruct the "design pattern"? Erich: We did this exercise in 2005. The following are some records of our meeting. We found that since then, the object-oriented design principles and most patterns have not changed. (...) When discussing which models to abandon, we found that we still love them all. (Not really-I'm in favor of giving up Singleton. Its use is almost always a design smell. —— Design pattern after 15 years: Interview with Erich Gamma, Richard Helm and Ralph Johnson, 2009 [InformIT09]

那么,为什么 Erich Gamma 说单例模式几乎总是一种设计气味呢?有什么问题吗?

为了回答这个问题,让我们首先来看看通过单线图要达到什么目标。这种模式可以满足什么要求?下面是 GoF 书中的 Singleton 模式的任务声明:

Ensure that there is only one instance of a class and provide a global access point. Erich Gama and others. Al. , design mode [Gamma95]

这种说法包含两个显著的方面。一方面,该模式的任务是控制和管理其唯一实例的整个生命周期。根据关注点分离的原则,对象生命周期的管理应该是独立的,并与其特定领域的业务逻辑相分离。在单例中,这两个问题基本上是分开的。

另一方面,提供了对该实例的全局访问,因此应用程序中的每个其他对象都可以使用它。这个关于面向对象环境中的“全局访问点”的演讲已经显得可疑,应该引起警惕。

让我们先来看一个 C++ 中单例的一般实现风格,所谓的 Meyers' Singleton,以《高效 C++ 书[Meyers05]的作者 Scott Meyers 命名:

#ifndef SINGLETON_H_

#define SINGLETON_H_

class Singleton final {

public:
  static Singleton& getInstance() {
    static Singleton theInstance { };
    return theInstance;
  }

  int doSomething() {
    return 42;
  }

  // ...more member functions doing more or less useful things here...

private:
  Singleton() = default;
  Singleton(const Singleton&) = delete;
  Singleton(Singleton&&) = delete;
  Singleton& operator=(const Singleton&) = delete;
  Singleton& operator=(Singleton&&) = delete;
  // ...
};

#endif

Listing 9-1.An implementation of Meyers’ Singleton in modern C++

这种单例实现风格的主要优点之一是,从 C++11 开始,在getInstance()中使用静态变量的唯一实例的构造过程在默认情况下是线程安全的(参见【ISO11】中的 6.7)。要小心,因为这并不自动意味着 Singleton 的所有其他成员函数也是线程安全的!后者必须由开发商保证。

在源代码中,这种全局单例实例的使用通常如下所示:

001  #include "AnySingletonUser.h"
002  #include "Singleton.h"
003  #include <string>
004
...  // ...
024
025  void AnySingletonUser::aMemberFunction() {
...    // ...
040    std::string result = Singleton::getInstance().doThis();
...    // ...
050  }
051
...  // ...
089
090  void AnySingletonUser::anotherMemberFunction() {
...    //...
098    int result = Singleton::getInstance().doThat();
...    //...
104    double value = Singleton::getInstance().doSomethingMore();
...    //...
110  }
111  // ...
Listing 9-2.An excerpt from the implementation of an arbitrary class that uses the Singleton

我想现在单身族的一个主要问题是什么变得很清楚了。由于它们的全局可见性和可访问性,它们可以简单地在其他类的实现中的任何地方使用。这意味着在软件设计中,对这个单例的所有依赖都隐藏在代码中。通过检查类的接口,也就是它们的属性和方法,你看不到这些依赖关系。

上面举例说明的类AnySingletonUser只是一个大型代码库中数百个类的代表,其中许多也在不同的地方使用Singleton。换句话说:OO 中的单例就像过程编程中的全局变量。您可以在任何地方使用这个全局对象,您在 using 类的接口中看不到这种用法,而只能在它的实现中看到。

这对项目中的依赖情况有很大的负面影响,如图 9-1 所示。

A429836_1_En_9_Fig1_HTML.jpg

图 9-1。

Loved by everyone: the Singleton! Note

当你看着图 9-1 时,也许你想知道在 Singleton 类中有一个私有成员变量instance,这在 Meyers 推荐的实现中是找不到的。嗯,UML 是与编程语言无关的,也就是说,作为一种多用途的建模语言,它不了解 C++、Java 或其他面向对象语言。事实上,在 Meyers 的 Singleton 中也有一个变量保存唯一的实例,但是在 UML 中没有一个带有静态存储持续时间的变量的图形符号,因为这个特性是 C++ 专有的。因此,我选择了将这个变量表示为私有静态成员的方式。这使得这种表示也与 GoF 书籍[Gamma95]中描述的现在不再推荐的单例实现兼容。

我认为很容易想象所有这些依赖关系在可重用性、可维护性和可测试性方面都有很大的缺陷。Singleton 的所有匿名客户端类都与它紧密耦合(还记得我们在第三章中讨论过的松耦合的良好特性)。

因此,我们完全丧失了利用多态性来提供替代实现的可能性。想想单元测试吧。如果在要测试的类的实现中使用了不能被 Test Double 轻易替换的东西,那么实现真正的单元测试又怎么可能成功呢?参见第二章中关于测试替身的部分)?

记住我们在第二章中讨论的好的单元测试的所有规则,尤其是单元测试独立性。像 Singleton 这样的全局对象有时持有可变状态。如果一个代码库中的许多或者几乎所有的类都依赖于一个对象,而这个对象的生命周期随着程序的终止而结束,并且可能拥有一个在它们之间共享的状态,那么如何保证测试的独立性呢?!

单例的另一个缺点是,如果由于新的或不断变化的需求而必须对它们进行更改,这种更改可能会在所有依赖类中引发一连串的更改。图 9-1 中可见的所有指向单例的依赖关系都是变更的潜在传播路径。

最后,在分布式系统中也很难保证一个类只有一个实例,这在当今的软件架构中是很常见的。想象一下微服务模式,一个复杂的软件系统由许多小的、独立的、分布式的进程组成。在这样的环境中,单例不仅很难防止多实例化,而且由于它们造成的紧密耦合,它们也是有问题的。

所以,也许你现在会问:“好吧,我明白了,单身是不好的,但有什么替代方案呢?”也许令人惊讶的简单答案,当然需要一些进一步的解释,是这样的:只需创建一个,并将其注入任何需要的地方!

依赖注入拯救

在上面提到的对 Erich Gamma 等人的采访中,作者也对那些设计模式做了一个声明,他们希望在他们的书的新版本中包含它们。他们只提名了几个可能成为他们传奇作品的模式,其中之一就是依赖注入。

基本上,依赖注入(DI)是一种从外部提供依赖客户机对象所需的独立服务对象的技术。客户端对象不必关心它自己需要的服务对象,或者主动请求服务对象,例如,从工厂(参见本章后面的工厂模式),或者从服务定位器。

DI 背后的意图可以表述如下:

Separate components from the services they need in such a way that they don't have to know the names of these services or how they are obtained.

让我们看一个具体的例子,上面已经提到的日志记录器,例如,一个服务类,它提供了写日志条目的可能性。这种记录器通常被实现为单件。因此,记录器的每个客户端都依赖于那个全局单例对象,如图 9-2 所示。

A429836_1_En_9_Fig2_HTML.jpg

图 9-2。

Three domain-specific classes of a web shop are dependent on the Logger singleton

这是 Logger singleton 类在源代码中的样子(只显示了相关部分):

#include <string_view>

class Logger final {

public:
  static Logger& getInstance() {
    static Logger theLogger { };
    return theLogger;
  }

  void writeInfoEntry(std::string_view entry) {
    // ...
  }

  void writeWarnEntry(std::string_view entry) {
    // ...
  }

  void writeErrorEntry(std::string_view entry) {
    // ...
  }
};

Listing 9-3.The Logger implemented as a Singleton

std::string_view [C++17]

从 C++17 开始,C++ 语言标准中新增了一个类:std :: string_view(在头文件<string_view>中定义)。这个类的对象是字符串的高性能代理(顺便说一下,代理也是一种设计模式),构造起来很便宜(没有为原始字符串数据分配内存),因此复制起来也很便宜。

另一个很好的特性是:std::string_view还可以作为 C 风格字符串(char*)、字符数组的适配器,甚至可以作为来自不同框架的专有字符串实现的适配器,例如CString (MFC)或QString (Qt):

CString aString("I'm a string object of the MFC type CString");
std::string_view viewOnCString { (LPCTSTR)aString };

因此,如果需要只读访问(例如,在函数执行期间),它是表示其数据已被他人拥有的字符串的理想类。例如,代替广泛使用的常量引用std::string,现在std::string_view应该被用来代替现代 C++ 程序中的只读字符串函数参数。

出于演示的目的,我们现在只挑选其中一个在实现中使用 Logger Singleton 来写日志条目的类,即类CustomerRepository:

#include "Customer.h"

#include "Identifier.h"

#include "Logger.h"

class CustomerRepository {

public:
  //...
  Customer findCustomerById(const Identifier& customerId) {
    Logger::getInstance().writeInfoEntry("Starting to search for a customer specified by a
      given unique identifier...");
    // ...
  }
  // ...
};

Listing 9-4.An excerpt from class CustomerRepository

为了摆脱 Singleton,并且能够在单元测试中用测试 Double 替换Logger对象,我们必须首先应用依赖倒置原则(DIP 参见第六章。这意味着我们首先必须引入一个抽象(一个接口),并使CustomerRepository和具体的Logger都依赖于该接口,如图 9-3 所示。

A429836_1_En_9_Fig3_HTML.jpg

图 9-3。

Decoupling through the applied Dependency Inversion Principle

这是新引入的接口LoggingFacility在源代码中的样子:

#include <memory>

#include <string_view>

class LoggingFacility {

public:
  virtual ∼LoggingFacility() = default;
  virtual void writeInfoEntry(std::string_view entry) = 0;
  virtual void writeWarnEntry(std::string_view entry) = 0;
  virtual void writeErrorEntry(std::string_view entry) = 0;
};

using Logger = std::shared_ptr<LoggingFacility>;

Listing 9-5.The LoggingFacility interface

StandardOutputLogger是实现LoggingFacility接口并在标准输出中写入日志的特定日志记录器类的一个例子,顾名思义:

#include "LoggingFacility.h"

#include <iostream>

class StandardOutputLogger : public LoggingFacility {

public:
  virtual void writeInfoEntry(std::string_view entry) override {
    std::cout << "[INFO] " << entry << std::endl;
  }

  virtual void writeWarnEntry(std::string_view entry) override {
    std::cout << "[WARNING] " << entry << std::endl;
  }

  virtual void writeErrorEntry(std::string_view entry) override {
    std::cout << "[ERROR] " << entry << std::endl;
  }
};

Listing 9-6.One possible implementation of a LoggingFacility: the StandardOutputLogger

接下来我们需要修改CustomerRepository类。首先,我们创建一个智能指针类型别名Logger的新成员变量。这个指针实例通过初始化构造函数传递到类中。换句话说,我们允许在构造期间将实现LoggingFacility接口的类的实例注入到CustomerRepository对象中。我们还删除了默认的构造函数,因为我们不希望在没有记录器的情况下创建一个CustomerRepository。此外,我们移除了实现中对 Singleton 的直接依赖,而是使用智能指针Logger来写日志条目。

#include "Customer.h"

#include "Identifier.h"

#include "LoggingFacility.h"

class CustomerRepository {

public:
  CustomerRepository() = delete;
  explicit CustomerRepository(const Logger& loggingService) : logger { loggingService } { }
  //...

  Customer findCustomerById(const Identifier& customerId) {
    logger->writeInfoEntry("Starting to search for a customer specified by a given unique identifier...");
  // ...
  }
  // ...

private:
  // ...
  Logger logger;
};

Listing 9-7.The modified class Customer Repository

作为这一重构的结果,我们现在已经实现了CustomerRepository类不再依赖于特定的日志记录器。相反,CustomerRepository只是依赖于一个抽象(接口),这个抽象现在在类及其接口中显式可见,因为它由一个成员变量和一个构造函数参数表示。这意味着CustomerRepository类现在接受从外部传入的用于记录目的的服务对象,如下所示:

Logger logger = std::make_shared<StandardOutputLogger>();
CustomerRepository customerRepository { logger };
Listing 9-8.The Logger object is injected into the instance of CustomerRepository

这一设计变更具有显著的积极影响。松散耦合得到了提升,客户端对象CustomerRepository现在可以配置各种提供日志功能的服务对象,如下面的 UML 类图所示(图 9-4 ):

A429836_1_En_9_Fig4_HTML.jpg

图 9-4。

Class CustomerRepository can be supplied with specific logging implementations via its constructor

另外,CustomerRepository类的可测试性也得到了显著的改进。不再有对单例的隐藏依赖。现在我们可以很容易地用一个模拟对象替换一个真正的日志服务(参见第二章关于单元测试和测试加倍)。我们可以用 spy 方法装备模拟对象,例如,在单元测试中检查哪些数据将通过LoggingFacility接口离开我们的CustomerRepository对象。

namespace test {

#include "../src/LoggingFacility.h"

#include <string>

class LoggingFacilityMock : public LoggingFacility {

public:
  virtual void writeInfoEntry(std::string_view entry) override {
    recentlyWrittenLogEntry = entry;
  }

  virtual void writeWarnEntry(std::string_view entry) override {
    recentlyWrittenLogEntry = entry;
  }

  virtual void writeErrorEntry(std::string_view entry) override {
    recentlyWrittenLogEntry = entry;
  }

  std::string_view getRecentlyWrittenLogEntry() const {
    return recentlyWrittenLogEntry;
  }

private:
  std::string recentlyWrittenLogEntry;
};

using MockLogger = std::shared_ptr<LoggingFacilityMock>;

}

Listing 9-9.A test double (mock object) for Unit-Testing of classes that have a dependency on LoggingFacility

在这个示例性的单元测试中,您可以看到运行中的模拟对象:

#include "../src/CustomerRepository.h"

#include "LoggingFacilityMock.h"

#include <gtest/gtest.h>

namespace test {

TEST(CustomerTestCase, WrittenLogEntryIsAsExpected) {
  MockLogger logger = std::make_shared<LoggingFacilityMock>();
  CustomerRepository customerRepositoryToTest { logger };
  Identifier customerId { 1234 };

  customerRepositoryToTest.findCustomerById(customerId);

  ASSERT_EQ("Starting to search for a customer specified by a given unique identifier...",
    logger->getRecentlyWrittenLogEntry());}

}

Listing 9-10.An example unit test

using the mock object

在前面的例子中,我将依赖注入作为一种模式来消除烦人的单例,但是当然这只是许多应用中的一种。基本上,一个好的面向对象软件设计应该确保所涉及的模块或组件尽可能地松散耦合,而依赖注入是实现这一目标的关键。通过始终如一地应用这种模式,软件设计将会出现一个非常灵活的插件架构。作为一种积极的副作用,这种技术产生了高度可测试的对象。

对象创建和链接的责任从对象本身中移除,并集中在一个基础设施组件中,即所谓的组装器或注入器。该组件(见图 9-5 )通常在程序启动时运行,并为整个软件系统处理类似于“构建计划”(例如,配置文件)的东西,也就是说,它以正确的顺序实例化对象和服务,并将服务注入需要它们的对象。

A429836_1_En_9_Fig5_HTML.jpg

图 9-5。

The Assembler is responsible for object creation and injection

请注意愉快的依赖情况。创建依赖关系的方向(原型创建的虚线箭头)从Assembler指向其他模块(类)。换句话说,在这个设计中没有一个类“知道”像Assembler这样的基础设施元素的存在(这并不完全正确,因为软件系统中至少有一个其他元素知道这个组件的存在,因为组装过程必须由某人触发,通常是在程序开始时)。

Assembler组件中的某个地方,可能会发现类似下面几行代码的内容:

// ...
Logger loggingServiceToInject = std::make_shared<StandardOutputLogger>();

auto customerRepository = std::make_shared<CustomerRepository>(loggingServiceToInject);
// ...
Listing 9-11.Parts of the implementation of the Assembler could look like this

这种 DI 技术称为构造函数注入,因为要注入的服务对象作为参数传递给客户机对象的初始化构造函数。构造函数注入的优点是客户机对象在构造过程中被完全初始化,然后可以立即使用。

但是,如果服务对象在程序运行时被注入到客户机对象中,例如,如果客户机对象只是在程序执行期间偶尔被创建,或者特定的记录器应该在运行时被交换,我们该怎么办呢?然后,客户端对象必须为服务对象提供一个 setter,如下例所示:

#include "Address.h"

#include "LoggingFacility.h"

class Customer {

public:
  Customer() = default;

  void setLoggingService(const Logger& loggingService) {
    logger = loggingService;
  }

  //...

private:
  Address address;
  Logger logger;
};

Listing 9-12.The class Customer provides a setter to inject a Logger

这种 DI 技术被称为 setter 注入。当然,也可以将构造函数注入和设置器注入结合起来。

依赖注入是一种设计模式,它使得软件设计松散耦合并且非常容易配置。它允许为不同的客户或软件产品的预期目的创建不同的产品配置。它极大地增加了软件系统的可测试性,因为它能够非常容易地注入模拟对象。因此,在设计任何严肃的软件系统时,都不应该忽略这种模式。如果您想更深入地研究这种模式,我推荐您阅读由 Martin Fowler 撰写的引领潮流的博客文章“反转控制容器和依赖注入模式”。

在实践中,通常使用依赖注入框架,它既可以作为商业解决方案也可以作为开源解决方案。

适配器

我确信适配器(同义词:包装器)是最常用的设计模式之一。其原因是,在软件开发中,不兼容接口的适应肯定是经常需要的,例如,如果必须集成另一个团队开发的模块,或者当使用第三方库时。

下面是适配器模式的任务声明:

Convert the interface of one class into another interface expected by the customer. Allow adapter classes to work together, otherwise they will not work because of incompatible interfaces. Erich Gama and others. Al. , design mode [Gamma95]

让我们进一步开发上一节中关于依赖注入的例子。让我们假设我们想要使用 BoostLog v2(参见 http://www.boost.org )进行日志记录,但是我们想要保持这个第三方库的用法可以与其他日志记录方法和技术交换。

解决方案很简单:我们只需要提供LoggingFacility接口的另一个实现,它将 BoostLog 的接口适配成我们想要的接口,如图 9-6 所示。

A429836_1_En_9_Fig6_HTML.jpg

图 9-6。

An adapter for a Boost logging solution

在源代码中,我们对接口BoostTrivialLogAdapter的额外实现如下所示:

#include "LoggingFacility.h"

#include <boost/log/trivial.hpp>

class BoostTrivialLogAdapter : public LoggingFacility {

public:
  virtual void writeInfoEntry(std::string_view entry) override {
    BOOST_LOG_TRIVIAL(info) << entry;
  }

  virtual void writeWarnEntry(std::string_view entry) override {
    BOOST_LOG_TRIVIAL(warn) << entry;
  }

  virtual void writeErrorEntry(std::string_view entry) override {
    BOOST_LOG_TRIVIAL(error) << entry;
  }
};

Listing 9-13.The Adapter for Boost.Log is just another implementation of LoggingFacility

优点是显而易见的:通过适配器模式,现在在我的整个软件系统中正好有一个类依赖于第三方日志解决方案。这也意味着我们的代码不会被专有的日志语句污染,比如BOOST_LOG_TRIVIAL()。因为这个适配器类只是LoggingFacility接口的另一个实现,所以我也可以使用依赖注入(见上一节)将这个类的实例——或者完全相同的实例——注入到所有想要使用它的客户机对象中。

适配器可以为不兼容的接口提供广泛的适应和转换可能性。这包括从简单的修改(如操作名和数据类型转换)到支持一整套不同的操作。在上面的例子中,带有字符串参数的成员函数的调用被转换成流的插入操作符的调用。

如果要适配的接口是相似的,那么接口适配当然更容易。如果接口非常不同,适配器也可能变成非常复杂的代码。

战略

如果我们记得第六章中描述的开闭原则(OCP)作为可扩展面向对象设计的指导方针,那么策略设计模式可以被认为是这一重要原则的“名人表演”。下面是这个模式的任务声明:

Define an algorithm family, encapsulate each algorithm and make them interchangeable. Let the algorithm change independently of the client using it. Erich Gama and others. Al. , design mode [Gamma95]

用不同的方式做事是软件设计中常见的需求。想想列表的排序算法。有各种排序算法,它们在时间复杂度(所需的操作数)和空间复杂度(除输入列表之外的额外所需存储空间)方面具有不同的特征。例如冒泡排序、快速排序、合并排序、插入排序和堆排序。

例如,冒泡排序是最不复杂的一种,它在内存消耗方面非常有效,但也是最慢的排序算法之一。相比之下,快速排序是一种快速有效的排序算法,通过其递归结构易于实现,并且不需要额外的内存,但对于预排序和倒排列表,它的效率非常低。在策略模式的帮助下,可以实现排序算法的简单交换,例如,取决于要排序的列表的属性。

让我们考虑另一个例子。假设我们希望在任意的业务 IT 系统中有一个类Customer实例的文本表示。一个涉众需求声明文本表示应该被格式化为各种输出格式:纯文本、XML(可扩展标记语言)和 JSON (JavaScript 对象符号)。

好的,首先让我们为各种格式化策略引入一个抽象,抽象类Formatter:

#include <memory>

#include <string>

#include <string_view>

#include <sstream>

class Formatter {

public:
  virtual ∼Formatter() = default;

  Formatter& withCustomerId(std::string_view customerId) {
    this->customerId = customerId;
    return *this;
  }

  Formatter& withForename(std::string_view forename) {
    this->forename = forename;
    return *this;
  }

  Formatter& withSurname(std::string_view surname) {
    this->surname = surname;
    return *this;
  }

  Formatter& withStreet(std::string_view street) {
    this->street = street;
    return *this;
  }

  Formatter& withZipCode(std::string_view zipCode) {
    this->zipCode = zipCode;
    return *this;
  }

  Formatter& withCity(std::string_view city) {
    this->city = city;
    return *this;
  }

  virtual std::string format() const = 0;

protected:
  std::string customerId { "000000" };
  std::string forename { "n/a" };
  std::string surname { "n/a" };
  std::string street { "n/a" };
  std::string zipCode { "n/a" };
  std::string city { "n/a" };n
};

using FormatterPtr = std::unique_ptr<Formatter>;

Listing 9-14.The abstract Formatter contains everything that all specific formatter classes have in common

提供风险承担者所要求的格式化样式的三个特定格式化程序如下:

#include "Formatter.h"

class PlainTextFormatter : public Formatter {

public:
  virtual std::string format() const override {
    std::stringstream formattedString { };
    formattedString << "[" << customerId << "]: "
      << forename << " " << surname << ", "
      << street << ", " << zipCode << " "
      << city << ".";
    return formattedString.str();
  }
};

class XmlFormatter : public Formatter {

public:
  virtual std::string format() const override {
    std::stringstream formattedString { };
    formattedString <<
      "<customer id=\"" << customerId << "\">\n" <<
      "  <forename>" << forename << "</forename>\n" <<
      "  <surname>" << surname << "</surname>\n" <<
      "  <street>" << street << "</street>\n" <<
      "  <zipcode>" << zipCode << "</zipcode>\n" <<
      "  <city>"  << city << "</city>\n" <<
      "</customer>\n";
    return formattedString.str();
  }
};

class JsonFormatter : public Formatter {

public:
  virtual std::string format() const override {
    std::stringstream formattedString { };
    formattedString <<
      "{\n" <<
      "  \"CustomerId : \"" << customerId << END_OF_PROPERTY <<
      "  \"Forename: \"" << forename << END_OF_PROPERTY <<
      "  \"Surname: \"" << surname << END_OF_PROPERTY <<
      "  \"Street: \"" << street << END_OF_PROPERTY <<
      "  \"ZIP code: \"" << zipCode << END_OF_PROPERTY <<
      "  \"City: \"" << city << "\"\n" <<
      "}\n";
    return formattedString.str();
  }

private:
  static constexpr const char* const END_OF_PROPERTY { "\",\n" };
};

Listing 9-15.The three specific formatters override the pure virtual format() member function of Formatter

从这里可以清楚地看出,OCP 得到了特别好的支持。一旦需要新的输出格式,只需实现抽象类Formatter的另一个专门化。不需要对已经存在的格式化程序进行修改。

#include "Address.h"

#include "CustomerId.h"

#include "Formatter.h"

class Customer {

public:
  // ...
  std::string getAsFormattedString(const FormatterPtr& formatter) const {
    return formatter->
    withCustomerId(customerId.toString()).
    withForename(forename).
    withSurname(surname).
    withStreet(address.getStreet()).
    withZipCode(address.getZipCodeAsString()).
    withCity(address.getCity()).
    format();
  }
  // ...

private:
  CustomerId customerId;
  std::string forename;
  std::string surname;
  Address address;
};

Listing 9-16.This is how the passed-in formatter object is used inside the member function getAsFormattedString()

成员函数Customer::getAsFormattedString()有一个参数,该参数需要一个指向格式化程序对象的唯一指针。这个参数可以用来控制通过这个成员函数检索的字符串的格式,或者换句话说:成员函数Customer::getAsFormattedString()可以提供一个格式化策略。

顺便说一下:也许你已经注意到了Formatter的公共接口的特殊设计,它有许多链接的with...()成员函数。这里也使用了另一种设计模式,叫做流畅界面。在面向对象编程中,流畅的接口是一种设计 API 的风格,其代码的可读性接近于普通的书面语言。在前一章关于测试驱动开发(第八章)中,我们已经看到了这样的界面。在这里,我们引入了一个自定义断言(参见“使用自定义断言进行更复杂的测试”一节)来编写更优雅、可读性更好的测试。在我们这里的例子中,技巧是每个with...()成员函数都是自引用的,也就是说,在格式化程序上调用成员函数的新上下文等同于以前的上下文,除非最后一个format()函数被调用。

像往常一样,这里也有一个我们的代码示例的类结构的可视化图形,一个 UML 类图(图 9-7 ):

A429836_1_En_9_Fig7_HTML.jpg

图 9-7。

An abstract Formatting strategy and its three concrete Formatting strategies

显而易见,本例中的策略模式确保了成员函数Customer::getAsFormattedString()的调用者可以根据需要配置输出格式。您想支持另一种输出格式吗?没问题:由于开闭原则的出色支持,可以很容易地添加另一种具体的格式化策略。其他的格式化策略,以及类Customer,完全不受这个扩展的影响。

命令

由于接收到指令,软件系统通常必须执行各种动作。例如,文本处理软件的用户通过与软件的用户界面交互来发出各种命令。他们想要打开文档、保存文档、打印文档、复制一段文本、粘贴一段复制的文本等。这种普遍模式在其他领域也可以观察到。例如,在金融领域,客户可以向其证券交易商发出购买股票、出售股票等指令。在制造业等更具技术性的领域,命令用于控制工业设施和机器。

当实现由命令控制的软件系统时,确保动作请求与实际执行动作的对象分离是很重要的。这背后的指导原则是松耦合(参见第三章)和关注点分离。

一个很好的比喻是餐馆。在餐馆里,服务员接受顾客的订单,但她不负责烹饪食物。这是餐厅厨房的任务。事实上,对于顾客来说,食物是如何准备的甚至是透明的。也许餐馆自己准备食物,但食物也可能是从其他地方送来的。

在面向对象的软件开发中,有一种称为命令(同义词:动作)的行为模式促进了这种分离。它的使命陈述如下:

Encapsulate a request into an object, so that you can parameterize client, queue or log requests of different requests and support revocable operations. Erich Gama and others. Al. , design mode [Gamma95]

命令模式的一个很好的例子是客户机/服务器体系结构,其中客户机——所谓的调用程序——发送应该在服务器上执行的命令,服务器被称为接收者。

让我们从抽象的Command开始,它是一个简单的小界面,看起来如下:

#include <memory>

class Command {

public:
  virtual ∼Command() = default;
  virtual void execute() = 0;
};

using CommandPtr = std::shared_ptr<Command>;

Listing 9-17.The Command interface

我们还为指向命令的智能指针引入了类型别名(CommandPtr)。

这个抽象的Command接口现在可以通过各种具体的命令来实现。让我们先来看看一个非常简单的命令,输出字符串“Hello World!”:

#include <iostream>

class HelloWorldOutputCommand : public Command {

public:
  virtual void execute() override {
    std::cout << "Hello World!" << "\n";
  }
};

Listing 9-18.A first and very simple implementation of a concrete Command

接下来,我们需要接受和执行命令的元素。在这个设计模式的一般描述中,这个元素被称为接收器。在我们的例子中,扮演这个角色的是一个名为Server的类:

#include "Command.h"

class Server {

public:
  void acceptCommand(const CommandPtr& command) {
    command->execute();
  }
};

Listing 9-19.The Command receiver

目前,这个类只包含一个可以接受和执行命令的简单公共成员函数。

最后,我们需要所谓的 Invoker,它是我们的客户机/服务器架构中的类Client:

class Client {

public:
  void run() {
    Server theServer { };
    CommandPtr helloWorldOutputCommand = std::make_shared<HelloWorldOutputCommand>();
    theServer.acceptCommand(helloWorldOutputCommand);
  }
};
Listing 9-20.The Client sends commands to the Server

main()函数中,我们可以找到下面的简单代码:

#include "Client.h"

int main() {
  Client client { };
  client.run();
  return 0;
}

Listing 9-21.The main() function

如果这个程序现在正在编译和执行,那么输出“Hello World!”会出现在 stdout 上。乍一看,这似乎不是很令人兴奋,但是我们通过命令模式实现的是,命令的发起和发送与其执行是分离的。我们现在可以处理命令对象以及其他对象。

由于这种设计模式支持开闭原则(Open 参见第六章)很好,添加新命令也很容易,只需对现有代码进行微不足道的微小修改。例如,如果我们想强制Server等待一段时间,我们可以添加以下新命令:

#include "Command.h"

#include <chrono>

#include <thread>

class WaitCommand : public Command {

public:
  explicit WaitCommand(const unsigned int durationInMilliseconds) noexcept :
    durationInMilliseconds{durationInMilliseconds} { };

  virtual void execute() override {
    std::chrono::milliseconds dur(durationInMilliseconds);
    std::this_thread::sleep_for(dur);
  }

private:
  unsigned int durationInMilliseconds { 1000 };
};

Listing 9-22.Another concrete command that instructs the server to wait

现在我们可以这样使用新的WaitCommand:

class Client {

public:
  void run() {
    Server theServer { };
    const unsigned int SERVER_DELAY_TIMESPAN { 3000 };

    CommandPtr waitCommand = std::make_shared<WaitCommand>(SERVER_DELAY_TIMESPAN);
    theServer.acceptCommand(waitCommand);

    CommandPtr helloWorldOutputCommand = std::make_shared<HelloWorldOutputCommand>();
    theServer.acceptCommand(helloWorldOutputCommand);
  }
};

Listing 9-23.Our new WaitCommand in use

为了获得到目前为止已经产生的结构的概述,图 9-8 描绘了一个相应的 UML 类图:

A429836_1_En_9_Fig8_HTML.jpg

图 9-8。

The Server just knows the Command interface, but not any concrete command

从这个例子中可以看出,我们可以用值来参数化命令。由于纯虚拟execute()成员函数的签名被Command接口指定为无参数,因此参数化是在初始化构造函数的帮助下完成的。此外,我们不需要改变Server类中的任何东西,因为它能够立即处理和执行新命令。

命令模式提供了多种可能的应用。例如,命令可以排队。这也支持命令的异步执行:调用者发送命令,然后可以立即做其他事情,但是命令是由接收者在稍后的时间执行的。

但是,少了点什么!在上面引用的命令模式的使命陈述中,你可以读到一些关于“…支持可撤销操作”的内容下一节将专门讨论这个话题。

命令处理程序

在上一节的客户机/服务器架构的小例子中,我做了一点手脚。实际上,服务器不会像我上面演示的那样执行命令。到达服务器的命令对象将被分发到负责执行命令的服务器内部。举例来说,这可以借助于另一种叫做责任链的模式来完成(这种模式在本书中没有描述)。

让我们考虑另一个更复杂的例子。假设我们有一个绘图程序。这个程序的用户可以画出许多不同的形状,例如,圆形和矩形。为此,在程序的用户界面中提供了相应的菜单,通过这些菜单可以调用这些绘图操作。我敢肯定你已经猜到了:这个程序的熟练软件开发人员实现了命令模式来执行这些绘图操作。然而,涉众的需求表明程序的用户也可以撤销绘图操作。

为了满足这个需求,我们首先需要可撤销的命令。

#include <memory>

class Command {

public:
  virtual ∼Command() = default;
  virtual void execute() = 0;
};

class Revertable {

public:
  virtual ∼Revertable() = default;
  virtual void undo() = 0;
};

class UndoableCommand : public Command, public Revertable { };

using CommandPtr = std::shared_ptr<UndoableCommand>;

Listing 9-24.The UndoableCommand interface is created by combining Command and Revertable

根据接口隔离原理(ISP 参见第六章)我们添加了另一个支持撤销功能的接口Revertable。这个新接口可以使用对一个UndoableCommand的继承与现有的Command接口相结合。

作为许多不同的可撤销绘图命令的示例,我在这里只显示了圆的具体命令:

#include "Command.h"

#include "DrawingProcessor.h"

#include "Point.h"

class DrawCircleCommand : public UndoableCommand {

public:
  DrawCircleCommand(DrawingProcessor& receiver, const Point& centerPoint,
    const double radius) noexcept :
    receiver { receiver }, centerPoint { centerPoint }, radius { radius } { }

  virtual void execute() override {
    receiver.drawCircle(centerPoint, radius);
  }

  virtual void undo() override {
    receiver.eraseCircle(centerPoint, radius);
  }

private:
  DrawingProcessor& receiver;
  const Point centerPoint;
  const double radius;
};

Listing 9-25.An undoable command for drawing circles

很容易想象绘制矩形和其他形状的命令看起来非常相似。命令的执行接收者是一个名为DrawingProcessor的类,它是执行绘图操作的元素。在命令的构造过程中,对该对象的引用与其他参数一起传递(请参见初始化构造函数)。在这里,我只展示了可能很复杂的类DrawingProcessor的一小部分摘录,因为它对于理解模式并不重要:

class DrawingProcessor {

public:
  void drawCircle(const Point& centerPoint, const double radius) {
    // Instructions to draw a circle on the screen...
  };

  void eraseCircle(const Point& centerPoint, const double radius) {
    // Instructions to erase a circle from the screen...
  };

  // ...
};

Listing 9-26.The DrawingProcessor is the element that will perform the drawing operations

现在我们来看这个模式的核心部分,即CommandProcessor:

#include <stack>

class CommandProcessor {

public:
  void execute(const CommandPtr& command) {
    command->execute();
    commandHistory.push(command);
  }

  void undoLastCommand() {
    if (commandHistory.empty()) {
      return;
    }
    commandHistory.top()->undo();
    commandHistory.pop();
  }

private:
  std::stack<std::shared_ptr<Revertable>> commandHistory;
};

Listing 9-27.The class CommandProcessor manages a stack of undoable command objects

CommandProcessor类(顺便说一下,在使用上面的实现时,它不是线程安全的)包含一个std::stack<T>(在头文件<stack>中定义),它是一个抽象数据类型,以后进先出(LIFO)的方式运行。在一个命令的执行被CommandProcessor::execute()成员函数触发后,命令对象被存储在commandHistory堆栈中。当调用CommandProcessor::undoLastCommand()成员函数时,保存在堆栈上的最后一个命令被撤销,然后从堆栈顶部删除。

撤销操作现在也可以被建模为一个命令对象。在这种情况下,命令接收者当然是CommandProcessor本身:

#include "Command.h"

#include "CommandProcessor.h"

class UndoCommand : public UndoableCommand {

public:
  explicit UndoCommand(CommandProcessor& receiver) noexcept :
      receiver { receiver } { }

  virtual void execute() override {
    receiver.undoLastCommand();
  }

  virtual void undo() override {
    // Intentionally left blank, because an undo should not be undone.
  }

private:
  CommandProcessor& receiver;
};

Listing 9-28.The UndoCommand prompts the CommandProcessor to perform an undo

丢了总览?好了,又到了以 UML 类图的形式展示“大图”的时候了(图 9-9 )。

A429836_1_En_9_Fig9_HTML.jpg

图 9-9。

The CommandProcessor (on the right) executes the Commands he receives and manages a command history

在实践中使用命令模式时,您经常会遇到这样的需求,即能够将几个简单的命令组合成一个更复杂的命令,或者记录和重放命令(脚本)。为了能够以优雅的方式实现这样的需求,下面的设计模式是合适的。

复合材料

在计算机科学中广泛使用的数据结构是树的结构。到处都可以找到树。例如,数据介质(例如,硬盘)上的文件系统的层次结构符合树的结构。集成开发环境(IDE)的项目浏览器通常具有树形结构。在编译器设计中,抽象语法树(AST),顾名思义,是源代码的抽象语法结构的树表示,通常是编译器语法分析阶段的结果。

树状数据结构的面向对象蓝图被称为复合模式。这个模式有如下意图:

The objects are grouped into a tree structure to represent the part-whole hierarchy. Composite allows clients to handle single objects and combinations of objects uniformly. Erich Gama and others. Al. , design mode [Gamma95]

我们之前在命令和命令处理器小节中的例子应该可以扩展,我们可以构建复合命令,命令可以被记录和重放。所以我们在之前的设计中添加了一个新的类,一个CompositeCommand:

#include "Command.h"

#include <vector>

class CompositeCommand : public UndoableCommand {

public:
  void addCommand(CommandPtr& command) {
    commands.push_back(command);
  }

  virtual void execute() override {
    for (const auto& command : commands) {
      command->execute();
    }
  }

  virtual void undo() override {
    for (const auto& command : commands) {
      command->undo();
    }
  }

private:
  std::vector<CommandPtr> commands;
};

Listing 9-29.A new concrete UndoableCommand that manages a list of commands

复合命令有一个成员函数addCommand(),它允许您向CompositeCommand的实例添加命令。由于类CompositeCommand也实现了UndoableCommand接口,它的实例可以像普通命令一样处理。换句话说,也可以将复合命令与其他复合命令分层组装。通过复合模式的递归结构,您能够生成命令树。

下面的 UML 类图(图 9-10 )描述了扩展设计。

A429836_1_En_9_Fig10_HTML.jpg

图 9-10。

With the added CompositeCommand (on the left), commands can now be scripted

新添加的类别CompositeCommand现在可以用作宏记录器,以记录和重放命令序列:

int main() {
  CommandProcessor commandProcessor { };
  DrawingProcessor drawingProcessor { };

  auto macroRecorder = std::make_shared<CompositeCommand>();

  Point circleCenterPoint { 20, 20 };
  CommandPtr drawCircleCommand = std::make_shared<DrawCircleCommand>(drawingProcessor,
  circleCenterPoint, 10);
  commandProcessor.execute(drawCircleCommand);
  macroRecorder->addCommand(drawCircleCommand);

  Point rectangleCenterPoint { 30, 10 };
  CommandPtr drawRectangleCommand = std::make_shared<DrawRectangleCommand>(drawingProcessor,
  rectangleCenterPoint, 5, 8);
  commandProcessor.execute(drawRectangleCommand);
  macroRecorder->addCommand(drawRectangleCommand);

  commandProcessor.execute(macroRecorder);

  CommandPtr undoCommand = std::make_shared<UndoCommand>(commandProcessor);
  commandProcessor.execute(undoCommand);

  return 0;
}

Listing 9-30.Our new CompositeCommand in action as a Macro Recorder

在复合模式的帮助下,现在很容易从简单的命令组装复杂的命令序列(后者在规范形式中被称为“叶子”)。由于CompositeCommand也实现了UndoableCommand接口,它们可以像简单的命令一样使用。这极大地简化了客户端代码的使用。

仔细观察,有一个小缺点。您可能已经注意到,只有使用具体类型CompositeCommand的实例(macroRecorder)时,才能访问成员函数CompositeCommand::addCommand()(参见上面的源代码)。该成员功能无法通过接口UndoableCommand使用。换句话说,这里没有给出复合物和叶子的承诺的平等待遇(记住模式的意图)!

如果你看一下[Gamma95]中的通用复合模式,你会发现用于管理子元素的管理功能是在抽象中声明的。然而,在我们的例子中,这意味着我们必须在接口UndoableCommand中声明一个addCommand()(顺便说一下,这违反了 ISP)。致命的后果是叶子元素必须覆盖addCommand(),并且必须为这个成员函数提供一个有意义的实现。这是不可能的!请问,如果我们给DrawCircleCommand的一个实例添加一个命令,会发生什么,什么不违反最小惊讶原则(见第三章)?

如果我们那样做,就违反了利斯科夫替代原理(LSP 参见第六章。因此,在我们的情况下,最好做一个折衷,不要同等对待复合材料和叶片。

观察者

构建软件系统的一个众所周知的架构模式是模型-视图-控制器(MVC)。在这种架构模式的帮助下,《面向模式的软件架构》[Busch96]一书中详细描述了这种架构模式,通常应用程序的表现部分(用户界面)是结构化的。其背后的原则是关注点分离(SoC)。其中,要显示的数据保存在所谓的模型中,与这些数据的多种视觉表示(所谓的视图)相分离。

在 MVC 中,视图和模型之间的耦合应该尽可能的松散。这种松散耦合通常通过观察者模式来实现。观察者是一种在[Gamma95]中描述的行为模式,它有如下意图:

Define one-to-many dependencies between objects, so that when an object changes state, all its dependent objects will be notified and automatically updated. Erich Gama and others. Al. , design mode [Gamma95]

通常,这种模式可以用一个例子来解释。让我们考虑一个电子表格应用程序,它是许多办公软件套件的自然组成部分。在这样的应用程序中,数据可以显示在工作表中、饼图图形中以及许多其他呈现形式中;所谓的观点。可以创建数据的不同视图,也可以再次关闭。

首先,我们需要一个抽象的视图元素,叫做观察者。

#include <memory>

class Observer {

public:
  virtual ∼Observer() = default;
  virtual int getId() = 0;
  virtual void update() = 0;
};

using ObserverPtr = std::shared_ptr<Observer>;

Listing 9-31.The abstract Observer

观察者观察一个所谓的对象。为此,他们可以在主体处注册,也可以注销。

#include "Observer.h"

#include <algorithm>

#include <vector>

class IsEqualTo final {

public:
  explicit IsEqualTo(const ObserverPtr& observer) :
    observer { observer } { }
  bool operator()(const ObserverPtr& observerToCompare) {
    return observerToCompare->getId() == observer->getId();
  }

private:
  ObserverPtr observer;
};

class Subject {
public:
  void addObserver(ObserverPtr& observerToAdd) {
    auto iter = std::find_if(begin(observers), end(observers),
        IsEqualTo(observerToAdd));
    if (iter == end(observers)) {
      observers.push_back(observerToAdd);
    }
  }

  void removeObserver(ObserverPtr& observerToRemove) {
    observers.erase(std::remove_if(begin(observers), end(observers),
        IsEqualTo(observerToRemove)), end(observers));
  }

protected:
  void notifyAllObservers() const {
    for (const auto& observer : observers) {
      observer->update();
    }
  }

private:
  std::vector<ObserverPtr> observers;
};

Listing 9-32.Observers can be added to and removed from a so-called Subject

除了类Subject之外,还定义了一个名为IsEqualTo的函子(见第七章关于函子),用于添加和移除观察者时的比较。仿函数比较Observer的 id。也可以想象它会比较Observer实例的内存地址。然后,甚至可能有几个相同类型的观察员在Subject登记。

核心是notifyAllObservers()成员函数。它是protected,因为它旨在由从这个对象继承的具体对象调用。这个函数遍历所有注册的观察者,并调用它们的update()成员函数。

让我们来看一个具体的主题SpreadsheetModel

#include "Subject.h"

#include <iostream>

#include <string_view>

class SpreadsheetModel : public Subject {

public:

  void changeCellValue(std::string_view column, const int row, const double value) {
    std::cout << "Cell [" << column << ", " << row << "] = " << value << std::endl;
    // Change value of a spreadsheet cell, and then...
    notifyAllObservers();
  }
};

Listing 9-33.The SpreadsheetModel is a concrete Subject

当然,这只是绝对最小值的一个SpreadsheetModel。它只是用来解释模式的功能原理。这里您唯一能做的就是调用一个成员函数,该函数调用继承的notifyAllObservers()函数。

在我们的例子中,实现Observer接口的update()成员功能的三个具体观察者是三个视图TableViewBarChartViewPieChartView

#include "Observer.h"

#include "SpreadsheetModel.h"

class TableView : public Observer {

public:
  explicit TableView(SpreadsheetModel& theModel) :
    model { theModel } { }
  virtual int getId() override {
    return 1;
  }

  virtual void update() override {
    std::cout << "Update of TableView." << std::endl;
  }

private:
  SpreadsheetModel& model;
};

class BarChartView : public Observer {

public:
  explicit BarChartView(SpreadsheetModel& theModel) :
    model { theModel } { }
  virtual int getId() override {
    return 2;
  }

  virtual void update() override {
    std::cout << "Update of BarChartView." << std::endl;
  }

private:
  SpreadsheetModel& model;
};

class PieChartView : public Observer {

public:
  explicit PieChartView(SpreadsheetModel& theModel) :
    model { theModel } { }
  virtual int getId() override {
    return 3;
  }

  virtual void update() override {
    std::cout << "Update of PieChartView." << std::endl;
  }

private:
  SpreadsheetModel& model;
};

Listing 9-34.Three concrete views implement the abstract Observer interface

我认为是时候再次以类图的形式展示一个概述了。图 9-11 描述了已经出现的结构(类和依赖关系)。

A429836_1_En_9_Fig11_HTML.jpg

图 9-11。

When the SpreadsheetModel gets changed, it notifies all its observers

main()函数中,我们现在使用SpreadsheetModel和如下三个视图:

#include "SpreadsheetModel.h"

#include "SpreadsheetViews.h"

int main() {
  SpreadsheetModel spreadsheetModel { };

  ObserverPtr observer1 = std::make_shared<TableView>(spreadsheetModel);
  spreadsheetModel.addObserver(observer1);

  ObserverPtr observer2 = std::make_shared<BarChartView>(spreadsheetModel);
  spreadsheetModel.addObserver(observer2);

  spreadsheetModel.changeCellValue("A", 1, 42);

  spreadsheetModel.removeObserver(observer1);

  spreadsheetModel.changeCellValue("B", 2, 23.1);

  ObserverPtr observer3 = std::make_shared<PieChartView>(spreadsheetModel);
  spreadsheetModel.addObserver(observer3);

  spreadsheetModel.changeCellValue("C", 3, 3.1415926);

  return 0;
}

Listing 9-35.Our SpreadsheetModel and the three Views assembled together and in action

编译并运行程序后,我们在标准输出中看到以下内容:

Cell [A, 1] = 42
Update of TableView.
Update of BarChartView.
Cell [B, 2] = 23.1
Update of BarChartView.
Cell [C, 3] = 3.14153
Update of BarChartView.
Update of PieChartView.

除了松散耦合的积极特征(具体主体对观察者一无所知),这种模式还非常支持开闭原则。可以非常容易地添加新的具体观察者(在我们的例子中是新的视图),因为在现有的类中不需要调整或更改任何东西。

工厂

根据关注点分离(SoC)原则,对象创建或采购应该与对象拥有的特定领域任务分离。上面讨论的依赖注入模式以一种直接的方式遵循这个原则,因为整个对象创建过程都集中在一个基础结构元素中,并且对象不必担心它。

但是如果需要在运行时的某个时刻动态创建一个对象,我们该怎么办呢?那么,这个任务可以由对象工厂来接管。

工厂设计模式基本上相对简单,并且以许多不同的形式和种类出现在代码库中。除了 SoC 原则之外,信息隐藏(参见第三章)也得到了极大的支持,因为实例的创建过程应该对用户隐藏。

正如已经说过的,工厂有无数的形式和变种。我们只讨论一个简单的变体。

简单工厂

工厂最简单的实现可能是这样的(我们从上面的 DI 小节中获取日志示例):

#include "LoggingFacility.h"

#include "StandardOutputLogger.h"

class LoggerFactory {

public:
  static Logger create() {
    return std::make_shared<StandardOutputLogger>();
  }
};

Listing 9-36.Probably the simplest imaginable object factory

这个非常简单的工厂的用法如下:

#include "LoggerFactory.h"

int main() {
  Logger logger = LoggerFactory::create();
  // ...log something...
  return 0;
}

Listing 9-37.Using the LoggerFactory to create a Logger instance

也许你现在会问,为这样一个微不足道的任务多上一节课是否值得。嗯,也许不是。更明智的做法是,如果工厂能够创建不同的记录器,并决定它应该是哪种类型。例如,这可以通过读取和评估配置文件,或者从 Windows 注册表数据库中读取某个键来完成。还可以想象,生成的对象的类型依赖于一天中的时间。可能性是无穷的。这对于客户端类应该是完全透明的,这一点很重要。所以,这里有一个稍微复杂一点的LoggerFactory,它读取一个配置文件(例如,从硬盘)并决定当前的配置,创建哪个特定的记录器:

#include "LoggingFacility.h"

#include "StandardOutputLogger.h"

#include "FilesystemLogger.h"

#include <fstream>

#include <string>

#include <string_view>

class LoggerFactory {

private:
  enum class OutputTarget : int {
    STDOUT,
    FILE
  };

public:
  explicit LoggerFactory(std::string_view configurationFileName) :
    configurationFileName { configurationFileName } { }

  Logger create() const {
    const std::string configurationFileContent = readConfigurationFile();
    OutputTarget outputTarget = evaluateConfiguration(configurationFileContent);
    return createLogger(outputTarget);
  }

private:
  std::string readConfigurationFile() const {
    std::ifstream filestream(configurationFileName);
    return std::string(std::istreambuf_iterator<char>(filestream),
      std::istreambuf_iterator<char>());  }

  OutputTarget evaluateConfiguration(std::string_view configurationFileContent) const {
    // Evaluate the content of the configuration file...
    return OutputTarget::STDOUT;
  }

  Logger createLogger(OutputTarget outputTarget) const {
    switch (outputTarget) {
    case OutputTarget::FILE:
      return std::make_shared<FilesystemLogger>();
    case OutputTarget::STDOUT:
    default:

      return std::make_shared<StandardOutputLogger>();
    }
  }

  const std::string configurationFileName;
};

Listing 9-38.A more sophisticated Factory that reads and evaluates a configuration file

图 9-12 中的 UML 类图描绘了我们基本上从依赖注入部分了解到的结构(图 9-5 ,但是现在用我们简单的LoggerFactory代替了汇编器。

A429836_1_En_9_Fig12_HTML.jpg

图 9-12。

The Customer uses a LoggerFactory to obtain concrete Loggers

这个图与图 9-5 的比较显示了一个显著的不同:当类CustomerRepository不依赖于汇编器时,客户在使用工厂模式时“知道”工厂类。据推测,这种依赖并不是一个严重的问题,但是它再次清楚地表明,依赖注入将松散耦合带到了最大程度。

外表

Facade 模式是一种结构模式,常用于架构级别,其目的如下:

Provide a unified interface for a group of interfaces in the subsystem. Facade defines a more advanced interface, which makes the subsystem easier to use. Erich Gama and others. Al. , design mode [Gamma95]

根据关注点分离原则、单一责任原则(参见第六章)和信息隐藏(参见第三章)构建大型软件系统通常会产生某种更大的组件或模块。通常,这些组件或模块有时可以被称为“子系统”即使在分层架构中,各个层也可以被视为子系统。

为了促进封装,组件或子系统的内部结构应该对其客户端隐藏(参见第三章中的信息隐藏)。子系统之间的通信以及它们之间的依赖程度应该最小化。如果一个子系统的客户必须知道它的内部结构和各部分的交互的细节,这将是致命的。

外观通过为客户端提供定义良好的简单接口来控制对复杂子系统的访问。对子系统的任何访问都只能在外观上完成。

下面的 UML 图(图 9-13 )显示了一个名为Billing的准备发票子系统。它的内部结构由几个相互连接的部分组成。子系统的客户端不能直接访问这些部分。他们必须使用 Facade BillingService,它由子系统边界上的 UML 端口(原型 Facade)表示。

A429836_1_En_9_Fig13_HTML.jpg

图 9-13。

The Billing subsystem provides a facade BillingService as an access point for clients

在 C++ 和其他语言中,Facade 没有什么特别的。它通常只是一个简单的类,在其公共接口接收调用,并将它们转发到子系统的内部结构。有时它只是简单地将一个调用转发给子系统的一个内部结构元素,但偶尔一个 Facade 也执行数据转换,那么它也是一个适配器(参见关于适配器的部分)。

在我们的例子中,Facade 类BillingService实现了两个接口,由 UML 球符号表示。根据接口隔离原理(ISP 参见第六章,Billing子系统的配置(接口Configuration)与账单生成(接口InvoiceCreation)分离。因此,Facade 必须覆盖在两个接口中声明的操作。

金钱阶级

如果高精度很重要,您应该避免浮点值。类型为floatdoublelong double的浮点变量在简单加法中已经失败,如这个小例子所示:

#include <assert.h>

#include <iostream>

int main() {

  double sum = 0.0;
  double addend = 0.3;

  for (int i = 0; i < 10; i++) {
    sum = sum + addend;
  };

  assert(sum == 3.0);
  return 0;
}

Listing 9-39.When adding 10 floating point numbers this way, the result is possibly not accurate enough

如果您编译并运行这个小程序,您将看到它的控制台输出:

Assertion failed: sum == 3.0, file ..\main.cpp, line 13

我认为这种偏差的原因是众所周知的。浮点数在内部以二进制格式存储。因此,不可能在floatdoublelong double类型的变量中精确存储 0.3(及其他)的值,因为它在二进制中没有有限长度的精确表示。在十进制中,我们也有类似的问题。我们不能只用十进制来表示 1/3(三分之一)这个值。0.33333333 并不完全准确。

这个问题有几种解决方法。对于货币,将货币值存储为具有所需精度的整数可能是一种合适的方法,例如,$12.45 将存储为 1245。如果要求不是很高,整数可能是一个可行的解决方案。请注意,C++ 标准没有以字节为单位指定整型的大小;因此,您必须小心处理非常大的数量,因为可能会发生整数溢出。如果有疑问,应该使用 64 位整数,因为它可以容纳非常大量的钱。

Determining the Range of an Arithmetic Type

算术类型(整数或浮点)的实际实现特定范围可以在头文件<limits>中的类模板中找到。例如,这就是你如何找到int的最大范围:

#include <limits>

constexpr auto INT_LOWER_BOUND = std::numeric_limits<int>::min();

constexpr auto INT_UPPER_BOUND = std::numeric_limits<int>::max();

另一种流行的方法是为此提供一个特殊的类,即所谓的 Money 类:

Provide a class to represent the exact amount. Currency class deals with different currencies and the exchange between them. —Martin Fowler, Enterprise Application Architecture Pattern [Fowler02]

A429836_1_En_9_Fig14_HTML.jpg

图 9-14。

A Money Class

Money 类模式基本上是一个封装了金融金额及其货币的类,但是处理货币只是这类中的一个例子。还有许多其他的性质或维度,必须精确地表示出来,例如,物理学中的精确测量(时间、电压、电流、距离、质量、频率、物质的数量……)。

1991: Patriot Missile Mistiming

MIM-104 爱国者是一种地对空导弹(SAM)系统,由美国雷神公司设计和制造。其典型应用是对抗高空战术弹道导弹、巡航导弹和先进飞机。在第一次波斯湾战争(1990-1991)期间,又名“沙漠风暴”行动,“爱国者”被用于击落来袭的伊拉克飞毛腿或阿尔·侯赛因短程弹道导弹。

1991 年 2 月 25 日,位于沙特阿拉伯东部省份达兰市的一个炮台未能拦截一枚飞毛腿导弹。导弹击中了一个军营,造成 28 人死亡,98 人受伤。

一份调查报告[GAOIMTEC92]揭示,这起故障的原因是,由于计算机运算错误,对系统通电后的时间计算不准确。为了使爱国者导弹能够在发射后发现并击中目标,它们必须在空间上接近目标,也就是所谓的“距离门”。为了预测目标下一步将出现在哪里(所谓的偏转角),必须对系统的时间和目标的飞行速度进行一些计算。系统启动后经过的时间以十分之一秒为单位,用整数表示。目标的速度以英里/秒为单位,用十进制数值表示。为了计算“距离门”,系统计时器的值必须乘以 1/10 才能得到以秒为单位的时间。这个计算是通过使用只有 24 位长的寄存器来完成的。

问题是十进制的 1/10 值在 24 位寄存器中无法准确表示。该值在小数点后 24 位被截断。结果是,时间从整数到实数的转换会导致精度的轻微损失,从而导致时间计算不太精确。根据其作为移动系统运行的概念,如果系统仅运行几个小时,这种精度误差可能不会成为问题。但在这种情况下,系统已经运行了 100 多个小时。代表系统正常运行时间的数字相当大。这意味着 1/10 转换成 24 位十进制表示的小误差会导致将近半秒的大偏差误差!一枚伊拉克飞毛腿导弹大约。这段时间跨度为 800 米——足以超出正在逼近的爱国者导弹的“射程门”。

尽管在许多商业 IT 系统中,准确处理货币数量是一个非常常见的情况,但是在大多数主流的 C++ 基础类库中,你将徒劳地寻找一个货币类。但是不要多此一举!有许多不同的 C++ Money 类实现,只要问问你信任的搜索引擎,你就会得到成千上万的点击。通常,一个实现不能满足所有需求。关键是要了解你的问题域。在选择(或设计)货币类别时,您可能需要考虑几个约束和要求。这里有几个你可能必须首先澄清的问题:

  • 要处理的值的完整范围是什么(最小值、最大值)?
  • 哪些舍入规则适用?有些国家有关于四舍五入的国家法律或惯例。
  • 法律对准确性有要求吗?
  • 必须考虑哪些标准(例如,ISO 4217 货币代码国际标准)?
  • 这些值将如何显示给用户?
  • 转换多久发生一次?

从我的角度来看,对于一个 Money 类来说,拥有 100%的单元测试覆盖率(参见第二章关于单元测试)是绝对必要的,这样可以检查这个类是否在所有情况下都像预期的那样工作。当然,与用整数表示的纯数字相比,Money 类有一个小缺点:您会损失一点点性能。在某些系统中,这可能是一个问题。但是我确信在大多数情况下优势会占优势(永远记住过早的优化是不好的)。

特殊情况对象(空对象)

在第四章的“不要传递或返回 0 (NULL,nullptr)”一节中,我们了解到从一个函数或方法返回一个nullptr是不好的,应该避免。在那里,我们还讨论了在现代 C++ 程序中避免常规(原始)指针的各种策略。在“异常就是异常——从字面上看!”在第五章中,我们了解到异常应该只用于真正的异常情况,而不是为了控制正常的程序流程。

现在,一个开放且有趣的问题是:我们如何在不使用非语义nullptr或其他奇怪值的情况下,处理那些不是真正异常的特殊情况(例如,内存分配失败)?

让我们再来看看我们的代码示例,这个示例我们已经看过几次了:通过名称查询 a Customer

Customer CustomerService::findCustomerByName(const std::string& name) {
  // Code that searches the customer by name...
  // ...but what shall we do, if a customer with the given name does not exist?!
}
Listing 9-40.A look up method for customers by name

一种可能是总是返回列表,而不是单个实例。如果返回的列表为空,则查询的业务对象不存在:

#include "Customer.h"

#include <vector>

using CustomerList = std::vector<Customer>;

CustomerList CustomerService::findCustomerByName(const std::string& name) {
  // Code that searches the customer by name...
  // ...and if a customer with the given name does not exist:
  return CustomerList();
}

Listing 9-41.An alternative to nullptr: Returning an empty list if the look-up for a customer fails

现在可以在下一个程序序列中查询返回的列表是否为空。但是什么语义有空列表呢?是一个错误导致了列表的空空如也吗?嗯,成员函数std::vector<T>::empty()不能够回答这个问题。空是列表的一种状态,但是这种状态没有特定于域的语义。

伙计们,毫无疑问,这个解决方案比返回一个nullptr要好得多,但是在某些情况下可能不够好。更令人满意的是,可以查询返回值的来源,以及可以用它做什么。答案是特例模式!

A subclass that provides special behavior for special situations. —Martin Fowler, Enterprise Application Architecture Pattern [Fowler02]

特例模式背后的思想是我们利用了多态性,并且我们提供了代表特例的类,而不是返回nullptr或其他一些奇怪的值。这些特例类与调用者所期望的“普通”类具有相同的接口。图 9-15 中的类图描述了这样一种专门化。

A429836_1_En_9_Fig15_HTML.jpg

图 9-15。

The class(es) representing a special case are derived from class Customer

在 C++ 源代码中,代表特殊情况的Customer类和NotFoundCustomer类的实现如下所示(只显示了相关部分):

#ifndef CUSTOMER_H_

#define CUSTOMER_H_

#include "Address.h"

#include "CustomerId.h"

#include <memory>

#include <string>

class Customer {

public:
  // ...more member functions here...
  virtual ∼Customer() = default;

  virtual bool isPersistable() const noexcept {
    return (customerId.isValid() && ! forename.empty() && ! surname.empty() &&
      billingAddress->isValid() && shippingAddress->isValid());
  }

private:
  CustomerId customerId;
  std::string forename;
  std::string surname;
  std::shared_ptr<Address> billingAddress;
  std::shared_ptr<Address> shippingAddress;
};

class NotFoundCustomer final : public Customer {

public:
  virtual bool isPersistable() const noexcept override {
    return false;
  }
};

using CustomerPtr = std::unique_ptr<Customer>;

#endif /* CUSTOMER_H_ */

Listing 9-42.An excerpt from file Customer.h with the classes Customer and NotFoundCustomer

代表特殊情况的对象现在可以像类Customer的有效(正常)实例一样使用。即使对象在程序的不同部分之间传递,永久的空检查也是多余的,因为总是有一个有效的对象。使用NotFoundCustomer对象可以做很多事情,就好像它是Customer的一个实例一样,例如,在用户界面中呈现它。对象甚至可以揭示它是否是持久的。对于“真实的”Customer,这是通过分析其数据字段来完成的。然而,在NotFoundCustomer的例子中,这种检查总是有否定的结果。

与无意义的空检查相比,如下语句更有意义:

if (customer.isPersistable()) {
  // ...write the customer to a database here...
}

std::optional [C++17]

自 C++17 以来,有另一种有趣的替代方法可以用于可能丢失的结果或值:std::optional<T>(在头文件<optional>中定义)。这个类模板的实例表示一个“可选的包含值”,即一个可能存在也可能不存在的值。

通过引入类型别名,可以使用std::optional<T>将类Customer用作可选值,如下所示:

#include "Customer.h"

#include <optional>

using OptionalCustomer = std::optional<Customer>;

我们的搜索功能CustomerService::findCustomerByName()现在可以实现如下:

class CustomerRepository {

public:
  OptionalCustomer findCustomerByName(const std::string& name) {
    if ( /* the search was successful */ ) {
      return Customer();
    } else {
      return {};
    }
  }
};

在函数的调用位置,您现在有两种方法来处理返回值,如下例所示:

int main() {
  CustomerRepository repository { };
  auto optionalCustomer = repository.findCustomerByName("John Doe");

  // Option 1: Catch an exception, if 'optionalCustomer' is empty
  try {
    auto customer = optionalCustomer.value();
  } catch (std::bad_optional_access& ex) {
    std::cerr << ex.what() << std::endl;
  }

  // Option 2: Provide a substitute for a possibly missing object
  auto customer = optionalCustomer.value_or(NotFoundCustomer());

  return 0;
}

例如,在第二个选项中,如果optionalCustomer为空,则可以提供一个标准(默认)客户,或者——在本例中——一个特例对象的实例。当一个对象的缺失是意料之外的,并且是一定发生了严重错误的线索时,我建议选择第一个选项。对于其他情况,丢失对象并不罕见,我推荐选项 2。

什么是成语?

编程习惯用法是用特定的编程语言或技术解决问题的一种特殊模式。也就是说,与更一般的设计模式不同,习惯用法的适用性是有限的。通常,它们的适用性仅限于一种特定的编程语言或某种技术,例如框架。

如果编程问题必须在较低的抽象层次上解决,习惯用法通常在详细设计和实现过程中使用。C 和 C++ 领域中一个众所周知的习惯用法是所谓的 Include Guard,有时也称为宏保护或头文件保护,用于避免重复包含同一个头文件:

#ifndef FILENAME_H_

#define FILENAME_H_

// ...content of header file...

#endif

这种习惯用法的一个缺点是必须确保文件名的一致命名方案,因此也必须确保包含保护宏名的一致命名方案。因此,现在大多数 C 和 C++ 编译器都支持非标准的#pragma once指令。该指令插入到头文件的顶部,将确保头文件只被包含一次。

顺便说一下,我们已经了解了一些成语。在第四章中,我们讨论了资源获取即初始化(RAII)习惯用法,在第七章中,我们看到了擦除-移除习惯用法。

一些有用的 C++ 习惯用法

这不是一个笑话,但你实际上可以找到一个近 100(!)网上的 C++ 成语(WikiBooks:更多 C++ 成语;网址: https://en.wikibooks.org/wiki/More_C++_Idioms )。问题是,并不是所有这些习惯用法都有利于一个现代的、干净的 C++ 程序。它们有时非常复杂,甚至对于相当熟练的 C++ 开发人员来说也难以理解(例如,代数层次)。此外,随着 C++11 和后续标准的发布,一些习惯用法在很大程度上已经过时了。因此,我在这里只介绍一小部分,我认为它们很有趣,仍然有用。

永恒的力量

有时,拥有一旦创建就不能改变其状态的对象的类(也称为不可变类)是非常有利的(这实际上意味着不可变对象,因为准确地说,一个类只能由开发人员更改)。例如,不可变对象可以用作散列数据结构中的键值,因为键值在创建后不应改变。另一个已知的不可变的例子是其他语言中的 String 类,比如 C# 或 Java。

不可变类和对象的好处如下:

  • 默认情况下,不可变对象是线程安全的,所以如果几个线程或进程以不确定的方式访问这些对象,您不会有任何同步问题。因此,不变性使得创建可并行化的软件设计更加容易,因为对象之间没有冲突。
  • 不变性使得编写、使用和推理代码变得更加容易,因为类不变量(即一组必须始终为真的约束)在对象创建时就已建立,并且确保在对象的生存期内保持不变。

要在 C++ 中创建一个不可变的类,必须采取以下措施:

  • 该类的成员变量必须都是不可变的,也就是说,它们必须都是const(参见第四章中关于常量正确性的章节)。这意味着它们只能在构造函数中初始化一次,使用构造函数的成员初始化列表。
  • 操作方法不会更改调用它们的对象,但会返回一个状态已改变的类的新实例。原始对象不会改变。为了强调这一点,不应该有 setter,因为名称以set…()开头的成员函数会引起误解。不可变对象上没有什么可设置的。
  • 该类应标记为final。这不是一个硬性的规则,但是如果一个新类可以从一个声称不可变的类继承,就有可能绕过它的不变性。

下面是一个 C++ 中不可变类的例子:

#include "Identifier.h"

#include "Money.h"

#include <string>

#include <string_view>

class Employee final {

public:
  Employee(std::string_view forename,
    std::string_view surname,
    const Identifier& staffNumber,
    const Money& salary) noexcept :
    forename { forename },
    surname { surname },
    staffNumber { staffNumber },
    salary { salary } { }

  Identifier getStaffNumber() const noexcept {
    return staffNumber;
  }

  Money getSalary() const noexcept {
    return salary;
  }

  Employee changeSalary(const Money& newSalary) const noexcept {
    return Employee(forename, surname, staffNumber, newSalary);
  }

private:
  const std::string forename;
  const std::string surname;
  const Identifier  staffNumber;
  const Money       salary;
};

Listing 9-43.Employee is designed as an immutable class

替换失败不是错误(SFINAE)

事实上,替换失败不是一个错误(简称:SFINAE)不是一个真正的习惯用法,而是 C++ 编译器的一个特性。它已经是 C++98 标准的一部分,但是 C++11 增加了几个新特性。然而,它仍然被称为一种习惯用法,也是因为它以一种非常习惯的风格使用,特别是在模板库中,如 C++ 标准库或 Boost。

标准中的定义文本段落可以在 14.8.2 节关于模板参数推导中找到。在那里我们可以在 8 中读到下面的语句:

If a substitution leads to an invalid type or expression, the type deduction fails. An invalid type or expression of refers to a type or expression that will be formatted incorrectly if it is written with an alternative parameter. Only invalid types and expressions in the direct context of function types and their template parameter types will cause the derivation to fail. —— Programming language C++ [ISO11] standard

C++ 模板实例化错误时的错误消息,例如,带有错误模板参数的错误消息,可能会非常冗长和隐晦。SFINAE 是一种编程技术,可以确保模板参数替换失败不会产生令人讨厌的编译错误。简而言之,这意味着如果模板参数替换失败,编译器将继续搜索合适的模板,而不是因出错而中止。

下面是一个非常简单的例子,有两个重载的函数模板:

#include <iostream>

template <typename T>

void print(typename T::type) {
  std::cout << "Calling print(typename T::type)" << std::endl;
}

template <typename T>

void print(T) {
  std::cout << "Calling print(T)" << std::endl;
}

struct AStruct {
  using type = int;
};

int main() {
  print<AStruct>(42);
  print<int>(42);
  print(42);

  return 0;
}

Listing 9-44.SFINAE by example of two overloaded function templates

这个小例子在 stdout 上的输出将是:

Calling print(typename T::type)
Calling print(T)
Calling print(T)

可以看出,编译器使用第一个版本的print()进行第一次函数调用,使用第二个版本进行两次后续调用。并且这段代码在 C++98 中也是有效的。

嗯,但是 SFINAE 之前的 C++11 有几个缺点。关于在实际项目中使用这种技术的真正努力,上面这个非常简单的例子有点欺骗性。以这种方式在模板库中应用 SFINAE 导致了非常冗长和复杂的代码,难以理解。此外,它的标准化很差,有时是编译器特有的。

随着 C++11 的出现,引入了所谓的类型特征库,这一点我们在第七章中已经了解过了。尤其是元函数std::enable_if()(在头文件<type_traits>中定义),从 C++11 开始就有了,现在在 SFINAE 中扮演着核心角色。通过这个函数,我们可以根据类型特征从重载决策中获得一个有条件的“移除函数能力”。换句话说,例如,我们可以根据参数的类型选择一个函数的重载版本,如下所示:

#include <iostream>

#include <type_traits>

template <typename T>

void print(T var, typename std::enable_if<std::is_enum<T>::value, T>::type* = 0) {
  std::cout << "Calling overloaded print() for enumerations." << std::endl;
}

template <typename T>
void print(T var, typename std::enable_if<std::is_integral<T>::value, T>::type = 0) {
  std::cout << "Calling overloaded print() for integral types." << std::endl;
}

template <typename T>
void print(T var, typename std::enable_if<std::is_floating_point<T>::value, T>::type = 0) {
  std::cout << "Calling overloaded print() for floating point types." << std::endl;
}

template <typename T>
void print(const T& var, typename std::enable_if<std::is_class<T>::value, T>::type* = 0) {
  std::cout << "Calling overloaded print() for classes." << std::endl;
}

Listing 9-45.SFINAE by using function template std::enable_if<>

可以通过简单地用不同类型的参数调用重载函数模板来使用它们,如下所示:

enum Enumeration1 {
  Literal1,
  Literal2
};

enum class Enumeration2 : int {
  Literal1,
  Literal2
};

class Clazz { };

int main() {
  Enumeration1 enumVar1 { };
  print(enumVar1);

  Enumeration2 enumVar2 { };
  print(enumVar2);

  print(42);

  Clazz instance { };
  print(instance);

  print(42.0f);

  print(42.0);

  return 0;
}

Listing 9-46.Thanks to SFINAE, there is a matching print() function for arguments of different type

编译和执行后,我们在标准输出中看到以下结果:

Calling overloaded print() for enumerations.
Calling overloaded print() for enumerations.
Calling overloaded print() for integral types.
Calling overloaded print() for classes.
Calling overloaded print() for floating point types.
Calling overloaded print() for floating point types.

由于 C++11 版本的std::enable_if有点冗长,C++14 增加了一个别名叫做std::enable_if_t

复制和交换的习惯用法

在第五章的“预防胜于治疗”一节中,我们学习了异常安全保证的四个级别:无异常安全、基本异常安全、强异常安全和不抛出保证。类的成员函数应该始终保证的是基本的异常安全,因为这种异常安全级别通常很容易实现。

在第五章的“零的规则”一节中,我们已经了解到我们应该总是以这样一种方式设计类,使得编译器自动生成的特殊成员函数(复制构造函数、复制赋值操作符等等)。)自动做正确的事情。或者换句话说:当我们被迫提供一个非平凡的析构函数时,我们正在处理一个异常情况,它需要在对象的析构过程中进行特殊处理。因此,编译器生成的特殊成员函数不足以处理这种情况,我们必须自己实现它们。

然而,偶尔也不可避免地无法满足零规则,即一个开发人员必须自己实现特殊的成员函数。在这种情况下,创建重载赋值运算符的异常安全实现可能是一项具有挑战性的任务。在这种情况下,复制和交换的习惯用法是解决这个问题的好方法。

因此,这个习惯用法的意图如下:

Implement the copy assignment operator with strong exception security.

解释问题及其解决方案的最简单方法是举一个小例子。考虑以下类别:

#include <cstddef>

class Clazz final {

public:
  Clazz(const std::size_t size) : resourceToManage { new char[size] }, size { size } { }
  ∼Clazz() {
    delete [] resourceToManage;
  }

private:
  char* resourceToManage;
  std::size_t size;
};

Listing 9-47.A class that manages a resource that is allocated on the heap

当然,这个类只是为了演示的目的,不应该成为真实程序的一部分。

让我们假设我们想对类Clazz做以下事情:

int main() {
  Clazz instance1 { 1000 };
  Clazz instance2 { instance1 };
  return 0;
}

从第五章我们已经知道,编译器生成的复制构造函数在这里做了错误的事情:它只创建了字符指针resourceToManage的平面副本!

因此,我们必须提供自己的复制构造函数,如下所示:

#include <algorithm>

class Clazz final {

public:
  // ...
  Clazz(const Clazz& other) : Clazz { other.size } {
    std::copy(other.resourceToManage, other.resourceToManage + other.size, resourceToManage);
  }
  // ...
};

目前为止,一切顺利。现在,复制结构可以正常工作了。但是现在我们还需要一个复制赋值操作符。如果您不熟悉复制和交换的习惯用法,赋值运算符的实现可能如下所示:

#include <algorithm>

class Clazz final {

public:
  // ...
  Clazz& operator=(const Clazz& other) {
    if (&other == this) {
      return *this;
    }
    delete [] resourceToManage;
    resourceToManage = new char[other.size];
    std::copy(other.resourceToManage, other.resourceToManage + other.size, resourceToManage);
    size = other.size;
    return *this;
  }
  // ...
};

基本上,这个赋值操作符可以工作,但是它有几个缺点。例如,构造函数和析构函数代码在其中重复,这违反了 DRY 原则(见第三章)。此外,在开始时有一个自我分配检查。但是最大的缺点是我们不能保证异常安全。例如,如果new语句导致一个异常,那么对象可能会处于一种违反基本类不变量的奇怪状态。

现在,复制和交换的习惯用法开始发挥作用,也称为“创建临时和交换”!

为了更好地理解,我现在介绍全班同学:

#include <algorithm>

#include <cstddef>

class Clazz final {

public:
  Clazz(const std::size_t size) : resourceToManage { new char[size] }, size { size } { }

  ∼Clazz() {
    delete [] resourceToManage;
  }

  Clazz(const Clazz& other) : Clazz { other.size } {
    std::copy(other.resourceToManage, other.resourceToManage + other.size, resourceToManage);
  }

  Clazz& operator=(Clazz other) {
    swap(other);
    return *this;
  }

private:
  void swap(Clazz& other) noexcept {
    using std::swap;
    swap(resourceToManage, other.resourceToManage);
    swap(size, other.size);
  }

  char* resourceToManage;
  std::size_t size;
};

Listing 9-48.A much better implementation of an assignment operator using the copy-and-swap idiom

这里的诀窍是什么?让我们看看完全不同的赋值操作符。这不再有 const 引用(const Clazz& other)作为参数,而是一个普通的值参数(Clazz other)。这意味着当这个赋值操作符被调用时,首先调用Clazz的复制构造函数。反过来,复制构造函数调用为资源分配内存的默认构造函数。这正是我们想要的:我们需要一个other的临时副本!

现在我们来看看这个习惯用法的核心:私有成员函数Clazz::swap()的调用。在这个函数中,临时实例other的内容,也就是它的成员变量,与我们自己的类上下文(this)的相同成员变量的内容交换(“交换”)。这通过使用非投掷std::swap()功能(在标题<utility>中定义)来完成。在交换操作之后,临时对象other现在拥有以前由this对象拥有的资源,反之亦然。

此外,Clazz::swap()成员函数现在使得实现移动构造函数变得非常容易:

class Clazz {

public:
  // ...
  Clazz(Clazz&& other) noexcept {
    swap(other);
  }
  // ...
};

当然,一个好的类设计的主要目标应该是完全没有必要实现显式的复制构造函数和赋值操作符(零规则)。但是当你被迫这样做时,你应该记住复制和交换的习惯用法。

实施指南(PIMPL)

这一章的最后一节是关于一个成语的,这个成语有一个有趣的首字母缩略词 PIMPL。PIMPL 代表实施的指针;这个习语也被称为句柄体、编译防火墙或柴郡猫技术(柴郡猫是一个虚构的角色,一只咧嘴大笑的猫,来自刘易斯·卡罗尔的小说《爱丽丝漫游奇境记》)。顺便说一下,它与[Gamma95]中描述的桥接模式有一些相似之处。

PIMPL 的意图可以表述如下:

By relocating the internal class implementation details to a hidden implementation class, the compilation dependence on the internal class implementation details is removed, thus improving the compilation time.

让我们来看一下 Customer 类的摘录,这个类我们以前在很多例子中都见过:

#ifndef CUSTOMER_H_

#define CUSTOMER_H_

#include "Address.h"

#include "Identifier.h"

#include <string>

class Customer {

public:
  Customer();
  virtual ∼Customer() = default;
  std::string getFullName() const;
  void setShippingAddress(const Address& address);
  // ...

private:
  Identifier customerId;
  std::string forename;
  std::string surname;
  Address shippingAddress;
};

#endif /* CUSTOMER_H_ */

Listing 9-49.An excerpt from the content of header file Customer.h

让我们假设这是我们商业软件系统中的一个中心商业实体,它被许多其他类使用(#include "Customer.h")。当这个头文件改变时,任何使用该文件的文件都需要重新编译,即使只添加、重命名了一个私有成员变量。

为了将这些重新编译减少到最低限度,PIMPL 的习惯用法开始发挥作用。

首先我们如下重建类Customer的类接口:

#ifndef CUSTOMER_H_

#define CUSTOMER_H_

#include <memory>

#include <string>

class Address;

class Customer {

public:
  Customer();
  virtual ∼Customer();
  std::string getFullName() const;
  void setShippingAddress(const Address& address);
  // ...

private:
  class Impl;
  std::unique_ptr<Impl> impl;
};

#endif /* CUSTOMER_H_ */

Listing 9-50.The altered header file Customer.h

很明显,所有以前的私有成员变量,以及它们相关的 include 指令,现在都消失了。取而代之的是一个名为Impl的类的前向声明,以及这个前向声明类的std::unique_ptr<T>

现在,让我们来看看核心绑定实现文件:

#include "Customer.h"

#include "Address.h"

#include "Identifier.h"

class Customer::Impl final {

public:
  std::string getFullName() const;
  void setShippingAddress(const Address& address);

private:
  Identifier customerId;
  std::string forename;
  std::string surname;
  Address shippingAddress;
};

std::string Customer::Impl::getFullName() const {
  return forename + " " + surname;
}

void Customer::Impl::setShippingAddress(const Address& address) {
  shippingAddress = address;
}

// Implementation of class Customer starts here...

Customer::Customer() : impl { std::make_unique<Customer::Impl>() } { }

Customer::∼Customer() = default;

std::string Customer::getFullName() const {
  return impl->getFullName();
}

void Customer::setShippingAddress(const Address& address) {
  impl->setShippingAddress(address);
}

Listing 9-51.The content of file Customer.cpp

在实现文件的上半部分(直到源代码注释),我们可以看到类Customer::Impl。在这个类中,所有的东西现在都被重新定位了,而前者是由类Customer直接完成的。这里我们也找到了所有的成员变量。

在下半部分(从注释开始),我们现在找到了类Customer的实现。构造函数创建了一个Customer::Impl的实例,并将它保存在智能指针impl中。至于其余的,类Customer的 API 的任何调用都被委托给内部实现对象。

如果现在必须在Customer::Impl的内部实现中改变一些东西,编译器必须只编译Customer.h/Customer.cpp,然后链接器可以立即开始工作。这种改变对外界没有任何影响,并且避免了对几乎整个项目进行耗时的编译。

posted @ 2024-08-05 14:01  绝不原创的飞龙  阅读(14)  评论(0编辑  收藏  举报