一、定义案例研究
1.1 简要介绍
这本书是关于程序设计的。然而,与这个主题的许多书籍不同,这本书通过探索而不是通过指导来教授设计。一般来说,大多数作者在撰写设计的某些方面时,会建立他们想要传达的原则,将这些原则抽象出来,然后给出支持当前观点的例子。这不是这样一本书。更确切地说,这本书定义了一个需要解决的实际问题,并继续详细研究它的解决方案。也就是说,我没有决定一个主题并创造琐碎的例子来支持它的教学,而是定义了一个难题,然后让这个问题的解决方案决定应该讨论什么主题。
有趣的是,前面的方法正是我告诉某人而不是去学习一门学科的方法。我总是强调,人们应该首先学习广泛的基本原理,然后应用这些原理来解决问题。然而,这不是一本旨在教授设计原则的书。相反,这本书是为那些已经知道基本原理,但希望加深实践知识的人准备的。这本书旨在教人们从头到尾设计和实现一个现实的,尽管很小的程序。这个过程不仅仅包括了解设计的元素。它包括理解何时以及如何使用你所知道的,理解如何在看似等同的方法之间做出决定,以及理解各种决定的长期影响。这本书在数据结构、算法、设计模式或 C++ 最佳实践的覆盖面上并不全面;有大量的书籍涵盖了这些主题。这是一本关于学习如何应用这些知识来编写有组织的、内聚的、合理的、有目的的和实用的代码的书。换句话说,这本书是关于学习编写既能完成现在的工作(开发)又能让其他人在未来继续完成工作(维护)的代码。我称之为实用设计。
为了探索实用的设计,我们需要一个案例研究。理想情况下,案例研究问题应该是
-
大到不仅仅是微不足道的
-
小到可以处理
-
熟悉到不需要特定领域的专业知识
-
有趣到足以让读者在整个阅读过程中保持注意力
在考虑了前面的标准之后,我决定选择一个基于栈的反向波兰符号(RPN)计算器作为案例研究。计算器要求的细节将在下面定义。我相信一个全功能计算器的代码足够重要,以至于对其设计的详细研究提供了足以涵盖一本书的材料。然而这个项目足够小,所以这本书可以有一个合理的长度。当然,专业领域的专业知识不是必需的。我怀疑这本书的每个读者都使用过计算器,并且非常熟悉它的基本功能。最后,我希望制作计算器 RPN 能提供一个合适的扭转来避免无聊。
1.2 关于需求的几句话
不管多大,多小,所有的程序都有要求。需求是程序必须遵循的那些特性,无论是显式的还是隐式的,功能性的还是非功能性的。整本书都是关于收集和管理软件需求的(例如,参见[36]或[28])。通常,尽管尽了最大的努力,实际上不可能预先收集所有的需求。有时,所需的努力在经济上是不可行的。有时,领域专家忽略了对他们来说似乎是显而易见的需求,他们只是忽略了将他们所有的需求与开发团队联系起来。有时,只有在程序开始成形后,需求才变得明显。有时,客户并没有很好地理解他们自己的需求,无法向开发团队清楚地表达出来。虽然使用敏捷开发方法可以缓解一些困境,但事实仍然是,许多设计决策,其中一些可能具有深远的影响,必须在了解所有需求之前做出。
在本书中,我们不会学习收集需求的技术;相反,我们的需求被简单地提前放弃了。好吧,他们中的大部分会被提前放弃。一些需求已经被明确地保留到后面的章节,这样我们可以研究我们的设计如何改变来适应未知的未来扩展。当然,人们可以公正地争辩说,由于作者知道需求将如何变化,最初的设计将正确地“预测”不可预见的特性。虽然这种批评是公平的,但我仍然认为设计决策背后的思维过程和讨论仍然是相关的。作为一名软件架构师,你工作的一部分将是预测未来的请求。尽管任何请求都是可能的,但是一开始就包含太多的灵活性是不经济的。为未来的扩展而设计必须始终被认为是一种权衡,即预先明确适应可扩展性的成本差异与以后需要更改时修改代码的成本差异。设计应该在简单性和灵活性之间的哪个范围内,最终必须根据功能请求实现的可能性和添加新功能的可行性来衡量,如果在开始时没有考虑新功能的加入。
1.3 反向波兰符号(RPN)
我认为任何阅读这本书的人都熟悉计算器的典型操作。然而,除非您从小使用惠普计算器,否则您可能不熟悉基于栈的 RPN 计算器的工作方式(如果您不熟悉栈的工作方式,请参见[10])。简单地说,输入的数字被推送到一个栈上,对已经在栈上的数字执行操作。二元运算符(如加法)从栈中弹出前两个数字,将这两个数字相加,然后将结果推送到栈上。一元运算符(如正弦函数)从栈顶部弹出一个数字,将该数字用作操作数,并将结果推送到栈上。对于那些熟悉基本编译器术语的人来说,RPN 充当操作的后缀符号(参见[4]对后缀符号的详细讨论)。下面的列表描述了我对逆波兰符号相对于传统语法的一些优势的看法:
-
所有的运算都可以用无括号的方式表达。
-
可以同时显示多个输入和输出。
-
大型计算可以被平凡地分解成多个简单的操作。
-
中间结果可以轻松地保留和重用。
虽然 RPN 一开始可能看起来非常笨拙,但是一旦你习惯了它,当你执行比简单算术更复杂的任务时,你会诅咒每一个不使用它的计算器。
为了确保 RPN 计算器的操作清晰明了,我们来看一个简短的例子。假设我们希望评估以下表达式:
)
在一个典型的非 RPN 计算器上,我们会键入((4+7)∫3+2)/7,然后按=键。在 RPN 计算器上,我们应该键入 4 7+3∫2+7*/*,其中每个数字后面都有一个 enter 命令,以便将输入推送到栈上。注意,对于许多计算器来说,为了减少按键输入,像+这样的操作也可以隐式地输入栈上的前一个数字。图 1-1 显示了在 RPN 计算器上逐步执行的上述计算。
图 1-1
在 RPN 计算器上执行的示例计算显示了中间步骤。与直觉相反,栈的顶部在屏幕的底部
1.4 计算器的要求
一旦你理解了逆向波兰符号的本质,计算器的其余功能应该从需求描述中变得简单明了。如果 RPN 仍然不清楚,我建议在继续之前花一些时间澄清这个概念。考虑到这一点,计算器的要求现在定义如下:
-
计算器将基于栈;栈大小不应该是硬编码的。
-
计算器将使用 RPN 来执行运算。
-
计算器将只对浮点数进行运算;应该实现用于输入数字(包括科学符号)的技术。
-
计算器将具有撤销和重做操作的能力;撤销/重做栈的大小在概念上应该是无限的。
-
计算器将能够交换栈顶的两个元素。
-
计算器将能够从栈顶部删除一个元素。
-
计算器将能够清除整个栈。
-
计算器将能够从栈顶复制元素。
-
计算器将能够从栈顶开始对元素求反。
-
计算器将实现四种基本的算术运算:加、减、乘、除。不允许除以 0。
-
计算器将实现三个基本的三角函数及其逆函数:sin、cos、tan、arcsin、arccos 和 arctan。三角函数的参数将以弧度给出。
-
计算器将实现yxT5】和
的功能。
-
计算器将实现一个运行时插件结构来扩展计算器可以执行的操作。
-
该计算器将实现命令行界面(CLI)和图形用户界面(GUI)。
-
计算器不支持无穷大或虚数。
-
计算器将是容错的(即,如果用户输入错误,它不会崩溃),但不需要处理浮点异常。
既然计算器有了要求,它就应该有一个名字。我选择称这个计算器为 pdCalc,是实用设计计算器的缩写。请接受我对命名创意不足的道歉。
本书的其余部分将详细考察满足上述要求的计算器的完整设计。除了描述为最终设计做出的决策,我们还将讨论备选方案,以了解为什么做出最终决策,以及不同决策可能产生的后果。我会注意到,本书中呈现的最终设计并不是唯一会满足需求的设计,它甚至可能不是满足需求的最佳设计。我鼓励雄心勃勃的读者尝试不同的设计,扩展计算器以满足他们自己的需求和兴趣。
1.5 源代码
在本书的整个文本中,我们将在设计计算器时检查大量代码片段。这些代码片段大部分直接取自 pdCalc 的 GitHub 源代码库(参见附录 A 中下载源代码的说明)。我将指出文本中的代码和存储库中的代码之间的任何显著差异。偶尔,代码片段由小的、人为的例子组成。这些代码片段不是 pdCalc 的源代码库的一部分。所有的代码都可以在 GPL 版本 3 [12]下获得。我强烈建议您尝试源代码,并以您认为合适的任何方式进行修改。
为了构建 pdCalc,您需要访问兼容 C++20 的编译器、Qt(版本 5 或 6)和 CMake。为了不引入额外的依赖,单元测试是用 Qt 的 QtTest 来执行的。在编写这个版本的时候,微软的 Visual C++ (MSVC)是唯一一个具有足够的 C++20 兼容性来构建 pdCalc 的编译器。希望 GCC 和 clang 能很快达到 C++20 的成熟度。然而,由于 GCC 或 clang 无法构建 pdCalc,我只能使用 MSVC 在 Windows 中构建和测试该程序。然而,随着更多的编译器达到足够的 C++20 成熟度,代码也应该在其他系统上构建和执行,而只需很少或不需要修改源代码。为了移植到不同的平台,对 CMake 项目文件进行一些调整是必要的,尽管我至少提供了一些钩子来帮助人们使用 GCC 或 clang 开始使用 Linux。因为我预计本书的读者倾向于有多年经验的开发人员,所以我怀疑从源代码构建代码将是一项相当琐碎的任务。但是,为了完整起见,我在附录 a 中包含了构建指南。此外,我还包含了附录 B 来解释 pdCalc 的源代码、库和可执行文件的组织。虽然这两个附录出现在本书的末尾,但是如果您打算在阅读文本的同时构建 pdCalc 并探索其完整实现,您可能希望先阅读它们。
二、分解
软件是复杂的,是人类有史以来最复杂的努力之一。当您第一次阅读大型编程项目的需求文档时,您可能会感到不知所措。这是意料之中的。任务是压倒性的!由于这个原因,大型编程项目通常从分析开始。
项目的分析阶段包括探索问题领域的时间,以便完全理解问题,阐明需求,并解决客户和开发人员领域之间的任何模糊之处。如果没有完全理解问题,作为架构师或开发人员,您绝对没有机会开发出可维护的设计。然而,对于本书选择的案例研究,领域应该是熟悉的(如果不熟悉,您可能希望在这里停下来参与分析练习)。因此,我们跳过一个正式的、独立的分析阶段。也就是说,分析的各个方面永远不能完全跳过,我们将在设计的构建过程中探索几种分析技术。这种分析和设计的有意耦合强调了这两种活动之间的相互作用,以证明即使对于最简单的问题领域,产生一个好的设计也需要一些分析问题的正式技术。
作为软件设计者,我们解决固有问题复杂性的最重要的技术之一是层次分解。大多数人倾向于以两种方式分解问题:自顶向下或自底向上。自上而下的方法首先着眼于全局,然后细分问题,直到达到最底层。在软件设计中,绝对的最底层是独立的功能实现。然而,自顶向下的设计可能会在实现之前停止,并通过设计对象及其公共接口来结束。自下而上的方法将从单个功能或对象级别开始,反复组合组件,直到最终包含整个设计。
在我们的案例研究中,自顶向下和自底向上的方法将在设计的不同阶段使用。我发现以自顶向下的方式开始分解是可行的,直到定义了大量模块及其接口,然后自底向上实际设计这些模块。在处理我们的计算器的分解之前,让我们首先检查一个好的分解的元素。
2.1 良好的要素分解
什么使得分解是好的?显然,我们可以随机地将功能分成不同的模块,并将完全不相关的组件分组。以计算器为例,我们可以将算术运算符和 GUI 放在一个模块中,而将三角函数和栈以及错误处理放在另一个模块中。这是一个分解,只是不太有用。
一般来说,一个好的设计应该展示模块化、封装性、内聚性和低耦合性。许多开发人员已经在面向对象设计的环境中看到了许多好的分解原则。毕竟,将代码分解成对象本身就是一个分解过程。让我们首先在一个抽象的上下文中检查这些原则。随后,我们将通过将这些原则应用于 pdCalc 来进行讨论。
模块化,或者说将组件分解成独立交互的部分(模块)是很重要的,原因有几个。首先,它立即允许人们将一个大的、复杂的问题分割成多个更小的、更易处理的部分。虽然试图一次实现整个计算器的代码很困难,但实现一个独立运行的栈是非常合理的。其次,一旦组件被分成不同的模块,就可以定义测试来验证单个模块,而不是要求在集成测试开始之前完成整个程序。第三,对于大型项目,如果定义了具有清晰边界和接口的模块,开发工作可以在多个程序员(或程序员团队)之间分配,防止他们因为需要修改相同的源文件而不断干扰彼此的进度。
好的设计、封装、内聚和低耦合的其余原则都描述了模块应该拥有的特征。基本上,它们防止意大利面条代码。封装或信息隐藏是指这样一种思想,即一旦定义了一个模块,它的内部实现(数据结构和算法)对其他模块是隐藏的。相应地,一个模块不应该利用任何其他模块的私有实现。这并不是说模块之间不应该相互作用。相反,封装坚持模块之间只能通过明确定义的,最好是有限的接口进行交互。这种截然不同的分离确保内部模块实现可以独立修改,而不必担心破坏外部的相关代码,前提是接口保持固定,并且满足接口保证的契约。
内聚指的是模块内部的代码应该是自洽的,或者顾名思义,是内聚的。也就是说,一个模块中的所有代码在逻辑上应该组合在一起。回到我们糟糕的计算器设计的例子,一个混合了算术代码和用户界面代码的模块会缺乏内聚力。这两个概念之间没有逻辑联系(除了它们都是计算器的组件)。虽然像我们的计算器这样的小代码,如果缺乏内聚性,也不会完全无法理解,但一般来说,一个大的、无内聚性的代码库是很难理解、维护和扩展的。
差的内聚性可以表现为两种方式中的一种:不应该在一起的代码被塞在一起,或者应该在一起的代码被分开。在第一种情况下,代码功能几乎不可能分解成易于管理的抽象,因为逻辑子组件之间不存在明确的界限。在后一种情况下,阅读或调试不熟悉的代码(尤其是第一次)可能会非常令人沮丧,因为代码的典型执行路径以看似随机的方式从一个文件跳到另一个文件。任何一种表现都是适得其反的,因此我们更喜欢内聚的代码。
最后,我们研究耦合。耦合代表组件之间的相互联系,无论是功能耦合还是数据耦合。当一个模块的逻辑流需要调用另一个模块来完成其动作时,就会发生功能耦合。相反,数据耦合是指数据在各个模块之间通过直接共享(例如,一个或多个模块指向某组共享数据)或通过数据传递(例如,一个模块将指向内部数据结构的指针返回给另一个模块)来共享。主张零耦合显然是荒谬的,因为这种状态意味着任何模块都不能以任何方式与任何其他模块进行通信。然而,在好的设计中,我们确实努力实现低耦合。低应该低到什么程度?圆滑的回答是尽可能低,同时仍然保持必要的功能。事实上,在不使代码复杂化的情况下最小化耦合是一项通过经验获得的技能。与封装一样,低耦合是通过确保模块仅通过定义明确的有限接口相互通信来实现的。高度耦合的代码很难维护,因为一个模块设计中的微小变化可能会导致许多看似不相关的模块发生不可预见的级联变化。注意,封装保护模块 A 免受模块 B 内部实现变化的影响,而低耦合保护模块 A 免受模块 B 接口变化的影响。
2.2 选择架构
虽然现在很容易遵循我们前面的指导方针,简单地开始将我们的计算器分解成看起来合理的组成部分,但最好先看看别人是否已经解决了我们的问题。因为类似的问题在编程中经常出现,所以软件架构师创建了一个解决这些问题的模板目录;这些原型被称为模式。模式通常有多种。本书中将要探讨的两类模式是设计模式[11]和架构模式。
设计模式是概念模板,用于解决软件设计过程中出现的类似问题;它们通常适用于地方决策。在计算器的详细设计过程中,我们会在本书中反复遇到设计模式。然而,我们的第一个顶级分解需要一个全局范围的模式,它将定义总体设计策略,或者软件架构。这种模式自然被称为架构模式。
架构模式在概念上类似于设计模式;这两者的主要区别在于它们的适用范围。设计模式通常应用于特定的类或相关类的集合,而架构模式通常概述整个软件系统的设计。请注意,我指的是软件系统而不是程序,因为架构模式可以超越简单的程序边界,包括硬件接口、网络、安全、数据库、多个独立程序的耦合等。在现代的云部署解决方案中,整个系统的复杂架构模式非常普遍。
我们案例研究中特别感兴趣的两种架构模式是多层架构和模型-视图-控制器(MVC)架构。在将这两种模式应用到 pdCalc 之前,我们将抽象地研究它们。架构模式在我们案例研究中的成功应用将代表计算器的第一级分解。
多层架构
在多层或 n 层体系结构中,组件按层顺序排列。通过相邻层的通信是双向的,但是不相邻的层不允许直接通信。图 2-1 描述了一个 n 层架构。
图 2-1
箭头指示通信的多层架构
多层架构最常见的形式是三层架构。第一层是表示层,由所有用户界面代码组成。第二层是逻辑层,它捕获应用程序的所谓业务逻辑。第三层是数据层,顾名思义,它封装了系统的数据。通常,三层体系结构被用作一个简单的企业级平台,其中每一层不仅可以表示不同的本地流程,还可能表示在不同机器上运行的不同流程。在这样的系统中,表示层将是客户端界面,无论它是传统的桌面应用程序还是基于浏览器的界面。程序的逻辑层可以运行在应用程序的客户端或服务器端,或者可能同时运行在两者上。最后,数据层将由可以在本地或远程运行的数据库来表示。然而,正如我们将在 pdCalc 中看到的,三层架构也可以应用于单个桌面应用程序。
让我们检查一下三层架构如何遵守我们的一般分解原则。首先,在分解的最高层,架构是模块化的。至少有三个模块,每层一个。然而,三层架构并不排除在每一层存在多个模块。如果系统足够大,每个主要模块将保证细分。其次,这种体系结构鼓励封装,至少在层间是这样。虽然人们可以愚蠢地设计一个三层架构,其中相邻层访问相邻层的私有方法,但这样的设计是违反直觉的,而且非常脆弱。也就是说,在各层共存于同一个进程空间的应用程序中,各层很容易纠缠在一起,必须小心确保不会出现这种情况。这种分离是通过明确的界面清晰地描绘每一层来实现的。第三,三层架构具有内聚性。体系结构的每一层都有不同的任务,这些任务不会与其他层的任务混合在一起。最后,三层架构作为有限耦合的一个例子确实很出色。通过清晰定义的接口分离每一层,每一层都可以独立于其他层进行更改。对于必须在多个平台上执行的应用程序(只有表示层会随平台而变化)或在其生命周期中经历给定层的不可预见的替换的应用程序(例如,由于可伸缩性问题,必须更改数据库),此功能尤其重要。
模型-视图-控制器(MVC)架构
在模型-视图-控制器(MVC)架构中,组件被分解成三个不同的元素,分别恰当地命名为模型、视图和控制器。模型抽象领域数据,视图抽象用户界面,控制器管理模型和视图之间的交互。通常,MVC 模式应用于框架级别的单个 GUI 部件,其设计目标是在多个不同视图可能与相同数据相关联的情况下,将数据与用户界面分离。例如,考虑一个日程安排应用程序,要求该应用程序必须能够存储约会的日期和时间,但是用户可以在可以按日、周或月查看的日历中查看这些约会。应用 MVC,约会数据将由一个模型模块(可能是面向对象框架中的一个类)抽象,每种日历样式将由一个不同的视图(可能是三个独立的类)抽象。将引入一个控制器来处理视图生成的用户事件,并操纵模型中的数据。
乍一看,MVC 似乎与三层架构没有什么不同,模型取代了数据层,视图取代了表示层,控制器取代了业务逻辑层。然而,这两种架构模式在交互模式上是不同的。在三层体系结构中,各层之间的通信是严格线性的。也就是说,表示层和数据层只与逻辑层进行双向通信,而不会相互通信。在 MVC 中,通信是三角形的。虽然不同的 MVC 实现在确切的通信模式上有所不同,但图 2-2 中描述了一个典型的实现。在这个图中,视图既可以生成由控制器处理的事件,也可以直接从模型中获取要显示的数据。控制器处理来自视图的事件,但是它也可以直接操作模型或控制器。最后,视图或控制器可以直接作用于模型,但是它也可以生成由视图处理的事件。典型的这种事件是状态改变事件,该事件将导致视图更新其对用户的呈现。
图 2-2
用箭头指示通信的 MVC 架构。实线表示直接交流。虚线表示间接沟通(例如,通过事件)[38]
正如我们对三层架构所做的那样,现在让我们来看看 MVC 是如何遵循我们的一般分解原则的。首先,MVC 架构通常至少分为三个模块:模型、视图和控制器。然而,与三层体系结构一样,更大的系统将接纳更多的模块,因为每个模型、视图和控制器都需要细分。其次,这种架构也鼓励封装。模型、视图和控制器应该只通过明确定义的接口相互交互,其中事件和事件处理被定义为接口的一部分。第三,MVC 架构具有内聚性。每个组件都有不同的、定义明确的任务。最后,我们问 MVC 架构是否是松散耦合的。通过检查,这种架构模式比三层架构耦合得更紧密,因为表示层和数据层允许有直接的依赖关系。在实践中,这些依赖性通常通过松散耦合的事件处理或抽象基类的多态性来限制。然而,这种增加的耦合通常会将 MVC 模式转移到一个内存空间中的应用程序。这一限制与三层架构的灵活性形成了鲜明对比,三层架构可能会将应用程序跨越多个内存空间。
2.2.3 应用于计算器的架构模式
现在让我们回到我们的案例研究,将前面讨论的两个架构模式应用到 pdCalc。最终,我们将选择一个作为我们应用程序的架构。如前所述,三层体系结构由表示层、逻辑层和数据层组成。对于计算器,这些层被清楚地标识为分别输入命令和查看结果(通过图形或命令行用户界面)、命令的执行和栈。对于 MVC 架构,我们将栈作为模型,将用户界面作为视图,将命令调度器作为控制器。两种计算器架构如图 2-3 所示。注意,在三层和 MVC 架构中,表示层或视图的输入方面只负责接受命令,而不解释或执行它们。加强这种区分缓解了开发人员为自己制造的一个常见问题,即表示层与逻辑层的混合。
图 2-3
计算器架构选项
2.2.4 选择计算器架构
从图 2-3 中,人们很快发现这两种架构将计算器划分为相同的模块。事实上,在架构层面上,这两种相互竞争的架构只是在耦合性上有所不同。因此,在选择这两种架构时,我们只需要考虑它们两种通信模式之间的设计权衡。
显然,三层架构和 MVC 架构之间的主要区别是用户界面(UI)和栈之间的通信模式。在三层架构中,UI 和栈只允许通过命令调度器间接通信。这种分离的最大好处是减少了系统中的耦合。UI 和栈不需要知道对方的接口。当然,缺点是,如果程序需要大量的直接 UI 和栈通信,将需要命令调度器来代理这种通信,这降低了命令调度器模块的内聚性。MVC 架构有着完全相反的权衡。也就是说,以额外的耦合为代价,UI 可以直接与栈交换消息,避免了命令调度器执行与其主要目的无关的附加功能的尴尬。因此,架构决策简化为检查 UI 是否经常需要直接连接到栈。
在 RPN 计算器中,栈充当程序输入和输出的存储库。通常,用户希望看到栈上显示的输入和输出。这种情况有利于视图和数据之间直接交互的 MVC 架构。也就是说,计算器的视图不需要命令调度器来翻译数据和用户之间的通信,因为不需要数据的转换。因此,我选择模型-视图-控制器作为 pdCalc 的架构。不可否认,对于我们的案例研究来说,MVC 架构相对于三层架构的优势很小。如果我选择使用三层架构,pdCalc 仍然会有一个非常有效的设计。
2.3 接口
尽管宣布我们的第一级分解完成并选择了 MVC 架构可能很诱人,但我们还不能宣布胜利。虽然我们已经定义了三个最高级别的模块,但是我们还必须定义它们的公共接口。然而,如果不利用一些正式的方法来捕获问题中的所有数据流,我们很可能会错过接口中关键的必要元素。因此,我们转向面向对象的分析技术,用例。
用例是一种分析技术,它生成用户对系统的特定操作的描述。本质上,一个用例定义了一个工作流。重要的是,一个用例并不指定一个实现。在用例生成的过程中,应该咨询客户,特别是在用例发现需求不明确的情况下。关于用例图和用例图的细节可以在 Booch 等人的文章中找到。
为了设计 pdCalc 高级模块的界面,我们将首先定义最终用户与计算器交互的用例。每个用例应该定义一个工作流,我们应该提供足够的用例来满足计算器的所有技术需求。然后可以研究这些用例,以发现模块之间所需的最小交互。这些通信模式将定义模块的公共接口。这种用例分析的额外好处是,如果我们现有的模块不足以实现所有的工作流,我们将会发现在我们的顶层设计中需要额外的模块。
计算器使用案例
让我们为我们的需求创建用例。为了一致性,用例是按照它们在需求中出现的顺序来创建的。
用例:用户在栈上输入一个浮点数
-
*场景:*用户在栈上输入一个浮点数。输入后,用户可以看到栈上的数字。
-
*异常:*用户输入了无效的浮点数。将显示一个错误情况。
用例:用户撤销最后一次操作
-
*场景:*用户输入命令撤销上一次操作。系统撤消上一次操作并显示上一个栈。
-
*异常:*没有可以撤销的命令。将显示一个错误情况。
用例:用户重做最后一个操作
-
*场景:*用户输入命令重做上一次操作。系统重做最后的操作并显示新的栈。
-
*异常:*没有命令重做。将显示一个错误情况。
用例:用户交换顶层栈元素
-
*场景:*用户输入命令交换栈顶的两个元素。系统交换栈顶的两个元素并显示新的栈。
-
*异常:*栈没有至少两个数字。将显示一个错误情况。
用例:用户放下顶部的栈元素
-
*场景:*用户输入从栈中删除顶部元素的命令。系统从栈中删除顶部元素,并显示新的栈。
-
*异常:*栈为空。将显示一个错误情况。
用例:用户清除栈
-
*场景:*用户输入命令清空栈。系统清除栈并显示空栈。
-
*异常:*无。让 clear 即使对于空栈也能成功(什么也不做)。
用例:用户复制顶部栈元素
-
*场景:*用户输入命令复制栈顶元素。系统复制栈顶元素并显示新栈。
-
*异常:*栈为空。将显示一个错误情况。
用例:用户否定顶部栈元素
-
*场景:*用户输入命令对栈顶元素求反。系统对栈顶元素求反并显示新的栈。
-
*异常:*栈为空。将显示一个错误情况。
用例:用户执行算术运算
-
*场景:*用户输入加、减、乘、除的命令。系统执行操作并显示新的栈。
-
*异常:*栈大小不足以支持操作。将显示一个错误情况。
-
*异常:*检测到被零除。将显示一个错误情况。
用例:用户执行三角运算
-
*场景:*用户输入命令 sin、cos、tan、arcsin、arccos 或 arctan。系统执行操作并显示新的栈。
-
*异常:*栈大小不足以支持操作。将显示一个错误情况。
-
*异常:操作的输入无效(例如,反正弦(-*50)会产生一个假想的结果)。将显示一个错误情况。
用例:用户执行yxT5】
-
场景:用户输入命令为yx。系统执行操作并显示新的栈。
-
*异常:*栈大小不足以支持操作。将显示一个错误情况。
-
*异常:操作的输入无效(如-*10.5会产生一个假想的结果)。将显示一个错误情况。
用例:用户执行
)
-
*场景:*用户输入
)的命令。系统执行操作并显示新的栈。
-
*异常:*栈大小不足以支持操作。将显示一个错误情况。
-
*异常:*操作的输入无效(例如,
)会产生一个假想的结果)。将显示一个错误情况。
用例:用户加载一个插件
-
*场景:*用户将一个插件放入插件目录。系统在启动时加载插件,使插件功能可用。
-
*异常:*插件无法加载。将显示一个错误情况。
用例分析
我们现在将分析用例,以便为 pdCalc 的模块开发 C++ 接口。目前,我们将简单地把这些接口抽象地看作是面向公众的函数签名,这些函数签名是对类和函数的集合进行逻辑分组以定义一个模块。我们将在 2.5 节把这些非正式的概念翻译成 C++20 模块。为了简洁起见,文本中省略了名称空间前缀std
。
让我们按顺序检查用例。随着公共接口的开发,将进入表 2-2 。第一个用例除外,其接口将在表 2-1 中描述。通过为第一个用例使用一个单独的表,我们将能够保留我们在第一次通过时犯的错误,以便与我们的最终产品进行比较。到本节结束时,所有 MVC 模块的整个公共接口都将被开发和编目。
我们从第一个用例开始,输入一个浮点数。用户界面的实现将负责将用户的数字输入计算器。这里,我们关心的是将数字从 UI 放到栈上所需的接口。
不管数字从 UI 到栈的路径是什么,我们最终都必须有一个函数调用来将数字推送到栈上。因此,我们接口的第一部分只是栈模块上的一个函数push()
,用于将一个双精度数推送到栈上。我们将该功能输入到表 2-1 中。请注意,该表包含完整的函数签名,而文本中省略了返回类型和参数类型。
现在,我们必须探索从用户界面模块到栈模块获取编号的选项。从图 2-3b 中,我们看到用户界面有一个到栈的直接链接。因此,最简单的选择是使用我们刚刚定义的push()
函数将浮点数直接从 UI 推送到栈上。这是个好主意吗?
根据定义,命令调度器模块或控制器的存在是为了处理用户输入的命令。例如,输入一个数字是否应该与加法命令区别对待?让 UI 绕过命令调度器,直接在栈模块上输入一个数字,违反了最小惊讶原则(也称为最小惊讶原则)。本质上,这个原则表明,当设计师面对多个有效的设计选项时,正确的选择是符合用户直觉的。在界面设计的背景下,用户是另一个程序员或设计师。在这里,任何在我们的系统上工作的程序员都希望所有的命令都被同样地处理,所以一个好的设计应该遵循这个原则。
为了避免违反最小惊奇原则,我们必须构建一个接口,通过命令调度器从 UI 路由新输入的数字。我们再次参考图 2-3b 。不幸的是,UI 没有与命令调度器的直接连接,使得直接通信成为不可能。然而,它有一个间接的途径。因此,我们唯一的选择是 UI 引发一个事件(我们将在第三章中详细研究事件)。具体来说,UI 必须引发一个事件,表明已经输入了一个数字,并且命令调度器必须能够接收该事件(最终,通过其公共接口中的函数调用)。让我们在表 2-1 中再添加两个函数,一个用于 UI 引发的numberEntered()
事件,一个用于命令调度器中的numberEntered()
事件处理函数。
一旦数字被接受,UI 必须显示修改后的栈。这是通过栈发信号通知它已经改变,视图从栈请求 n 个元素并显示给用户来实现的。我们必须使用这种途径,因为栈只有一个到 UI 的间接通信通道。我们向表 2-1 中添加了三个函数,一个是栈模块上的stackChanged()
事件,一个是 UI 上的stackChanged()
事件处理程序,还有一个是栈模块上的getElements()
函数(参见现代 C++ 关于移动语义的侧栏,查看getElements()
函数签名的选项)。与输入数字本身不同,让 UI 直接调用栈的函数来获取元素以响应stackChanged()
事件是合理的。事实上,这正是我们希望视图在 MVC 模式中与数据交互的方式。
当然,上述工作流假设用户输入了一个有效的数字。然而,为了完整性,用例还指定必须对数字输入执行错误检查。因此,在将数字压入栈之前,命令调度器实际上应该检查数字的有效性,如果出现错误,它应该向用户界面发出信号。相应地,UI 应该能够处理错误事件。表 2-1 还有两个函数,一个是命令调度器上的error()
事件,另一个是 UI 上的函数displayError()
,用于处理错误事件。请注意,我们可以选择另一种错误处理设计,让 UI 执行自己的错误检查,并且只为有效数字引发数字输入事件。然而,为了提高内聚性,我们更喜欢将错误检查的“业务逻辑”放在控制器中,而不是放在接口中。
唷!这就完成了我们对第一个用例的分析。如果您迷路了,请记住表 2-1 中总结了刚才描述的所有功能和事件。现在只剩下 12 个令人兴奋的用例来完成我们的接口分析!别担心,苦差事很快就会结束。我们将很快衍生出一种设计,可以将几乎所有的用例整合到一个统一的界面中。
在直接进入下一个用例之前,让我们暂停一下,讨论一下我们刚刚隐含地做出的关于错误处理的两个决定。首先,用户界面通过捕捉事件而不是捕捉异常来处理错误。因为用户界面不能直接向命令调度器发送消息,所以 UI 永远不能在 try 块中包装对命令调度器的调用。这种通信模式立即消除了使用 C++ 异常进行模块间错误处理(注意,它并不排除在单个模块内部使用异常)。在这种情况下,由于数字输入错误被捕获在命令调度器中,我们可以使用回调直接通知 UI。但是,这种约定不够通用,因为它会因为栈中检测到的错误而失效,因为栈与 UI 没有直接通信。其次,我们已经决定,所有错误,不管是什么原因,都将通过向 UI 传递一个描述错误的字符串来处理,而不是创建一个错误类型的类层次结构。这个决定是合理的,因为 UI 从不试图区分错误。相反,UI 只是作为一个管道来显示来自其他模块的错误消息。
Modern C++ Design Note: Move Semantics
在表 2-1 中,栈具有函数void getElements(size_t, vector<double>&)
,该函数使调用者能够用栈中的顶部 n 元素填充一个vector
。然而,函数的接口并没有告诉我们元素是如何被添加到vector
中的。它们是加在前面的吗?它们是加在后面的吗?是否假定vector
的大小已经正确,并且使用operator[]
输入了新元素?在添加新元素之前,旧元素会从矢量中删除吗?希望开发人员的文档能够解决这种不确定性(祝你好运)。在缺乏进一步信息的情况下,人们可能会认为新元素只是被推到了vector
的后面。
然而,从 C++11 开始,前面的接口歧义可以通过语言本身在语义上解决。右值引用和移动语义允许我们非常明确地做出这个接口决定。我们现在可以高效地(即,无需复制vector
或依赖编译器来实现返回值优化)实现函数vector<double> getElements(size_t)
。在函数内部创建一个临时的vector
,函数返回时,它的内容被移入调用者。接口契约现在是显式的:一个大小为 n 的新vector
将被返回,并用栈顶的 n 元素填充。
为了不夸大文本中的接口,函数的两种变体都没有显式地出现在定义接口的表中。然而,这两种变体都出现在源代码中。本书中会经常用到这个约定。当执行相同操作的多个助手调用在实现中有用时,两个调用都出现在那里,但是在文本中只出现一个变体。出于本书的说明目的,这种省略是可以接受的,但对于真实项目的详细设计规范来说,这种省略是不可接受的。
接下来的两个用例,操作的撤销和重做,非常相似,我们可以同时分析它们。首先,我们必须向用户界面添加两个新事件:一个用于撤销,一个用于重做。相应地,我们必须在命令调度器中添加两个事件处理函数,分别用于撤销和重做。在简单地将这些函数添加到表 2-2 之前,我们先退一步,看看是否可以简化。
此时,您应该开始看到从添加到表中的用户界面事件中出现了一种模式。每个用例添加一个形式为xCommandEntered()
的新事件,其中x
到目前为止已经被number
、undo
或redo
所取代。在后续用例中,x
可能会被替换为swap
、add
、sin
和exp
等操作。我们没有继续通过在 UI 中给每个命令一个新事件和在命令调度器中给每个命令一个相应的事件处理程序来膨胀界面,而是用命令调度器中更通用的探测 UI 事件commandEntered()
和伙伴事件处理程序commandEntered()
来替换这一系列命令。这个事件/处理程序对的单个参数是一个string
,它对给定的命令进行编码。通过使用数字的 ASCII 表示作为string
参数,commandEntered()
额外替换了表 2-1 中的numberEntered()
。
表 2-1
从将浮点数输入栈的用例分析中得出的公共接口
|组件
|
功能
|
事件
|
| — | — | — |
| 用户界面 | void displayError(const string&)``void stackChanged()
| numberEntered(double)
|
| 命令调度器 | void numberEntered(double)
| error(string)
|
| 堆 | void push(double)``void getElements(size_t, vector<double>&)
| stackChanged()
|
将所有 UI 命令事件合并到一个带有字符串参数的事件中,而不是将每个命令作为一个单独的事件发出,这样可以满足多种设计目的。首先,也是最显而易见的,这个选择使界面变得混乱。我们现在只需要一对函数来处理来自所有命令的事件,而不需要 UI 中的每对函数和每个命令的命令调度器。这包括需求中已知的命令和任何可能从未来扩展中得到的未知命令。适应未知命令所需的运行时灵活性驱动使用string
参数,而不是使用枚举类型。然而,更重要的是,这种设计促进了内聚力,因为现在 UI 不需要理解它所触发的任何事件。相反,对命令事件的解密被放在命令调度器中,这个逻辑自然属于这个程序。为命令创建一个commandEntered()
事件甚至会直接影响命令、图形用户界面按钮和插件的实现。我们将在第四章第四章、第六章和第七章中讨论这些话题。
我们现在回到撤销和重做用例的分析。如前所述,对于我们遇到的每个新命令,我们将放弃在表 2-2 中添加新命令事件。相反,我们将commandEntered()
事件添加到 UI,将commandEntered()
事件处理程序添加到命令调度器。这个事件/处理程序对将满足所有用例中的所有命令。然而,该栈还不具备实现每个命令的所有必要功能。例如,为了撤销对栈的推送,我们需要能够从栈中弹出数字。让我们在表 2-2 的栈中添加一个pop()
函数。最后,我们注意到,如果我们试图弹出一个空栈,可能会发生栈错误。因此,我们将一个通用的error()
事件添加到栈中,以反映命令调度器上的错误事件。
我们转到下一个用例,交换栈的顶部。很明显,这个命令将重用前面用例中的commandEntered()
和error()
模式,所以我们只需要确定是否需要向栈的接口添加新的函数。显然,交换栈顶的两个元素既可以通过栈上的swapTop()
函数实现,也可以通过现有的push()
和pop()
函数实现。有些随意地,我选择实现一个单独的swapTop()
函数,所以我将它添加到表 2-2 中。这个决定可能下意识地植根于我的自然设计倾向,即以重用为代价最大化效率(我的大多数专业项目都是高性能数值模拟)。事后看来,这可能不是更好的设计决策,但这个例子表明,有时,设计决策只不过是基于设计师的本能,带有个人经验的色彩。
在这一点上,对剩余用例的快速浏览表明,除了加载插件,表 2-2 定义的现有模块接口足以处理所有用户与计算器的交互。每个新命令只增加命令调度器内部的新功能,其逻辑将在第四章中详述。因此,剩下的唯一要检查的用例是关于为 pdCalc 加载插件。插件的加载虽然复杂,但对计算器中其他模块的影响很小。除了命令和用户界面注入(我们将在第七章中遇到这些话题),插件加载器是一个独立的组件。因此,我们推迟了其接口的设计(以及对其他接口的必要的相应更改),直到我们准备好实现插件。
推迟顶层界面重要部分的设计有点冒险,设计纯粹主义者可能会反对。然而,实际上,我发现当设计了足够多的主要元素时,就需要开始编码了。无论如何,设计会随着实现的进展而改变,因此过度使用初始设计来寻求完美是徒劳的。当然,也不应该在敏捷狂潮中完全放弃所有的前期设计!
也就是说,对于采用延迟主要组件设计的策略,存在一些警告。首先,如果设计的延迟部分会对架构产生重大影响,那么延迟可能会导致以后的重大返工。第二,延迟部分设计延长了界面的稳定性。这种延迟对于独立处理连接组件的大型团队来说可能是问题,也可能不是问题。只有通过经验才能知道什么可以推迟,什么不可以推迟。如果您不确定组件的设计是否可以安全地推迟,那么您最好谨慎行事,预先执行一些额外的设计和分析工作,以最小化对整个体系结构的影响。影响程序架构的糟糕设计将会影响项目持续期间的开发。与糟糕的实现相比,它们会导致更多的返工,在最坏的情况下,糟糕的设计决策在经济上变得不可行。有时,它们只能在重大重写中修复,这可能永远不会发生。
表 2-2
整个一级分解的公共接口
|组件
|
功能
|
事件
|
| — | — | — |
| 用户界面 | void postMessage(const string&)``void stackChanged()
| commandEntered(string)
|
| 命令调度器 | void commandEntered(const string&)
| error(string)
|
| 堆 | void push(double)``void getElements(size_t, vector<double>&)``double pop()``void swapTop()
| stackChanged()``error(string)
|
在完成用例分析之前,让我们将表 2-1 中为第一个用例开发的接口与表 2-2 中包含所有用例开发的接口进行比较。令人惊讶的是,工作台 2-2 仅比工作台 2-1 稍长。这证明了将命令抽象成一个通用函数而不是每个命令的单独函数的设计决策。模块间通信模式的这种简化是设计代码而不仅仅是修改代码的许多节省时间的优点之一。第一个接口和完整接口之间唯一的其他区别是添加了一些栈函数和修改了一些函数名(例如,将displayError()
函数重命名为postMessage()
以增加操作的通用性)。
2.3.3 实际实施的快速说明
出于本文的目的,如表 2-2 所示,开发的接口代表代码中部署的实际接口的理想化。实际的代码可能在语法上有所不同,但是接口的语义意图将总是被保留。例如,在表 2-2 中,我们将获取 n 元素的接口定义为void getElements(size_t, vector<double>&)
,这是一个非常好的可服务接口。然而,通过使用现代 C++ 的新特性(参见侧栏中的 move 语义),实现利用了右值引用和 move 构造,还提供了一个逻辑上等价的重载接口vector<double> getElements(size_t)
。
定义好的 C++ 接口是一项非常重要的任务;我知道至少有一本非常好的书专门讨论这个主题[27]。在这本书里,我只提供足够详细的界面来清楚地解释设计。可用的源代码展示了开发高效 C++ 接口所需的复杂性。在一个非常小的项目中,允许开发人员在修改接口时有一定的自由度通常是可以容忍的,并且通常是有益的,因为它允许实现细节被延迟,直到它们可以被实际确定。然而,在大规模开发中,为了防止独立团队之间的绝对混乱,在实现开始之前尽快完成接口是明智的。至关重要的是,外部接口必须在向客户公开之前完成。面向客户端的接口应该像契约一样对待。
2.4 对我们当前设计的评估
在开始我们的三个主要组件的详细设计之前,让我们停下来,根据我们在本章开始时确定的标准来评估我们当前的设计。首先,已经定义了三个不同的模块,我们的设计显然是模块化的。第二,每个模块作为一个内聚单元,每个模块致力于一个特定的任务。用户界面代码属于一个模块,操作逻辑属于另一个模块,而数据管理属于另一个独立的模块。此外,每个模块都封装了自己的所有功能。最后,模块是松散耦合的,在需要耦合的地方,通过一组明确定义的、简洁的公共接口进行耦合。我们的顶层架构不仅符合我们良好的设计标准,而且还符合一个众所周知的、经过充分研究的、已经成功使用了几十年的架构设计模式。在这一点上,我们已经再次确认了我们设计的质量,并且当我们进行下一步分解,即各个组件的设计时,应该会感到非常舒服。
2.5 使用 C++20 模块实现我们的设计
从 C++20 开始,模块已经成为 C++ 语言的正式组成部分。在本节中,我们将讨论模块相对于头文件的一般优势,支持模块所需的源代码和工具更改,以及我们将如何使用模块实现 pdCalc。尽管这个语言特性很新,但为了与本书的精神保持一致,我将不再介绍模块的语法,而是从设计的角度重点介绍 C++20 模块的使用。我向不熟悉模块的读者推荐关于 vector-of-bool [3]上的模块的优秀的三部分博客文章。我们从描述模块解决的 C++ 问题开始。
2.5.1 为什么是模块?
模块的大部分动机源于头文件包含模型的缺点。在 C++20 之前,源文件是翻译单元(TU)的唯一输入。本质上,一个翻译单元由生成一个目标文件所需的所有源代码组成。当然,作为有经验的 C++ 程序员,我们知道大多数程序依赖于来自多个翻译单元的交互组件,这些组件最终通过链接组合在一起。
考虑依赖于 TU B
中的函数或类的 TU A
的编译。C++20 之前的语言模型要求来自B
的相关接口在A
的翻译过程中在文本上可见。这种将“外来”源代码文本包含和组装到当前正在编译的 TU 中的操作通常由预处理器执行,由程序员通过无处不在的#include
语句来指导。
几十年来,包含头文件的文本一直给 C++ 程序员带来问题。本质上,这些问题有三个主要来源:重复编译相同的代码、预处理宏和违反一个定义规则。我们将依次检查每个问题,以理解为什么使用模块比使用头文件有所改进。
首先,考虑构建时间。每个人都写过以下第一个 C++ 程序(或某个变体):
#include <iostream>
int main(int argc, char* argv[])
{
std::cout << "hello, world!" << std::endl;
return 0;
}
算上空白和只包含括号的行,前面的“hello world”程序的源代码清单有 7 行长,是吗?在预处理器执行之后,GCC 版本 10.2.0 中生成的翻译单元有 30,012 行长,这(直接)来自于只包含一个单独用于发出命令行输出的标准头文件!每次你在一个文件中包含<vector>
,你就在你的 TU 中增加了 14,000 行。想要智能指针(<memory>
)?这将花费您 23,000 多一点的线路。考虑到头文件可能非常大,并且可以在任何给定的程序中跨许多 tu 重用,如果这种语言提供了一种机制来重用它们而不用在任何地方都包含它们,那不是很好吗?如果“hello,world”只有 7 行,那么它的编译速度会有多快?
模块确实解决了文本头包含问题(或者一旦它们变得普遍,将会解决这个问题)。模块引入了一种新的翻译单元,模块单元,与传统的头文件不同,它可以通过语言import
语句而不是预处理程序文本包含来使用。现在,除了目标文件之外,编译器还通过生成编译模块接口(CMI)单元来实现模块,CMI 单元携带必要的符号信息,以便其他 tu 在导入 CMI 时针对接口进行编译,而无需在文本中包含源代码。因此,模块可以编译一次并重用,从而通过消除重新编译模块接口的需要来减少总的编译时间。加速至少是一个理论上的承诺。实际上,文本包含模型允许令人尴尬的并行编译,而模块意味着编译时源代码依赖,这可能会部分消除并行编译。当工具赶上新的编译模型时,这个问题的严重性有望减轻。对于复杂的构建,模块是否比传统的头文件包含带来更快的构建时间还有待观察。我敢打赌,在编译器和工具编写者获得几年的模型实践经验后,模块最终会减少大多数复杂构建的构建时间。
头文件包含模型的第二个问题源于将头文件中的宏提升到翻译单元中。这个问题以两种方式之一表现出来,要么是错误的、意外的符号定义,要么是更令人惊讶的行为,即头文件包含的顺序可能会改变代码的行为。考虑下面这个(非常)做作的例子:
// File A.h
#define FOO_IS_FOO
inline void foo() { cout << "foo" << endl; }
// File B.h
#ifdef FOO_IS_FOO
#define FOO foo
#else
#define FOO bar
#endif
inline void bar() { cout << "bar" << endl; }
// File exec1.cpp
#include "A.h"
#include "B.h"
void exec1()
{
FOO(); // prints: foo - great, FOO is foo
}
// File exec2.cpp
#include "B.h"
#include "A.h"
void exec2()
{
FOO(); // prints: bar - what, FOO is bar?!
}
前面的潜在错误很少如此容易诊断。通常,当另一个开发人员在头文件中定义了一个临时符号(比如在调试时)并且在移除宏之前意外地签入了代码时,就会出现错误。当宏是一个常用的符号,如DEBUG
或FLAG
时,如果您改变包含的顺序(可能在重构时),您的代码可能会改变行为。
模块解决了由宏定义引起的问题,因为模块通常不会将预处理器宏导出到导入翻译单元中。预处理器通过文本替换实现宏。由于模块是导入的,而不是以文本形式包含在消费翻译单元中,因此模块中定义的任何宏都保留在模块实现的本地。这种行为与头文件不同,头文件仅通过文本可见性隐式导出宏,而不考虑意图。
包含头文件导致的第三个问题源于 C++ 的一个定义规则(ODR)。ODR 规定一个非线性函数只能在一个给定的翻译单元和一个程序中定义一次。具有外部链接的内联函数可以定义多次,前提是所有定义都相同。当使用报头包含模型时,ODR 问题是如何产生的?考虑一个必须通过链接从foo.cpp
和bar.cpp
生成的单独编译的目标代码来汇编的程序,如下面的代码清单中所定义:
// File A.h
#ifndef A_H
#define A_H
void baz() { /* cool stuff */ }
#endif
// File foo.cpp
#include "A.h"
// bunch of foo-y functions
// File bar.cpp
#include "A.h"
// bunch of bar-y functions
乍一看,您可能认为A.h
中的 include guard 使我们避免了 ODR 违规。然而,include guard 只是防止A.h
的内容在一个翻译单元中被文本化地包含两次(避免循环包含)。这里,A.h
被正确地包含在两个不同的翻译单元中,每个翻译单元编译成单独的目标代码。当然,因为baz()
没有被内联,如果foo.o
和bar.o
在一个程序中链接在一起,那么在foo.o
和bar.o
中分别包含它的定义会导致 ODR 违规。
老实说,我发现前面的问题在实践中很少发生。有经验的程序员知道要么内联baz()
要么在A.h
中声明baz()
并在单独的源文件中定义它。无论如何,模块消除了这种类型的 ODR 冲突,因为函数声明是通过导入语句而不是文本包含对消费者可见的。
现在你知道了–
模块只是更好的头文件。虽然前面的陈述是正确的,但是如果程序员只把模块作为改进的头文件,我会非常失望。虽然我怀疑模块确实会用于这个目的,特别是当程序员过渡到在遗留软件中使用模块时,我相信模块的主要作用应该是提供一种语言机制来正式实现模块化的设计概念。我们将很快看到 C++20 模块如何支持 pdCalc 的模块化,但是首先,我们需要考虑那些仍然必须使用遗留头文件的时候。
使用传统标题
理想情况下,所有代码都可以移植到模块中,并且import
语句可以快速取代头文件包含。然而,在转换过程中,您可能需要混合模块和头文件。也就是说,实际上,因为头文件已经存在了几十年,所以您可能会在很长一段时间内混合处理模块和头文件。让我们来看看这是如何做到的。
首先,在非模块代码中,没有什么可以阻止您像往常一样使用头文件。如果不是这样,遗留代码的每一部分都将立即停止工作。此外,如果您没有创作一个命名的模块,您可以自由地混合和匹配import
和#include
。但是,如果您正在编写一个模块,包含头文件有特殊的语法规则。
准确地说,所有 C++ 代码现在都存在于某个模块权限中。模块范围就是模块中包含的所有代码。当您创作命名模块时,也就是说,您的文件以模块名的声明开始,例如
export module myMod;
文件的其余部分在myMod
的模块范围内。所有不在命名模块中的代码都驻留在全局模块中。将驻留在全局模块中的头文件包含到命名模块中会将头文件的所有符号注入到命名模块的范围中。这一行动不可能产生预期的效果。相反,我们有两个选择。
在模块中使用头文件的第一个选择是import
头文件,而不是#include
头文件。对于名为MyCoolHeader.h
的头文件,我们将使用以下代码:
import <MyCoolHeader.h>;
双引号也可以用来代替尖括号。header-unit import,更恰当地说,基本上把头文件当作一个模块,头文件的代码像模块导入一样被导入,而不是像传统的 header #include
语句那样以文本形式包含。不幸的是,有一种边缘情况,即头文件本身期望某个预处理器状态在 include 语句之前预先存在,但这种情况并不像预期的那样工作。考虑以下MyCoolheader.h
的实现概要:
// MyCoolHeader.h
#ifdef OPTION_A
// cool option A stuff...
#elif OPTION_B
// cool option B stuff...
#else
#error Must specify an option
// uh oh, not cool stuff...
#endif
MyCoolHeader.h
不能被导入和使用,因为导入一个模块,即使它实际上是一个伪装成模块的头文件,也看不到导入代码范围内的任何宏。此外,虽然标准没有要求,但许多编译器要求在使用前单独编译头文件单元。要解决这些问题,请输入在模块范围内使用遗留头的第二个选项。
在命名模块中使用遗留头文件的第二种选择是简单地将头文件包含在命名模块文件中位于模块范围之前的特殊定义区域中。这个特殊区域被称为全局模块片段。其访问方式如下:
module;
// The global module fragment
#define OPTION_A // or B, if you prefer
#include <MyCoolheader.h>
export module mod;
// mod's purview begins now...
前面的语法可以在模块接口或模块实现文件中使用。为了简单起见,在 pdCalc 中,必须使用遗留头文件(例如,目前的标准库),我选择将遗留头文件直接包含到全局模块片段中,而不是预编译和导入头文件。
我们几乎准备好检查 pdCalc 本身是如何模块化的了。然而,由于模块是一个如此新的特性,我们将首先快速迂回一下,检查它们如何影响源代码组织。
2 . 5 . 3 c++ 20 之前的源代码组织
模块化的设计概念并不新鲜。然而,在 C++20 之前,不存在实现模块的语言机制。由于缺乏直接的语言支持,开发人员采用三种机制之一来逻辑地“模仿”模块:源代码隐藏、动态链接库(DLL)隐藏或隐式隐藏。我们将简要讨论每一个。
在 C++20 之前,通过利用头文件包含模型,可以从单个源文件和单个头文件构造模块。头文件只列出了模块的公共接口,模块的实现将驻留在一个单独的源文件中;语言可见性规则加强了实现的私密性。虽然这种技术适用于小模块,但是对于大模块来说,源代码管理变得不实用,因为许多不同的函数和类需要被分组到一个单独的源文件中,这就造成了源文件级内聚性的缺乏。
我个人至少在一个开源包中看到了源代码隐藏策略。虽然从技术角度来看,这个项目确实实现了模块接口隐藏,但结果是整个库作为单个头文件和单个源文件分发。头文件超过 3000 行,源文件将近 20000 行。虽然有些程序员可能不反对这种风格,但我不认为这种解决方案是为可读性或可维护性而优化设计的。据我所知,这个开源包只有一个作者。因此,对于一个开发团队来说,可读性和可维护性不太可能是他的主要目标。
在 C++20 之前用来创建模块的第二种技术是依靠操作系统和编译器从动态链接库中有选择地导出符号的能力。虽然 DLL 隐藏是一种真正的模块化形式,但是使用这个选项当然超出了 C++ 语言本身的范围。DLL 隐藏基于操作系统的库格式,并通过编译器指令实现。本质上,程序员用特殊的编译器指令来修饰类或函数,以指示函数是从 DLL 导入还是导出。然后,编译器创建一个 DLL,只公开导出适当标记的符号,链接到 DLL 的代码指定它打算导入哪些符号。由于在编译 DLL 时必须将同一个头文件标记为导出,而在使用 DLL 编译代码时必须将其标记为导入,因此通常通过使用特定于编译器/操作系统的预处理器指令来实现。
虽然 DLL 隐藏确实创建了真正的模块封装,但是它有三个严重的问题。首先,因为 DLL 隐藏是从操作系统和编译器而不是语言本身派生出来的,所以它的实现是不可移植的。除了需要用预处理器指令扩充代码之外,特定于系统的不可移植性总是使构建脚本变得复杂,为需要在不同系统上编译的代码带来了维护问题。DLL 隐藏的第二个问题是,人们实际上被迫沿着 DLL 边界对齐模块。虽然一个共享库中可以放置多个模块,但 DLL 只隐藏外部 DLL 接口中已定义的模块。因此,没有什么能阻止共享一个共享库的两个模块看到彼此的内部接口。最后,DLL 隐藏需要构造一个 DLL,这显然不适用于,例如,在一个只有头文件的库中定义的模块。
有趣的是,因为 C++ 模块是一种语言结构,而动态链接库是一种操作系统结构,我们现在有了额外的复杂性,即 C++ 模块必须与 dll 共存和交互,尽管它们在语法上完全独立。例如,一个 DLL 可以包含一个或多个 C++ 模块,程序员可以自由地独立设置每个 C++ 模块的 DLL 可见性。也就是说,包含三个 C++ 模块的 DLL 可能会公开零个(尽管有些无用的 DLL)、一个、两个或三个单独的 C++ 模块。更奇怪的是,虽然我自己没有验证过,但是一个模块可以跨多个 dll。不管怎样,跨库边界的模块组织现在是程序员必须考虑的另一个问题,也是我们在讨论 pdCalc 的源代码组织时要解决的一个决定。
最后一种遗留的模块化技术,我称之为隐式隐藏,只不过是通过不记录来隐藏接口。这在实践中意味着什么?由于 C++ 语言不直接支持模块,隐式隐藏只是在一组类和函数周围画出一个逻辑结构,并声明这些类组成一个模块。通常,不打算被消费者使用的代码会被放在一个单独的名称空间中,通常命名为detail
。这种风格在只有头文件的库中很常见。该语言允许从模块外部的代码调用任何类的任何公共函数。因此,模块的公共接口是通过只记录那些应该从外部调用的函数来“声明”的。从纯技术的角度来看,隐式隐藏根本不是隐藏!
为什么有人会选择隐式隐藏而不是源代码隐藏或 DLL 隐藏呢?很简单,这种选择要么是出于方便,要么是出于需要(只有标题的模块)。使用隐式隐藏允许开发人员以逻辑的、可读的和可维护的方式组织类和源代码。每个类(或一组密切相关的类)可以被分组到它自己的头文件和源文件对中。这使得只包含必要的代码成为可能,从而加快了编译速度。隐式隐藏也不会强制将边界定义包含到一个特定的共享库中,如果设计目标是最小化一个包中包含的单个共享库的数量,这一点可能很重要。当然,隐式隐藏的问题是,不存在语言机制来防止误用设计者不打算在逻辑模块之外使用的函数和类。
现在模块是 C++20 的一部分,你会继续看到前面描述的三种“模仿”模块技术吗?绝对的。首先,C++20 模块既没有完全实现,也不健壮。今天试图采用跨平台的模块,商业代码库实际上会是一个障碍。很明显,这是我在更新本书第二版的 pdCalc 时遇到的最大障碍。其次,在可预见的未来,遗留代码将继续占据主导地位。虽然新项目可能从一开始就采用 C++20 模块,但是旧项目将继续使用它们现有的技术,除非进行重大的重构工作。一般来说,采用新的语言特性并不是保证重构的充分理由。因此,在实践中,对遗留代码中的模块的任何重构,充其量都是零碎的。最后,旧习难改。永远不要低估人们不愿意学习新技术或拒绝放弃根深蒂固的立场。我毫不怀疑,你甚至会遇到程序员出于各种原因强烈反对使用模块。
2.5.4 使用 C++20 模块的源代码组织
尽管在过去的几十年里语言有了很大的发展,但是模块带来了第一个变化,它从根本上影响了源代码的组织和编译方式。抛开遗留代码问题不谈,从 C++20 开始,我们不再需要依赖前面提到的“黑客”来将代码组织成模块——这种语言现在直接支持模块化。以前我们只有翻译单元的组织概念,C++20 增加了模块单元,非常松散地说,它是一个源文件,声明源代码是模块的一部分。我们现在将研究模块单元如何改变 C++20 源代码的组织方式。
首先,我们必须了解模块本身是如何构造的。模块单元在语法上分为模块接口单元和模块实现单元。模块接口单元是那些导出模块及其接口的模块单元。编译器只需要一个模块接口单元就可以生成可导入的 CMI。相反,模块实现单元是任何不导出模块或其接口的模块单元。顾名思义,模块实现单元实现模块的功能。模块的接口及其实现可能出现在同一个文件中,也可能出现在不同的文件中。
在可能的情况下,我更喜欢将模块单元组织在一个文件中;我觉得这种简单很吸引人。然而,实现这种简单的文件结构并不总是可能的。首先,CMI 不是可分发的工件。因此,被分发的任何二进制模块还需要为其模块接口提供源代码,以供消费者重新编译(例如,插件系统的接口)。假设您不想向二进制模块消费者提供实现细节,您会希望将这些模块接口和实现放在不同的文件中,并且只分发前者。第二,因为 CMI 必须在模块被导入之前存在,具有循环依赖的模块需要将接口从实现中分离出来。然后,可以通过在接口中使用用 forward 声明声明的不完整类型来打破循环编译依赖。然后,这些接口可以独立地编译成 CMIs,CMIs 随后可以在单独的模块实现编译期间导入。
知道我们将会遇到模块接口单元和实现单元文件,让我们简单地讨论一下文件命名约定。虽然没有标准化,但 C++ 头文件和实现文件扩展名有一些通用约定(例如。cpp,。cxx,。h,。hpp 等。).然而,模块接口单元文件既不是头文件也不是实现文件(而实现单元显然是实现文件),那么我们应该为它们使用什么文件扩展名呢?目前,编译器实现者还没有采用统一的标准。MSVC 和铿锵采用了文件扩展名。ixx 和。cppm,而 GCC 的主要模块实现者没有为模块接口单元采用任何不同的文件扩展名。当然,程序员可以自由地为模块接口单元选择他们想要的任何文件扩展名,但是 MSVC 和 clang 要求设置一个编译器标志,以指示模块接口单元的翻译是否偏离了编译器特定的预期文件扩展名。幸运的是,没有人为模块实现单元采用新的文件扩展名。pdCalc 使用的惯例是,任何导出模块接口的文件都使用. m.cpp 文件扩展名,而实现文件(模块或其他)使用。cpp 文件扩展名,而旧头文件使用。h 文件扩展名。采用不引入新文件扩展名的 pdCalc 约定,可以确保任何现有的代码编辑器都将源文件识别为 C++ 文件。
根据前面的解释,人们可能会得出这样的结论:模块及其接口和实现文件对在组织上似乎并不比头文件及其关联的实现文件好。以前我们使用头文件来定义接口,现在我们使用模块接口文件。以前我们使用实现文件,现在我们使用模块实现文件。当然,作为更好的头文件,我们获得了模块的所有优点,但是我们仍然被定义在单个接口/实现文件对中的模块所困扰,这仅仅是对我们遗留方法的一个渐进的改进。进入模块分区。
模块分区正如您从它的名字中所期望的那样,是一种将模块划分成独立组件的机制。具体来说,分区提供了一种降低模块复杂性的方法,它将模块分成任意数量的类、函数和源文件的逻辑子单元,同时仍然保持模块级封装。从语法上讲,模块分区是由父模块名和用冒号分隔的分区名定义的。例如,模块A
可以由分区A:part1
和A:part2
组成。如同普通模块一样,模块分区在模块分区接口单元和模块分区实现单元之间划分。这两部分可能出现在同一个文件中,也可能出现在不同的文件中。每个模块分区的行为就像它自己的模块一样,只是它不能作为一个单独的单元从外部访问。也就是说,只有模块的组件(主模块或另一个分区)可以导入模块分区。如果模块分区打算构成模块接口的一部分,那么主模块接口必须export import
该分区。请注意,虽然一个模块可以包含任意数量的模块分区及其关联的模块分区接口,但是一个模块本身只能有一个主模块接口,这是其可导出接口的单一定义。
当通过一个例子来解释时,模块划分的相关性要高得多,所以让我们直接从 pdCalc 来研究一个。考虑三个类:Publisher
、Observer
和Tokenizer
。我们将在本书的后面深入讨论每个类的功能。现在,只需注意每个类都为 pdCalc 提供了实用功能。我们有几个选项来提供这些类。在一个极端,我们可以把每个类做成它自己的模块。例如:
export module pdCalc.Publisher;
export class Publisher{...};
请注意,分隔pdCalc
和Publisher
的句点没有语义含义。句点只是一种语法约定,用于对模块进行分类,以避免模块名称冲突。不幸的是,由于 MSVC 的一个链接器错误,pdCalc 的源代码使用下划线而不是句点来分隔模块名。但是,该书的文本保留了句点。
任何需要使用Publisher
的代码都使用下面的命令:
import pdCalc.Publisher;
// use Publisher like any other class:
Publisher p;
类似地,我们将定义模块pdCalc.Observer
和pdCalc.Tokenizer
,它们将分别由import pdCalc.Observer
和import pdCalc.Tokenizer
导入。本质上,前面的策略是采用模块作为更好的头文件。然而,回想一下,我们在开始这个例子时提到Publisher
、Observer
和Tokenizer
一起向 pdCalc 提供公用事业服务。因此,从逻辑上来说,我们可能希望提供一个Utilities
模块,当它被导入时,提供对所有三个类Publisher
、Observer
和Tokenizer
的访问。我们可以通过使用模块分区来实现这一目标,而不必将所有的类混合到一个模块接口中:
// Utilities.m.cpp (or .cppm or .ixx)
export module pdCalc.Utilities;
export import :Observer;
export import :Publisher;
export import :Tokenizer;
// Observer.m.cpp (or .cppm or .ixx)
export module pdCalc.Utilities:Observer;
export class Observer{...};
// Analogous implementations for Publisher and Tokenizer...
export import
语法仅仅意味着一个模块分区接口单元被导入到主模块接口单元中,随后被模块重新导出。现在,可以使用三个类:
import pdCalc.Utilities;
// use classes from any of the partitions:
Publisher p;
Tokenizer t;
为了方便起见,模块可以使用相同的语法导出其他模块,即使这些其他模块不是分区。我们很快就会看到这种替代策略。
使用模块分区的主要优点是每个分区可以作为一个模块编写,但是分区不能作为单独的模块单独访问。相反,分区将模块分成内聚的逻辑组件,而模块的接口通过单个主模块接口来集中和控制。任何特定分区的接口都可以通过主模块接口中的export import
语句直接重新导出。
在使每个类成为它自己的可单独导入的模块和使每个类成为一个Utilities
模块的模块分区之间确实存在一个中间点。具体来说,每个类都可以写成自己的模块:
export module pdCalc.Observer;
export class Observer{...};
然而,我们可以提供一个方便的Utilities
模块接口,用于export import
每个单独的模块:
// Utilities.m.cpp (or .cppm or .ixx)
export module pdCalc.Utilities;
export import pdCalc.Observer;
export import pdCalc.Publisher;
export import pdCalc.Tokenizer;
与使用模块分区一样,所有的类都可以通过导入Utilities
类来使用:
import pdCalc.Utilities;
// use classes from any of the partitions:
Publisher p;
Tokenizer t;
前面的模型类似于创建一个不包含任何内容但包含其他头文件语句的头文件。
假设我们可以使用前面描述的任何模块技术实现相同的功能,那么我们如何选择正确的设计呢?使每个类成为它自己的模块为最终用户提供了最大的粒度,因为每个类都可以根据需要单独导入。然而,C++ 模块的这种用法忽略了开发者提供逻辑上内聚的Utilities
模块的意图。同样,它只是将 C++ 模块作为更好的头文件。相反,通过使用分区,我们提供了一个真正的、内聚的Utilities
模块,但是我们强迫终端用户要么全部导入,要么什么都不导入。最后,我们有一个折衷的解决方案,最终用户可以导入单个类,或者通过一个模块接口一起导入所有类。折衷的设计与模块化关系不大,与便利性和灵活性关系更大。
描述了几种不同模块策略的权衡之后,我们如何为任何给定的设计选择正确的策略呢?在许多方面,构造一个模块类似于构造一个类,但是规模不同。并非巧合的是,我们可以使用完全相同的设计标准:封装、高内聚和低耦合。然而,与设计类一样,许多选择都归结于粒度、意图和个人观点。就像设计的许多方面一样,不存在唯一正确的答案。试错、品味和经验大有帮助。
模块和 pdCalc
我们现在回到 pdCalc 的具体模块化。在 2.2 节中,我们根据 MVC 架构模式将 pdCalc 分解为三个高级模块:栈模块、用户界面模块和命令调度器模块。在第 2.3 节中,我们采用用例分析来帮助定义这些模块的接口,随后将它们分类在表 2-2 中。我们还指出,插件管理至少需要一个额外的模块。我们现在问,是否需要任何额外的模块,这些模块如何在代码中表示,以及这些 C++ 模块应该如何分布到动态链接库中?我们将依次回答这些问题。
细化 pdCalc 的模块
如您所料,pdCalc 模块的实际实现并不像设计的理想化那样简单。出现这种差异有几个原因。让我们详细考虑一下这些原因。
首先,前面定义 pdCalc 模块的分析只考虑了计算器的功能需求。我们没有考虑基础设施对实用程序类的需求,正如在 2.5.4 节中提到的,它可能被多个模块重用。仅举一个例子,考虑对一个通用错误处理类的需求,该类可以被栈和命令调度器模块使用。从程序上讲,我们可以在一个现有的模块中实现这些实用程序类和函数。然而,这种策略会降低模块的内聚性,并潜在地增加模块之间不必要的耦合。相反,我们将提供一个独立的、内聚的实用程序模块,可以被多个其他模块使用。
提供附加模块的第二个原因与 pdCalc 的概念设计无关,而是与模块的 C++ 语言机制有关。如前所述,编译后的模块接口不是为分布式构件而设计的。调用分布式二进制模块需要访问模块接口的源代码。因此,当一个大模块只有一小部分接口需要外部调用时,将这个大模块分解成独立的模块是有利的,可以避免不必要的模块接口分配。对于由分区构造的模块来说尤其如此。考虑一个由六个分区组成的大型模块,其接口如下:
// BigModule.m.cpp
export module BigModule;
export import :PartOne;
export import :PartTwo;
export import :PartThree;
export import :PartFour;
export import :PartFive;
export import :PartSix;
假设所有的BigModule
都被主程序使用,但是只需要在PartFive
分区中定义的类来构造插件。在主程序中可以重用 CMI 的地方,BigModule.m.cpp
需要分发给插件编写者。然而,因为BigModule.m.cpp
导出了它的分区接口,所以如果没有包含这六个分区接口的文件,它就不能被编译。与其分发所有这些源文件,不如将PartFive
分解成一个独立的模块,只将它的接口文件分发给插件编写者。当然,如果为了方便起见,这种新的独立模块仍然可以通过export import
添加到BigModule
的接口,同时保持其独立性以用于分发目的。当我们在第四章遇到Command
接口时,我们会在 pdCalc 中看到这种模式。
pdCalc 实现的模块与表 2-2 中定义的模块不完全匹配的第三个原因是,目前并不是所有的遗留代码都可以模块化。这种情况是意料之中的,在现实项目中也经常遇到。一些现有的项目将需要时间来采用新的特性,而一些现有的项目将永远不会采用新的特性,因为采用的好处相对于它们的成本来说是不合理的。具体到 pdCalc,图形用户界面不能模块化为用户界面模块的一个分区,因为在撰写本文时,Qt 的元对象编译器(MOC)与 C++ 模块不兼容。因此,虽然我最初打算让 pdCalc 的 GUI 作为用户界面模块的一个分区出现,但是它是使用传统的头文件界面设计的。本质上,这种设计意味着 GUI 是一个独立的、遗留的、“纯逻辑”模块。
pdCalc 的模块化稍微偏离表 2-2 的最后一个原因是表 2-2 没有包含整个接口。一些次要的功能被有意地从表中省略了(例如,构造器,测试工具代码),当然,一些必要的功能在设计的这个阶段还不能被预期。表 2-2 中定义的模块接口将随着我们设计 pdCalc 而扩展。
pdCalc 中模块的代码表示
我们现在准备列出 pdCalc 的最终模块,并从表面上解释每个模块存在的原因。第 3 到 7 章将详细探讨这些模块。
首先,我们在表 2-2 中定义了三个模块,它们源自 pdCalc 的模型-视图-控制器架构的实现。这些模块被命名为stack
(模型,章节 3 )、userInterface
(视图,章节 5 和 6 )、commandDispatcher
(控制器,章节 4 )。每个模块被分成许多分区,这些分区包括实现这些模块的内部类和功能,从而允许模块的逻辑被分布到内聚的子单元中,同时仍然保持模块级封装。如前所述,虽然由于 Qt 不兼容,pdCalc 的 GUI 不能使用 C++20 语法进行正式模块化,但它在逻辑上属于userInterface
模块。通过包含适当的头文件而不是通过一个import
语句来访问userInterface
模块的 GUI 部分。显然,userInterface
模块的 GUI 组件并没有从 C++20 对模块的新语言支持中受益。
第二,如前所述,pdCalc 需要一个utilities
模块。utilities
模块由一个Exception
级、Publisher
级、Observer
级和Tokenizer
级组成。每个类都包含在一个模块分区中。第三章中详细描述了Publisher
和Observer
类,在那里它们被用作实现事件的基础构件。第五章介绍了Tokenizer
类,它将输入的字符流分解成不同的词汇标记。
下一个模块系列是那些需要成为可独立发布的工件的模块。pdCalc 包含三个这样的模块:command
、plugin
和stackInterface
模块。这些模块需要是可独立分发的,因为每个模块接口都必须分发给插件实现者。command
模块包含执行命令所需的抽象类(如加、减、输入数字、撤销等)。).当我们在第四章讨论命令模式时,我们会遇到这些命令类。plugin
模块包含定义一个 pdCalc 消耗插件所需的抽象类。插件在第七章有深入讨论。stackInterface
模块将Stack
类的 C++ 风格接口转换成普通的 C 风格接口。第七章也描述了为什么插件需要这个步骤。
我们之前提到的下一个模块是管理插件的模块。具体来说,pluginManagement
模块查找插件,加载插件,卸载插件,将插件的功能注入到 pdCalc 中。第七章讨论了pluginManagement
模块的实现。
用于 pdCalc 的模块和 dll
在第 2.5.5 节中,我们定义了八个不同的 C++ 模块。然而,八个模块并不立即意味着需要八个 dll。那么正确的数字是多少呢?
实际上,pdCalc 足够小,可以很容易地将整个代码捆绑到一个库中。然而,出于指导性的目的,我选择将 pdCalc 细分成几个不同的 dll,有些只包含一个模块,有些包含多个模块。最初,我打算创建五个 dll,分别用于模型、视图、控制器、实用程序和插件管理。这五个模块代表了 pdCalc 最高层分解的逻辑架构。剩余的三个模块由于创建可独立分发的工件所需的语法规则而单独存在;它们不保证独立的 dll。然而,stack
模块只是一个单一的模块接口文件。为这个模块创建一个 DLL 的开销看起来比价值更大。一旦我意识到集中是必要的,我决定将控制器、插件管理和栈模块合并成一个统一的后端 DLL。最终结果是,pdCalc 被分成三个 DLL:一个实用程序 DLL,一个后端 DLL 和一个用户界面 DLL。当然,根据定义,任何插件本身必须包含在单独的 dll 中。应用程序的主例程被编译成自己的可执行文件。
三个 dll 是 pdCalc 共享库的正确数量吗?不完全是。我认为 1 到 5 之间的任何数量的 dll 都是合理的。正如在设计中经常发生的那样,通常没有正确或错误的答案,只有取舍。这里,我们在简单性和 DLL 内聚性之间权衡利弊。有时,没有令人信服的优势或劣势来区分选择。在这些交叉点上,你只需要做一个决定,记录下来,然后继续下一个任务。建筑学不是从错误中选择正确的科学,因为专家会立即抛弃错误。更确切地说,体系结构是一门艺术,它从一系列好的选择中选择出能够优化给定需求的设计的决策。好的架构并不总是“正确的”,但它应该总是有意的。
2.6 后续步骤
我们从这里去哪里?我们现在已经建立了计算器的总体架构,但是我们如何处理选择首先设计和实现哪个组件的任务呢?在公司环境中,对于大规模的项目,可能会同时设计和编码许多模块。毕竟,这难道不是创建由接口清晰分隔的不同模块的主要原因之一吗?当然,对于我们的项目,模块将被顺序处理,通过某种程度的迭代来进行后验改进。所以一定要选择一个模块先设计构建。
在组成模型-视图-控制器设计的三个主要模块中,最合理的起点是对其他模块依赖最少的模块。从图 2-3 中,我们看到,事实上,栈是唯一一个不依赖于其他模块接口的模块。栈中唯一指向外的箭头是虚线,这意味着通信是通过事件间接进行的。尽管该图清楚地表明了这一决定,但是如果没有体系结构图,人们可能会得出相同的结论。栈本质上是一个独立的数据结构,易于独立实现和测试。一旦栈完成并经过测试,就可以将其集成到其余模块的设计和测试中。因此,我们通过设计和实现栈来开始下一级的分解。
三、栈
栈是我们将要设计和实现的计算器的第一个模块。虽然我们在第二章中定义了模块的公共接口,但我们对它的实现说得很少。我们现在需要将栈分解成提供模块功能的函数和类。因此,这是我们开始的地方。如果你对栈数据结构的机制有点生疏,现在是查阅你最喜欢的数据结构和算法书籍的好时机。我个人最喜欢的是科尔曼等人的作品[10]。
3.1 栈模块的分解
在分解栈模块时要问的第一个问题是,“栈应该分成多少块?”在面向对象的说法中,我们问,“我们需要多少对象,它们是什么?”在这种情况下,答案相当明显:一,栈本身。本质上,整个栈模块是单个数据结构的表现,可以很容易地用单个类封装。这个类的公共接口已经在第二章描述过了。
人们可能会问的第二个问题是,“我需要构建一个类吗?或者我可以直接使用标准模板库(STL) stack
类吗?”这其实是一个很好的问题。所有的设计书籍都宣扬,当您可以使用库中的数据结构时,您不应该编写自己的数据结构,尤其是当数据结构可以在 STL 中找到时,STL 保证是符合标准的 C++ 发行版的一部分。事实上,这是明智的建议,我们不应该重写栈数据结构的机制。然而,我们也不应该在我们的系统中直接使用 STL stack
作为栈。相反,我们将编写自己的 stack 类,将 STL 容器封装为私有成员。
假设我们选择使用 STL stack
来实现栈模块。与直接利用相比,人们更喜欢封装 STL 容器(或来自任何供应商的数据结构)有几个原因。首先,通过包装 STL stack
,我们为计算器的其余部分添加了一个接口保护。也就是说,我们通过将栈的接口与其实现分离,将其他计算器模块与底层栈实现的潜在变化隔离开来(还记得封装吗?).当使用供应商软件时,这种预防措施可能特别重要,因为这种设计决策将对包装器实现的更改本地化,而不是对栈模块接口的更改。如果供应商修改其产品的接口(供应商都是这样狡猾的),或者您决定用一个供应商的产品替换另一个供应商的产品,这些更改只会在本地影响您的栈模块的实现,而不会影响栈模块的调用方。即使底层实现是标准化的,比如 ISO 标准化 STL stack
,接口保护也能让用户在不影响相关模块的情况下改变底层实现。例如,如果您改变了主意,后来决定使用vector
而不是stack
来重新实现您的栈类,该怎么办?
包装 STL 容器而不是直接使用它的第二个原因是,这个决定允许我们限制接口以完全符合我们的需求。在第二章中,我们花费了大量精力为栈模块设计了一个有限的、最小的接口,能够满足 pdCalc 的所有用例。通常,底层实现可能提供比您实际希望公开的更多的功能。如果我们直接选择 STL stack
作为栈模块,这个问题不会很严重,因为 STL stack
的接口与我们为计算器的栈定义的接口非常相似,这并不奇怪。然而,假设我们选择了 Acme Corporation 的RichStack
类及其 67 个公共成员函数作为我们的栈模块。一个初级开发人员,如果忽略了阅读设计规范,可能会在不知不觉中调用一个本不应该在应用程序上下文中公开的RichStack
函数,从而违反了我们的栈模块的一些隐式设计契约。虽然这种滥用可能与模块的文档化接口不一致,但是我们不应该依赖其他开发人员真正阅读或遵守文档(可悲,但却是事实)。如果您可以通过编译器可以强制执行的语言构造(例如,访问限制)来强制防止误用的发生,请这样做。
包装 STL 容器的第三个原因是扩展或修改底层数据结构的功能。例如,对于 pdCalc,我们需要添加 STL stack
类中没有的两个函数(getElements()
和swapTop()
),并将错误处理从标准异常转换为自定义错误事件。因此,包装类使我们能够修改 STL 的标准容器接口,以便我们能够符合我们自己内部设计的接口,而不是被 STL 提供给我们的功能所束缚。
正如人们所预料的,前面描述的封装场景经常出现,因此已经被编码为一种设计模式,适配器(包装器)模式[11]。正如 Gamma 等人所描述的,适配器模式用于将一个类的接口转换成客户机期望的另一个接口。通常,适配器提供了某种形式的转换功能,从而也充当了不兼容类之间的代理。
在模式的原始描述中,适配器被抽象为允许一条消息通过多态使用适配器类层次结构包装多个不同的适配器。对于 pdCalc 的栈模块的需求,一个简单的具体适配器类就足够了。记住,设计模式的存在是为了帮助设计和交流。尽量不要陷入完全按照文本中规定的方式实现模式的陷阱。使用文献作为指导来帮助阐明您的设计,但是,最终,更喜欢实现适合您的应用的最简单的解决方案,而不是最接近学术理想的解决方案。
我们应该问的最后一个问题是,“我的栈应该是通用的(即模板化的)吗?”这里的答案是一个响亮的也许。理论上,设计一个抽象的数据结构来封装任何数据类型都是合理的做法。如果数据结构的最终目标是出现在一个库中或者被多个项目共享,那么数据结构应该是一般化的。然而,在单个项目的环境中,我不建议将数据结构通用化,至少一开始不建议。通用代码更难编写,更难维护,更难测试。除非预先存在多种类型使用场景,否则我觉得编写通用代码不值得这么麻烦。我已经完成了太多的项目,在这些项目中,我花了额外的时间来设计、实现和测试一个通用数据结构,只是为了将它用于一种类型。实际上,如果你有一个非泛型的数据结构,突然发现你需要把它用于一个不同的类型,必要的重构通常不会比类从一开始就被设计成泛型更困难。此外,现有的测试将很容易适应通用接口,为单一类型建立的正确性提供基线。因此,我们将把我们的栈设计成double
特定的。
3.2 栈类
既然我们已经确定我们的模块将包含一个类,一个底层栈数据结构的适配器,我们开始设计它。设计类时首先要问的一个问题是,“这个类将如何被使用?”例如,你是否设计了一个抽象基类来继承,从而可以多态地使用?你设计一个类主要是作为一个普通的旧数据(POD)仓库吗?在任何给定的时间,这个类会有许多不同的实例吗?任何给定实例的生命周期是多长?谁通常拥有这些类的实例?实例会被共享吗?这个类会并发使用吗?通过提出这些和其他类似的问题,我们发现了我们栈的以下功能需求列表:
-
系统中应该只有一个栈。
-
栈的生命周期就是应用程序的生命周期。
-
UI 和命令调度器都需要访问栈;两者都不应该拥有栈。
-
栈访问不是并发的。
只要满足前面提到的前三个标准,这个类就是单例模式的绝佳候选[11]。
单例模式
singleton 模式用于创建一个类,在这个类中,系统中应该只存在一个实例。singleton 类不属于它的任何消费者,但是类的单个实例也不是全局变量(然而,有些人认为 singleton 模式是伪装的全局数据)。为了不依赖荣誉系统,使用语言机制来确保只有一个实例化存在。
此外,在单例模式中,实例的生命周期通常是从第一次实例化开始,直到程序终止。根据实现的不同,可以创建线程安全的或者仅适用于单线程应用程序的单件。关于不同 C++ 单例实现的精彩讨论可以在 Alexandrescu [5]中找到。对于我们的计算器,我们更喜欢满足我们目标的最简单的实现。
为了得到一个简单的单例实现,我们参考我们的 C++ 语言知识。首先,如前所述,没有其他类拥有单例实例,单例实例也不是全局对象。这意味着单例类需要拥有它的单个实例,并且所有权访问应该是私有的。为了防止其他类实例化我们的 singleton,我们还需要将它的构造器和赋值操作符私有或删除。第二,知道系统中应该只存在一个 singleton 实例意味着我们的类应该静态地保存它的实例。最后,其他类将需要访问这个实例,我们可以通过一个公共静态函数来提供。结合前面提到的要点,我们为 singleton 类构建了以下 shell:
class Singleton
{
public:
static Singleton& Instance
{
static Singleton instance;
return instance;
}
void foo(){ /* does foo things */ }
private:
// prevent public instantiation, copying, assignment, movement,
// & destruction
Singleton() { /* constructor */ }
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
Singleton(Singleton&&) = delete;
Singleton&& operator=(Singleton&&) = delete;
~Singleton() { /* destructor */ }
};
Singleton
类的静态实例保存在函数作用域而不是类作用域,以防止在一个单例类的构造器依赖于另一个单例的情况下出现不可控的实例化顺序冲突。C++ 的实例化排序规则的细节超出了本书的范围,但是可以在 Alexandrescu [5]中找到关于单例的详细讨论。
注意,由于缺少对一个实例访问的锁定,我们的模型 singleton 目前只适合单线程环境。在这个多核处理器时代,这样的限制明智吗?对于 pdCalc,绝对!我们的简单计算器不需要多线程。编程很难。多线程编程要难得多。除非绝对必要,否则不要把简单的设计问题变成困难的问题。
现在我们有了一个Singleton
类的外壳,让我们看看如何使用它。为了访问实例并调用foo()
函数,我们只需使用以下代码:
Singleton::Instance().foo();
在对Instance()
函数的第一次函数调用中,instance
变量被静态实例化,并返回对该对象的引用。因为在函数作用域静态分配的对象会一直保留在内存中,直到程序终止,instance
对象在Instance()
函数作用域结束时不会被析构。在将来对Instance()
的调用中,instance
变量的实例化被跳过(它已经从之前的函数调用中构造好并存在内存中),对instance
变量的引用被简单地返回。注意,虽然底层的单例实例是静态的,但是foo()
函数本身并不是静态的。
好奇的读者现在可能会问,“为什么要费心保存一个类的实例呢?为什么不干脆将所有数据和Singleton
类的所有函数都变成静态的呢?”原因是因为单例模式允许我们在需要实例语义的地方使用Singleton
类。这些语义的一个特别重要的用途是在回调的实现中。举个例子,以 Qt 的信号和插槽机制(我们会在第六章中遇到信号和插槽)为例,它可以被松散地解释为一个强大的回调系统。为了将一个类中的信号连接到另一个类中的插槽,我们必须提供指向两个类实例的指针。如果我们在没有Singleton
类的私有实例化的情况下实现了我们的 singleton(也就是说,只利用静态数据和静态函数),那么将我们的Singleton
类与 Qt 的信号和插槽一起使用将是不可能的。
3.2.2 作为单例类的栈模块
我们现在拥有了栈模块的基本设计。我们决定将整个模块封装在一个类中,这个类实质上充当了 STL 容器的适配器。我们已经决定我们的一个类符合单例的模型标准,这个单例类将拥有在第二章中设计的公共接口。将这些设计元素结合起来,我们就有了类的初始声明。
// All module names in the repository source code are separated by
// underscores instead of periods due to a Visual Studio compiler bug.
// The book text uses the more conventional period as the module name
// separator (i.e., pdCalc_stack in source code).
export module pdCalc.stack;
export class Stack
{
public:
static Stack& Instance();
void push(double);
double pop();
void getElements(int, vector<double>&) const;
void swapTop();
private:
Stack();
~Stack();
// appropriate blocking of copying, assigning, moving...
deque<double> stack_;
};
Listing 3-1The stack as a singleton
因为这本书的重点是设计,除非细节特别有指导意义或者突出了设计的关键元素,否则在正文中不提供每个成员函数的实现。提醒一下,pdCalc 的完整实现可以从 GitHub 资源库下载。偶尔,存储库源代码会是文本中出现的理想化接口的更复杂的变体。这将是本书其余部分的通用格式。
你可能注意到了,尽管 STL 提供了一个stack
容器,我们的Stack
类是用一个deque
实现的;太奇怪了。让我们绕一小段路来讨论这个相关的实现细节。我们花了很多时间回顾在Stack
的设计中使用适配器模式来隐藏底层数据结构的重要性。这个决定的理由之一是,它能够无缝地改变底层实现,而不会影响依赖于Stack
接口的类。问题是,“为什么Stack
的底层实现可能会改变?”
在我的第一个版本的Stack
实现中,我选择了底层数据结构 STL stack
。然而,我很快就遇到了使用 STL stack
的效率问题。我们的Stack
类的接口提供了一个getElements()
函数,使用户界面能够查看计算器栈的内容。不幸的是,STL stack
的接口没有提供类似的功能。查看 STL stack
顶部元素之外的元素的唯一方法是连续弹出stack
直到到达感兴趣的元素。显然,因为我们只是试图看到stack
的元素,而不是改变stack
本身,所以我们需要立即将所有条目推回到stack
上。有趣的是,对于我们的目的来说,STL stack
被证明是不适合实现栈的数据结构!一定有更好的解决办法。
幸运的是,STL 提供了另一种适合我们任务的数据结构,双端队列,或deque
。deque
是一个 STL 数据结构,其行为类似于vector
,除了deque
允许将元素推到它的正面和背面。尽管vector
被优化为在提供连续性保证的同时增长,但是deque
被优化为通过牺牲连续性来快速增长和收缩。这个特性正是有效实现栈所必需的设计权衡。事实上,实现 STL stack
最常见的方法就是简单地包装 STL deque
(是的,就像我们的Stack
,STL 的stack
也是适配器模式的一个例子)。幸运的是,STL deque
也允许非破坏性迭代,这是 STL stack
中额外缺少的需求,我们需要实现Stack
的getElements()
方法。我使用封装对接口隐藏了Stack
的实现,这很好。在意识到可视化 STL stack
的局限性后,我能够更改Stack
类的实现来使用 STL deque
,而不会影响 pdCalc 的任何其他模块。
3.3 添加事件
构建符合第二章中栈接口的Stack
的最后一个必要元素是事件的实现。事件是弱耦合的一种形式,它允许一个对象(通知者或发布者)向任意数量的其他对象(侦听器或订阅者)发出信号,告知发生了一些有趣的事情。耦合很弱,因为通知者和监听器都不需要直接知道对方的接口。事件的实现方式依赖于语言和库,即使在一种给定的语言中,也可能存在多种选择。比如在 C#中,事件是核心语言的一部分,事件处理相对容易。在 C++ 中,我们就没有这么幸运了,必须实现我们自己的事件系统,或者依赖一个提供这种功能的库。
C++ 程序员有几个已发布的库选项来处理事件;这些选择中最突出的是 boost 和 Qt。boost 库支持信号和插槽,这是发布者通过回调向订阅者发送事件信号的静态类型机制。另一方面,Qt 提供了完整的事件系统和动态类型的事件回调机制,巧合的是,这也被称为信号和插槽。这两个库都有很好的文档记录,经过了很好的测试,受到了广泛的尊重,并且可以用于开源和商业用途。这两个库都是在我们的计算器中实现事件的可行选择。然而,出于指导性的目的,也为了最小化我们的计算器后端对外部库的依赖性,我们将实现我们自己的事件系统。在设计您自己的软件时,做出适当的决定是非常依赖于具体情况的,您应该检查使用库与为您自己的应用程序构建自定义事件处理的利弊。也就是说,除非有令人信服的理由,否则默认情况下应该使用库。
3.3.1 观察者模式
因为事件是一个如此普遍实现的 C++ 特性,所以您可以放心,描述事件的设计模式是存在的;这个模式就是观察者。观察者模式是发布者和监听器的抽象实现的标准方法。正如该模式的名称所暗示的,在这里,侦听器被称为观察者。
在 Gamma 等人[11]描述的模式中,具体发布者实现抽象发布者接口,具体观察者实现抽象观察者接口。名义上,实现是通过公共继承实现的。每个发布者拥有一个观察者容器,发布者的接口允许附加和分离观察者。当事件发生(引发)时,发布者循环访问其观察器集合,并通知每个观察器事件已经发生。通过虚拟调度,每个具体的观察者根据自己的实现来处理这个通知消息。
观察器可以通过两种方式之一接收来自发布者的状态信息。首先,一个具体的观察者可以有一个指向它所观察的具体发布者的指针。通过这个指针,观察者可以查询事件发生时发布者的状态。这种机制被称为拉语义。或者,可以实现推送语义,从而发布者将状态信息与事件通知一起推送给观察者。在图 3-1 中可以找到展示推送语义的观察者模式的简化类图。
图 3-1
观察者模式的类图的简化版本,因为它是为 pdCalc 实现的。该图说明了事件数据的推送语义
增强观察者模式的实现
在我们的计算器的实际实现中,除了图 3-1 中描述的抽象之外,还添加了几个额外的特性。首先,在图中,每个发布者拥有一个观察者列表,当事件发生时,所有的观察者都会得到通知。然而,这种实现意味着发布者只有一个事件,或者发布者有多个事件,但是无法区分每个事件调用了哪些观察者。一个更好的 publisher 实现将一个关联数组保存到观察者列表中。以这种方式,每个发布者可以有多个不同的事件,每个事件只通知有兴趣观看该特定事件的观察者。虽然关联数组中的键在技术上可以是设计者选择的任何合适的数据类型,但我选择对计算器使用字符串。也就是说,发布者通过名称来区分各个事件。这种选择增强了可读性,并使运行时能够灵活地添加事件(比如说,选择枚举值作为键)。
一旦 publisher 类可以包含多个事件,程序员就需要能够在调用attach()
或detach()
时通过名称指定事件。因此,这些方法签名必须根据它们在图 3-1 中的显示进行适当的修改,以包含一个事件名称。对于附件,方法签名通过添加事件的名称来完成。调用者只需用具体的观察器实例和该观察器所连接的事件的名称来调用attach()
方法。然而,将观察者与发布者分离需要稍微复杂一些的机制。由于发布者中的每个事件可以包含多个观察者,程序员需要能够区分观察者以实现分离。自然地,这个需求也导致了对观察者的命名,并且必须修改detach()
函数签名以适应观察者和事件的名称。
为了便于分离观察器,每个事件上的观察器应该被间接存储,并通过它们的名称来引用。因此,我们没有存储观察器列表的关联数组,而是选择使用观察器关联数组的关联数组。
在现代 C++ 中,程序员可以选择使用map
或unordered_map
作为关联数组的标准库实现。这两种数据结构的规范实现分别是红黑树和哈希表。因为关联数组中元素的顺序并不重要,所以我为 pdCalc 的Publisher
类选择了unordered_map
。然而,对于订阅每个事件的少数观察者来说,这两种数据结构都是同样有效的选择。
到目前为止,我们还没有详细说明观察者是如何存储在发布器中的,只知道它们以某种方式存储在关联数组中。因为观察器是以多种形式使用的,所以语言规则要求通过指针或引用来保存它们。那么问题就变成了,发布者应该拥有观察者还是仅仅引用其他类拥有的观察者?如果我们选择引用路径(通过引用或原始指针),那么除了发布者之外,还需要一个类来拥有观察者的内存。这种情况是有问题的,因为不清楚在任何特定情况下谁应该拥有观察器。因此,每个开发人员可能会选择不同的选项,长期来看,观察者的维护会陷入混乱。更糟糕的是,如果观察器的所有者释放了观察器的内存,而没有将观察器从发布器分离,则触发发布器的事件将导致崩溃,因为发布器将持有对观察器的无效引用。由于这些原因,我更喜欢让发布者拥有观察者的记忆。
避开了引用,我们必须使用所有权语义,并且,由于 C++ 的多态机制,我们必须通过指针实现所有权。在现代 C++ 中,指针类型的唯一所有权是通过unique_ptr
实现的(参见现代 C++ 关于所有权语义的侧栏,以理解设计含义)。将前面所有的建议放在一起,我们能够为Publisher
类设计最终的公共接口:
// Publisher.m.cpp
export module pdCalc.utilities:Publisher;
import :Observer;
export class Publisher
{
using ObserversList = unordered_map<string, unique_ptr<Observer>>;
using Events = unordered_map<string, ObserversList>;
public:
void attach(const string& eventName,
unique_ptr<Observer> observer);
unique_ptr<Observer> detach(const string& eventName,
const string& observerName);
// ...
private:
Events events_;
};
注意,Publisher
是从utilities
模块的Publisher
分区导出的。utilities
模块的Observer
分区被导入以提供Observer
类的定义。乍一看,您可能想知道为什么要导入Observer
模块分区,而不是简单地向前声明Observer
类。毕竟,在Publisher
的声明中,只有不完整的Observer
类型用于声明Observer
智能指针。然而,Publisher.m.cpp
文件包含了分区接口单元及其实现。因此,对于Publisher
的定义,这个文件中需要Observer
类的完整定义。如果Publisher
分区被分割成独立的接口和实现文件,那么接口将只需要一个Observer
的前向声明。
Observer
类的接口比Publisher
类的接口简单得多。然而,因为我们还没有描述如何处理事件数据,我们还没有准备好设计Observer
的接口。我们将在“处理事件数据”一节中讨论事件数据和Observer
类的接口。
Modern C++ Design Note: Owning Semantics and Unique_Ptr
在 C++ 中,拥有一个对象的概念意味着当不再需要这个对象时,有责任删除它的内存。在 C++11 之前,尽管任何人都可以实现自己的智能指针(很多人都这样做了),但该语言本身并没有表达指针所有权的标准语义(除了auto_ptr
,它在 C++11 中被弃用,在 C++17 中被完全删除)。通过本机指针传递内存更像是一个信任问题。也就是说,如果你“新建”了一个指针,并通过原始指针将它传递给一个库,你希望库在使用完它时删除内存。或者,库的文档可能会通知您在执行某些操作后删除内存。如果没有标准的智能指针,在最坏的情况下,你的程序会泄漏内存。在最好的情况下,您必须使用非标准智能指针连接到库。
C++11 通过标准化一组主要从 boost 库中借用的智能指针纠正了未知指针所有权的问题。unique_ptr
最终允许程序员正确地实现唯一所有权(因此不赞成使用auto_ptr
)。从本质上来说,unique_ptr
确保了在任何时候只有一个指针的实例存在。对于执行这些规则的语言,没有实现对unique_ptr
的复制和非移动赋值。相反,使用移动语义来确保所有权的转移(显式函数调用也可以用于手动管理内存)。Josuttis [13]对使用unique_ptr
的机制提供了极好的详细描述。需要记住的重要一点是不要在unique_ptr
和原始指针之间混合指针类型。
从设计的角度来看,unique_ptr
意味着我们可以使用标准 C++ 编写接口,明确表达独特的所有权语义。正如在 observer 模式的讨论中所看到的,在一个类创建内存供另一个类使用的任何设计中,惟一的所有权语义都是非常重要的。例如,在计算器的事件系统中,虽然事件的发布者应该拥有它的观察器,但是发布者很少有足够的信息来创建它的观察器。因此,能够在一个位置为观察者创建内存,但能够将该内存的所有权传递给另一个位置,即发布者,这一点很重要。unique_ptr
提供这种服务。因为观察者是通过一个unique_ptr
传递给发布者的,所以所有权转移给了发布者,当发布者不再需要观察者时,智能指针会删除观察者的内存。或者,任何类都可以从发布者那里收回一个观察者。由于detach()
方法在unique_ptr
中返回观察者,发布者显然通过将观察者的内存转移回调用者而放弃了它的所有权。
观察者模式的上述实现明确地实施了一种设计,其中Publisher
拥有它的Observer
。使用这种实现的最自然的方式是创建小的、专用的、中间的Observer
类,这些类本身持有指针或对应该响应事件的实际类的引用。比如从第二章,我们知道 pdCalc 的用户界面是Stack
类的观察者。然而,我们真的希望用户界面是如图 3-2a 所示的Stack
所拥有的Observer
吗?不会。图 3-2c 描述了一个更好的解决方案。这里,Stack
拥有一个栈ChangeEvent
观察器,当栈改变时,它依次通知UserInterface
。这种模式使得Stack
和UserInterface
能够保持真正的独立。当我们在第五章中研究我们的第一个用户界面时,我们会对这个话题进行更多的讨论。
图 3-2
观察者模式的不同所有权策略
现代 C++ 确实承认观察者模式的所有权语义的另一个合理的替代方案:共享所有权。正如我们之前所说的,Stack
拥有用户界面是不合理的。然而,有些人可能认为创建一个额外的ChangeEvent
中间类而不是直接让用户界面成为观察者同样不合理。唯一的折中选择似乎是让Stack
引用用户界面。但是,之前我们说过让发布者引用它的观察者是不安全的,因为观察者可能会从发布者下面消失,留下一个悬空的引用。如果我们能解决这个悬而未决的引用问题呢?
幸运的是,现代 C++ 再一次用共享语义拯救了我们(如图 3-2b 所示)。在这个场景中,观察者将使用一个shared_ptr
(参见关于shared_ptr
s 的侧栏)来共享,而发布者将保留一个对具有weak_ptr
(相对于shared_ptr
)的观察者的引用。weak_ptr
是专门为减轻对共享对象的悬空引用而设计的。Meyers [24]在第 20 项中描述了发布者共享观察者所有权的设计。就我个人而言,我更喜欢使用拥有语义和轻量级专用观察者类的设计。
处理事件数据
在描述观察者模式时,我们提到了两种不同的处理事件数据的范例:拉和推语义。在拉语义中,观察者被简单地通知事件已经发生。然后,观察者具有获取可能需要的任何额外数据的额外责任。实现非常简单。观察器维护对任何对象的引用,它可能需要从该对象获取状态信息,并且观察器调用成员函数来获取该状态以响应事件。
拉语义有几个优点。首先,观察者可以在处理事件时选择它想要获得的确切状态。其次,在向观察者传递潜在未使用的参数时,不会消耗不必要的资源。第三,拉语义很容易实现,因为事件不需要携带数据。然而,拉语义也有缺点。首先,拉语义增加了耦合性,因为观察者需要引用并理解发布者的状态获取接口。第二,观察者只能访问发布者的公共接口。这种访问限制使得观察者无法从发布者处获得私人数据。
与拉语义相反,推语义是通过让发布者在事件被引发时发送与该事件相关的状态数据来实现的。观察器然后接收这个状态数据作为通知回调的参数。该接口通过在抽象基类Observer
中使 notify 函数成为纯虚拟的来实施推送语义。
事件处理的推送语义也有优点和缺点。第一个优点是推语义减少了耦合。发布者和观察者都不需要知道彼此的接口。他们只需要服从抽象事件接口。其次,发布者可以在推送状态时向观察者发送私有信息。第三,作为引发事件的对象,发布者可以准确地发送处理事件所需的数据。推送语义的主要缺点是,在观察者不需要发布者推送的状态数据的情况下,实现起来稍微困难一些,并且可能带来不必要的开销。最后,我们注意到,对于特殊情况,使用 push 语义的设计总是可以通过添加对 push 数据的回调引用,用 pull 语义进行简单的扩充。反之则不然,因为推送语义需要事件处理机制中的专用基础设施。
基于前面描述的推和拉语义之间的权衡,我选择为 pdCalc 的事件处理实现推语义。推送语义的主要缺点是实现的潜在计算开销。然而,由于我们的应用程序不是性能密集型的,所以这种模式所表现出的耦合性降低和发布者维护的参数控制超过了轻微的性能开销。我们现在的任务是设计一个实现,通过推送语义传递事件数据。
为了实现事件处理的推语义,必须标准化接口,以便在事件发生时将参数从发布者传递给观察者。理想情况下,每个发布者/观察者对要传递的参数类型达成一致,当事件发生时,发布者将调用观察者上适当的成员函数。然而,在我们的发布者/观察者类层次结构中,这种理想情况实际上是不可能的,因为具体的发布者不知道具体的观察者的接口。具体的发布者只能通过调用Publisher
基类中的raise()
函数来引发事件。反过来,raise()
函数通过Observer
基类的虚拟notify()
函数多态地通知一个具体的观察者。因此,我们寻求一种通用的技术,通过抽象的 raise/notify 接口传递定制的数据。
本质上,我们的问题归结为定义一个到notify(T)
的接口,使得T
可以包含任何类型的数据,包括数据可能为空的情况。我介绍了完成这项任务的两种类似技术;只有第二个在 pdCalc 中实现。第一种技术更像是基于多态设计的“经典”解决方案。这是我在第一版中展示的唯一设计。第二种解决方案是基于一种更现代的技术,称为类型擦除。如果你愿意写很多锅炉板代码,类型擦除在 C++17 之前是可能的。然而,C++17 中引入的any
类使得对对象应用这种技术变得微不足道。这种技术被称为类型擦除,因为对象的类型在传递给any
类时被“擦除”,只有在对象被提取时才被any_cast
重新创建。让我们依次检查每个解决方案。
为了将多态解决方案应用于事件数据问题,我们为事件数据创建了一个并行对象层次结构,并通过这个抽象状态接口将事件数据从发布者传递给观察者。这个层次结构中的基类EventData
是一个空类,只包含一个虚析构函数。然后,每个需要参数的事件都会对这个基类进行子类化,并实现任何被认为合适的数据处理方案。当事件被引发时,发布者通过一个EventData
基类指针将数据传递给观察者。收到数据后,具体的观察器将状态数据向下转换到具体的数据类,然后通过派生类的具体接口提取必要的数据。虽然具体的发布者和具体的观察者必须就数据对象的接口达成一致,但是具体的发布者和具体的观察者都不需要知道对方的接口。因此,我们保持松散耦合。
事件数据问题的类型擦除解决方案在概念上类似于多态方法,除了我们不需要一个EventData
基类。相反,标准的any
类代替了接口中的抽象基类(参见讨论any
、variant
和optional
的侧栏)。只要具体的发布者和具体的观察者对这个类中包含的内容达成一致,任何对象,包括内置类型,都可以作为数据传递。发布者通过一个any
对象传递一个具体类型,观察者通过any_cast
事件数据有效负载重新创建适当的具体类型,从而执行该协议。和以前一样,虽然在具体的发布者和具体的观察者之间必须存在关于数据的隐式协议,但是他们都不需要知道对方的接口。
Modern C++ Design Note: Using Std::Any, Std::Variant, Std::Optional
, and Structured Bindings
C++17 标准库引入了三种新的有用的类型:std::any
、std::variant
和std::optional
。any
设计用于保存任何类型——逻辑上等同于类型安全的 void 指针。它是对象类型擦除的一般实施例。variant
提供类型安全的联合。optional
实现可空类型。让我们来看一个简单的例子,看看它们是如何使用的。
any
的用法和你所想的完全一样。也就是说,any
是一个可以保存任何值的对象,而无需事先指定所包含值的类型。例如:
any a = 7; // assign an int
a = "hello"; // now assign a const char*
cout << any_cast<int>(a); // a not an int; throws std::bad_any_cast
cout << any_cast<const char*>(a); // works as expected
正文中展示了一个更实际的例子,使用any
在事件之间传递任意数据。
当你需要一个容器能够容纳一组特定的预先知道的类型中的任何一个时,就使用一个union
。union
的内存效率非常高,因为它们仅拥有足够的内存来保存最大的类型。考虑以下支持的语言union
:
union
{
int i;
double d;
} w;
w.i = 102; // ok, assign an int
cout << w.i; // no problem
cout << w.d; // oops, this "works" but results in nonsense
w.d = 107.3; // no problem
标准库variant
是基于相同概念的类型安全改进。使用variant
,我们可以以类型安全的方式编写与前面描述的代码相同的代码:
variant<int, double> v;
v = 102; // ok, assign an int
cout << std::get<int>(v); // no problem
cout << std::get<double>(v); // throws std::bad_variant_access
v = 107.3; // no problem
就我个人而言,我很少使用联合。然而,当需要联合时,我强烈倾向于标准库variant
而不是本地语言union
。
现在我们来考察一下optional
是如何使用的。你见过类似下面的代码吗:
pair<bool, double> maybeReturnsDouble(); // function declaration
// ok, but tedious:
auto [flag, val] = maybeReturnsDouble();
if(flag) { /* ok to use val */ }
// downright dreadful (and common in computational code!):
const double NullDouble = -999999999.0
double d = maybeReturnsDouble();
if(d != NullDouble) { /* ok to use d */ }
前面的攻击是必要的,因为 C++ 内置类型(除了指针)不能表达空状态,该语言也不支持检查d
是否未初始化的工具。如果您选择不初始化d
,d
肯定是有效的双精度值,但是不能保证它的值是除了编译器分配给d
的字节中的位模式以外的任何值。这种行为经常会导致难以解释的错误,这些错误出现在发布版本中,但不会出现在调试版本中,因为调试模式通常会将未初始化的数字初始化为0
,而发布模式不会初始化未初始化的数字。因此,以下代码在发布和调试模式下的行为有所不同:
int flag; // uh oh, forgot to initialize
// flag == 0 for debug but probably not 0 for release
if(flag) {/* will likely execute this path for release */}
else {/* will execute this path for debug */}
我花了很多时间向初级程序员解释,不,他们不只是发现了一个编译器错误,而是编译器发现了他们的错误。
标准库optional
类使程序员能够避免前面的问题。考虑以下代码:
optional<double> maybeReturnsDouble(); // new function declaration
auto d = maybeReturnsDouble();
if(d) { /* ok to use d */ }
啊,好多了!显然,d
转换为bool
,如果d
为非空,则返回true
。如果你喜欢更详细的语法,你可以调用has_value()
成员函数。可以通过解引用(即*d
)或通过value()
成员函数来访问d
的值。如果一个optional
没有被初始化,用空的构造器初始化(即{}
),或者用nullopt
显式初始化,那么它被认为是空的。
您是否注意到前面的代码中有什么语法上的奇怪之处?让我们重复一句看起来很陌生的台词:
auto [flag, val] = maybeReturnsDouble();
前面的语法称为结构化绑定。C++17 中引入的结构化绑定为表达式的元素命名提供了语法上的便利。回想一下我们最初版本的maybeReturnsDouble()
,它返回一个pair<bool, double>
,首先指示double
是否被定义,其次指示double
本身的值。在结构化绑定之前,我们有几个使用返回值的选项:直接使用pair
的first
和second
成员(不透明和混乱),创建新的变量并将它们分配给pair
的first
和second
成员(清晰,但冗长),或者使用std::tie
(现在没有必要)。虽然该示例在绑定到可访问类成员的上下文中显示了结构化绑定,但是结构化绑定也可以用于绑定到类似元组的对象和数组。此外,如果底层元素必须通过绑定名称进行修改,那么可以将结构化绑定声明为const
或引用类型。虽然结构化绑定从根本上说不允许你做以前不能做的事情,但是它们确实很方便,并且通过紧凑的语法更好地表达了程序员的意图。我发现我经常使用它们。
为了巩固上述观点,让我们来看看计算器的Stack
是如何实现状态数据的。回想一下第二章,其中的Stack
实现了两个事件:stackChanged()
事件和error(string)
事件。在这种情况下,stackChanged()
事件是没有意义的,因为该事件不携带任何数据。然而,错误事件确实携带数据。考虑下面的代码,它解释了如何为多态或类型擦除技术实现Stack
的错误条件:
// Polymorphic event data strategy:
// Publisher.m.cpp
export class EventData
{
public:
virtual ~EventData();
};
// Stack.m.cpp
// export to become part of the stack module's interface
export class StackErrorData : public EventData
{
public:
enum class ErrorConditions { Empty, TooFewArguments };
StackErrorData(ErrorConditions e) : err_(e) { }
static const char* Message(ErrorConditions ec);
const char* message() const;
ErrorConditions error() const { return err_; }
private:
ErrorConditions err_;
};
// Type erasure event data strategy:
// Publisher.m.cpp - no code necessary in this file
// Stack.m.cpp
export public StackErrorData
{
// Same implementation as above, but no inheritance needed
};
StackErrorData
类定义了Stack
的事件数据如何打包并发送给观察Stack
的类。当栈模块中出现错误时,Stack
类会引发一个事件,并将有关该事件的信息推送给它的观察者。在这个实例中,Stack
创建了一个StackErrorData
的实例,指定了构造器中的错误类型。这个包含有限错误条件集的枚举类型可以使用message()
函数转换成一个字符串。当观察者得到事件发生的通知时,他们可以自由地使用或忽略这些信息。如果你注意的话,是的,我巧妙地改变了error()
接口的签名。
作为一个具体的例子,假设由于弹出一个空栈而触发了一个错误。为了引发这个事件,Stack
调用下面的代码:
// Polymorphic strategy:
raise(Stack::StackError(), make_shared<StackErrorData>(
StackErrorData::ErrorConditions::Empty));
// Type erasure strategy:
raise(Stack::StackError(),
StackErrorData{StackErrorData::ErrorConditions::Empty});
对于这两种策略,raise()
函数的第一个参数是一个静态函数,它返回一个解析为"error"
的字符串。回想一下,为了处理多个事件,发布者给每个事件命名。这里,Stack::StackError()
返回这个事件的名称。使用函数而不是直接使用字符串来防止由于在源代码中错误键入事件名称而导致的运行时错误。raise()
函数的第二个参数创建了StackErrorData
实例,并用空栈错误条件初始化它。对于多态策略,实现使用shared_ptr
清楚地传递事件数据。这个决定在关于共享语义的侧栏中讨论。对于类型擦除策略,构造一个StackErrorData
类,并将其作为构造器参数隐式传递给raise()
函数接口中的any
类。虽然还没有引入StackObserver
类,但是为了完整起见,我们注意到可以用以下代码来解释事件:
// Polymorphic strategy:
void StackObserver::notify(shared_ptr<EventData> d)
{
shared_ptr<StackErrorData> p = dynamic_pointer_cast<StackErrorData>(d);
if(p)
{
// do something with the data
}
else
{
// uh oh, what event did we just catch?!
}
}
// Type erasure strategy:
void StackObserver::notify(const any& d)
{
try
{
const auto& d = any_cast<StackErrorData>(data);
// do something with the data
}
catch(const std::bad_any_cast&)
{
// uh oh, what event did we just catch?!
}
}
为什么选择一种策略而不是另一种?就个人而言,我发现类型擦除方法比多态方法更简洁;在许多情况下,它也可能更有效率。首先,使用any
类比使用多态层次结构需要更少的代码。第二,使用any
类限制较少。虽然前面提到的例子在两种情况下都显示了使用StackErrorData
类的实例,但是any
可以用于存储简单类型,如double
或string
,完全不需要用户定义的类。最后,根据any
的实现,类型擦除方法可能比多态方法更有效。在多态方法总是需要使用shared_ptr
进行堆分配的情况下,any
的高质量实现将避免为适合小内存占用的对象进行堆分配。当然,多态方法确实有一个明显的优势。它应该在需要多态的情况下使用(例如,在使用虚函数而不是类型转换的接口中),或者在需要通过抽象接口实现强制的、一致的接口的情况下使用。如前所述,多态接口是为这本书的第一版实现的。现在 C++17 在标准库中包含了any
类,本书第二版中 pdCalc 的实现实现了类型擦除策略。
Modern C++ Design Note: Sharing Semantics and Shared_Ptr
鉴于unique_ptr
使程序员能够安全地表达唯一所有权,shared_ptr
使程序员能够安全地表达共享所有权。在 C++11 标准之前,C++ 通过原始指针或引用实现数据共享。因为类数据的引用只能在构造期间初始化,所以对于后期绑定数据,只能使用原始指针。因此,通常两个类共享一段数据,每个类都包含一个指向公共对象的原始指针。当然,这种情况的问题是不清楚哪个对象拥有共享对象。特别是,这种模糊性意味着不确定何时可以安全地删除这样的共享对象,以及哪个拥有对象最终应该释放内存。shared_ptr
让我们在标准库层面纠正这一困境。
shared_ptr
通过引用计数实现共享语义。当新对象指向一个shared_ptr
时,内部引用计数增加(通过构造器和赋值来强制)。当一个shared_ptr
超出范围时,它的析构函数被调用,这将减少内部引用计数。当计数变为零时,最后一个shared_ptr
的销毁会触发底层内存的回收。与unique_ptr
一样,显式成员函数调用也可以用来手动管理内存。Josuttis [13]对使用shared_ptr
的机制提供了极好的详细描述。与unique_ptr
一样,必须小心不要混淆指针类型。当然,这个规则的例外是与weak_ptr
混合使用。此外,引用计数会带来时间和空间开销,因此读者应该在部署共享指针之前熟悉这些权衡。
就设计考虑而言,shared_ptr
构造使程序员能够共享堆内存,而无需直接跟踪对象的所有权。通过值传递多态类型的对象不是一个选项,因为对于存在于层次结构中的对象,通过值传递对象会导致切片。然而,使用原始指针(或引用)来传递事件数据也是有问题的,因为这些数据对象的生命周期在共享它们的类中是未知的。考虑到 pdCalc 在使用多态事件数据策略时需要使用一个shared_ptr
。自然,发布者在引发事件时会分配内存。由于观察者可能希望在事件处理完成后保留内存,所以发布者不能在事件被处理后简单地释放内存。此外,因为可以为任何给定的事件调用多个观察者,所以发布者也不能将数据的唯一所有权转移给任何给定的观察者。对于 pdCalc 中的事件数据,我们看到 C++17 允许使用std::any
的替代设计。然而,类型擦除并不总是能够取代共享所有权。在需要共享所有权的地方,C++11 中标准化的shared_ptr
提供了理想的语义。
现在我们理解了事件数据,我们终于准备好编写抽象的Observer
接口了。不出所料,这正是你所期待的。
export module pdCalc.utilities:Observer;
export class Observer
{
public:
explicit Observer(std::string_view name);
virtual ~Observer();
virtual void notify(const any& data) = 0;
};
也许这个接口并不完全符合您的预期,特别是因为Observer
类的构造器使用了 C++17 中引入的 C++ 标准库的一个新特性string_view
。我们将暂停一下来讨论下面边栏中的string_view
。在短暂的转移之后,我们将通过演示Stack
如何发布事件来结束Stack
类接口的设计。
Modern C++ Design Note: Referring to Std::Strings with Std::String_View
在 C++17 之前,当引用一个不可变的字符串(特别是一个字符序列)时,我们通常使用const char*
或const string&
,这取决于底层的类型。为什么我们需要一个新的容器来引用字符串?
使用上述两种类型来引用字符串可能会有问题。首先,要使用一个const char*
,我们要么需要知道底层类型是一个char*
,要么我们需要将一个string
转换成一个const char*
。另外,const char*
不存储底层字符串的长度。相反,假设字符序列是空终止的(即以'\0'
结束)。相反,如果我们改为使用一个const string&
,如果底层类型已经是一个字符串,这很好,但是如果底层类型是一个const char*
,我们需要不必要地构造一个临时的string
。类别解决了这些问题。
string_view
类本质上是一个容器,它保存一个指向字符类型的常量指针和一个整数,该整数指定组成字符串的连续字符序列的长度。其实施的影响既有其优点,也有其不足之处。先说优势。
string_view
类最大的优点是非常高效,可以指向大多数用 C++ 表示的字符串类型。相对于普通的const char*
,string_view
更安全,因为string_view
知道它所代表的嵌入字符串的长度。作为一个类,string_view
也有更丰富的接口(尽管有人可能会说const char*
有丰富的库支持)。相对于一个const string&
,一个string_view
永远不会隐式地创建一个const char*
的临时副本,并且因为一个string_view
是不拥有的,它有非常有效的成员函数来实现像创建子字符串这样的功能。这种效率的提高是因为对string_view
的substr()
函数的调用返回一个新的string_view
,这不需要构造新的string
,只需要将一个字符指针(新的开始)和一个整数(新的长度)分配给同一个引用的原始字符串。
s 也有一些缺点。虽然string_view
知道自己的大小是有好处的,但这对于期望空终止字符串的库调用来说是不利的。从一个string_view
产生一个空终止字符串的最简单的方法是构造一个string
并使用它的c_str()
函数。在这一点上,使用一个const string&
将是更好的选择。另外两种情况下,const string&
优于string_view
的情况是已知string
已经存在,以及现有接口需要string
或const char*
。
最后,我们必须小心管理一个string_view
的生命周期。重要的是,string_view
是不拥有的,因此只能“查看”一个单独拥有的字符串。如果一个字符串在一个引用的string_view
之前被销毁,那么string_view
将处于无效状态(与悬空指针相同)。因此,你必须确保一个字符串的生命周期等于或超过任何指向它的string_view
的生命周期。
总之,string_view
是对const char*
和const string&
传弦的一个现代的、不为人知的改进。除了在我们需要一个空终止的字符串,我们需要一个string
用于后续的函数调用,或者我们已经有了一个string
的情况下,string_view
通常应该是首选。当使用string_view
时,要注意对象的寿命,确保底层的字符串存储比string_view
长。
3.3.2 作为事件发布者的栈
构建Stack
的最后一步是简单地将所有的部分放在一起。清单 3-1 将Stack
显示为单例。为了实现事件,我们简单地修改代码,从Publisher
基类继承。我们现在必须问自己,这份继承应该是公有的还是私有的?
通常,在面向对象编程中,人们使用公共继承来表示是一个关系。也就是说,公共继承表达了一种关系,即派生类是基类的一种类型或一种专门化。更准确地说, is-a 关系遵循利斯科夫替换原则(LSP) [37],该原则声明(通过多态)将基类指针(引用)作为参数的函数必须能够在不知道的情况下接受派生类指针(引用)。简而言之,只要基类可以互换使用,派生类就必须是可用的。当人们提到继承时,他们通常是指公共继承。
私有继承用于表达实现——一种关系。简单地说,私有继承用于将一个类的实现嵌入到另一个类的私有实现中。它不遵守 LSP,事实上,如果继承关系是私有的,C++ 语言不允许用派生类替换基类。为了完整性,密切相关的受保护继承在语义上与私有继承相同。唯一的区别是,在私有继承中,基类实现在派生类中变为私有,而在受保护继承中,基类实现在派生类中变为受保护。
我们的问题现在已经细化到,“是Stack
Publisher
还是Stack
实现了 Publisher
?答案是肯定的,肯定的。这是无益的,所以我们如何选择?
为了明确在这个实例中我们应该使用公共继承还是私有继承,我们必须更深入地研究Stack
类的用法。公共继承,或者说是一种*关系,将表明我们作为发布者多态地使用栈的意图。然而,事实并非如此。虽然Stack
类是一个发布者,但在 LSP 的意义上,它不是一个可以替代Publisher
的发布者。因此,我们得出结论,我们应该使用私有继承来表明在Stack
中使用Publisher
的实现的意图。等价地,我们可以说Stack
提供了Publisher
服务。如果您一直关注存储库源代码,您可能会注意到一个很大的提示,即私有继承就是答案。Publisher
类是用非虚拟的、受保护的析构函数实现的,这使得它不能用于公共继承。
熟悉面向对象设计的读者可能会奇怪,为什么我们没有问无处不在的 has-a 问题,这个问题表示所有权或聚合关系。也就是说,为什么Stack
不应该简单地拥有一个Publisher
并重用它的实现,而不是从它那里私有地继承?许多设计者几乎只喜欢使用聚合来代替私有继承,他们认为当在这两者之间有一个等价的选择时,人们应该总是更喜欢导致松散耦合的语言特性(继承是比聚合更强的关系)。这个意见有可取之处。不过,就我个人而言,我只是更愿意接受这种用更强的耦合来换取更清晰的技术。我认为私有继承比聚合更清楚地陈述了实现Publisher
服务的设计意图。这个决定没有正确或错误的答案。在你的代码中,你应该选择适合你口味的风格。
私有继承Publisher
类的另一个结果是Publisher
的attach()
和detach()
方法变成私有的。然而,如果任何其他类打算订阅Stack
的事件,它们需要成为Stack
的公共接口的一部分。因此,实现者必须选择使用语句或转发成员函数来将attach()
和detach()
提升到Stack
的公共接口中。在这种情况下,两种方法都是可以接受的,实现者可以自由地使用他们的个人偏好。
3.3.3 完整的栈模块接口
我们终于准备好编写完整的Stack
公共接口,包括Stack
和StackErrorData
类。在下面的代码清单中,为了简洁起见,省略了 include 语句、导入、命名空间使用声明以及类的任何私有部分。当然,所有这些实现细节都包含在 GitHub 资源库附带的源代码中。
export module pdCalc.stack;
export namespace pdCalc {
class StackErrorData
{
public:
enum class ErrorConditions { Empty, TooFewArguments };
explicit StackErrorData(ErrorConditions e);
static const char* Message(ErrorConditions ec);
const char* message() const;
ErrorConditions error() const;
};
class Stack : private Publisher
{
public:
static Stack& Instance();
void push(double, bool suppressChangeEvent = false);
double pop(bool suppressChangeEvent = false);
void swapTop();
vector<double> getElements(size_t n) const;
using Publisher::attach;
using Publisher::detach;
static string StackChanged();
static string StackError();
};
} // namespace pdCalc
如本章所述,Stack
是实现Publisher
服务的单例类(注意Instance()
方法)(注意Publisher
类的私有继承和attach()
和detach()
方法到公共接口的提升)。Stack
类的公共部分与StackErrorData
类一起,包含了第二章表 2-2 中介绍的栈模块的完整接口。虽然我们还没有为Stack
描述任何具体的观察者,但是我们已经为 pdCalc 完全定义了我们的事件系统,它是基于可靠的观察者模式。至此,我们已经准备好设计 pdCalc 的下一个组件,命令调度器模块。
3.4 测试的快速说明
在结束介绍 pdCalc 源代码的第一章之前,我们应该暂停一下,说几句关于测试的话。测试绝不是本书的中心探索主题,试图深入涵盖设计和测试肯定会破坏本文的凝聚力。相反,对开发人员测试的彻底探索感兴趣的读者可以参考 Tarlinder 的优秀著作[35]。尽管如此,测试是任何高质量实现不可或缺的一部分。
除了在 GitHub 上找到的计算器的源代码,我还包含了我所有的自动化单元测试代码。因为我选择使用 Qt 作为 pdCalc 的图形用户界面框架(参见第六章),QtTest 框架是构建 pdCalc 的单元测试套件的自然选择。首先,这种选择不会在项目上增加任何额外的库依赖,并且测试框架保证可以在移植了 Qt 的所有平台上工作。也就是说,许多高质量的 C++ 单元测试框架中的任何一个都足够了。
就我个人而言,我发现即使是对小项目进行编程时,单元测试也是不可或缺的。首先也是最重要的,单元测试提供了一种方法来确保你的代码按预期运行(验证)。第二,单元测试使你能够在开发用户界面之前很久就看到一个模块正确地工作。早期测试能够实现早期的错误检测,软件工程中一个众所周知的事实是,早期的错误检测会导致以指数方式降低的错误修复成本。我还发现,在开发周期的早期看到模块完全工作是一种奇怪的激励。最后,单元测试还能让你知道代码在修改前后的功能是一样的(回归测试)。由于迭代是设计和实现的基本元素,您的代码将会改变无数次,甚至在您认为已经完成之后。在每次构建时自动运行全面的单元测试将确保新的变化不会不可预测地破坏任何现有的功能单元。
因为我非常重视测试(这是我试图教给新的专业开发人员的第一课),所以我努力确保 pdCalc 代码测试的完整性。虽然我希望测试代码是高质量的,但我承认我的测试术语有时有点草率,在某些情况下,我可能严重混淆了单元、集成和系统测试之间的界限。尽管如此,所有的测试都运行得非常快,而且他们向我保证,我的代码在编写本书的整个代码开发阶段都得到了验证。然而,尽管我尽了最大的努力来编写没有错误的代码,甚至在对源代码进行了不合理的多次审查之后,我确信最终产品中仍然存在缺陷。请随时给我发电子邮件,告诉我你发现的所有错误。我将尽最大努力在 GitHub 资源库和本书的任何未来版本中加入对代码的更正,并对第一个向我报告我的任何错误的读者给予适当的说明。*
四、命令调度器
命令调度器是计算器的核心。作为 MVC 框架中的控制器,命令调度器负责应用程序的整个业务逻辑。本章不仅介绍了计算器的命令调度器模块的具体设计,而且更广泛地介绍了松散耦合的命令基础设施的灵活设计。
4.1 命令调度器的分解
当分解栈时,我们问的第一个问题是,“栈应该分成多少个组件?”我们现在向指挥调度器提出同样的问题。为了回答这个问题,让我们考虑一下命令调度器必须封装的功能。命令调度器的功能是
-
存储已知命令的集合
-
接收并解释对这些命令的请求
-
分派命令请求(包括撤销和重做的能力)
-
执行实际操作(包括更新计算器的状态)
在第二章中,我们讨论了衔接的原则。在最顶层的分解层,命令调度器实际上只做一件事:它解释命令,这是命令调度器模块合适的抽象层。然而,在实现层面,从我们前面提到的功能列表来看,该模块显然必须执行多个任务。因此,我们将 command dispatcher 分解成几个不同的类,每个类负责它必须执行的一个主要任务,因为在类的层次上,设计内聚性意味着每个类应该只做一件事,而且应该做得很好。因此,我们定义了以下类别:
-
CommandFactory:
创建可用命令 -
接收并解释执行命令的请求
-
分派命令并管理撤销和重做
-
Command
层级:执行命令
CommandFactory
、CommandInterpreter
和CommandManager
类都是命令调度器模块的组件。正如在第二章中所讨论的,虽然Command
类层次结构逻辑上属于命令调度器模块,但是Command
类层次结构包含在一个单独的command
模块中,因为这些类对于插件实现者必须是可独立导出的。本章的剩余部分将专门描述前面提到的类列表和类层次结构的设计和突出的实现细节。
4.2 命令类
在分解的这个阶段,我发现切换到自底向上的设计方法更有用。在严格的自顶向下方法中,我们可能会从接收和解释命令请求的类CommandInterpreter
开始,然后一路向下直到命令。然而,在这种自下而上的方法中,我们将从研究命令本身的设计开始。我们从称为命令模式的抽象开始。
4.2.1 命令模式
命令模式是一种简单但非常强大的行为模式,它以对象的形式封装请求。在结构上,该模式被实现为一个抽象的命令基类,它提供了一个执行请求的接口。具体的命令只是实现接口。在最普通的情况下,抽象接口只包含一个命令来执行该命令封装的请求。琐碎实现的类图如图 4-1 所示。
本质上,该模式做两件事。首先,它将命令的请求者与命令的分派者分离开来。其次,它将一个动作的请求封装到一个对象中,否则这个请求可能会通过函数调用来实现。该对象可以携带状态,并拥有比请求本身的直接生存期更长的生存期。
实际上,这两个特征给了我们什么?首先,因为请求者与分派者是分离的,所以执行命令的逻辑不需要与负责执行命令的类驻留在同一个类中,甚至不需要驻留在同一个模块中。这显然降低了耦合性,但也增加了内聚性,因为可以为系统必须实现的每个唯一命令创建一个唯一的类。第二,因为请求现在被封装在命令对象中,其生存期不同于动作的生存期,所以命令可以在时间上被延迟(例如,排队
命令)并撤消。撤销操作之所以成为可能,是因为已经执行的命令可以保留足够的数据,以便将状态恢复到命令执行之前的时刻。当然,将排队能力与撤销能力相结合允许为实现命令模式的所有请求创建无限制的撤销/重做。
图 4-1
命令模式最简单的层次结构
4.2.2 关于实现撤消/重做的更多信息
对 pdCalc 的要求之一是实现无限制的撤销和重做操作。大多数书籍都指出,撤销可以通过命令模式实现,只需用撤销命令扩充抽象命令接口。然而,这种简单化的处理掩盖了正确实现撤销特性所必需的实际细节。
实现撤销和恢复包括两个不同的步骤。首先(很明显),撤销和重做必须在具体的命令类中正确实现。第二,必须实现一种数据结构,以便在命令对象被分派时跟踪和存储它们。当然,这种数据结构必须保持命令执行的顺序,并且能够发出撤销、重做或执行新命令的请求。这种撤销/重做数据结构将在 4.4 节中详细描述。现在讨论撤消和重做的实现。
实现撤销和重做操作本身通常很简单。重做操作与命令的执行功能相同。假设在第一次执行命令之前和调用撤销之后系统的状态是相同的,那么实现重做命令基本上是免费的。当然,这直接意味着实现撤销实际上是将系统状态恢复到命令第一次执行之前的状态。
撤销可以通过两种相似但略有不同的机制来实现,每种机制以不同的方式负责恢复系统的状态。第一种机制正如名字 undo 所暗示的那样:它获取系统的当前状态,并完全逆转 forward 命令的过程。从数学上讲,也就是说,撤销是作为执行的逆操作来实现的。例如,如果向前操作是取栈顶数字的平方根,那么撤销操作就是取栈顶数字的平方。这种方法的优点是不需要存储额外的状态信息来实现撤销。缺点是该方法并不适用于所有可能的命令。让我们检查一下上一个例子的反面。也就是说,考虑取栈顶数字的平方。撤销操作是取平方操作结果的平方根。然而,原数是平方根还是负平方根?没有保留额外的状态信息,反演方法就失败了。
作为反向操作实现撤销的替代方案是在命令第一次执行之前保留系统的状态,然后将撤销实现为对该先前状态的回复。回到我们平方一个数的例子,向前操作将计算平方并保存栈顶的数。然后,撤消操作将通过从栈中删除结果并从执行前向操作之前推送保存的状态来实现。该过程由命令模式实现,因为所有命令都被实现为被允许携带状态的具体命令类的实例。这种实现撤销的方法的一个有趣的特点是操作本身不需要数学上的逆运算。注意,在我们的例子中,撤销甚至不需要知道向前操作是什么。它只需要知道如何用保存的状态替换栈中的顶部元素。
在应用程序中使用哪种机制实际上取决于应用程序执行的不同操作。当操作没有反转时,存储状态是唯一的选择。当逆运算的计算成本过高时,存储状态通常是更好的实现方式。当存储状态的开销很大时,假设存在反向操作,那么通过反向实现撤销是首选。当然,由于每个命令都是作为一个单独的类实现的,所以不需要为整个系统做出如何实现撤销的全局决定。给定命令的设计者可以在逐个命令的基础上自由选择最适合该特定操作的方法。在某些情况下,甚至混合方法(存储和反转操作的独立部分)也可能是最佳的。在下一节中,我们将检查我为 pdCalc 所做的选择。
4.2.3 应用于计算器的命令模式
为了执行、撤销和重做计算器中的所有操作,我们将实现命令模式,并且每个计算器操作将被其自己的具体类封装,该类从抽象的Command
类派生。从前面关于命令模式的讨论中,我们可以看到,为了将该模式应用于计算器,必须做出两个决定。首先,我们必须决定每个命令必须支持哪些操作。这个操作集合将定义Command
基类的抽象接口。其次,我们必须选择如何支持撤销的策略。准确地说,这个决定总是由特定具体命令的实施者做出。然而,通过预先选择状态重建或命令反转,我们可以实现一些基础设施来简化命令实现者的撤销。我们将连续处理这两个问题。
命令界面
选择在抽象Command
类中包含什么公共函数等同于为计算器中的所有命令定义接口。所以,这个决定一定不能掉以轻心。虽然每个具体命令将执行不同的功能,但所有具体命令必须可以相互替换(回想一下 LSP)。因为我们希望界面最小但完整,所以我们必须确定最少数量的函数,这些函数可以抽象地表达所有命令所需的操作。
要包含的前两个命令是最明显和最容易定义的。它们是execute()
和undo()
,分别用于执行命令的正向和反向操作。这两个函数返回 void,并且不需要参数。不需要参数,因为计算器的所有数据都是通过Stack
类处理的,这个类可以通过 singleton 模式全局访问。另外,Command
类需要一个构造器和一个析构函数。因为该类是一个具有虚函数的接口类,所以析构函数应该是虚的。下面的代码片段说明了我们对接口的第一次尝试:
export module pdCalc.command;
export class Command
{
public:
virtual ~Command();
void execute();
void undo();
protected:
Command();
private:
virtual void executeImpl() = 0;
virtual void undoImpl() = 0;
};
注意省略了pdCalc
名称空间,这在整个文本中通常都是这样做的。尽管前面已经明确列出,但是如果可以从上下文中暗示模块导出行和类名或名称空间声明前面的export
关键字的存在,我也会经常从文本中省略它们。
在前面的清单中,读者会立即注意到构造器是受保护的,execute()
和undo()
都是公共的和非虚拟的,并且存在单独的executeImpl()
和undoImpl()
虚函数。构造器受到保护的原因是向实现者发出信号,表明Command
类不能被直接实例化。当然,因为该类包含纯虚函数,所以无论如何,编译器会阻止直接实例化Command
类。让构造器受保护在某种程度上是多余的。另一方面,使用虚函数和非虚函数的组合来定义公共接口值得更详细的解释。
通过混合使用公共非虚拟函数和私有虚拟函数来定义一个类的公共接口是一种被称为非虚拟接口(NVI)模式的设计原则。NVI 模式规定多态接口应该总是使用非虚拟的公共函数来定义,这些函数将调用转发给私有的虚函数。这种模式背后的推理非常简单。因为具有虚函数的基类充当接口类,所以客户端应该只通过基类的接口经由多态性来访问派生类的功能。通过使公共接口成为非虚拟的,基类实现者保留了在分派之前截取虚函数调用的能力,以便向所有派生类实现的执行添加前置条件或后置条件。将虚拟函数私有会迫使消费者使用非虚拟接口。在不需要前置条件或后置条件的简单情况下,非虚函数的实现简化为对虚函数的转发调用。即使在微不足道的情况下,坚持 NVI 模式的额外冗长性也是有保证的,因为它以零计算开销保留了未来扩展的设计灵活性,因为转发函数调用可以内联。Sutter [34]详细讨论了 NVI 模式背后更深入的基本原理。
现在让我们考虑execute()
或undo()
是否需要前置条件或后置条件;我们从execute()
开始。快速浏览第二章中的用例,我们可以看到,pdCalc 必须完成的许多操作只有在满足一组先决条件的情况下才能执行。例如,要将两个数相加,我们必须在栈上有两个数。显然,加法是有前提条件的。从设计的角度来看,如果我们在命令执行之前捕获这个前提条件,我们就可以在它们导致执行问题之前处理前提条件错误。在调用executeImpl()
之前,作为基类execute()
实现的一部分,我们肯定要检查前提条件。
所有命令都必须检查什么前提条件?也许,和加法一样,所有的命令在栈中必须至少有两个数?让我们检查另一个用例。考虑取一个数的正弦值。这个命令只要求栈上有一个数字。啊,前提条件是命令特有的。我们关于前提条件一般处理的问题的正确答案是,让execute()
首先调用一个checkPreconditionsImpl()
虚函数,让派生类检查它们自己的前提条件。
execute()
的后置条件呢?事实证明,如果每个命令的前提条件都得到满足,那么所有命令在数学上都得到了很好的定义。很好,不需要后置条件检查!不幸的是,数学正确性不足以确保浮点数的无错计算。例如,当使用 pdCalc 所需的双精度数时,浮点加法可能导致正溢出,即使加法是数学定义的。然而,幸运的是,我们在第一章中的要求指出浮点错误可以忽略。因此,从技术上讲,我们不需要处理浮点错误,也不需要后置条件检查。
为了保持代码相对简单,我选择遵守要求,忽略 pdCalc 中的浮点异常。如果我想在设计中更主动,捕捉浮点错误,可以使用一个checkPostconditions()
函数。因为浮点错误对所有命令都是通用的,所以后置条件检查可以在基类级别处理。
理解我们的前置条件和后置条件需求,使用 NVI 模式,我们能够为execute()
编写以下简单的实现:
void Command::execute()
{
checkPreconditionsImpl();
executeImpl();
return;
}
假设checkPreconditionsImpl()
和executeImpl()
都必须被派生类连续调用和处理,我们能不能把这两个操作合并到一个函数调用中?我们可以,但是这个决定会导致一个次优的设计。首先,通过将这两个操作合并成一个executeImpl()
函数调用,我们会因为要求一个函数执行两个不同的操作而失去内聚性。第二,通过使用单独的checkPreconditionsImpl()
调用,我们可以选择强制派生类实现者检查前提条件(通过使checkPreconditionsImpl()
成为纯虚拟的),或者可选地提供前提条件检查的默认实现。最后,谁说checkPreconditionsImpl()
和executeImpl()
会调度到同一个派生类?请记住,层次结构可以有多个层次。
类似于execute()
函数,可以假设撤销命令需要前提条件检查。然而,事实证明我们实际上从来不需要检查撤销的前提条件,因为它们总是被构造为真。也就是说,因为撤销命令只能在执行命令成功完成后调用,所以保证满足undo()
的前提条件(当然,假设execute()
的正确实现)。与前向执行一样,undo()
不需要后置条件检查。
对execute()
和undo()
的前置条件和后置条件的分析导致仅向虚拟接口添加一个功能checkPreconditionsImpl()
。然而,为了完成这个函数的实现,我们必须确定这个函数的正确签名。首先,函数的返回值应该是什么?我们可以选择使返回值无效,并通过异常处理前提条件的失败,或者使返回值成为可以指示前提条件不满足的类型(例如,在前提条件失败时返回 false 的布尔值,或者指示发生的失败类型的枚举)。对于 pdCalc,我选择通过异常来处理前提条件失败。这种策略支持更大程度的灵活性,因为错误不需要由直接调用者execute()
函数来处理。此外,可以将异常设计为携带自定义的描述性错误消息,该消息可以由派生的命令扩展。这与使用枚举类型形成对比,后者必须完全由基类实现者定义。
在指定checkPreconditionsImpl()
的签名时,我们必须解决的第二个问题是选择函数应该是纯虚拟的还是有默认的实现。虽然大多数命令确实需要满足一些前提条件,但并不是每个命令都是如此。例如,在栈中输入一个新数字不需要前提条件。因此,checkPreconditionsImpl()
不应该是一个纯虚函数。而是给它一个默认的实现,什么都不做,相当于声明前提条件满足。
因为命令中的错误是通过checkPreconditionsImpl()
函数检查的,所以任何命令的正确实现都不应该抛出异常,除了来自checkPreconditionsImpl()
的异常。因此,为了增加接口保护,Command
类中的每个纯虚函数都应该标记为noexcept
。为了简洁,我经常在正文中跳过这个关键词;但是,noexcept
确实出现在实施中。这个说明符实际上只在插件命令的实现中重要,这将在第七章中讨论。
添加到Command
类的下一组函数是多态复制对象的函数。这个集合包括一个受保护的复制构造器、一个公共的非虚拟clone()
函数和一个私有的cloneImpl()
函数。在设计的这一点上,为什么命令必须是可复制的基本原理不能被充分证明。然而,当我们检查CommandFactory
的实现时,推理将变得清晰。然而,为了保持连续性,我们现在将讨论复制接口的实现。
对于为多态使用而设计的类层次结构,简单的复制构造器是不够的,对象的复制必须由克隆虚函数来执行。考虑以下仅显示复制构造器的简化命令层次结构:
class Command
{
protected:
Command(const Command&);
};
class Add : public Command
{
public:
Add(const Add&);
};
我们的目标是复制多态使用的。让我们以下面的例子为例,我们通过一个Command
指针持有一个Add
对象:
Command* p = new Add;
根据定义,复制构造器将对它自己的类类型的引用作为它的参数。因为在多态设置中我们不知道底层类型,所以我们必须尝试如下调用复制构造器:
auto p2 = new Command{*p};
前面的构造是非法的,不会编译。因为Command
类是抽象的(并且它的复制构造器是受保护的),编译器不允许创建Command
对象。然而,并不是所有的层次结构都有抽象基类,所以在合法的情况下,人们可能会尝试这种结构。当心。这种结构会分割层级。也就是说,p2
将被构造为一个Command
实例,而不是一个Add
实例,并且来自p
的任何Add
状态都将在副本中丢失。
假设我们不能直接使用复制构造器,我们如何在多态环境中复制类呢?解决方案是提供一个虚拟克隆操作,可按如下方式使用:
Command* p2 = p->clone();
在这里,非虚拟的clone()
函数将克隆操作分派给派生类的cloneImpl()
函数,它的实现只是调用自己的复制构造器,用一个解引用的this
指针作为它的参数。对于前面的示例,扩展的接口和实现如下所示:
class Command
{
public:
Command* clone() const { return cloneImpl(); }
protected:
Command(const Command&) = default;
private:
virtual Command* cloneImpl() const = 0;
};
class Add : public Command
{
public:
Add(const Add& rhs) : Command{rhs} { }
private:
Add* cloneImpl() const { return new Add{*this}; }
};
这里唯一有趣的实现特性是cloneImpl()
函数的返回类型。注意,基类指定返回类型为Command*
,而派生类指定返回类型为Add*
。这种构造被称为返回类型协方差,这是一种规则,它规定派生类中的重写函数可以返回比虚拟接口中的返回类型更具体的类型。协方差允许克隆函数总是返回与调用克隆的层次级别相适应的特定类型。这个特性对于具有公共克隆功能并允许从层次结构中的所有级别进行克隆调用的实现非常重要。
我选择用一个帮助消息函数和一个相应的虚拟实现函数来完善命令界面。此帮助功能的目的是强制各个命令实施者为可通过用户界面中的帮助命令查询的命令提供简要文档。帮助功能对于命令的功能来说并不重要,它是否作为设计的一部分是可选的。然而,为命令的使用提供一些内部文档总是好的,即使是在像计算器这样简单的程序中。
结合前面提到的所有信息,我们最终可以为我们的Command
类编写完整的抽象接口:
class Command
{
public:
virtual ~Command();
void execute();
void undo();
Command* clone() const;
const char* helpMessage() const;
protected:
Command();
Command(const Command&);
private:
virtual void checkPreconditionsImpl() const;
virtual void executeImpl() noexcept = 0;
virtual void undoImpl() noexcept = 0;
virtual Command* cloneImpl() const = 0;
virtual const char* helpMessageImpl() const noexcept = 0;
};
如果你查看Command.m.cpp
中的源代码,你还会看到一个虚拟的deallocate()
函数。这个功能是插件专用的,它在界面上的添加将在第七章讨论。
Modern C++ Design Note: The Override Keyword
override 关键字是在 C++11 中引入的。从功能上来说,它防止了一个经常让新 C++ 程序员感到惊讶的常见错误。考虑下面的代码片段:
class Base
{
public:
virtual void foo(int);
};
class Derived : public Base
{
public:
void foo(double);
};
Base* p = new Derived;
p->foo(2.1);
调用哪个函数?大多数 C++ 程序员新手认为调用了Derived::foo()
,因为他们认为Derived
的foo()
会覆盖Base
的实现。然而,因为foo()
函数的签名在基类和派生类之间是不同的,Base
的foo()
实际上隐藏了Derived
的实现,因为重载不能跨越作用域边界。因此,调用p->foo()
将调用Base
的foo()
,而不管参数的类型。有趣的是,出于同样的原因
Derived d;
d->foo(2);
除了Derived
的foo()
永远不能调用别的。
在 C++03 和 C++11 中,前面的代码以完全相同的令人困惑但技术上正确的方式运行。然而,从 C++11 开始,派生类可以选择用关键字override
标记重写函数:
class Derived : public Base
{
public:
void foo(double) override;
};
现在,编译器会将该声明标记为错误,因为程序员明确声明派生函数应该重写。因此,override 关键字的添加允许程序员明确自己的意图,从而防止令人困惑的错误发生。
从设计的角度来看,override
关键字显式地将函数标记为覆盖。虽然这看起来并不重要,但在处理大型代码库时却非常有用。当实现基类在代码的另一个不同部分的派生类时,不必查看基类的声明就可以很方便地知道哪些函数重写了基类函数,哪些没有。
撤销策略
已经为我们的命令定义了抽象接口,我们接下来继续设计撤销策略。从技术上来说,因为我们界面中的undo()
命令是纯虚拟的,我们可以简单地放弃我们的手,声称撤销的实现是每个具体命令的问题。然而,这既不雅又低效。相反,我们寻求所有命令(或至少命令组)的一些功能共性,这可能使我们能够在比命令层次结构中的每个叶节点更高的级别上实现撤销。
如前所述,撤销可以通过命令反转或状态重建(或两者的某种组合)来实现。命令反演已经被证明是有问题的,因为对于某些命令来说,反演问题是不适定的(具体来说,它有多个解)。因此,让我们将状态重建作为 pdCalc 的通用撤销策略来研究。
我们首先考虑一个用例,加法运算。加法从栈中移除两个元素,将它们相加,并返回结果。简单的撤销可以通过从栈中删除结果并恢复原始操作数来实现,前提是这些操作数由execute()
命令存储。现在,考虑减法、乘法或除法。这些命令也可以通过丢弃它们的结果并恢复它们的操作数来撤消。为所有命令实现撤销是否如此简单,我们只需要在execute()
期间存储栈中的前两个值,并通过丢弃命令的结果和恢复存储的操作数来实现撤销?不。考虑正弦,余弦和正切。它们各自从栈中取出一个操作数并返回一个结果。考虑互换。它从栈中取出两个操作数并返回两个结果(操作数的顺序相反)。一个完全统一的撤销策略不能在所有命令上实现。也就是说,我们不应该放弃希望,回到为每个命令单独实现撤销。
仅仅因为我们计算器中的所有命令都必须从Command
类继承而来,没有规则要求这种继承是图 4-1 中描述的直接继承。相反,考虑图 4-2 中描述的命令层级。虽然有些命令仍然直接继承自Command
基类,但是我们已经创建了两个新的子类,UnaryCommand
和BinaryCommand
,从中可以继承更多的专用命令。事实上,很快就会看到,这两个新的基类本身就是抽象的。
图 4-2
计算器命令模式的多级层次结构
我们前面的用例分析确定了操作的两个重要子类,它们为各自的成员统一实现撤销:二元命令(接受两个操作数并返回一个结果的命令)和一元命令(接受一个操作数并返回一个结果的命令)。因此,我们可以通过处理这两类命令的撤销来大大简化我们的实现。虽然不属于一元或二元命令家族的命令仍然需要单独执行undo()
,但这两个子类别占计算器核心命令的 75%。创建这两个抽象将节省大量的工作。
让我们检查一下UnaryCommand
类。根据定义,所有一元命令都需要一个参数并返回一个值。例如,f(x)= sin(x)从栈中取出一个数字 x ,并将结果 f ( x )返回到栈中。如前所述,将所有一元函数作为一个家族来考虑的原因是,不管是什么函数,所有一元命令都同样实现向前执行和撤消,不同之处仅在于 f 的函数形式。此外,它们还必须至少满足相同的前提条件。也就是说,栈上必须至少有一个元素。
在代码中,通过覆盖UnaryCommand
基类中的executeImpl()
、undoImpl()
和checkPreconditionsImpl()
,并创建一个新的unaryOperation()
纯虚拟,将每个命令的精确实现委托给一个进一步的派生类,来加强一元命令的上述共同特征。结果是一个带有以下声明的UnaryCommand
类:
class UnaryCommand : public Command
{
public:
virtual ~UnaryCommand() = default;
protected:
void checkPreconditionsImpl() const override;
UnaryCommand() = default;
UnaryCommand(const UnaryCommand&);
private:
void executeImpl() override final;
void undoImpl() override final;
virtual double unaryOperation(double top) const = 0;
double top_;
};
注意,executeImpl()
和undoImpl()
功能都标有final
,但checkPreconditionsImpl()
功能没有。UnaryCommand
类存在的全部原因是为了优化其所有后代的撤销操作。因此,为了被归类为一元命令,派生类必须接受UnaryCommand
对 undo 和 execute 的处理。我们通过使用final
关键字禁用派生类覆盖undoImpl()
和executeImpl()
的能力来实施这个约束。我们将在本章后面的边栏中看到对关键字final
更详细的解释。checkPreconditionsImpl()
功能不同。虽然所有一元命令都有一个共同的前提条件,即栈上必须至少有一个元素,但个别函数可能需要更多的前提条件。例如,考虑一元函数反正弦,它要求其操作数在[-1, 1]范围内。必须允许Arcsine
类实现自己版本的checkPreconditionsImpl()
函数,该函数应该在执行自己的前提条件检查之前调用UnaryCommand
的checkPreconditionsImpl()
。
为了完整起见,让我们检查一下来自Command
的三个被覆盖函数的实现。检查前提条件是琐碎的;我们确保栈中至少有一个元素。否则,将引发异常:
void UnaryCommand::checkPreconditionsImpl() const
{
if( Stack::Instance().size() < 1 )
throw Exception{"Stack must have at least one element"};
}
executeImpl()
命令也很简单:
void UnaryCommand::executeImpl()
{
top_ = Stack::Instance().pop(true);
Stack::Instance().push( unaryOperation(top_) );
}
顶部元素从栈中弹出,并存储在UnaryCommand
的状态中,以便撤销。请记住,因为我们已经检查了前提条件,所以我们可以确信unaryOperation()
将正确无误地完成。如前所述,带有特殊前置条件的命令仍然需要实现checkPreconditionsImpl()
,但它们至少可以将一元前置条件检查向上委托给UnaryCommand
的checkPreconditionsImpl()
函数。然后,我们一举将一元函数操作分派给另一个派生类,并将其结果推回栈。
UnaryCommand
的executeImpl()
函数的唯一特点是栈的弹出命令的布尔参数。此布尔值可以选择抑制栈更改事件的发出。因为我们知道对栈的下一个 push 命令将立即再次改变栈,所以不需要发出两个后续的 stack changed 事件。此事件的抑制允许命令实施者将命令的动作合并到一个用户表面事件中。虽然Stack
的pop()
的布尔参数不是最初设计的一部分,但是为了方便起见,现在可以将该功能添加到Stack
类中。记住,设计是迭代的。
要检查的最后一个成员函数是undoImpl()
:
void UnaryCommand::undoImpl()
{
Stack::Instance().pop(true);
Stack::Instance().push(top_);
}
这个函数也有预期的明显实现。一元运算的结果从栈中删除,在执行executeImpl()
期间存储在类的top_
成员中的前一个顶部元素被恢复到栈中。
作为使用UnaryCommand
类的一个例子,我们给出了 sine 命令的部分实现:
class Sine : public UnaryCommand
{
private:
double unaryOperation(double t) const override { return std::sin(t); }
};
很明显,使用UnaryCommand
作为基类而不是最高级的Command
类的好处是,我们不再需要实现undoImpl()
和checkPreconditionsImpl()
,我们用稍微简单一点的unaryOperation()
代替了executeImpl()
的实现。我们不仅需要更少的代码,而且因为undoImpl()
和checkPreconditionsImpl()
的实现在所有一元命令中都是相同的,我们也减少了代码重复,这总是一个积极的方面。
二进制命令的实现方式类似于一元命令。唯一的区别是执行操作的函数将两个命令作为操作数,并且相应地必须存储这两个值以便撤消。在 GitHub 源代码库中的Command.m.cpp
文件中,可以在Command
和UnaryCommand
类旁边找到BinaryCommand
类的完整定义。
具体命令
定义前面提到的Command
、UnaryCommand
和BinaryCommand
类完成了在计算器中使用命令模式的抽象接口。让这些接口正确包含了命令设计的大部分。然而,在这一点上,我们的计算器还没有一个具体的命令(除了部分的Sine
类实现)。这一部分将最终纠正这个问题,我们的计算器的核心功能将开始成形。
计算器的核心命令都在CoreCommands.m.cpp
文件中定义。什么是核心命令?我已经将核心命令定义为包含从第一章中列出的需求中提取的功能的命令集。计算器必须执行的每个不同的操作都有一个唯一的核心命令。为什么我称这些为核心命令?它们是核心命令,因为它们与计算器一起编译和链接,因此在加载计算器时立即可用。事实上,它们是计算器固有的一部分。这与插件命令相反,插件命令可以在运行时由计算器动态加载。插件命令将在第七章中详细讨论。
有趣的是,尽管Command
、UnaryCommand
和BinaryCommand
类是从command
模块定义和导出的,但核心命令都包含在命令调度器模块的CoreCommands
分区中。CoreCommands
不从命令调度器模块导出,测试除外。这种设计是合理的,因为与抽象命令类不同,根据定义,核心命令是那些直接内置到 pdCalc 中的命令,并且这些类的使用完全在命令调度器模块本身内。
虽然有人可能怀疑我们现在需要执行一个分析来确定核心命令,但事实证明这个分析已经完成了。具体来说,核心命令是由第二章的用例中描述的动作定义的。敏锐的读者甚至会记得用例中的异常列表定义了每个命令的前提条件。因此,必要时参考用例,可以轻松地导出核心命令。为了方便起见,它们都列在表 4-1 中。
表 4-1
按直接抽象基类列出的核心命令
| `Command` | `UnaryCommand` | `BinaryCommand` | | `EnterCommand` | `Sine` | `Add` | | `SwapTopOfStack` | `Cosine` | `Subtract` | | `DropTopOfStack` | `Tangent` | `Multiply` | | `Duplicate` | `Arcsine` | `Divide` | | `ClearStack` | `Arccosine` | `Power` | | | `Arctangent` | `Root` | | | `Negate` | |在比较前面提到的核心命令列表和第二章中的用例时,我们注意到明显缺少撤销和重做命令,尽管它们都是用户可以请求计算器执行的操作。这两个命令很特殊,因为它们作用于系统中的其他命令。因此,在命令模式意义上,它们不是作为命令来实现的。相反,它们本质上是由将要讨论的CommandManager
处理的,这个类负责请求命令、执行命令以及请求撤销和重做操作。撤销和重做动作(与每个命令定义的撤销和重做操作相反)将在第 4.4 节详细讨论。
每个核心命令的实现,包括检查前提条件、向前操作和撤销实现,都相对简单。大多数命令类可以用大约 20 行代码实现。如果感兴趣的读者希望查看细节,可以参考存储库源代码。
深层命令层级的替代方案
为每个操作创建一个单独的Command
类是实现命令模式的一种非常经典的方式。然而,现代 C++ 给了我们一个非常令人信服的选择,使我们能够扁平化层次结构。具体来说,我们可以使用 lambda 表达式(见侧栏)来封装操作,而不是创建额外的派生类,然后使用标准的function
类(见侧栏)在UnaryCommand
或BinaryCommand
级别的类中存储这些操作。为了使讨论具体化,让我们考虑一个替代BinaryCommand
类的局部设计:
class BinaryCommandAlternative final : public Command
{
using BinaryCommandOp = double(double, double);
public:
BinaryCommandAlternative(string_view help,
function<BinaryCommandOp> f);
private:
void checkPreconditionsImpl() const override;
const char* helpMessageImpl() const override;
void executeImpl() override;
void undoImpl() override;
double top_;
double next_;
string helpMsg_;
function<BinaryCommandOp> command_;
};
现在,我们声明了一个具体的final
(见侧栏)类,它接受一个可调用的目标并通过调用这个目标来实现executeImpl()
,而不是抽象的BinaryCommand
通过一个binaryOperation()
虚函数来实现executeImpl()
。事实上,BinaryCommand
和BinaryCommandAlternative
之间唯一的实质性区别是在executeImpl()
命令的实现上的细微差别:
void BinaryCommandAlternative::executeImpl()
{
top_ = Stack::Instance().pop(true);
next_ = Stack::Instance().pop(true);
// invoke callable target instead of virtual dispatch:
Stack::Instance().push( command_(next_, top_) );
}
现在,作为一个例子,代替声明一个Multiply
类和实例化一个Multiply
对象:
auto mult = new Multiply;
我们创造了一个能够乘法的BinaryCommandAlternative
:
auto mult = new BinaryCommandAlternative{ "help msg",
[](double d, double f){ return d * f; } };
为了完整起见,我们提到因为没有进一步从BinaryCommandAlternative
派生的类,我们必须直接在构造器中处理帮助消息,而不是在派生类中。此外,在实现时,BinaryCommandAlternative
只处理二元前置条件。然而,可以以类似于处理二元运算的方式来处理附加的前提条件。也就是说,在对checkPreconditionsImpl()
中的两个栈参数进行测试之后,构造器可以接受并存储一个 lambda 来执行前提条件测试。
显然,通过创建一个UnaryCommandAlternative
类,可以像处理二进制命令一样处理一元命令。有了足够的模板,我敢肯定你甚至可以将二进制和一进制命令统一到一个类中。不过,事先警告一下。太多的聪明,虽然在水冷器上令人印象深刻,但通常不会导致可维护的代码。在这种扁平化的命令层次结构中,为二元命令和一元命令保留单独的类可能会在简洁性和可理解性之间取得适当的平衡。
BinaryCommand
的executeImpl()
和BinaryCommandAlternative
的executeImpl()
之间的实现差异相当小。然而,我们不应该低估这一变化的程度。最终结果是在命令模式的实现中出现了显著的设计差异。一般情况下一个比另一个好吗?我不认为这样的声明可以明确;每种设计都有权衡。BinaryCommand
策略是命令模式的经典实现,大多数有经验的开发人员都会这样认为。源代码非常容易阅读、维护和测试。对于每个命令,只创建一个执行一个操作的类。而BinaryCommandAlternative
则非常简洁。不是有 n 个类用于 n 个操作,而是只有一个类存在,每个操作由构造器中的 lambda 定义。如果你的目标是减少代码,这种替代风格是很难被击败的。然而,因为根据定义,lambdas 是匿名对象,所以不命名系统中的每个二进制操作会失去一些清晰度。
对于 pdCalc,深层命令层次和浅层命令层次哪个策略更好?就个人而言,我更喜欢深层次的命令,因为命名每个对象会带来清晰性。然而,对于像加法和减法这样简单的运算,我认为人们可以提出一个很好的论点,即减少的行数比匿名带来的损失更能提高清晰度。由于我个人的偏好,我使用深层次和BinaryCommand
类实现了大多数命令。尽管如此,我还是通过BinaryCommandAlternative
实现了乘法,以说明实践中的实现。在生产系统中,我强烈建议选择其中一种策略。在同一个系统中实现这两种模式肯定比采用一种模式更令人困惑,即使选择的模式被认为是次优的。
Modern C++ Design Note: Lambdas, Standard FUNCTION
, AND THE FINAL
KEYWORD
Lambdas、standard function
和final
关键字实际上是三个独立的现代 C++ 概念。因此,我们将分别处理它们。
Lambdas:
Lambdas(更正式的说法是 lambda 表达式)可以被认为是匿名函数对象。推理 lambdas 最简单的方法是考虑它们的函数对象等价。定义 lambda 的语法如下所示:
捕获列表 { 函数体 }
前面的 lambda 语法等同于一个函数对象,它通过构造器将捕获列表存储为成员变量,并为operator() const
成员函数提供由参数列表提供的参数,以及由函数体提供的函数体。operator()
的返回类型通常是从函数体推导出来的,但是如果需要,也可以使用可选的函数返回类型语法(即参数列表和函数体之间的-> ret
)手动指定。使用auto
可以指定或自动推导出参数列表的参数类型。当自动推导出参数列表时,等价于这个通用 lambda 的函数对象有一个模板化的operator()
。
给定 lambda 表达式和函数对象之间的等价关系,lambda 实际上并没有给 C++ 提供新的功能。任何可以在 C++11 中用 lambda 实现的事情都可以在 C++03 中用不同的语法实现。然而,lambdas 确实提供了一种引人注目的、简洁的语法来声明内嵌的匿名函数。lambdas 的两个非常常见的用例是作为 STL 算法的谓词和异步任务的目标。有些人甚至认为 lambda 语法如此引人注目,以至于不再需要用高级代码编写for
循环,因为它们可以被 lambda 和算法所取代。我个人觉得这个观点太偏激了。
在二进制命令的替代设计中,我们看到了 lambdas 的另一种用途。它们可以存储在对象中,然后按需调用,为实现算法提供不同的选项。在某些方面,这个范例编码了策略模式的一个微观应用。为了避免与命令模式混淆,我特意没有在正文中介绍策略模式。感兴趣的读者可以参考 Gamma 等人[11]的详细资料。
标准 function
:
function
类是 C++ 标准库的一部分。这个类在任何可调用的目标周围提供了一个通用的包装器,将这个可调用的目标转换成一个函数对象。本质上,任何可以像函数一样调用的 C++ 构造都是可调用的目标。这包括函数、lambdas 和成员函数。
标准function
提供了两个非常有用的特性。首先,它提供了与任何可调用目标接口的通用工具。也就是说,在模板编程中,将一个可调用的目标存储在一个function
对象中统一了目标上的调用语义,而与底层类型无关。其次,function
支持存储其他难以存储的类型,比如 lambda 表达式。在BinaryCommandAlternative
的设计中,我们利用function
类来存储 lambdas,以实现小算法来将策略模式叠加到命令模式上。虽然在 pdCalc 中没有实际使用,function
类的一般性质实际上使BinaryCommandAlternative
构造器能够接受除 lambdas 之外的可调用目标。
final
关键词 :
**C++11 中引入的关键字final
使类设计者能够声明一个类不能被继承或者一个虚函数不能被重写。对于那些来自 C#或 Java 的程序员来说,你会知道 C++ 在最终(双关语)加入这一功能方面是姗姗来迟。
在 C++11 之前,需要使用卑鄙的手段来阻止类的进一步派生。从 C++11 开始,final
关键字使编译器能够实施这个约束。在 C++11 之前,许多 C++ 设计者认为final
关键字是不必要的。设计者希望一个类是不可继承的,可以让析构函数成为非虚拟的,从而暗示从这个类派生超出了设计者的意图。任何看过从 STL 容器继承的代码的人都会知道开发人员有多倾向于遵循编译器没有强制执行的意图。你有多少次听到一个开发伙伴说,“当然,一般来说,这是一个坏主意,但是,不要担心,在我的特殊情况下这很好。”这个经常被提及的评论几乎不可避免地伴随着一个长达一周的调试会议,以追踪不明显的错误。
为什么要防止从类继承或重写以前声明的虚函数?很可能,因为你有这样一种情况,继承虽然被语言很好地定义了,但在逻辑上却毫无意义。一个具体的例子是 pdCalc 的BinaryCommandAlternative
类。虽然您可以尝试从它派生并覆盖executeImpl()
成员函数(即没有final
关键字),但该类的目的是终止层次结构并通过一个可调用的目标提供二元操作。从BinaryCommandAlternative
继承超出了它的设计范围。因此,防止派生可能会防止微妙的语义错误。在本章的前面,当介绍UnaryCommand
类时,我们看到了这样一种情况:从一个类派生,同时禁止重写它的虚函数子集,这就强制了设计者的预期用途。
4.3 指挥工厂
我们的计算器现在拥有满足其要求所需的所有命令。然而,我们还没有定义存储命令和随后按需访问它们所必需的基础设施。在这一节中,我们将探索几种存储和检索命令的设计策略。
4.3.1 命令工厂类
乍一看,实例化一个新命令似乎是一个需要解决的小问题。例如,如果用户请求将两个数字相加,下面的代码将执行此功能:
Command* cmd = new Add;
cmd->execute();
太好了,问题解决了,对吧?不完全是。这个代码怎么叫?这段代码出现在哪里?如果添加了新的核心命令(即需求改变),会发生什么?如果新命令是动态添加的(就像在插件中一样)会怎样?看似容易解决的问题实际上比最初预期的要复杂。让我们通过回答前面的问题来探索可能的设计方案。
首先,我们问代码如何被调用的问题。计算器的部分要求是同时拥有命令行界面(CLI)和图形用户界面(GUI)。显然,初始化命令的请求将在用户界面的某个地方产生,以响应用户的动作。让我们考虑一下用户界面如何处理减法。假设 GUI 有一个减法按钮,当单击这个按钮时,会调用一个函数来初始化和执行减法命令(我们暂时忽略撤销)。现在考虑 CLI。当减法标记被识别时,一个类似的函数被调用。起初,人们可能期望我们可以调用相同的函数,只要它存在于业务逻辑层而不是用户界面层。然而,GUI 回调的机制使得这不可能,因为它会在 GUI 的小部件库的业务逻辑层中强加一个不期望的依赖(例如,在 Qt 中,按钮回调是类中的一个槽,它要求回调的类是一个Q_OBJECT
)。或者,GUI 可以部署双重间接方式来分派每个命令(每个按钮单击将调用一个函数,该函数将调用业务逻辑层中的一个函数)。这种场景看起来既不优雅又低效。
虽然前面的策略看起来相当麻烦,但是这种初始化方案具有比不便更深的结构性缺陷。在我们为 pdCalc 采用的模型-视图-控制器体系结构中,不允许视图直接访问控制器。由于命令理所当然地属于控制器,由 UI 直接初始化命令违反了我们的基本架构。
我们如何解决这个新问题?从表 2-2 中回忆,命令调度器唯一的公共接口是事件处理函数commandEntered
(const string&)
。这个实现实际上回答了我们最初提出的前两个问题:初始化和执行代码是如何调用的,它驻留在哪里?该代码必须通过从 UI 到命令调度器的事件间接触发,具体命令通过字符串编码。代码本身必须驻留在命令调度器中。请注意,此界面还有一个额外的好处,即在创建新命令时,可以消除 CLI 和 GUI 之间的重复。现在,两个用户界面都可以通过引发commandEntered
事件并通过字符串指定命令来创建命令。我们将分别在第 5 和 6 章中看到每个用户界面如何实现引发该事件。
根据前面的分析,我们有理由向命令调度器添加一个新类,负责拥有和分配命令。我们将把这个类称为CommandFactory
。目前,我们假设命令调度器的另一部分(CommandInterpreter
类)接收到commandEntered()
事件并从CommandFactory
请求适当的命令(通过commandEntered()
的string
参数),命令调度器的另一个组件(CommandManager
类)随后执行命令(并处理撤销和重做)。也就是说,我们将命令的初始化和存储与它们的分派和执行分离开来。CommandManager
和CommandInterpreter
类是后续部分的主题。现在,我们将关注命令存储、初始化和检索。
我们现在的任务是实现一个函数,该函数能够实例化从Command
类派生的任何类,只给定一个表示其特定类型的string
参数。正如所料,将对象创建与类型分离是设计中常见的现象。任何提供这种抽象的构造通常被称为工厂。这里,我们介绍一个特定的实施例,工厂功能设计模式。
在继续之前,我应该指出工厂函数和工厂方法模式之间的语义差异,正如四人帮所定义的[11]。如前所述,一般来说,工厂是一种从逻辑实例化的角度来分离层次结构中特定派生类的选择的机制。工厂方法模式通过单独的类层次结构实现工厂,而工厂函数只是一个实现工厂概念的单一函数接口,没有类层次结构。
通常,工厂函数是通过调用带有标志(整数、枚举、字符串等)的函数来实现的。)来限定层次结构的专门化,并返回一个基类指针。让我们检查一个人为的例子。假设我们有一个包含派生类Circle
、Triangle
和Rectangle
的Shape
层次结构。此外,假设我们已经定义了下面的枚举类:
enum class ShapeType {Circle, Triangle, Rectangle};
以下工厂函数可用于创建形状:
unique_ptr<Shape> shapeFactory(ShapeType t)
{
switch(t)
{
case ShapeType::Circle:
return make_unique<Circle>();
case ShapeType::Triangle:
return make_unique<Triangle>();
case ShapeType::Rectangle:
return make_unique<Rectangle>();
}
}
可以通过以下函数调用创建一个Circle
:
auto s = shapeFactory(ShapeType::Circle);
为什么前面的结构比打字有用
auto s = make_unique<Circle>();
事实上,考虑到类似的编译时间依赖性,事实并非如此。然而,相反,考虑一个接受string
参数而不是枚举类型的工厂函数(用一系列if
语句替换switch
语句)。我们现在可以用下面的代码构造一个Circle
:
string t = "circle";
auto s = shapeFactory(t);
前面提到的是一个比使用类名或枚举类型直接实例化更有用的构造,因为t
值的发现可以推迟到运行时。例如,工厂函数的典型用法是实例化特定的派生类,其类型是从配置文件、输入文件或动态用户交互(即,通过 UI)中发现的。
回到 pdCalc,我们希望设计一个类CommandFactory
,它实例化一个特定的Command
,给定从用户交互生成的事件数据中获得的string
标识符。因此,我们用一个工厂函数开始我们的CommandFactory
类的接口,这个工厂函数返回一个给定了string
参数的Command
:
class CommandFactory
{
public:
unique_ptr<Command> allocateCommand(const string&) const;
};
该接口使用智能指针返回类型来明确调用者拥有新构造命令的内存。
现在让我们考虑一下allocateCommand()
的实现可能是什么样子。这个练习将帮助我们修改设计以获得更大的灵活性。
unique_ptr<Command> CommandFactory::allocateCommand(const string& c)
const
{
if(c == "+") return make_unique<Add>();
else if(c == "-") return make_unique<Subtract>();
// ...all known commands...
else return nullptr;
}
前面的接口简单而有效,但是由于需要系统中每个命令的先验知识而受到限制。一般来说,由于几个原因,这样的设计是非常不理想和不方便的。首先,向系统添加新的核心命令需要修改工厂的初始化功能。其次,部署运行时插件命令需要完全不同的实现。第三,这种策略在特定命令的实例化和它们的存储之间产生了不必要的耦合。相反,我们更喜欢一种设计,其中CommandFactory
只依赖于由Command
基类定义的抽象接口。
前面的问题可以通过应用一种简单的模式来解决,这种模式被称为原型模式[11]。原型模式是一种创建模式,其中存储了一个原型对象,这种类型的新对象可以通过复制原型来创建。现在,考虑一个将我们的CommandFactory
仅仅视为命令原型容器的设计。此外,让原型都由Command
指针存储,比方说,在一个散列表中,使用一个字符串作为键(可能是在commandEntered()
事件中产生的同一个字符串)。然后,可以通过添加(或删除)新的原型命令来动态地添加(或删除)新的命令。为了实现这一策略,我们向我们的CommandFactory
类添加了以下内容:
class CommandFactory
{
public:
unique_ptr<Command> allocateCommand(const string&) const;
void registerCommand(const string& name, unique_ptr<Command> c);
private:
using Factory = unordered_map<string, unique_ptr<Command>>;
Factory factory_;
};
注册命令的实现非常简单:
void CommandFactory::registerCommand(const string& name,
unique_ptr<Command> c)
{
if( factory_.contains(name) )
// handle duplicate command error
else
factory_.emplace( name, std::move(c) );
}
在这里,我们检查命令是否已经在工厂中。如果是,那么我们处理错误。如果不是,那么我们将命令参数移入工厂,在这里命令成为命令name
的原型。注意,unique_ptr
的使用表明注册一个命令会将这个原型的所有权转移到命令工厂。实际上,核心命令都是通过CommandFactory.m.cpp
文件中的一个函数注册的,每个插件内部都有一个类似的函数来注册插件命令(当我们在第七章中研究插件的构造时会看到这个接口)。这些函数分别在计算器初始化和插件初始化期间被调用。可选地,可以用一个带有明显实现的注销命令来扩充命令工厂。
使用我们的新设计,我们可以将allocateCommand()
函数重写如下:
unique_ptr<Command> CommandFactory::allocateCommand(const string& name)
const
{
if( factory_.contains(name) )
{
const auto& command = factory_.find(name)->second;
return unique_ptr<Command>( command->clone() );
}
else return nullptr;
}
现在,如果在工厂中找到该命令,就会返回原型的副本。如果没有找到该命令,则返回一个nullptr
(或者抛出一个异常)。原型的副本在一个unique_ptr
中返回,表明调用者现在拥有这个命令的副本。注意来自Command
类的clone()
函数的使用。克隆函数最初被添加到Command
类中是为了将来的合理性。现在很明显,我们需要clone()
函数,以便为原型模式的实现多形态地复制Command
s。当然,如果我们在设计Command
类时没有预见到为所有命令实现克隆功能,那么现在可以很容易地添加它。记住,你不会在第一遍就得到完美的设计,所以要习惯迭代设计的思想。
本质上,registerCommand()
和allocateCommand()
体现了CommandFactory
类的最小完整接口。但是,如果您检查这个类包含的源代码,您会看到一些不同之处。首先,在界面上添加了额外的功能。额外的功能大多是方便和语法糖。第二,我用了一个别名,CommandPtr
,而不是直接用unique_ptr<Command>
。出于本章的目的,只需将CommandPtr
视为由以下 using 语句定义:
using CommandPtr = std::unique_ptr<Command>;
真正的别名,可以在Command.m.cpp
里找到,稍微复杂一点。此外,我使用了一个函数MakeCommandPtr()
而不是unique_ptr
的构造器来创建CommandPtr
,这些差异的原因将在第七章中详细解释。
最后,存储库代码中唯一没有讨论的影响设计的接口部分是选择使CommandFactory
成为单例。这个决定的原因很简单。不管系统中有多少不同的命令解释器(有趣的是,我们最终会看到有多个命令解释器的情况),函数的原型永远不会改变。因此,使CommandFactory
成为单例集中了计算器所有命令的存储、分配和检索。
Modern C++ Design Note: Uniform Initialization
你可能已经注意到了,我经常用花括号来初始化。对于长期使用 C++ 编程的开发人员来说,使用花括号来初始化一个类(即调用其构造器)可能会显得很奇怪。虽然我们习惯了初始化数组的列表语法:
int a[] = { 1, 2, 3 };
使用花括号初始化类是 C++11 中的一个新特性。虽然圆括号仍可用于调用构造器,但使用花括号的新语法(称为统一初始化)是现代 C++ 的首选语法。虽然这两种初始化机制在功能上执行相同的任务,但新语法有三个优点:
-
统一初始化是非箭头:
-
统一初始化(结合初始化列表)允许用列表初始化用户定义的类型:
class A { A(int a); };
A a(7.8); // ok, truncates
A a{7.8}; // error, narrows
- 统一初始化绝不会被错误地解析为函数:
vector<double> v{ 1.1, 1.2, 1.3 }; // valid since C++11; initializes vector with 3 doubles
struct B { B(); void foo(); };
B b(); // Are you declaring a function that returns a B?
b.foo(); // error, requesting foo() in non-class type b
B b2{}; // ok, default construction
b2.foo(); // ok, call B::foo()
使用统一初始化时,只有一个重要的注意事项:列表构造器总是在任何其他构造器之前被调用。典型的例子来自 STL vector
类,它有一个初始化列表构造器和一个单独的构造器,接受一个整数来定义vector
的大小。因为如果使用花括号,初始化列表构造器在任何其他构造器之前被调用,所以我们有以下不同的行为:
vector<int> v(3); // vector, size 3, all elements initialized to 0
vector<int> v{3}; // vector with 1 element initialized to 3
幸运的是,前面的情况并不经常出现。但是,当它发生时,您必须理解统一初始化和函数样式初始化之间的区别。
从设计的角度来看,统一初始化的主要优点是用户定义的类型可以被设计为接受相同类型值的列表进行构造。因此,容器,如vectors
,可以用一个值列表静态初始化,而不是默认初始化后再进行连续赋值。这一现代 C++ 特性使派生类型的初始化能够使用与内置数组类型相同的初始化语法,这是 C++03 中缺少的语法特性。
注册核心命令
我们现在已经定义了计算器的核心命令和一个按需加载和服务命令的类。然而,我们还没有讨论将核心命令加载到CommandFactory
中的方法。为了正常运行,所有核心命令的加载必须只执行一次,并且必须在使用计算器之前执行。本质上,这定义了命令调度器模块的初始化需求。由于在退出程序时不需要注销核心命令,因此不需要终结功能。
命令调度器调用初始化操作的最佳位置是在计算器的main()
函数中。因此,我们简单地创建一个全局RegisterCoreCommands()
函数,在CommandFactory.m.cpp
文件中实现它,确保该函数从模块中导出,并从main()
中调用它。创建一个全局函数而不是在CommandFactory
的构造器中注册核心命令的原因是为了避免将CommandFactory
类与命令层次结构的派生类相耦合。另一种方法是在CoreCommands.m.cpp
文件中定义RegisterCoreCommands()
,但是这需要额外的接口文件、实现文件和模块导出。当然,注册功能可以被称为类似于InitCommandDispatcher()
的东西,但是我更喜欢一个更具体地描述该功能的名称。
隐式地,我们只是将接口扩展到了命令调度器模块(最初在表 2-2 中定义),尽管这相当琐碎。我们应该能够提前预测这部分界面吗?可能不会。这次界面更新是由一个设计决策引起的,这个决策比第二章的高级分解要详细得多。我发现在开发过程中稍微修改一个关键接口是一种可以接受的程序设计方式。要求不变性的设计策略过于死板,不切实际。但是,请注意,在开发过程中容易接受关键接口修改与在发布后接受关键接口修改形成对比,只有在充分考虑更改将如何影响已经使用您的代码的客户端之后,才应该做出决定。
4.4 命令管理器
已经设计了命令基础结构,并为系统中命令的存储、初始化和检索创建了一个工厂,现在我们准备设计一个负责按需执行命令和管理撤销和重做的类。这个类叫做CommandManager
。本质上,它通过对每个命令调用execute()
函数来管理命令的生命周期,并随后以适合实现无限撤销和重做的方式保留每个命令。我们将从定义CommandManager
的接口开始,并通过讨论实现无限撤销和重做的策略来结束本节。
4.4.1 界面
CommandManager
的界面非常简单明了。CommandManager
需要一个接口来接受要执行的命令、撤销命令和重做命令。可选地,还可以包括用于查询撤销和重做操作的可用数量的界面,这对于 GUI 的实现可能是重要的(例如,对于重做大小等于零,使重做按钮变灰)。一旦命令被传递给CommandManager
,则CommandManager
拥有该命令的生命周期。因此,CommandManager
的接口应该强制拥有语义。结合起来,CommandManager
的完整界面如下:
class CommandManager
{
public:
size_t getUndoSize() const;
size_t getRedoSize() const;
void executeCommand(unique_ptr<Command> c);
void undo();
void redo();
};
在CommandManager.m.cpp
中列出的实际代码中,接口额外定义了一个enum class
,用于在构造期间选择撤销/重做实现策略。这些策略将在下一节讨论。我包含这个选项只是为了说明。生产代码将简单地实现一个撤销/重做策略,而不在构造时定制底层数据结构。
4.4.2 实现撤消和重做
为了实现无限制的撤销和重做,我们必须有一个动态增长的数据结构,能够按照命令执行的顺序存储和重新访问命令。虽然可以设计许多不同的数据结构来满足这一需求,但我们将研究两个同样好的策略。这两种策略都已经在计算器上实现,可以在CommandManager.m.cpp
文件中看到。
图 4-3
撤销/重做列表策略
考虑图 4-3 中的数据结构,我称之为列表策略。在命令被执行之后,它被添加到列表中(实现可以是list
、vector
或其他合适的有序容器),并且指针(或索引)被更新以指向最后执行的命令。每当调用 undo 时,当前指向的命令被撤消,指针向左移动(以前命令的方向)。调用 redo 时,命令指针向右移动(后面命令的方向),执行新指向的命令。当当前命令指针到达最左侧(没有要撤消的命令)或最右侧(没有要重做的命令)时,存在边界条件。这些边界条件可以通过禁用使用户能够调用命令的机制(例如,使撤销或重做按钮变灰)或者通过简单地忽略将导致指针超出边界的撤销或重做命令来处理。当然,每次执行新命令时,在新命令被添加到撤销/重做列表之前,必须刷新当前命令指针右边的整个列表。为了防止撤消/重做列表变成具有多个重做分支的树,刷新列表是必要的。
作为替代,考虑图 4-4 中的数据结构,我称之为栈策略。我们维护两个栈,一个用于撤销命令,一个用于重做命令,而不是按照命令执行的顺序维护命令列表。执行新命令后,它会被推送到撤消栈上。通过从撤消栈弹出顶部条目、撤消命令并将命令压入重做栈来撤消命令。通过从重做栈弹出顶部条目、执行命令并将命令压入撤消栈来重做命令。边界条件是存在的,并且很容易通过栈的大小来识别。执行新命令需要刷新重做栈。
图 4-4
撤销/重做栈策略
实际上,选择通过栈还是列表策略实现撤销和重做很大程度上取决于个人偏好。列表策略只需要一个数据容器和较少的数据移动。然而,栈策略稍微容易实现,因为它不需要索引或指针移动。也就是说,这两种策略都很容易实现,并且只需要很少的代码。一旦您实现并测试了任一策略,CommandManager
就可以很容易地在未来需要撤销和重做功能的项目中重用,只要命令是通过命令模式实现的。更普遍的是,CommandManager
可以在抽象命令类上被模板化。为了简单起见,我选择专门为前面讨论的抽象Command
类实现包含的CommandManager
。
4.5 命令解释器
命令调度器模块的最后一个组件是CommandInterpreter
类。如前所述,CommandInterpreter
类有两个主要作用。第一个角色是充当命令调度器模块的主要接口。第二个角色是解释每个命令,从CommandFactory
请求适当的命令,并将每个命令传递给CommandManager
执行。我们依次处理这两个角色。
界面
尽管命令调度器模块的实现很复杂,但是CommandInterpreter
类的接口非常简单(大多数好的接口都是如此)。正如在第二章中所讨论的,命令调度模块的接口完全由一个用于执行命令的功能组成;命令本身由字符串参数指定。这个函数自然就是前面讨论过的executeCommand()
事件处理程序。因此,CommandInterpreter
类的接口如下所示:
class CommandInterpreter
{
class CommandInterpreterImpl;
public:
CommandInterpreter(UserInterface& ui);
void executeCommand(const string& command);
private:
unique_ptr<CommandInterpreterImpl> pimpl_;
};
回想一下,计算器的基础架构是基于模型-视图-控制器模式的,并且CommandInterpreter
作为控制器的一个组件,被允许直接访问模型(栈)和视图(用户界面)。因此,CommandInterpreter
的构造器引用了一个抽象的UserInterface
类,其细节将在第五章中讨论。不需要对栈的直接引用,因为栈是作为单例实现的。CommandInterpreter
的实际实现被委托给私有实现类CommandInterpreterImpl
。我们将在下一小节中讨论这种使用指向实现类的指针的模式,称为 pimpl 习惯用法。
前面提到的另一种设计是直接让CommandInterpreter
类成为一个观察者。正如在第三章中所讨论的,我更喜欢使用中间事件观察者的设计。在第五章中,我们将讨论一个CommandIssuedObserver
代理类的设计和实现,它在用户界面和CommandInterpreter
类之间代理事件。虽然CommandIssuedObserver
是在用户界面旁边描述的,但它实际上属于命令调度器模块。
通俗的习语
在本书的第一个版本中,我在 pdCalc 的实现中大量使用了 pimpl 模式。然而,当在 C++ 模块接口而不是头文件中声明类时,我发现我使用 pimpl 模式的频率大大降低了。尽管我减少了对该模式的使用,但由于它在 C++ 代码中的突出地位,pimpl 习惯用法仍然值得讨论。因此,我们将描述 pimpl 模式本身,为什么它在历史上如此重要,以及 pimpl 模式在模块存在的情况下仍然有意义的地方。
如果您查看足够多的 C++ 代码,您最终会发现许多类都有一个奇怪的单成员变量,通常名为pimpl_
(或类似的东西),抽象实现细节(桥模式的一种 C++ 专门化)。对于那些不熟悉术语 pimpl 的人来说,它是指向实现的指针的简写符号。实际上,不是在类的声明中声明类的所有实现,而是向前声明一个指向“隐藏”实现类的指针,并在一个单独的实现文件中完全声明和定义这个“隐藏”类。如果pimpl
变量仅在包含其完整声明的源文件中被解引用,那么包含和使用不完整类型(pimpl
变量)是允许的。例如,考虑下面的类A
,它有一个由函数f()
和g()
组成的公共接口;具有函数u()
、v()
和w()
的私有实现;以及私有数据v_
和m_
:
class A
{
public:
void f();
void g();
private:
void u();
void v();
void w();
vector<double> v_;
map<string, int> m_;
};
我们没有将A
的私有接口可视化地暴露给这个类的消费者(在头文件或模块接口单元中),而是使用 pimpl 习惯用法,编写
class A
{
class AImpl;
public:
void f();
void g();
private:
unique_ptr<AImpl> pimpl_;
};
其中u
、v
、w
、v_
和m_
现在都是类AImpl
的一部分,这些类将只在与类A
关联的实现文件中声明和定义。为了确保AImpl
不能被任何其他类访问,我们声明这个实现类是一个完全在A
中定义的私有类。Sutter 和 Alexandrescu [34]简要解释了 pimpl 习语的优点。假设使用一个头文件/实现文件对(与模块相对),一个主要的优点是通过将类A
的私有接口从A.h
移动到A.cpp
,当只有A
的私有接口改变时,我们不再需要重新编译任何代码消耗类A
。对于大规模的软件项目,编译过程中节省的时间可能是显著的。
对于具有适度复杂的私有接口的代码,我倾向于使用 pimpl 习惯用法,而不考虑它对编译时间的影响。我的一般规则的例外是计算密集型的代码(例如,pimpl 的间接开销很大的代码)。假设一个遗留的头文件实现,除了在只有类AImpl
改变时不必重新编译包括A.h
在内的文件的编译好处之外,我发现 pimpl 习惯用法极大地增加了代码的清晰度。这种清晰性源于在实现文件中隐藏助手函数和类的能力,而不是在接口文件中列出它们。通过这种方式,接口文件真正反映的只是接口的基本要素,从而防止类膨胀,至少在可见的接口级别是这样。对于任何其他只是使用你的类的程序员来说,实现的细节在视觉上是隐藏的,因此不会影响到你的文档良好的有限的公共接口。pimpl 习语确实是封装的缩影。
模块消除了 Pimpl 习语吗?
C++20 模块给接口带来了几个设计和实现上的好处。这些好处消除了对 pimpl 习语的需求吗?要回答这个问题,我们需要列举使用 pimpl 模式的原因,并确定模块是否反对使用这种习惯用法。
使用 pimpl 模式的第一个原因是为了加速编译。在经典的头文件包含模型中,pimpl 模式分离了类的私有实现和它的公共声明之间的依赖关系。这种分离意味着,当只对私有实现类进行更改时,依赖于 pimpl-ed 类的头文件的翻译单元不需要重新编译,因为这些更改将出现在单独编译的源文件中,而不是 pimpl-ed 类的头文件中。模块部分解决了这个问题。为了理解为什么,我们需要简单地研究一下模块编译模型。
假设我们在foo.cpp
中定义了一个函数foo
,它使用了类A
。在头包含模型中,A
将在A.h
中声明,在A.cpp
中定义;A.h
将包含在foo.cpp
中。头文件包含模型本质上是在编译foo.cpp
时将A.h
的内容粘贴到foo.cpp
中。因此,A.h
中的任何变化都会强制对foo.cpp
进行重新编译,这包括对A.h
的全部内容进行重新编译,因为它是以文本形式包含在foo.cpp
中的。当A.h
很大时,这种情况是有问题的,并且这种情况是加倍有问题的,因为对于这个类的每个消费者都发生A.h
内容的重新编译。当然,A.cpp
的任何变化都不会导致其消费者的重新编译,因为A
的消费者只依赖于A.h
,而不依赖于A.cpp
。这种编译依赖性正是为什么 pimping 通过将A
的实现细节从A.h
移到A.cpp
而使我们受益的原因。
模块的编译模型不同于头文件包含模型。模块不会以文本形式包含在它们的使用者中;相反,它们是进口的。模块不需要单独的头文件和实现文件,导入也不直接需要声明的可见性。相反,导入模块依赖于编译器创建编译模块接口(CMI),不幸的是,这是一个依赖于编译器的过程。例如,当编译模块时,gcc 自动缓存 CMI,而 clang 依赖于构建系统手动定义 CMI 预编译步骤。无论如何,模块导入机制提供了超越头文件包含模型的明显优势,因为 CMI 在构建模块时编译一次,消除了每次包含头文件时重新编译头文件中的定义的需要。当然,前面对模块编译的解释稍微简化了一些,因为模块可以分成模块定义和模块实现文件,CMI 是编译器版本特定的,甚至是编译选项特定的。尽管如此,模块部分地解决了我们使用 pimpl 习语的第一个原因。如果类A
是在模块ModuleA
中定义的,而不是在A.h
中定义的,那么对A
定义的任何更改仍然需要重新编译A
的消费者。然而,A.h
的先前内容的重新编译将被预编译一次到 CMI 中,而不是被文本地包含并由每个消费者重新编译。是的,每个消费者仍然需要重新编译,但是这些编译应该更快。如果有足够的工具支持,如果构建工具能够检测到 CMI 中公共和私有变更之间的差异,甚至有可能不需要重新编译使用者。
使用 pimpl 模式的第二个原因是 pimpl 模式对消费编译单元隐藏了实现名和附加类型。既然实现细节会出现在类的私有部分,并且消费者无法访问,那么这又有什么关系呢?奇怪的是,即使私有名称被限制使用,它们也参与了重载决策。考虑以下示例:
// A.h
class A
{
public:
void bar(double);
private:
void bar(int);
};
// foo.cpp
#include "A.h"
void foo()
{
A a;
a.bar(7.0); // OK, calls A::bar(double)
a.bar(7); // error, tries calling inaccessible A::bar(int)
}
Listing 4-1Visibility versus accessibility
如果A::bar(int)
被隐藏在一个私有实现类中,那么前面函数foo()
中的错误代码行将会编译成数字 7 从 int 到 double 的隐式转换。
模块能解决前面的问题吗?同样,答案只是部分的。对于清单 4-1 中给出的类似例子,其中类A
将被重构为一个模块的导出,将会导致相同的错误。然而,让我们考虑下面这个更简单的例子:
// AMod.cpp
export module AMod;
export void bar(double);
void bar(int);
// implementations of bar
// foo.cpp
import AMod;
void foo()
{
bar(7);
bar(7.0);
}
前面的编译,但是只调用了bar(double)
。也就是说,虽然bar(int)
对人类是可见的,但对编译器是不可见的。有趣的是,bar(int)
即使不可见,也仍然可以到达。让我们回到我们的类示例,修改模块的 pimpl 模式。我们现在可以写了
// AMod.cpp
export module AMod;
struct AImpl
{
void bar(int){}
};
export class A
{
public:
void bar(double);
void baz(int i){impl_.bar(i);}
private:
AImpl impl_;
};
// foo.cpp
import AMod;
void foo()
{
A a;
a.bar(7);
a.bar(7.0);
a.baz(7);
}
前面的例子表明我们仍然必须使用 pimpl 模式来消除bar()
的名称歧义。然而,因为模块可以隐藏可见性而不阻塞可达性,所以我们可以在没有指针间接性的情况下构造我们的 pimpl,同时仍然从实例化的角度对消费者隐藏AImpl
。最后一个事实把我们带到了下一个困境。
假设客户端没有完整的源代码访问权限,pimpl 模式使我们能够通过将这些细节从人类可读的接口(头文件)移动到仅作为编译的二进制代码交付给客户端的实现文件中,来隐藏实现细节。模块允许隐藏类的实现细节而不求助于 pimpl 习惯用法吗?不幸的是,没有。模块提供了一种语言特性,使编译器能够对消费代码隐藏可见性,而不是对人类。虽然模块可以分解成独立的模块接口和模块实现单元,但是模块接口单元必须是人类可读的,因为 CMI 不能可靠地跨编译器版本或设置使用。也就是说,如果一个模块的接口必须是可导出的,那么其实现的源代码必须是可分发的。前面的语句扩展到了这样的情况,我们在类A
自己的模块AMod.Impl
中定义了类A
的实现细节,并将AMod.Impl
导入到AMod
(没有重新导出AMod.Impl
)。CMI 缺乏二进制可移植性意味着任何由AMod
导入的模块接口单元也必须与AMod
的模块接口单元一起发布,就像嵌套的头文件一样。此外,类似于头文件类声明,模块接口单元–
导出的类声明必须包含足够的信息来实例化一个对象。因此,类型必须完全可见(即使不可访问),这意味着要对人隐藏代码,我们必须求助于 pimpl 模式的实现,该模式使用指针间接寻址,而不是前面提到的利用类组合的更有效的方法。模块不能解决通过发送私有类实现细节来解决的人类可见性问题。
最后,我的观点是,pimpl 模式通过最大限度地减少出现在一个类的客户端接口的可视化表示中的代码行数,在风格上简化了接口代码。许多人可能不关心,甚至不承认 pimpl 习语的这一优势。这种风格上的优势适用于头文件和模块接口单元。
总的来说,如果你只是为了编译效率的好处而使用 pimpl 模式,那么一旦模块成熟了,就很可能不再使用 pimpl 了。如果您使用 pimpl 模式来避免冲突,模块可以部分解决您的问题。最后,如果您使用 pimpl 习惯用法从分布式接口源代码中删除实现细节,以避免人工可见性,或者只是为了清理接口,那么模块根本没有帮助。本节最后得出的结论是,根据您的使用情况,模块可能会部分消除 pimpl 习惯用法。虽然我仍然使用 pimpl 模式,但我发现我在模块中使用它的频率比在头文件中少。
实施细节
通常在这本书里,我们不会关注类的实现细节。不过,在这种情况下,CommandInterpreterImpl
类的实现特别有指导意义。CommandInterpreterImpl
类的主要功能是实现函数executeCommand()
。该函数必须接收命令请求,解释这些请求,检索命令,请求执行命令,并优雅地处理未知命令。如果我们从自顶向下开始分解 command dispatcher 模块,试图干净利落地实现这个功能会非常困难。然而,由于我们自底向上的方法,executeCommand()
的实现很大程度上是将现有组件粘合在一起的练习。考虑下面的实现,其中manager_
对象是CommandManager
类的一个实例:
1 void CommandInterpreter::CommandInterpreterImpl::executeCommand(const
2 string command&)
3 {
4 if(double d; isNum(command, d) )
5 manager_.executeCommand(MakeCommandPtr<EnterNumber>(d));
6 else if(command == "undo")
7 manager_.undo();
8 else if(command == "redo")
9 manager_.redo();
10 else
11 {
12 if( auto c = CommandFactory::Instance().allocateCommand(command) )
13 {
14 try
15 {
16 manager_.executeCommand( std::move(c) );
17 }
18 catch(Exception& e)
19 {
20 ui_.postMessage( e.what() );
21 }
22 }
23 else
24 {
25 auto t = std::format("Command {} is not a known command", command);
26 ui_.postMessage(t);
27 }
28 }
29
30 return;
31 }
第 4 行–
9 处理特殊命令。特殊命令是命令工厂中没有输入的任何命令。在前面的代码中,这包括输入新数字、撤消和重做。如果没有遇到特殊的命令,则假定可以在命令工厂中找到该命令;第 12 行是命令工厂请求。如果nullptr
从命令工厂返回,错误在第 25 行–
26 中处理。否则,命令由命令管理器执行。请注意,命令的执行是在 try/catch 块中处理的。通过这种方式,我们能够捕获由命令前提条件失败导致的错误,并在用户界面中报告这些错误。CommandManager
的实现确保了不满足前提条件的命令不会进入撤销栈。
在CommandInterpreter.cpp
中找到的executeCommand()
的实际实现与前面的代码略有不同。首先,实际的实现包括两个额外的特殊命令。这些附加的特殊命令中的第一个是 help。可以发出 help 命令来打印命令工厂中当前所有命令的简要说明消息。虽然实现一般会将帮助信息打印到用户界面,但我只在 CLI 中公开了 help 命令(即,我的 GUI 实现没有帮助按钮)。第二个特殊命令处理存储过程。存储过程在第八章中解释。此外,我将 try/catch 块放在它自己的函数中。这样做只是为了缩短executeCommand()
功能,并将命令解释的逻辑与命令执行分开。
根据您对 C++17 以来语言语法演变的熟悉程度,在executeCommand()
的实现中有两条代码语句对您来说可能是新的,也可能不是新的:if 语句中的初始化器和std::format()
函数。我们将在下面的侧栏中研究这两个新特性。
Modern C++ Design Note: If Statement Initializers And STD::FORMAT()
在 C++17 中引入了 if 语句中的初始化,在 C++20 中引入了std::format()
函数。让我们来看看这两个新特性。
If 语句初始值 :
在 C++17 之前,您多久会发现自己处于以下(或类似)情况?
auto i = getAnInt();
if(i % 2 == 0 ) { /* do even things with i */ }
else { /* do odd things with i */}
您希望像在 for 循环中作用于变量一样作用于 if 语句的频率有多高?在 C++17 中,这种困境通过 if 语句的扩展得到了补救,它包含了一个可选的初始化子句。现在,我们可以写
if(auto i = getAnInt(); i % 2 == 0 ) { /* do even things with i */ }
else { /* do odd things with i */}
这个特性从根本上改变了你能用 C++ 做什么吗?不,但这很方便,它为 if 语句和 for 循环的作用域和初始化规则带来了一致性。一个类似的特性为 switch 语句添加了初始化。while 循环呢?不,没有为 while 循环添加新的语法。为什么不呢?不需要新的语法。我们总是能够用 for 循环来表达这个结构。也就是说,我们有以下等价关系:
while(auto keepGoing = foo(); keepGoing) //not C++
{ /* do something that updates keepGoing */ }
与…相同
for(auto keepGoing = foo(); keepGoing;)
{ /* do something that updates keepGoing */ }
STD::format():
你是不是从来不喜欢过于冗长的字符串格式语法,而渴望一种类似于 C++ 的类型安全的字符串格式?如果你是,那么std::format()
将会真正让你兴奋!
C++20 增加了一个格式化库,有几个不同的格式化函数,其中两个在 pdCalc 中使用:format()
和format_to()
。format()
函数有以下逻辑签名:
template<class... Args>
string format(string_view fmtStr, const Args&... args);
其中fmtStr
是一个格式化字符串,接受任意数量的类型化参数。带格式的字符串是一个普通的字符串,默认情况下,它按顺序用参数替换任何出现的{}
。{}
可以为空(默认格式),也可以包含用户指定的格式参数。该函数的返回值是格式化的字符串,所有的{}
都被格式化的参数替换。举个例子:
cout << format("{0} to {1} decimal places: {2:.{1}f}", "pi", 4, 3.1415927);
生产
pi to four decimal places: 3.1416
在前面的示例中,我对格式说明符使用了带编号的参数,以便重用第二个参数,即精度值。此外,该示例还演示了如何打印参数或将其用作格式说明符的变量。相信我,前面提到的只是标准格式的皮毛。
使用format()
可以格式化单个字符串。如果您需要一个字符串生成器,您可以使用format_to()
。format_to()
使用与format()
相同的语法格式化字符串,除了format_to()
接受一个输出迭代器作为它的第一个参数,并返回由格式化字符串推进的相同的输出迭代器,而不是返回一个格式化的字符串。当输出迭代器是一个back_inserter<string>
时,那么format_to()
实质上代替了一个ostringstream
。
我必须承认,我不是那些被iostream
库过于冗长的语法所困扰的人之一。然而,自从我开始使用 C++20 格式化库以来,我还没有使用过ostream
来格式化文本。我想我实际上被之前的语法所困扰,但我甚至不知道它!
4.6 重新审视先前的决定
至此,我们已经完成了计算器的两个主要模块:栈和命令调度器。让我们重新审视我们的原始设计,讨论一个已经出现的重大细微偏差。
回想一下第二章,我们最初的设计是通过在栈和命令调度器中引发事件来处理错误,这些事件是由用户界面来处理的。做出这一决定的原因是为了保持一致。虽然命令调度器有对用户界面的引用,但栈没有。因此,我们决定简单地让两个模块通过事件通知用户界面错误。然而,敏锐的读者会注意到,和以前设计的一样,命令调度器从不引发错误事件。相反,当出现错误时,它直接调用用户界面。那么,我们没有打破系统中有意设计的一致性吗?不。实际上,我们在命令调度器的设计过程中隐式地重新设计了系统的错误处理机制,因此无论是栈还是命令调度器都不会引发错误事件。让我们来看看为什么。
正如我们刚才所说的,从它的实现中可以明显看出,命令调度器没有引发错误事件,但是栈事件发生了什么呢?我们没有改变Stack
类的源代码,那么错误事件是如何消除的呢?在最初的设计中,当发生错误时,栈通过引发事件间接通知用户界面。两种可能的栈错误情况是弹出一个空栈和交换大小不足的栈的顶部两个元素。在设计命令时,我意识到如果一个命令触发了这些错误条件中的任何一个,用户界面会得到通知,但是命令调度器不会得到通知(它不是栈事件的观察者)。在这两种错误情况下,一个命令可能已经完成,尽管没有成功,并且被错误地放在撤消栈上。然后我意识到,要么命令调度器必须捕获栈错误并防止错误放置到撤销栈上,要么不允许命令产生栈错误。正如最终设计所展示的,我选择了更简单、更干净的实现,即在执行命令之前使用前提条件来防止出现栈错误,从而隐式地抑制栈错误。
最大的问题是,为什么我没有改变描述原始设计的文本和相应的代码来反映错误报告中的变化?简单地说,我想让读者看到错误确实会发生。设计是一个迭代的过程,一本试图通过例子来教授设计的书应该接受这个事实,而不是隐藏它。设计应该有点流动性(但可能有高粘度)。尽管有证据表明原始设计中存在缺陷,但尽早改变一个糟糕的设计决策要比坚持下去好得多。一个糟糕的设计改变得越晚,修复它的成本就越高,开发人员在试图实现一个错误时就会承受越多的痛苦。至于更改代码本身,当我执行重构时,我会从生产系统中的Stack
类中删除多余的代码,除非Stack
类被设计为在另一个通过事件处理错误的程序中重用。毕竟,作为一种通用设计,通过引发事件来报告错误的机制是没有缺陷的。事后看来,这种机制根本不适合 pdCalc。**