原文:Clean C++
一、介绍
如何做和让别人做同样重要。爱德华多·纳穆尔
许多软件开发项目处于糟糕的状况,有些甚至可能处于严重的危机之中,这仍然是一个令人悲伤的现实。原因是多方面的。例如,一些项目因为糟糕的项目管理而受到影响。在其他项目中,条件和需求是不断变化的,但是过程不支持这种高动态的环境。
在一些项目中有纯粹的技术原因:他们的代码质量很差。这并不一定意味着代码不能正常工作。它的外部质量,由质量保证部门使用黑盒、用户或验收测试来测量,可能相当高。它可以毫无怨言的通过 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 上找到,网址是:
您可以通过以下命令使用 Git 来检查代码:
$> git clone
https://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 中描绘的是我的版本。
图 2-1。
The Test Pyramid
当然,金字塔的形状不是巧合。其背后的信息是,你应该比其他类型的测试有更多的低级单元测试(大约 100%的代码覆盖率)。但这是为什么呢?
经验表明,关于测试的实现和维护的总成本正在向金字塔的顶端增加。大型系统测试和手工用户验收测试通常很复杂,经常需要大量的组织工作,并且不容易自动化。例如,自动化的 UI 测试很难编写,通常很脆弱,并且相对较慢。因此,这些测试通常是手动执行的,这适合于客户批准(验收测试)和 QA 的常规探索性测试,但是对于开发期间的日常使用来说太耗时和昂贵了。
此外,大型系统测试,或者 UI 驱动的测试,完全不适合检查整个系统中所有可能的执行路径。软件系统中有许多代码处理可选路径、异常和错误处理、横切关注点(安全性、事务处理、日志记录……)以及其他所需的辅助功能,但通常无法通过普通用户界面实现。
最重要的是,如果系统级的测试失败,错误的确切原因可能很难定位。系统测试通常基于系统的用例。在用例的执行过程中,会涉及到许多组件。这意味着要执行数百甚至数千行代码。哪一行是测试失败的原因?这个问题通常不容易回答,需要进行耗时且昂贵的分析。
不幸的是,在一些软件开发项目中,你会发现退化的测试金字塔,如图 2-2 所示。在这样的项目中,巨大的努力被投入到更高层次的测试中,而基本的单元测试被忽略了(冰淇淋甜筒反模式)。在极端情况下,它们完全消失了(杯形蛋糕反模式)。
图 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 )。
图 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
是具体道的抽象基类,在这里是CustomerDAO
和FakeDAOForTest
。最后一个是所谓的伪对象,它只不过是一个测试替身(参见本章后面关于测试替身(伪对象)的部分)。它旨在取代真正的 DAO,因为我们不想测试它,并且我们不想在测试期间保存客户(记住我关于数据库的建议)。使用两个 DAO 中的哪一个由布尔数据成员inTestMode
控制。
这个代码可以工作,但是这个解决方案有几个缺点。
首先,我们的生产代码充斥着测试代码。虽然乍看起来并不引人注目,但它会增加复杂性并降低可读性。我们需要一个额外的成员来区分我们系统的测试模式和生产使用。这个布尔成员与客户无关,更不用说与我们系统的域有关。很容易想象,在我们的系统中,许多类都需要这样的成员。
此外,我们的类Customer
依赖于CustomerDAO
和FakeDAOForTest
。您可以在源代码顶部的包含列表中看到它。这意味着测试假人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 所示。
图 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 开始,看两个内聚性较弱的例子。
图 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 )。
图 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 描绘了这样一种情况。
图 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 所示。
图 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 所示。
图 3-5。
Loosely coupled Switch and Lamp via an interface
这种设计的优点是显而易见的。完全独立于受其控制的具体类。此外,Switch
可以通过提供一个实现Switchable
接口的测试替身来独立测试。你想控制风扇而不是灯?没问题:这个设计可以扩展。创建一个类Fan
或者其他表示实现接口Switchable
的电气设备的类,如图 3-6 所示。
图 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
多变的命名惯例经常会变成一场宗教战争,但是我非常确定大家都同意num
、flag
、list
和data
是非常糟糕的名字。什么是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
。通常,这个组件由更小的组件或类组成。例如,这些较小的模块之一可以负责折扣的计算。另一个模块可以负责创建发票行项目。因此,这些模块的好名字应该是DiscountCalculator
和LineItemFactory
。如果我们现在深入分解层次,组件、类以及函数或方法的标识符变得越来越具体、冗长,因此也越来越长。例如,类中最深层次的一个小方法可以有一个非常详细和冗长的名字,比如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 时,它可能是有用的
现代和复杂的开发工具今天很好地支持了开发者,并且显示了变量的类型和范围。没有更好的理由在名称中对变量的类型进行编码。远非如此,这样的前缀会妨碍代码的可读性。
最坏的情况是,在开发过程中,变量的类型在没有修改其名称前缀的情况下发生了改变。换句话说:前缀往往会变成谎言,正如你在上面的例子中看到的最后一个变量。那真的很糟糕!
另一个问题是,在支持多态性的面向对象语言中,前缀不容易指定,或者前缀甚至可能令人费解。哪个匈牙利前缀适合可以是整数也可以是双精度的多态变量?idX
?diX
?如何为一个实例化的 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.txt
和copyright.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_FILE
、FT_BASIC_SOURCE
和FT_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]
你现在可能会问:但是我怎么知道一个函数做了太多的事情?以下是一些可能的迹象:
- 这个函数很大,也就是说,它包含许多行代码(参见下面关于小函数的部分)。
- 您试图为函数找到一个有意义、有表现力的名称,准确描述其用途,但您无法避免使用连词,如“and”或“or”,来构建名称。(另请参见以下关于名称的章节之一。)
- 使用空行将函数体垂直分成代表后续步骤的组。通常这些群组还会被引入类似标题的评论。
- 圈复杂度很高。该函数包含许多“if”、“else”或“switch-case”语句。
- 该函数有许多参数(参见本章后面关于参数和返回值的部分),尤其是一个或多个类型为
bool
的标志参数。
让他们变小
关于函数的一个中心问题是:函数的最大长度应该是多少?
对于函数的长度,有许多经验法则和启发法。例如,有些人说函数应该垂直显示在屏幕上。好吧,乍一看,这似乎是一个不错的规则。如果一个功能适合屏幕,开发者就不需要滚动。另一方面,我的屏幕高度真的应该决定一个功能的最大尺寸吗?屏幕高度不尽相同。所以,我个人认为这不是一个好的规则。以下是我对这个话题的建议:
函数应该非常小。理想情况下 4-5 行,最多 12-15 行,但不能更多。
恐慌!我已经可以听到抗议声:“许多微小的功能?你说真的吗?!"
是的,我是认真的。正如 Robert C. Martin 在他的书《干净的代码》[Martin09]中所写的:函数应该很小,而且应该更小。
大型函数通常具有很高的复杂性。开发人员通常不能一眼看出这样的函数是做什么的。如果一个功能太大,它通常会有太多的职责(见上一节),而且不能做一件事情。功能越大,越难理解和维护。这种函数通常包含许多嵌套的决策(if
、else
、switch
)和循环。这也被称为高圈复杂度。
当然,和任何规则一样,很少有合理的例外。例如,一个包含一个大的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 编写的代码示例,所以当他谈到函数时,他实际上指的是类的方法。我们不得不考虑对象的方法还有一个额外的隐式“参数”:this
!this
指针代表执行的上下文。在this
的帮助下,成员函数可以访问其类的属性,读取或操作它们。换句话说:从成员函数的角度来看,类的属性只是全局变量。因此,马丁法则似乎是一个合适的指导方针,但我认为它主要适用于面向对象的设计。
但是为什么太多的争论是不好的呢?
首先,函数的参数列表中的每个参数都可能导致一个依赖,除了标准内置类型的参数,如int
或double
。如果在函数的参数列表中使用复杂类型(例如,类),则代码依赖于该类型。必须包含包含所用类型的头文件。
此外,每个参数都必须在函数内部的某个地方进行处理(否则,该参数就是不必要的,应该立即删除)。三个参数可以导致一个相对复杂的函数,正如我们从 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)。
并且几乎没有减少参数数量的可能性。您可以将x
、y
、nWidth
和nHeight
组合成一个名为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
标志参数的基本问题是通过函数引入了两条(有时甚至更多)路径。这种参数通常在函数内部的某个地方用if
或switch/case
语句进行评估。它用于决定是否采取某个动作。这意味着函数没有完全正确地做一件事,正如它应该做的那样(参见本章前面的“仅此一件事”一节)。这是一种弱衔接的情况(见第三章),违反了单一责任原则(见第六章关于对象定位)。
如果你在代码的某个地方看到函数调用,如果不详细分析函数Billing::createInvoice()
,你就不知道true
或false
到底是什么意思:
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;
}
接收NULL
或nullptr
(从这里开始,为了简单起见,我在下面的文本中只使用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;
另一个用途是防止传递给函数的参数发生变异。因为有几种变体,所以经常会导致混淆。以下是一些例子:
- 指针
car
指向类型Car
的常量对象,即Car
对象(“指针对象”)不能被修改。 - 指针
car
是一个类型为Car
的常量指针,也就是说,你可以修改Car
对象,但是你不能修改指针(例如,给它分配一个Car
的新实例)。 - 在这种情况下,指针和指针对象(
Car
对象)都不能被修改。 - 参数
message
通过 const 引用传递给函数,即被引用的字符串变量不允许在函数内部改变。 - 这只是 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::string
或std::wstring
(都在头文件<string>
中定义)。事实上,两者都是类模板std::basic_string<T>
上的类型定义,并且是这样定义的:
typedef basic_string<char> string;
typedef basic_string<wchar_t> wstring;
要创建这样的字符串,必须实例化这两个模板之一的对象,例如,使用它们的初始化构造函数:
std::string name("Stephan");
与此相比,所谓的 C 风格字符串只是一个以所谓的零终止符(有时也称为空终止符)结尾的字符数组(类型char
或wchar_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::string
和std::wstring
的对象提供了许多优势:
- C++ 字符串对象自己管理它们的内存,所以你可以很容易地复制、创建和销毁它们。这意味着它们将您从管理字符串数据的生命周期中解放出来,使用 C 风格的字符数组时,这可能是一项棘手且令人生畏的任务。
- 它们是可变的。可以通过各种方式轻松操作字符串:添加字符串或单个字符、连接字符串、替换字符串的一部分等。
- C++ 字符串提供了一个方便的迭代器接口。与所有其他标准库容器类型一样,
std::string
和std::wstring
分别允许您迭代它们的元素(即,它们的字符)。这也意味着在标题<algorithm>
中定义的所有合适的算法都可以应用于字符串。 - C++ 字符串与 C++ I/O 流(例如,
ostream
、stringstream
、fstream
等)完美地协同工作。)以便您可以轻松利用所有这些有用的流工具。 - 从 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()
分别适用于所有整型或浮点型,如int
、long
、long long
、unsigned int
、float
、double
等。但是这个简单的转换助手的一个主要缺点是在某些情况下不准确。
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
的所有结构组件都被写入函数内部的输出流中。这是可能的,因为类UniqueIdentifier
、InvoiceRecipient
和InvoiceLineItem
对于输出流也有它们自己的插入操作符函数(这里没有显示)。为了打印 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++ 程序的类型转换的所有建议:
- 在任何情况下都要尽量避免类型转换(强制转换)。相反,请尝试消除迫使您使用转换的底层设计错误。
- 如果显式类型转换不可避免,请只使用 C++ 风格的强制转换(
static_cast<>
或const_cast<>
),因为这些强制转换会被编译器检查。永远不要使用旧的和不好的 C 风格造型。 - 注意
dynamic_cast<>
也不应该被使用,因为它被认为是糟糕的设计。对一个dynamic_cast<>
的需求是一个可靠的指示,表明专门化层次结构中有问题(这个主题将在关于面向对象的第六章中更深入)。 - 在任何情况下,都不要使用
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::min
或std::max
(参见下一章中关于<algorithm>
标题的部分):
#include <algorithm>
// ...
int maximum = std::max(12, value++);