C---设计实践教程-全-
C++ 设计实践教程(全)
一、定义案例研究
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会产生一个假想的结果)。将显示一个错误情况。
用例:用户执行![$$ \sqrt[\boldsymbol{x}]{\boldsymbol{y}} $$](https://gitee.com/OpenDocCN/vkdoc-c-cpp-zh/raw/master/docs/prac-cpp-design/img/454125_2_En_2_Chapter_TeX_IEq1.png)
-
场景:用户输入
的命令。系统执行操作并显示新的栈。
-
异常:栈大小不足以支持操作。将显示一个错误情况。
-
异常:操作的输入无效(例如,
会产生一个假想的结果)。将显示一个错误情况。
用例:用户加载一个插件
-
场景:用户将一个插件放入插件目录。系统在启动时加载插件,使插件功能可用。
-
异常:插件无法加载。将显示一个错误情况。
用例分析
我们现在将分析用例,以便为 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。**
五、命令行界面
这是非常激动人心的一章。虽然命令行界面(CLI)可能不具备现代图形用户界面(GUI)的魅力,尤其是那些手机、平板电脑或 Web 界面,但 CLI 仍然是非常有用和有效的用户界面。本章详细介绍了 pdCalc 命令行界面的设计和实现。到本章结束时,我们将第一次拥有一个功能正常的计算器,尽管功能不完整,这是我们开发过程中的一个重要里程碑。
5.1 用户界面抽象
虽然我们可以单独设计一个功能完整的 CLI,但我们从需求中知道,功能完整的计算器必须同时具有 CLI 和 GUI。因此,通过首先考虑这两个接口之间的共性并将这种功能分解到一个公共抽象中,我们的整体设计将会得到更好的服务。让我们考虑构造用户界面抽象的两种设计方案:自顶向下的方法和自底向上的方法。
在考虑具体类型之前设计抽象接口类似于自顶向下的设计。就用户界面而言,你首先要考虑任何用户界面都必须符合的最基本的要素,并基于这个极简主义的概念创建一个抽象的界面。当抽象概念缺少实现具体类型所需的东西时,对接口的细化就变得必要了。在考虑具体类型之后设计抽象接口类似于自底向上的设计。同样,就用户界面而言,您首先要考虑所有具体类型的需求(在本例中是 CLI 和 GUI),寻找所有类型之间的共性,然后将这些共性提取出来。当您添加一个新的具体类型,该类型需要最初提取抽象时没有考虑到的额外特性时,对接口的细化就变得必要了。
一般来说,自顶向下和自底向上哪种策略更适合创建抽象界面?通常,答案取决于具体的情况、个人舒适度和风格。在这个特定的场景中,我们更好地从抽象开始,向下工作到具体的类型(自顶向下的方法)。
为什么呢?在这种情况下,自顶向下的方法基本上是免费的。用户界面是 pdCalc 的高级模块之一,当我们执行初始分解时,我们已经在第二章中为 UI 模块定义了抽象接口。现在让我们把抽象的模块接口变成实际的面向对象的设计。
抽象接口
为用户界面提供一个抽象界面的目的是使程序的其余部分能够与用户界面进行交互,而不用考虑当前界面是图形界面、命令行界面还是其他什么界面。理想情况下,我们将能够将抽象接口分解为使用每个具体接口所需的最少数量的函数。任何共享实现的函数都可以在基类中定义,而任何需要基于具体类型的唯一实现的函数都可以在抽象基类中声明为虚拟的,并在派生类中定义。这个概念相当简单,但是,像往常一样,细节决定成败。
图 5-1
最小接口层次结构
考虑图 5-1 中描述的层级。我们的目标是为 pdCalc 的UserInterface
类设计一个最小但完整的接口,与 Liskov 替换原则一致,该接口既适用于 CLI,也适用于 GUI。如前所述,我们已经在第二章中为这个 UI 定义了一个高级接口。让我们从这个预定义的接口开始,并根据需要进行重构。
参照表 2-2 ,我们看到UserInterface
类的完整接口由两个事件处理函数postMessage()
和stackChanged()
以及一个UserInterface
引发的事件commandEntered()
组成。有趣的是,UserInterface
类是发布者、观察者、抽象用户接口类,也是模块接口的主要组件。
两个事件处理函数postMessage()
和stackChanged()
在接口级是简单明了的。正如我们对以前的观察者所做的那样,我们将简单地将这两个函数添加到UserInterface
类的公共接口中,并创建代理观察者类来代理发布者和实际观察者之间的通信。这些代理将在“用户界面观察者”一节中详细讨论。具体的用户界面必须根据单个用户界面与用户的交互方式,唯一地处理事件处理的实现。因此,postMessage()
和stackChanged()
必须都是纯虚拟的。因为在事件处理过程中不需要UserInterface
类的介入,为了简单起见,我选择放弃 NVI 模式。然而,正如在第四章中所讨论的,我们可以使用 NVI 模式来处理琐碎的转发非虚拟接口功能。
类作为发布者的角色比作为观察者的角色稍微复杂一些。正如我们在第三章中看到的,在设计Stack
类时,Stack
实现了发布者接口,而不是作为发布者。因此我们得出结论,从Publisher
类继承应该是私有的。对于UserInterface
类,除了UserInterface
类本身不是发布者之外,它与Publisher
类的关系是相似的。UserInterface
类是系统中用户界面的抽象接口,继承自Publisher
类只是为了强调用户界面必须自己实现发布者接口的概念。CLI 和 GUI 类都需要从Publisher
访问公共函数(例如,引发事件)。因此,在这种情况下,受保护的继承模式是合适的。
此外,回想一下第三章,为了让Stack
类实现发布者接口,一旦我们使用私有继承,我们需要将Publisher
类的attach()
和detach()
函数提升到Stack
的公共接口中。使用受保护的继承也是如此。然而,问题是提升应该发生在UserInterface
类中还是它的派生类中?要回答这个问题,我们需要知道 pdCalc 将如何使用特定的用户界面。显然,CLI 或 GUI 都是-a UserInterface
。因此,具体的用户界面将公开地从UserInterface
继承,并被期望遵守 LSP。因此,将事件附加到特定用户界面或从特定用户界面分离事件必须能够在不知道底层 UI 类型的情况下完成。因此,attach()
和detach()
函数必须作为UserInterface
公共接口的一部分可见。有趣的是,在 observer 模式的一个相当独特的实现中,发布者接口的一部分是在UserInterface
级别实现的,而发布者接口的另一部分是在派生类级别实现的。
结合前面的所有要点,我们最终可以定义UserInterface
类:
export module pdCalc.userInterface;
export class UserInterface : protected Publisher
{
public:
UserInterface();
virtual ~UserInterface();
virtual void postMessage(string_view m) = 0;
virtual void stackChanged() = 0;
using Publisher::attach;
using Publisher::detach;
static string CommandEntered();
};
CommandEntered()
函数返回一个字符串,它是命令输入事件的名称。它是附加或分离该事件所必需的,可以被赋予任何对UserInterface
类中的事件唯一的名称。
为了完整起见,我们在图 5-2 中展示了最终的用户界面层次。类图说明了 CLI、GUI、抽象的UserInterface
类和发布者接口之间的关系。记住UserInterface
类和Publisher
类之间的继承是受保护的,所以UserInterface
(或后续的派生类)不能用作Publisher
。然而,如前所述,具体 CLI 和 GUI 类与抽象UserInterface
类之间的继承意图是公共的,允许任一具体类型的实例化被替换为UserInterface
。
图 5-2
用户界面层次结构
用户界面事件
定义UserInterface
类并没有完成 UI 的接口。因为UserInterface
类是一个事件发布者,我们还必须定义对应于commandEntered()
事件的事件数据类。此外,定义UserInterface
类最终完成了发布者/观察者对,因此我们最终准备好设计和实现事件代理类,以便在用户界面、命令调度器和栈之间代理事件。
在第四章中,我们看到所有的命令都是通过事件传递给命令调度器的。具体来说,UI 引发一个包含编码为字符串参数的特定命令的事件,CommandInterpreter
接收该事件,字符串参数被传递给CommandFactory
,在那里检索具体的命令进行处理。就命令调度器而言,处理commandEntered()
事件是一样的,不管编码的命令字符串是来自 CLI 还是 GUI。同样,当Stack
类引发一个stackChanged()
事件时,Stack
对处理该事件的特定UserInterface
无关紧要。因此,我们被鼓励在用户界面层次结构的UserInterface
类级别上统一处理commandEntered()
事件的发布和stackChanged()
事件的处理。
我们从检查引发commandEntered()
事件的公共基础设施开始。在UserInterface
类的构造器中为所有用户界面注册了commandEntered()
事件。因此,任何派生的用户界面类都可以简单地通过调用由Publisher
接口定义的raise()
函数来引发这个事件,由于受保护的继承,它是任何具体 UI 实现的一部分。raise()
函数的签名需要事件的名称和事件的数据。因为事件的名称是在UserInterface
的构造器中预定义的,所以引发命令输入事件所需的唯一附加功能是处理事件数据的统一对象。现在让我们来看看它的设计。
命令数据
在第三章中,我们设计了我们的事件系统,使用推送语义来传递事件数据。回想一下,推送语义仅仅意味着发布者创建一个包含处理事件所需信息的对象,并在事件发生时将该对象推送给观察者。我们还研究了两种处理事件数据的技术。在多态技术中,事件数据对象必须公开地从抽象的EventData
类继承。当事件被引发时,观察器通过抽象接口接收事件数据,并通过将事件数据向下转换到适当的派生类来检索数据。在类型擦除技术中,如果具体的观察者知道如何将数据any_cast
为适当的类型,则事件数据不需要从公共基类派生。pdCalc 的实现使用类型擦除方法来实现事件。由于这两种技术都在第三章中进行了描述,下面只讨论 pdCalc 中实际使用的类型擦除技术。
对于命令输入的事件,事件数据通常是一个字符串,包含要输入到栈中的数字或要发出的命令的名称。虽然我们可以创建一个独特的CommandEnteredData
类,在其构造器中接受这个字符串,但类型擦除方法实际上允许一个简单得多的解决方案:事件数据可以只是字符串本身。当事件被观察者捕获时,事件数据d
由any_cast<string>(d)
而不是any_cast<CommandEnteredData>(d)
恢复。
对于commandEntered()
事件数据的任何一种设计都不能被认为优于另一种——它们只是做出相反的权衡。使用一个CommandEnteredData
类通过抽象提供了额外的类型特异性,代价是额外的代码和额外的函数调用来检索抽象的字符串。使用普通的string
作为事件的数据简单、轻量、高效,但是缺乏类抽象所带来的清晰性。对于复杂的代码库,引入一个新的类来抽象事件数据可能是更好的选择。然而,由于我们已经在第三章中描述了类抽象事件数据策略,为了便于说明,commandEntered()
事件的数据是使用普通的string
实现的。
虽然 CLI 和 GUI 确定如何以及何时引发commandEntered()
事件的机制有所不同,但两者都是通过最终调用Publisher
的raise()
函数来引发事件,其中的string
对发出的特定命令进行编码。也就是说,对于一些命令字符串cmd
,下面的代码在 CLI、GUI 或任何其他可能从UserInterface
继承的用户界面中引发一个commandEntered()
事件:
raise(UserInterface::CommandEntered(), cmd);
现在我们可以引发 UI 事件了,让我们看看它们是如何被处理的。
用户界面观察者
这一小节的目标是构建使类能够监听事件的机制。因为抽象用户界面既是事件的源,也是事件的接收器,所以 UI 是演示发布者和观察者如何相互交互的理想选择。
在第三章中,我们看到观察者是注册并监听发布者发起的事件的类。到目前为止,我们已经遇到了都需要观察事件的CommandInterpreter
和UserInterface
类。虽然可以直接让CommandInterpreter
或UserInterface
成为观察者,但我更喜欢在发布者和需要观察事件的类之间构建一个专用的观察者中介。我经常含糊地把这个中介称为代理。我们现在准备给这个术语一个更具体的含义。
代理模式[11]是一种设计模式,它使用一个类,代理,作为其他东西的接口。其他的东西,姑且称之为目标,并没有严格的定义。它可以是一个网络连接、一个文件、一个内存中的对象,或者就像我们的例子一样,只是另一个类。通常,当底层目标无法复制、不方便复制或复制成本很高时,会使用代理模式。代理模式使用一个类缓冲区来允许系统将目标视为一个独立于其底层组成的对象。在我们的上下文中,我们使用代理模式只是为了缓冲发布者和观察者之间的通信。
我们为什么要在这里为代理模式费心呢?这种策略有几个明显的优点。首先,它通过用描述性命名的事件处理函数替换一般命名的notify()
函数,增加了目标类的公共接口的清晰度。其次,从Observer
类中移除了一个不必要的继承。消除这种依赖性减少了耦合,增加了内聚力,并有助于在目标不是观察者的环境中重用目标。第三,使用代理类消除了需要监听多个事件的目标类所产生的模糊性。如果不使用代理类,观察者将需要在其单一的notify()
函数中消除事件的歧义。为每个事件使用单独的代理使每个事件能够调用目标对象中唯一的处理函数。使用代理实现观察者模式的主要缺点是处理每个事件的额外间接成本很小。然而,在适合使用观察者模式的情况下,额外的间接成本可以忽略不计。
使用代理模式实现观察者模式导致了下面两个处理commandEntered()
和stackChanged()
事件的类:分别是CommandIssuedObserver
和StackUpdatedObserver
。CommandIssuedObserver
在 UI 引发的commandEntered()
事件和 command dispatcher 中的观察之间进行协调。StackUpdatedObserver
在由栈引发的stackChanged()
事件和 UI 中的观察之间起中介作用。这两个类的实现相对简单并且非常相似。举例来说,让我们检查一下CommandIssuedObserver
的实现。
CommandIssuedObserver
的声明如下所示:
class CommandIssuedObserver : public Observer
{
public:
explicit CommandIssuedObserver(CommandInterpreter& ci);
private:
void notifyImpl(const any&) override;
CommandInterpreter& ci_;
};
因为它在作为发布者的 UI 和作为观察者的目标的CommandInterpreter
之间传递事件,所以CommandIssuedObserver
的构造器引用一个CommandInterpreter
实例,当 UI 引发一个commandEntered()
事件时,它保留这个实例以回调命令调度器。回想一下,当观察者连接到事件时,CommandIssuedObserver
将由 UI 存储在Publisher
的事件符号表中。notifyImpl()
的实现只是将函数的参数任意转换为string
,然后调用CommandInterpreter
的commandEntered()
函数。
当然,在事件被触发之前,CommandIssuedObserver
必须向 UI 注册。为了完整起见,以下代码说明了如何完成此任务:
ui.attach( UserInterface::CommandEntered(),
make_unique<CommandIssuedObserver>(ci) );
其中ui
是一个UserInterface
引用,ci
是一个CommandInterpreter
实例。注意,由于attach()
函数被有意提升到抽象的UserInterface
作用域中,通过引用附加允许我们对 CLI 和 GUI 重用同一个调用。也就是说,注册事件是通过抽象 UI 接口完成的,这大大简化了 pdCalc 的main()
例程中的用户界面设置。StackUpdatedObserver
的声明和注册是类似的。
观察者代理类的完整实现可以在AppObservers.m.cpp
中找到。虽然观察者代理的使用与事件观察类交织在一起,但是代理不是目标类接口的一部分。
因此,它们包含在自己的文件中。在main.cpp
中执行代理与事件的连接。这种代码结构保留了发布者和观察者之间的松散绑定。具体来说,发布者知道他们可以引发哪些事件,但不知道谁会观察它们,而观察者知道他们会观看哪些事件,但不知道谁会引发它们。发布者及其观察者外部的代码将两者绑定在一起。
5.2 具体的 CLI 类
本章的其余部分将专门详细介绍 CLI 具体类,它是用户界面模块的一个成员。让我们从重新检查 CLI 的要求开始。
要求
对 pdCalc 的要求表明计算器必须有一个命令行界面,但是准确地说,什么是 CLI 呢?我对命令行界面的定义是任何通过文本交互响应用户命令的程序用户界面。即使您对命令行界面的定义有些不同,我相信我们肯定会同意,仅仅简单地指出一个程序应该有一个 CLI 是远远不够的。
在产品开发的情况下,当你遇到一个太模糊的需求而不能设计一个组件时,你应该立即向你的客户寻求澄清。注意,我说的是当时,而不是如果时。无论您在尝试细化需求之前付出了多少努力,您总是会有不完整的、不一致的或者变化的需求。这通常有几个原因。有时,这是由于有意识地努力不要在前期花费时间提炼需求。有时,这是由于缺乏经验的团队成员不理解如何正确地收集需求。然而,通常情况下,它只是因为最终用户不知道他们真正想要或需要什么,直到产品开始成型。我发现这是真的,即使对于我自己的客户的小开发项目也是如此!虽然作为实现者,您总是保留在没有客户参与的情况下提炼需求的权宜之计,但我的经验表明,这条道路总是导致重复重写代码:一次是为了您认为用户想要的,一次是为了用户认为他们想要的,一次是为了用户实际想要的。
显然,对于我们的案例研究,我们只有一个假设的最终用户,所以我们将简单地自己进行细化。我们规定如下:
-
CLI 应该接受为计算器定义的任何命令的文本命令(存在于命令工厂中的命令以及撤销、重做、帮助和退出命令)。
-
help 命令应该显示所有可用命令的列表和简短的解释消息。
-
CLI 应该按照处理命令的顺序接受空格分隔的命令。回想一下,这个顺序对应于反向波兰符号。按下 return 键后,将处理一行中的所有命令。
-
处理完命令后,界面应该最多显示栈的前四个元素加上栈的当前大小。
令人惊讶的是,前面列出的最低要求足以构建一个简单的 CLI。虽然这些需求有些随意,但为了描述设计和实现,需要选择一些特定的东西。如果您不喜欢由此产生的 CLI,我强烈建议您指定自己的需求,并相应地修改设计和实现。
CLI 设计
CLI 的设计非常简单。因为我们的总体架构设计将计算器的整个“业务逻辑”放在了后端,所以前端只是一个薄层,它只不过接受和标记来自用户的输入,将输入顺序传递给控制器,并显示结果。让我们从描述接口开始。
界面
从本章前面的分析中,我们知道具体的 CLI 类将继承抽象的UserInterface
类。这个继承是公共的,因为 CLI 是-a UserInterface
并且必须替换为一个。因此,CLI 必须实现UserInterface
的两个抽象纯虚函数:postMessage()
和stackChanged()
。这两个方法只通过一个UserInterface
引用进行多态调用;因此,这两种方法都成为 CLI 私有接口的一部分。除了构造和销毁,CLI 需要公开的唯一功能是启动其执行的命令。这个函数驱动整个 CLI,并且只在用户请求退出程序时返回(正常情况下)。结合上述内容,CLI 的整个界面可由以下内容给出:
export module pdCalc.userInterface;
export class Cli : public UserInterface
{
public:
Cli(istream& in, ostream& out);
~Cli();
void execute(bool suppressStartupMessage = false, bool echo = false);
private:
void postMessage(string_view m) override;
void stackChanged() override;
};
虽然接口基本上是自解释的,但是构造器和execute()
函数的参数都值得解释一下。为了满足前面描述的需求,可以编写不带参数的execute()
函数。接口中包含的两个参数只是可以打开的可选特性。第一个参数指示 CLI 启动时是否显示横幅。第二个参数控制命令回显。如果echo
被设置为true
,那么在显示结果之前每个命令都会重复。这两个特性都可以在 CLI 中硬编码,但是为了增加灵活性,我选择将它们作为参数添加到execute()
方法中。
构造器的参数比execute()
命令的参数稍微不那么明显。根据定义,CLI 从cin
获取输入,并将结果输出到cout
或者cerr
。然而,对这些标准 I/O 流进行硬编码会任意地将该类的使用限制在传统的 CLI 中。通常,我主张将功能限制在您真正需要的范围内,而不是预期更广泛的用途。然而,使用 C++ 流 I/O 是我的经验法则的少数例外之一。
让我们讨论一下为什么使用对基类 C++ I/O 流的引用通常是一个好的设计实践。首先,使用不同 I/O 模式的愿望很普遍。具体来说,重定向到文件或从文件重定向是经常请求对 CLI 进行的修改。事实上,我们会在第八章看到这个请求!第二,实现通用与专用接口实际上并没有增加复杂性。例如,不是直接写入cout
,而是简单地保存对输出流的引用并写入。在基本情况下,这个引用简单地指向cout
。最后,使用任意的流输入和输出大大简化了测试。虽然程序可以使用cin
和cout
实例化Cli
类,但是测试可以使用文件流或字符串流实例化Cli
类。以这种方式,可以使用字符串或文件来模拟交互式流输入和输出。这种策略简化了对Cli
类的测试,因为输入可以很容易地传入,输出可以很容易地以字符串的形式捕获,而不是通过标准的输入和输出。
最后,请注意,Cli
类被声明为UserInterface.m.cpp
文件中userInterface
模块的一部分,而它是在Cli.m.cpp
文件中的分区userInterface:Cli
中定义的。由于在UserInterface.m.cpp
和Cli.m.cpp
之间会产生一个循环参考,所以需要这种有点奇怪的结构。另一种选择是简单地在userInterface
模块中定义Cli
类,而不是一个分区,很可能将Cli.m.cpp
重命名为UserInterface.cpp
。这个实现细节与 pdCalc 的设计没有任何关系——它只是对文件组织的好奇。
实施
Cli
类的实现值得研究,以观察 pdCalc 设计的模块化所带来的简单性。Cli
类的整个实现有效地包含在execute()
和postMessage()
成员函数中。execute()
函数驱动命令行界面。它向最终用户显示启动消息,等待输入命令,标记这些命令,并引发事件以通知命令调度器输入了新命令。stackChanged()
函数是一个观察者代理回调目标,它在stackChanged()
事件引发后将栈的顶部写入命令行。本质上,CLI 简化为两个 I/O 例程,其中execute()
处理输入,stackChanged()
处理输出。让我们从execute()
函数开始,看看这两个函数的实现:
void Cli::execute(bool suppressStartupMessage, bool echo)
{
if(!suppressStartupMessage) startupMessage();
for(string line; std::getline(in_, line, '\n'); )
{
istringstream iss{line};
// Tokenizer must be one of LazyTokenizer or GreedyTokenizer.
// See discussion below.
Tokenizer tokenizer{iss};
for(auto i : tokenizer)
{
if(echo) out_ << i << endl;
if(i == "exit" || i == "quit")
return;
else
raise(UserInterface::CommandEntered(), i);
}
}
return;
}
CLI 的主要算法相当简单。首先,CLI 等待用户输入一行。其次,这个输入行被Tokenizer
类标记化。然后,CLI 对输入行中的每个令牌进行循环,并以令牌字符串作为事件数据引发事件。CLI 在遇到quit
或exit
令牌时终止。
之前没有解释的execute()
函数的唯一部分是Tokenizer
类。简单地说,Tokenizer
类负责获取一个文本字符串,并将这个字符串拆分成单独的空格分隔的标记。CLI 和Tokenizer
都不能确定令牌的有效性。令牌只是作为事件引发,供命令调度器模块处理。注意,作为编写自己代码的替代方法,许多库(例如 boost)都提供了简单的记号赋予器。
记号化算法相对简单;我们马上会看到两个独立的实现。然而,首先,为什么要为Tokenizer
选择一个类设计,而不是,比方说,选择一个使用返回string
s 的vector
的函数的设计?实际上,两种设计在功能上都是可行的,并且两种设计都同样易于测试和维护。然而,我更喜欢类设计,因为它为Tokenizer
提供了一个独特的类型。让我们来看看为标记化创建不同类型的优势。
假设我们想在函数foo()
中对输入进行令牌化,但在单独的函数bar()
中处理令牌。考虑以下两对可能的函数来实现这一目标:
// use a Tokenizer class
Tokenizer foo(string_view);
void bar(const Tokenizer&);
// use a vector of strings
vector<string> foo(string_view);
void bar(const vector<string>&);
首先,使用一个Tokenizer
类,foo()
和bar()
的签名立即通知程序员函数的意图。我们知道这些函数涉及到标记化。使用string
s 的vector
会产生歧义,不需要更多的文档(我故意没有为参数提供名称)。然而,更重要的是,键入标记器使编译器能够确保只能使用Tokenizer
类作为参数调用bar()
,从而防止程序员意外地使用不相关的string
集合调用bar()
,类设计的另一个好处是Tokenizer
类封装了表示标记集合的数据结构。这种封装保护了与bar()
的接口,使其不必决定将底层数据结构从string
的vector
更改为string
的list
(或者,一个生成器,我们很快就会看到)。最后,如果需要的话,Tokenizer
类可以封装关于标记化的附加状态信息(例如,原始的、预标记的输入)。一个string
的集合显然仅限于携带令牌本身。
我们现在转向Tokenizer
类的实现。我选择呈现两个独立的实现:贪婪标记化和懒惰标记化。第一种方法,贪婪标记化,是我在本书第一版中采用的方法。第二种方法,惰性标记化,只是随着 C++20 中协程的引入才变得微不足道。
为了演示这两种算法的可交换性,我在一个相同的概念接口后面实现了这两种算法。但是,因为这两个类不打算以多种形式使用,所以接口不是通过继承实现的。让我们检查一下这个假设的接口:
class TokenizerInterface
{
public:
explicit TokenizerInterface(istream&);
~TokenizerInterface();
size_t nTokens() const;
// not a "real" interface class - just using auto conceptually
auto begin();
auto end();
};
记号赋予器可以从输入流中构造,它可以声明已经解析了多少个记号,并且可以返回一个迭代器到记号流的开始和结尾。当然,还可以添加更多的功能,但是这些成员函数构成了在 pdCalc 中解析标记所需的最小集合。现在让我们来看看各个实现。
是什么让一个符号化算法变得贪婪,又是什么让一个人变得懒惰?两种实现都在构造器中初始化标记化。但是,贪婪算法会立即解析流中的所有令牌,并将它们存储在一个容器中以备将来使用。TokenizerInterface
中的迭代器接口只是将请求转发给底层容器,例如GreedyTokenizer
中的vector<string>
。当用户迭代令牌时,不会发生令牌化;符号化在构造过程中已经贪婪地发生了。下面是贪婪标记化算法的一个简单实现(它也将所有条目弹出为小写):
void GreedyTokenizer::tokenize(istream& is)
{
for(istream_iterator<string> i{is}; i != istream_iterator<string>{};
++i)
{
string t;
ranges::transform(*i, back_inserter<string>(t), ::tolower);
tokens_.push_back( std::move(t) );
}
return;
}
相比之下,我们来看看懒惰标记化算法。惰性令牌化只在请求下一个令牌时解析每个令牌。C++20 协程使这种懒惰算法变得简单可行,这将在侧栏中讨论。然而,首先让我们检查一下LazyTokenizer
的实现:
cppcoro::generator<string> LazyTokenizer::tokenize(std::istream& is)
{
for(istream_iterator<string> i{is}; i != istream_iterator<string>{};
++i)
{
string t;
ranges::transform(*i, back_inserter<string>(t), ::tolower);
++nTokens_;
co_yield t;
}
co_return;
}
Listing 5-1Lazy tokenizer
贪婪和懒惰实现之间存在两个差异。首先,简单地说,惰性例程需要计算和存储被解析的标记(nTokens_
)的数量。对于贪婪算法来说,这一步是不必要的,因为它将所有的令牌保存在一个vector
中,而这个 ?? 知道自己的大小。第二个区别是,惰性算法使用了co_yield
操作符,并返回一个生成器(cppcoro 库[7]的一部分),这将在侧栏中详细讨论。实际上,co_yield
操作符向编译器发出信号,表明这个函数是一个协程,它可以被抢占,然后在它产生的地方重新启动,在这种情况下,恢复 for 循环来解析下一个令牌。co_yield
允许返回值,在本例中,是我们的延迟求值的令牌。
应该注意的是,虽然这两个记号赋予器有相同的接口,但是它们的行为略有不同。首先,贪婪的令牌化器解析流一次,但是令牌可以根据需要迭代多次。nTokens()
函数总是返回流中令牌的总数,因为在调用nTokens()
之前流已经被完全解析。相比之下,惰性标记化器只能迭代一次,因为迭代会导致标记化。因此,nTokens()
函数返回到那时为止解析的令牌数,这个数可能小于输入流中的令牌总数。当然,如果需要在LazyTokenizer
上进行多次迭代,令牌可以总是存储在一个容器中,因为流是被延迟解析的。一旦解析完成,这两个记号赋予器将表现相同。
贪婪标记器和懒惰标记器的实现都在Tokenizer.m.cpp
源文件中提供。默认情况下,pdCalc 被配置为只使用惰性标记器。然而,这两个记号赋予器是完全可以互换的。如果您希望尝试贪婪的记号赋予器,只需更改在CLI
的execute()
函数中实例化的记号赋予器。显然,两个标记化器之间的切换可以通过静态多态性在编译时进行配置。
Modern C++ Design Note: Coroutines and Generators
协程是一个古老的概念,最终在 C++20 中成为标准。在我看来,就可用性而言,协程是一个混合体。如果您不需要编写用于管理协程生命周期的支持代码,那么编写协程本身相当简单。具体来说,相对于列出 5-1 ,实现tokenize()
容易,实现generator<>
难。
不幸的是,C++20 标准(可能是 C++23)没有采用一个用于协同程序使用的公共库。).然而,幸运的是,高质量的 cppcoro 协程库已经存在[7],您可以放心地知道,它的作者已经为您实现了使用协程的困难部分。在我最初实现清单 5-1 时,我编写了自己的生成器类。我的实现不是通用的,也很粗糙,但是它确实像预期的那样工作。然而,理解实现对理解 pdCalc 的设计没有指导意义。最终,我认为描述协程的详细实现超出了本书的范围。而是决定直接用 cppcoro 的生成器类。许可的 MITgenerator.hpp
包含在 pdCalc 中,位于3rdparty/cppcoro
中。那些有兴趣了解协程细节的读者可以参考 cppcoro 的创建者刘易斯·贝克的精彩博文。相反,我们的讨论将集中于更高层次的设计目标,即概念上的协程是什么,以及我们如何能够使用它们来改进 pdCalc 的设计。
协程是子程序的推广,它允许通过程序从内部放弃执行来暂停控制。协程保持它们的状态,并且以后可以从它们放弃控制的点恢复。实际上,协程提供了一种语言机制来支持协作式多任务处理(相对于线程的抢占式多任务处理)。
协程支持的类型之一是生成器。生成器是在迭代时生成序列的对象。当数列是无穷大时,它们特别有用。生成器的规范实现似乎是斐波那契数的生成。斐波那契数列, F n ,由以下递归定义:
F 0 = 0 ,F 1 = 1 ,Fn=Fn1+Fn2,n > 1
在不使用协程的情况下,可以用下面的函数很容易地生成第 n 个序列:
auto fibLoop(unsigned int n)
{
vector<long long> v(n+1);
v[0] = 0;
if(n > 0) v[1] = 1;
for(auto i = 2u; i < v.size(); ++i)
v[i] = v[i-1] + v[i-2];
return v;
}
不幸的是,我们必须对所有数字进行贪婪的评估,直到第 n 个数字。fibLoop()
不允许通过重复调用函数来对斐波那契数列进行缓慢的顺序计算。
让我们试着创建一个函数,它可以在每次被调用时生成斐波纳契数列中的下一个数字。下面的代码是我们的第一次尝试:
long long fibStatic()
{
static long long cur = 0;
static long long prev = 1;
auto t = cur;
cur += prev;
prev = t;
return prev;
}
只要我们只想在单线程环境中每次程序执行时生成一次斐波那契数列,代码就能工作。为了解决代码只能被调用一次的问题,我们可以添加一个重置标志作为参数。然而,现在我们只是添加了黑客,我们仍然没有解决无法在多线程环境中运行的问题。让我们再试一次。
我们的下一个尝试是一个相当复杂的解决方案,包括一个 Fibonacci 类和一个迭代器:
class Fibonacci
{
class fibonacci_iterator
{
public:
using iterator_category = std::input_iterator_tag;
using difference_type = std::ptrdiff_t;
using value_type = long long;
using reference = long long&;
using pointer = long long*;
fibonacci_iterator(){}
fibonacci_iterator(Fibonacci* f): f_{f}{}
long long operator*() const { return f_->cur_; }
fibonacci_iterator& operator++(){f_->next(); return *this;}
void operator++(int){ operator++(); }
auto operator<=>(const fibonacci_iterator&) const = default;
private:
Fibonacci* f_ = nullptr;
};
public:
using iterator = fibonacci_iterator;
iterator begin() { return fibonacci_iterator{this}; }
iterator end() { return fibonacci_iterator{}; }
private:
void next()
{
long long t = cur_;
cur_ += prev_;
prev_ = t;
}
long long cur_ = 0l;
long long prev_ = 1l;
};
哎哟!前面的代码是草率的、不完整的,执行看似简单的任务真的很糟糕。然而,利用范围库(见下一个边栏),它确实使我们能够以一种非常简洁的方式生成和使用斐波那契数:
Fibonacci fib;
// grab the first 10 Fibonacci numbers and use them one at a time
ranges::for_each(fib | views::take(10), [](auto fn){useFib(fn);});
如果您不熟悉前面的语法,尤其是将范围传递给视图的函数式风格,不要担心。ranges 库是 C++20 的另一个新特性,我将在下一个边栏中简要介绍它。
简单的事情应该很容易,这就是协程和生成器的亮点。如果我们能提供fibLoop()
的简单性、fibStatic()
的一个接一个的语义,以及Fibonacci
类的可表达性和安全性,那会怎么样。如果有一种语言机制,允许我们编写一个可以被中断和恢复的函数,并在每次函数暂停时产生一个值,那么这将是可能的。当然,这个特性正是协程所提供的。我们现在可以简单地写
cppcoro::generator<long long> fibonacci()
{
long long prev = 1;
long long cur = 0;
while(true)
{
co_yield cur;
long long t = cur;
cur += prev;
prev = t;
}
}
前面的协程是一个无限但可中断的循环。co_yield
表达式使协程暂停并产生当前的斐波那契数。cppcoro 的generator
类将暂停和恢复fibonacci()
的复杂机制隐藏在简单易用的迭代器接口后面。当生成器迭代器被解引用时,返回当前斐波那契数的值。当向前迭代器前进时,fibonacci()
被恢复,继续无限循环,直到再次遇到co_yield
。通过这种方式,我们可以以有限的方式使用和访问无限循环,这为计算有限但不是预定深度的斐波那契数提供了一个非常简洁的实现。如前所述,协程可以简单地使用:
auto fib = fibonacci();
// grab the first 10 Fibonacci numbers and use them one at a time
ranges::for_each(fib | views::take(10), [](auto fn){useFib(fn);});
在这一点上,希望列出 5-1 是有意义的。我们的惰性令牌化器只是一个字符串令牌生成器,它通过循环流、提取每个空格分隔的字符串并暂停执行直到请求下一个令牌来生成。这是一个漂亮的设计,通过新的 C++20 语言特性变得很容易。
在离开这个侧栏之前,我想分享一个我从惨痛的教训中学到的快速技巧:仔细观察通过引用传递给协程的任何值的生命周期。事实上,我甚至认为应该避免通过引用协程来传递值。考虑我第一次为LazyTokenizer
编写字符串构造器的失败尝试:
LazyTokenizer::LazyTokenizer(const std::string& s)
: nTokens_{0}
{
istringstream t{s};
generator_ = tokenize(t);
}
前面看似正确的代码将会编译,但是标记器在使用时会导致分段错误。原因是虽然tokenize()
函数看起来是一个简单返回的常规函数调用,但它不是。tokenize()
是一个可恢复的协程,通过其返回的生成器来访问,在这种情况下,生成器存储在generator_
中。这里,generator_
,一个成员变量,比局部变量t
有更长的生存期。当第一次调用tokenize()
时,一切正常。发电机已初始化并处于就绪状态。然而,由于tokenize()
通过引用捕获它的流参数,当协程通过迭代generator_
前进时,t
已经超出范围并被销毁。在tokenize()
内部,我们被留下来推进一个istream_iterator<string>
,它正在迭代一个被销毁的istringstream
。显然,这段代码会失败。一旦你推理出这个失败,它就完全有意义了。然而,对我来说,我第一次遇到这个错误时并不明显,因为我的经验已经训练我将tokenize()
解释为一个单通函数,它只在控制权返回给调用者之前存在。当然,协程调用语义是不同的,因为协程在销毁之前一直存在。将控制权返回给调用者不会破坏协程内部的本地上下文,其中可能包含对函数参数的引用。相反,该状态被存储以供以后恢复。此后超出范围的任何被引用对象都变得无效。程序员当心。
作为 CLI 实现的最后一部分,我们研究一个简化版本的stackChanged()
函数:
void Cli::stackChanged()
{
unsigned int nElements{4};
auto v = Stack::Instance().getElements(nElements);
string s{"\n"};
auto bi = std::back_inserter(s);
for( auto j = v.size(); auto i : views::reverse(v) )
std::format_to(bi, "{}:\t{:.12g}\n", j--, i);
postMessage(s);
}
Cli.m.cpp
中的实现仅在印刷的花哨程度上有所不同。注意,每当栈改变时,CLI 简单地挑选栈的前四个(如我们的需求中所指定的)条目(getElements()
返回nElements
和栈大小的最小值),在string
中格式化它们,并将该字符串传递给postMessage()
函数。对于 CLI,postMessage()
只是将字符串写入输出流。
在我们继续之前,让我们停下来思考一下 CLI 的实现有多简洁明了。这种简单性是 pdCalc 整体设计的直接结果。尽管许多用户界面将“业务逻辑”与显示代码混杂在一起,但我们精心设计了这两个独立的层。命令的解释和处理,即“业务逻辑”,完全驻留在命令调度器中。因此,CLI 只负责接受命令、标记命令和报告结果。此外,基于我们的事件系统的设计,CLI 没有直接耦合到命令调度器,这与我们的 MVC 架构是一致的。命令调度器确实有到用户界面的直接链接,但是由于我们的抽象,命令调度器绑定到一个抽象的UserInterface
而不是一个具体的用户界面实现。通过这种方式,Cli
完美地替代了UserInterface
(LSP 的应用),并且可以作为计算器的许多独特视图中的任何一个来轻松地换入或换出。虽然对于计算器的设计来说,这种灵活性似乎有些过头了,但是从测试和关注点分离的角度来看,所有组件的模块化都是有益的,即使计算器没有设计另一个用户界面。
Modern C++ Design Note: The Ranges Library
ranges 库是 C++20 的四大特性之一,一个简单的侧边栏并不能代表这个库。然而,我们将绕一个很快的弯路来得到一个粗略的介绍。
范围库引入了三个主要的新构造:范围概念、范围算法和视图。忽略用 C++ 概念实现范围的详细 C++ 机制,从逻辑上讲,范围是一个由开始和结束划分的可迭代集合。虽然你可以选择实现自己的范围,但从 C++20 开始,STL 容器就是范围,所以每个人都可以直接访问范围。
很好,vector
s 是范围,那么现在怎么办?您将从范围中看到的第一个直接好处是它们所伴随的算法的语法得到了改进。假设您需要对string
s、v
中的vector
进行排序(我们假设使用默认的排序标准)。在 ranges 之前,使用标准库,您将编写以下代码:
std::sort( begin(v), end(v) );
前面的语法并不可怕,但是假设我们想要对整个vector
进行排序,为什么我们不能只调用sort(v)
?现在你可以了。因为vector
是一个范围,我们可以称之为基于范围的等价算法:
std::ranges::sort(v);
那就干净多了。如果不是全部的话,大多数标准算法现在都有基于范围的等价算法。
如果基于范围的算法语法是我们从 ranges 库中得到的全部,我会感到很无趣。然而,我们得到的要多得多,因为范围是有视图的。不严格地说,视图是一个延迟求值的范围适配器。让我们考虑一个例子。假设您有一个由 20 个整数组成的vector
、v
,并且想要存储您在vector
、t
中遇到的最后五个偶数的平方(是的,这是一个不可否认的人为例子)。下面一行可执行代码将完成这一壮举:
// note, code assumes
// namespace ranges = std::ranges;
// namespace views = std::ranges::views;
ranges::copy( v | views::filter([](int i){return i % 2 == 0;})
| views::reverse
| views::take(5)
| views::transform([](int i){return i * i;})
, back_inserter(t) );
在前面显示的一行代码中发生了三件重要的事情。首先,也是最明显的,所有的视图都被链接在一起。其次,视图的链接使我们能够在一个循环中通过v
执行所有这些操作。第三,因为视图是延迟评估的,所以只采取必要的操作。例如,我们不反转所有的v
,只反转偶数。此外,我们没有平方所有的偶数,只有最后五个(或者更少,如果在v
中偶数少于五个)。
虽然前面的代码既紧凑又高效,但我愿意承认它可能不是最易读的。当然,视图reverse
和take
清楚地陈述了它们在做什么,但是理解filter
和transform
做什么也需要理解它们嵌入的 lambda 表达式。幸运的是,我们可以将视图存储在变量中,并按如下方式应用它们:
auto takeEven = views::filter([](int i){ return i % 2 == 0; });
auto square = views::transform([](int i){return i * i;});
ranges::copy(v | takeEven | views::reverse | views::take(5) | square, back_inserter(t));
这就更清楚了。从现有视图中轻松创建新的命名视图的能力既增加了可读性,又支持可重用性。
我将以我开始时的方式结束这个侧边栏,声明这个小侧边栏绝不公正。然而,如果这篇边栏激起了你的兴趣,我鼓励你去读读 Eric Niebler 的文章《介绍 Range[26]和 Range-v3 用户手册[25]。Eric Niebler 的 Range-v3 库构成了构建 C++ 标准范围库的基础。
5.3 将它结合在一起:一个工作程序
在我们结束关于 CLI 的章节之前,有必要编写一个简单的主程序,将所有组件连接在一起,演示一个可以工作的计算器。pdCalc 在main.cpp
中的实际实现要复杂得多,因为它处理多个用户界面和插件。最终,我们将逐步理解main.cpp
中的完整实现,但是现在,下面的代码将使我们能够使用命令行界面执行一个有效的计算器(当然,包括适当的头文件和模块导入):
int main()
{
Cli cli{std::cin, std::cout};
CommandInterpreter ci{cli};
RegisterCoreCommands(cli);
cli.attach(UserInterface::CommandEntered(), make_unique<CommandIssuedObserver>(ci) );
Stack::Instance().attach(Stack::StackChanged(), make_unique<StackUpdatedObserver>(cli) );
cli.execute();
return 0;
}
由于设计的模块化,整个计算器只需六个可执行语句就可以设置、组装和执行!main()
函数中的逻辑很容易理解。从维护的角度来看,任何项目的新程序员都可以很容易地跟踪计算器的逻辑,并看到每个模块的功能都被清晰地划分为不同的抽象。正如我们将在以后的章节中看到的,随着更多模块的加入,抽象变得更加强大。
为了让您快速入门,在构建可执行文件pdCalc-simple-cli
的存储库源代码中包含了一个项目,使用前面的main()
函数作为应用程序的驱动程序。该可执行文件是一个独立的 CLI,包括本书到目前为止讨论的所有功能。
在下一章,我们将考虑计算器的图形用户界面的设计。一旦 GUI 完成,许多用户会很快认为 CLI 只是一个练习或以前时代的遗物。在此之前,我想鼓励读者不要这么快就对这个不起眼的 CLI 做出判断。CLI 是非常高效的界面,通常更容易为需要大型部署或自动化的任务编写脚本。至于 pdCalc,就个人而言,我更喜欢 CLI 而不是 GUI,因为它易于使用。当然,也许这只是表明我也是上一个时代的遗物。
六、图形用户界面
在本章中,我们将探索 pdCalc 的图形用户界面(GUI)的设计。任何时候设计一个 GUI,都需要选择一个窗口小部件平台。如前所述,我选择使用 Qt 来创建 GUI。也就是说,这不是一个如何使用 Qt 设计界面的章节。相反,我假设读者有 Qt 的工作知识,并且这一章本身集中在 GUI 的设计方面。事实上,我将尽可能地让读者阅读源代码来了解小部件实现的细节。任何关于 Qt 实现的讨论要么只是附带的,要么值得特别强调。如果你对图形用户界面设计不感兴趣,这一章可以完全跳过,几乎不会影响连贯性。
6.1 要求
在第五章中,我们开始了对命令行界面(CLI)的分析,导出了一个可供 CLI 和 GUI 使用的接口抽象。显然,我们将在这里重用这个接口,因此我们已经知道了我们的整个用户界面必须符合的抽象接口。因此,我们从定义 GUI 专门化的要求开始这一章。
和 CLI 一样,我们很快发现第一章的要求对于指定一个图形用户界面来说是远远不够的。给定的要求只是功能性的。也就是说,我们知道计算器应该支持哪些按钮和操作,但我们对预期的外观一无所知。
在一个商业项目中,人们会(希望)让客户、图形艺术家和用户体验专家来协助设计 GUI。对于我们的案例研究,充分说明我们自己的要求就足够了:
-
GUI 应该有一个显示输入和输出的窗口。输出是当前栈的前六个条目。
-
GUI 应该有可点击的按钮来输入数字和所有支持的命令。
-
GUI 应该有一个显示错误消息的状态显示区域。
前面的要求仍然没有解释计算器实际上应该是什么样子。为此,我们需要一张照片。图 6-1 显示了在我的 Windows 桌面上出现的工作计算器(使用 Qt 5.15.2 的 Windows 10)。将完成的图形用户界面作为设计图形用户界面的原型展示无疑是“欺骗”希望这条捷径不会太偏离案例研究的真实性。显然,在开发的这个阶段不会有成品。在生产环境中,人们可能会用手或用 Microsoft PowerPoint、Adobe Illustrator 或 Inkscape 等程序绘制模型。或者,也许 GUI 是从一个物理对象建模的,设计者要么有照片,要么直接访问那个对象。例如,一个人可能正在设计一个 GUI 来代替一个物理控制系统,并且需求指定界面必须显示相同的刻度盘和仪表(以减少运算符再培训的成本)。
图 6-1
没有插件的 Windows 上的 GUI
pdCalc 的 GUI 灵感来自我的 HP48S 计算器。对于那些熟悉本系列中任何一款惠普计算器的人来说,这个界面有些熟悉。对于那些不熟悉这一系列计算器的人(可能是大多数读者),下面的描述解释了 GUI 的基本行为。
GUI 的顶部三分之一是专用的输入/输出(I/O)窗口。I/O 窗口在左侧显示前六个栈级别的标签,栈的顶部在窗口的底部。栈上的值出现在窗口右侧与数字在栈上的位置相对应的行上。当用户输入一个数字时,栈减少到只显示顶部的五个栈元素,而输入的数字在底部行左对齐显示。一个数字被终止,并通过按 enter 按钮输入到栈中。
假设有足够的输入,一按下按钮就开始操作。如果输入不足,I/O 窗口上方会显示一条错误消息。对于命令,输入区域中的有效数字被视为栈中的顶部数字。也就是说,在输入数字的同时应用操作相当于按下 enter 然后应用操作。
为了节省空间,一些按钮的操作被移到了按钮本身的上方和左侧。首先按下 shift 按钮,然后按下移位文本下方的按钮,可以激活这些移位操作。按下 shift 按钮会将计算器置于移位模式,直到按下具有移位操作的按钮或再次按下 shift 按钮。为了清楚起见,移位操作通常是按钮操作的逆操作。
为了方便输入,许多按钮被绑定到键盘快捷键。也就是说,除了按压 GUI 按钮之外,还可以替代地按压键盘按键。比如按相应的数字键可以点击数字按钮,按 Enter 键可以点击 Enter 键,按 S 键可以点击 Shift 键,按 Backspace 键可以点击 Bksp 键,按 E 键可以点击取幂运算(eex),按相应的键盘键可以点击四则基本算术运算(+、-、*、/)。
最后,一些操作是半隐藏的。当不输入数字时,退格键从栈中删除顶部条目,而回车键复制栈中的顶部条目。这些组合中的一些并不直观,因此可能并不代表很好的 GUI 设计。但是,它们确实模拟了 HP48S 上使用的输入。如果您以前从未使用过 HP48 系列计算器,我强烈建议您在继续之前,从 GitHub 资源库构建并熟悉 GUI。
如果你想知道proc
键是做什么的,它会执行存储过程。这是我们将在第八章中遇到的“新”需求之一。
人们对 GUI 的第一个批评可能是它不太漂亮。我同意。本章中 GUI 的目的不是演示高级 Qt 特性。相反,目的是说明如何设计模块化、健壮、可靠和可扩展的代码库。添加代码使 GUI 更有吸引力而不是功能性会分散对这个信息的注意力。当然,这个设计允许一个更漂亮的 GUI,所以你可以在提供的基础设施上随意制作你自己的漂亮 GUI。
我们现在有足够的细节来设计和实现计算器的 GUI。然而,在我们开始之前,有必要对构建 GUI 的替代方法进行一个简短的讨论。
6.2 构建 GUI
本质上,构建 GUI 有两种不同的途径:在集成开发环境(IDE)中构建 GUI,或者在代码中构建 GUI。在这里,我不严格地使用术语代码来表示通过文本构建 GUI,无论是使用传统的编程语言(如 C++)还是声明性的标记语法(如 XML)。当然,介于两个极端之间的是混合方法,它利用来自 ide 和代码的元素。
6.2.1 在 ide 中构建 GUI
如果您需要的只是一个简单的 GUI,那么,在 IDE 中设计和构建您的 GUI 无疑是更容易的途径。大多数 ide 都有一个图形界面,用于在画布上展示可视元素,例如,画布可能代表一个对话框或小部件。一旦建立了新的画布,用户就可以通过将现有的小部件拖放到画布上来可视化地构建 GUI。现有的窗口小部件包括 GUI 工具包的内置图形元素(例如,按钮)以及在 IDE 框架中支持拖放的自定义窗口小部件。一旦布局完成,就可以用图形或一点点代码将动作捆绑在一起。最后,IDE 会创建与图形化 GUI 相对应的代码,IDE 创建的代码会与其余的源代码一起编译。
使用 IDE 构建 GUI 既有优点也有缺点。一些优点如下。首先,因为这个过程是可视化的,所以在执行布局时,您可以很容易地看到 GUI 的外观。这与为 GUI 编写代码形成了鲜明的对比,在编写代码时,您只能看到编译和执行代码后的 GUI 外观。这种区别非常类似于使用微软 Word 这样的 WYSIWYG 文本编辑器和 LaTeX 这样的标记语言来写论文的区别。其次,IDE 通过在后台自动生成代码来工作,因此图形化方法可以显著减少编写 GUI 所需的编码量。第三,ide 通常在属性表中列出 GUI 元素的属性,这使得在不经常查阅 API 文档的情况下对 GUI 进行风格化变得很简单。这对于很少使用的功能尤其有用。
使用 IDE 构建 GUI 的一些缺点如下。首先,您受限于 IDE 选择公开的 API 子集。有时候,完整的 API 是公开的,有时候,不是。如果您需要 IDE 作者没有授予您的功能,您将被迫编写自己的代码。也就是说,IDE 可能会限制对 GUI 元素的微调控制。其次,对于重复的 GUI 元素,您可能需要多次执行相同的操作(例如,单击以使所有按钮中的文本变为红色),而在代码中,很容易将任何重复的任务封装在类或函数调用中。第三,使用 IDE 设计 GUI 将 GUI 限制在可以在编译时做出的决定。如果你需要动态地改变一个 GUI 的结构,你需要为此写代码。第四,在 IDE 中设计 GUI 会将您的代码与特定的供应商产品联系起来。在公司环境中,这可能不是一个重要的问题,因为整个公司的开发环境可能是统一的。然而,对于一个开放源代码的分布式项目,并不是每个想为您的代码库做贡献的开发人员都希望被限制在您选择的 IDE 中。
6.2.2 用代码构建 GUI
顾名思义,用代码构建 GUI。您可以编写代码与 GUI 工具包进行交互,而不是以图形方式将小部件放在画布上。对于如何编写代码,有几种不同的选择,通常,对于任何给定的 GUI 工具包,都有不止一种选择。首先,您几乎总是可以用工具包的语言编写源代码。例如,在 Qt 中,您可以完全通过以命令式风格编写 C++ 来构建您的 GUI(即,您显式地指导 GUI 的行为)。其次,一些 GUI 工具包允许声明性风格(即,您编写描述 GUI 元素风格的标记代码,但是工具包定义元素的行为)。最后,一些工具包使用基于脚本的接口来构造 GUI(通常是 JavaScript 或 JavaScript 派生语法),可能与声明性标记结合使用。在本章的上下文中,用代码构建 GUI 专指用 C++ 针对 Qt 的桌面小部件集进行编码。
正如您所料,用代码构建 GUI 与用 IDE 构建 GUI 的权衡几乎相反。优点如下。首先,小部件的完整 API 是完全公开的。因此,程序员有尽可能多的微调控制。如果小部件库设计者希望用户能够做一些事情,你可以用代码来做。第二,通过使用抽象,重复的 GUI 元素很容易管理。例如,在设计计算器时,我们可以创建一个按钮类并简单地实例化它,而不必手动定制每个按钮。第三,在运行时动态添加小部件很容易。对于 pdCalc 来说,这个优势对于满足支持动态插件的需求非常重要。第四,如果构建系统独立于 IDE,那么用代码设计 GUI 就完全独立于 IDE。
虽然用代码构建 GUI 有很多优点,但也存在缺点。首先,布局不直观。为了看到 GUI 成形,您必须编译并执行代码。如果它看起来是错误的,你必须调整代码,再试一次,并重复这个过程,直到你得到它的权利。这可能是非常乏味和耗时的。其次,您必须自己编写所有代码。尽管 IDE 会自动生成 GUI 代码的很大一部分,尤其是与布局相关的部分,但是当您编写代码时,您必须手动完成所有工作。最后,当用代码编写 GUI 时,您将无法在属性表上简洁地访问小部件的所有属性。通常,您需要更频繁地查阅文档。也就是说,良好的 IDE 代码完成对这项任务有很大的帮助。有人可能会对我的最后一句话大呼冤枉,声称“使用 IDE 可以减轻不使用 IDE 的缺点是不公平的。”请记住,除非您是在纯文本编辑器中编写源代码(不太可能),否则代码编辑器仍然可能是一个复杂的 IDE。我比较了使用 IDE 的图形 GUI 布局工具构建 GUI 和使用现代代码编辑器(可能本身就是 IDE)手动编写代码。
6.2.3 哪种 GUI 构建方法比较好?
对于标题中过于笼统的问题,答案当然是否定的。哪种技术更适合构建 GUI 完全取决于上下文。当您在自己的编码追求中遇到这个问题时,请参考前面的权衡,并根据您的情况做出最明智的选择。通常,最好的解决方案是一种混合策略,其中 GUI 的某些部分将以图形方式进行布局,而 GUI 的其他部分将完全由代码构建。
在我们的上下文中,一个更具体的问题是,“哪种 GUI 构建方法更适合 pdCalc?”对于这个应用程序,权衡的结果是更倾向于基于代码的方法。首先,计算器的可视化布局相当简单(一个状态窗口、一个显示部件和一个按钮网格),很容易用代码实现。这一事实立即消除了 IDE 方法最显著的优势,即可视化地处理复杂的布局。第二,按钮的创建和布局是重复的,但是很容易封装,这是基于代码的方法的优点之一。最后,因为计算器必须支持运行时插件,所以代码方法更适合动态添加小部件元素(运行时发现的按钮)。
在本章的剩余部分,我们将探索 PD calc GUI 的代码设计。特别是,重点将放在组件及其接口的设计上。因为我们的重点不是小部件的构造,所以许多实现细节将被忽略。然而,不要害怕。如果您对细节感兴趣,所有代码都可以在 GitHub 资源库中找到。
6.3 模块化
从本书开始,我们已经讨论了计算器的分解策略。使用 MVC 架构模式,我们将设计分成一个模型、一个视图和一个控制器。在第四章中,我们看到其中一个主要模块,命令调度器,被分成了几个子组件。CLI 模块足够简单,可以用一个类来实现,而 GUI 模块足够复杂,因此分解非常有用。回想一下第二章中的内容,当我们提到 GUI 模块时,我们只是将模块作为一个逻辑结构,因为在撰写本文时,Qt 还不支持 C++20 模块。
在第五章中,我们决定我们系统的任何用户界面都必须继承UserInterface
抽象类。本质上,UserInterface
类定义了 MVC 模式中视图的抽象接口。虽然 GUI 模块必须从UserInterface
继承,因此向控制器呈现相同的抽象接口,但是我们可以自由地分解 GUI 的内部,只要我们认为合适。我们将再次使用松耦合和强内聚的指导原则来模块化 GUI。
当我分解一个模块时,我首先考虑的是强内聚性。也就是说,我试图将模块分成小的组件,每个组件做一件事(并且做得很好)。让我们用 GUI 来尝试一下。首先,任何 Qt GUI 都必须有一个主窗口,通过继承QMainWindow
来定义。主窗口也是 MVC 视图的入口点,所以我们的主窗口也必须从UserInterface
继承。MainWindow
是我们的第一堂课。接下来目测图 6-1 ,计算器明显分为用于输入的组件(按钮集合)和用于显示的组件。因此,我们增加了两个等级:?? 和 ??。我们已经讨论过使用代码方法构建 GUI 的一个优点是抽象了按钮的重复创建,所以我们也将创建一个CommandButton
类。最后,我添加了一个负责管理计算器外观的组件(例如,字体、边距、间距等)。)我恰当地将其命名为LookAndFeel
类。还存在一个用于存储过程条目的组件,但是我们将把对该组件的讨论推迟到第八章。现在让我们看看每个类的设计,从CommandButton
开始。我们将讨论对这个初始分解的任何必要的改进,如果它们出现的话。
6.3.1 命令按钮抽象
我们从描述按钮是如何被抽象的开始讨论。这是一个合理的起点,因为按钮是数字和命令输入到计算器的基础。
Qt 提供了一个按钮小部件类,它显示一个可点击的按钮,当按钮被点击时会发出一个信号。这个QPushButton
类提供了数字和命令输入所需功能的基础。我们可以采用的一种预期设计是按原样使用QPushButton
s。这种设计需要明确地编写代码,将每个QPushButton
手动连接到它自己定制的插槽。然而,这种方法是重复的、乏味的,并且非常容易出错。此外,一些按钮需要QPushButton
API 没有提供的附加功能(例如,移位输入)。因此,我们为我们的程序寻找一个按钮抽象,它建立在QPushButton
之上,用额外的功能补充这个 Qt 类,但同时也限制QPushButton
的接口以完全满足我们的需求。我们称这个类为CommandButton
。
用模式的话来说,我们提出了既作为适配器又作为门面的东西。我们在第三章中看到了适配器模式。门面模式是近亲。适配器模式负责将一个接口转换成另一个接口(可能经过一些调整),而外观模式负责为子系统中的一组接口提供统一的接口(通常是一种简化)。我们的CommandButton
肩负着这两项任务。我们都在将QPushButton
接口简化为 pdCalc 需要的受限子集,同时调整QPushButton
的功能以满足我们问题的需求。那么,CommandButton
到底是门面还是适配器?区别是无关紧要的;它共享每一个的特征。请记住,理解不同模式的目标并根据您的需求调整它们是非常重要的。为了模式的纯粹性,不要迷失在四人帮[11]的机械实现中。
CommandButton 设计
除了介绍性的评论,我们仍然必须确定我们的CommandButton
到底需要做什么,以及它将如何与 GUI 的其余部分交互。在许多方面,CommandButton
的外表和行为都与QPushButton
相似。例如,CommandButton
必须呈现一个可以点击的可视按钮,在按钮被点击后,它应该发出某种信号,让其他 GUI 组件知道发生了点击动作。然而,与标准的QPushButton
不同,我们的CommandButton
必须支持标准状态和移动状态(例如,一个支持 sin 和 arcsin 的按钮)。这种支持应该是可视化的(两种状态都应该由我们的CommandButton
小部件显示)和功能性的(点击信号必须描述标准点击和移位点击)。因此,我们需要回答两个设计问题。首先,我们如何设计和实现小部件以正确地出现在屏幕上?第二,一般来说,计算器如何处理移位运算?
让我们首先解决CommandButton
外观问题。当然,我们可以从头开始实现我们的按钮,手动绘制屏幕,并使用鼠标事件来捕获按钮点击,但这对CommandButton
来说太过了。相反,我们寻求一个重用 Qt 的QPushButton
类的解决方案。我们基本上有两种复用选择:继承和封装。
首先,让我们考虑通过继承在CommandButton
类的设计中重用QPushButton
类。这种方法是合理的,因为人们可以在逻辑上采用 a CommandButton
是-a QPushButton
的观点。然而,这种方法有一个直接的缺陷。一个 is-a 关系意味着公共继承,这意味着QPushButton
的整个公共接口将成为CommandButton
公共接口的一部分。然而,我们已经确定,为了简化 pdCalc,我们希望CommandButton
有一个受限的接口(facade 模式)。好,让我们尝试私有继承,并将我们的观点修改为一个实现——一个CommandButton
和QPushButton
之间的关系。现在我们遇到了第二个缺陷。没有来自QPushButton
的公共继承,CommandButton
失去了对QWidget
类的间接继承,这是 Qt 中一个类成为用户界面对象的先决条件。因此,任何私有继承QPushButton
的实现也需要从QWidget
进行公共继承。然而,因为QPushButton
也继承自QWidget
,所以CommandButton
对这两个类的多重继承会导致歧义,因此是不允许的。我们必须寻找另一种设计。
现在,考虑将一个QPushButton
封装在一个CommandButton
中(即CommandButton
有一个 QPushButton
)。我们可能应该从这个选项开始,因为一般的实践表明我们应该尽可能地选择封装而不是继承。然而,许多开发人员倾向于从继承开始,我想讨论这种方法的缺点,而不仅仅求助于 C++ 最佳实践标准。除了打破强继承关系之外,选择封装方法还克服了前面讨论的使用继承的两个缺点。首先,由于QPushButton
将被封装在CommandButton
中,我们可以自由地只暴露QPushButton
接口中对我们的应用程序有意义的那些部分(或者根本没有)。其次,通过使用封装,我们将避免同时从QWidget
和QPushButton
类继承的多重继承混乱。注意,原则上我不反对使用多重继承的设计。在这种情况下,多重继承是不明确的。
封装关系可以采用组合或聚合的形式。CommandButton
类哪个合适?考虑两个类,A
和B
,其中A
封装了B
。在复合关系中,B
是A
的组成部分。在代码中,这种关系表示如下:
class A
{
// ...
private:
B b_;
};
相反,聚合意味着A
只是在内部使用一个B
对象。在代码中,聚合表示如下:
class A
{
// ...
private:
B* b_; // or some suitable smart pointer or reference
};
对于我们的应用程序,我认为聚合更有意义。也就是说,我们的CommandButton
使用了一个QPushButton
,而不是由一个QPushButton
组成。这种差别是微妙的,同样合乎逻辑的论点可以用来声明这种关系是复合的。也就是说,两种设计都在 Qt 中机械地工作,所以你的编译器不会在乎你选择如何表达这种关系。
既然我们已经决定在CommandButton
中聚合QPushButton
,我们可以继续进行CommandButton
类的总体设计。我们的CommandButton
必须同时支持主命令和辅助命令。视觉上,我选择在按钮上显示主要命令,在按钮的左上方用蓝色显示辅助命令(我们将讨论如何暂时改变状态)。因此,CommandButton
仅仅实例化了一个QPushButton
和一个QLabel
,并将它们都放在一个QVBoxLayout
中。QPushButton
显示主命令的文本,QLabel
显示移位命令的文本。布局如图 6-2 所示。如前所述,为了完成设计,为了与 GUI 的其余部分进行图形交互,CommandButton
必须公开继承QWidget
类。该设计产生了一个可重用的CommandButton
小部件类,用于声明主要和次要命令的通用按钮。因为按钮动作是通过使用QPushButton
实现的,所以CommandButton
类的整体实现非常简单。
重用QPushButton
还有最后一个小细节。显然,由于QPushButton
被私有地封装在CommandButton
中,客户端无法从外部连接到QPushButton
的clicked()
信号,这使得客户端代码无法知道何时点击了CommandButton
。这个设计其实是有意的。CommandButton
将在内部捕获QPushButton
的clicked()
信号,随后重新发射自己的信号。这个公共CommandButton
信号的设计与移位状态的处理有着错综复杂的联系。
图 6-2
CommandButton
的布局
我们现在返回到对计算器内的移位状态进行建模。我们有两个实际的选择。第一个选项是让CommandButton
s 了解计算器何时处于移位状态,并且只发出正确的移位或未移位命令。或者,第二个选项是让CommandButton
的信号同时包含移位和未移位命令,并让信号接收器整理出计算器的当前状态。让我们检查这两个选项。
第一个选项,让CommandButton
s 知道计算器是处于移位状态还是非移位状态,实现起来相当容易。在一个实现中,当按下换档按钮时,换档按钮通知每个按钮(通过 Qt 信号和槽),并且按钮在换档和非换档状态之间切换。如果需要,甚至可以在每次切换转换状态时,将转换位置的文本与按钮上的文本进行交换。或者,切换按钮可以连接到一个设置全局切换状态标志的插槽,当按钮发出发生点击的信号时,按钮可以查询该标志。在任一实现场景中,当单击按钮时,只发出当前状态的命令,该命令的接收者最终通过一个commandEntered()
事件将该命令从 GUI 中转发出去。
在第二个选项中,CommandButton
不需要知道计算器的任何状态。相反,当一个按钮被点击时,它会以移动和非移动两种状态发出信号。本质上,按钮只是在被单击时通知它的侦听器,并提供两种可能的命令。然后,接收器负责确定在commandEntered()
事件中发出哪些可能的命令。接收方大概必须负责跟踪移位的状态(或者能够轮询持有该状态的另一个类或变量)。
对于CommandButton
,处理计算器状态的两种设计都相当不错。然而,就个人而言,我更喜欢不需要CommandButton
s 知道任何关于转移状态的设计。在我看来,这种设计促进了更好的内聚性和更松散的耦合。这个设计更有凝聚力,因为CommandButton
应该负责显示一个可点击的小部件,并在按钮被点击时通知系统。要求CommandButton
理解计算器状态侵犯了它们抽象的独立性。这些按钮不再是带有两个命令的普通可点击按钮,而是与计算器的全局状态概念紧密联系在一起。此外,通过迫使CommandButton
s 理解计算器的状态,通过迫使CommandButton
s 不必要地互连到移位按钮或它们必须轮询的类,增加了系统中的耦合。当 shift 按钮被按下时,通知每一个CommandButton
的唯一好处是能够交换主要和次要命令的标签。当然,标签交换可以独立于CommandButton
的信号参数来实现。
CommandButton 界面
获得正确的设计是困难的部分。有了设计,界面实际上就可以自己写了。让我们检查一下CommandButton
类定义的简化版本:
class CommandButton : public QWidget
{
Q_OBJECT // needed by all Qt objects with signals and slots
public:
CommandButton(const string& dispPrimaryCmd, const string& primaryCmd,
const string& dispShftCmd, const string& shftCmd,
QWidget* parent = nullptr);
CommandButton(const string& dispPrimaryCmd, const string& primaryCmd,
QWidget* parent = nullptr);
private slots:
void onClicked();
signals:
void clicked(string primCmd, string shftCmd);
};
CommandButton
类有两个构造器:四参数重载和两参数重载。四参数重载允许指定主要命令和辅助命令,而两参数重载只允许指定主要命令。每个命令都需要两个字符串来提供完整的说明。第一个字符串相当于标签将在 GUI 中显示的文本,可以在按钮上显示,也可以在移动后的命令位置显示。第二个字符串相当于由commandEntered()
事件引发的文本命令。可以通过要求这两个字符串相同来简化接口。但是,我选择增加显示不同于命令调度器所需的文本的灵活性。注意,由于尾部的parent
指针,我们需要重载而不是默认参数。
该接口唯一的另一个公共部分是clicked()
信号,它与按钮的主命令和移位命令一起发出。双变元对一变元信号背后的基本原理之前已经讨论过了。尽管是私有的,我还是在CommandButton
的界面中列出了onClicked()
插槽,以突出显示为了捕捉内部QPushButton
的clicked()
信号而必须创建的私有插槽。onClicked()
函数的唯一目的是捕获QPushButton
的clicked()
信号,并发出带有两个函数参数的CommandButton
的clicked()
信号。
如果你看看CommandButton.h
中CommandButton
类的实际声明,你会看到一些额外的函数作为CommandButton
公共接口的一部分。这些只是简单的转发功能,要么改变外观(例如,文本颜色),要么向底层QPushButton
添加可视元素(例如,工具提示)。虽然这些功能是CommandButton
界面的一部分,但它们在功能上是可选的,并且独立于CommandButton
的底层设计。
获取输入
GUI 需要接受两种不同类型的用户输入:数字和命令。这两种输入类型都是用户通过排列在网格中的CommandButton
(或映射到这些按钮的键盘快捷键)输入的。这些CommandButton
的集合、它们的布局以及它们给 GUI 其余部分的相关信号组成了InputWidget
类。
命令输入在概念上很简单。点击一个CommandButton
,然后发出一个信号,反映该特定按钮的命令。最终,GUI 的另一部分将接收这个信号,并引发一个由命令调度器处理的commandEntered()
事件。
输入数字比输入命令要复杂一些。在 CLI 中,我们可以简单地允许用户键入数字,并在输入完成后按 enter 键。然而,在 GUI 中,我们没有这样的内置机制(假设我们想要一个比 Qt 窗口中的 CLI 更复杂的 GUI)。虽然计算器确实有一个用于输入数字的Command
,但请记住,它假设的是完整的数字,而不是单个数字。因此,GUI 必须有一个构造数字的机制。
构建数字包括输入数字和特殊符号,如小数点、加/减运算符或取幂运算符。此外,当用户输入时,他们可能会出错,所以我们也希望启用基本编辑(例如退格)。数字的组合是一个两步过程。InputWidget
只负责发出编写和编辑数字所需的按钮点击。GUI 的另一部分将接收这些信号并汇编完整的数字输入。
输入输出集的设计
从概念上讲,InputWidget
类的设计很简单。小部件必须显示生成和编辑输入所需的按钮,将这些按钮绑定到按键(如果需要),并在这些按钮被单击时发出信号。如前所述,InputWidget
包含数字输入和命令输入按钮。因此,它负责数字 0 –
9、加号/减号按钮、小数点按钮、求幂按钮、回车按钮、退格按钮、shift 按钮以及每个命令的按钮。回想一下,作为一种节约,CommandButton
类允许每个可视按钮有两个不同的命令。
为了整个 GUI 的一致性,我们将使用CommandButton
专门作为所有输入按钮的表示,即使是既没有发布命令也没有辅助操作的按钮(例如 0 按钮)。我们对CommandButton
的设计如此灵活,真是太方便了!然而,这个决定仍然给我们留下了两个突出的设计问题,即我们如何在视觉上布局按钮,以及当按钮被点击时我们该做什么。
在InputWidget
中放置按钮有两种选择。首先,InputWidget
本身拥有一个布局,它将所有的按钮放在这个内部布局中,然后InputWidget
本身可以放在主窗口的某个地方。备选方案是InputWidget
在建造期间接受外部拥有的布局,并将其CommandButton
放置在该布局上。总的来说,让InputWidget
拥有自己的布局是最好的设计。与替代方法相比,它提高了内聚力,减少了耦合。让InputWidget
接受外部布局的唯一例外是,如果设计要求其他类共享相同的布局来放置额外的小部件。在这种特殊情况下,使用两个类外部拥有的共享布局会更简洁。
现在让我们把注意力转向点击InputWidget
中的按钮会发生什么。因为InputWidget
封装了CommandButton
s,每个CommandButton
的clicked()
信号不能被InputWidget
类的消费者直接访问。因此,InputWidget
必须捕捉所有的CommandButton
点击并重新发出。对于正弦或正切等计算器命令,重新发出单击是一个微不足道的转发命令。事实上,Qt 支持将CommandButton
的clicked()
信号直接连接到InputWidget commandEntered()
信号的简写符号,无需通过InputWidget
中的私有插槽。数字、数字编辑按钮(如加/减、退格)和计算器状态按钮(如 shift)通过在InputWidget
的专用槽中捕捉来自CommandButton
的特定clicked()
信号并随后为这些动作中的每一个发出InputWidget
信号来更好地处理。
如上所述,当按下每个输入按钮时,InputWidget
必须发出自己的信号。在一个极端情况下,InputWidget
可以为每个内部CommandButton
提供单独的信号。在另一个极端,不管按钮按下与否,InputWidget
只能发出一个信号,并通过一个参数来区分动作。正如所料,对于我们的设计,我们将寻求一些中间地带,分享来自每一个极端的元素。
本质上,InputWidget
接受三种不同类型的输入:修饰符(例如,回车、退格、加/减、shift)、科学符号字符(例如,0 –
9、十进制、取幂)或命令(例如,正弦、余弦等)。).每个修改器需要一个唯一的响应;因此,每个修饰物结合到它自己单独的信号上。另一方面,科学符号字符可以简单地通过在屏幕上显示输入字符来统一处理(Display
类的作用)。因此,科学符号字符都是通过发出一个信号来处理的,该信号将特定字符编码为一个参数。最后,通过发出单个信号来处理命令,该信号只是将主要和次要命令作为信号的函数参数一字不差地转发。
在构建信号处理时,重要的是将InputWidget
作为一个类来维护,以便向 GUI 的其余部分发送原始用户输入信号。让InputWidget
解释按钮按下会导致问题。例如,假设我们设计了InputWidget
来聚合字符,并且只发出完整有效的数字。由于这种策略意味着每个字符输入都不会发出信号,所以在数字完成之前,字符既不能显示也不能编辑。这种情况显然是不可接受的,因为用户肯定希望在输入时看到屏幕上的每个字符。
现在让我们将注意力转向将我们的设计转化为InputWidget
的最小界面。
InputWidget 的接口
我们通过展示类声明开始讨论InputWidget
的接口。正如所料,我们清晰的设计带来了简单明了的界面。
class InputWidget : public QWidget
{
Q_OBJECT
public:
explicit InputWidget(QWidget* parent = nullptr);
signals:
void characterEntered(char c);
void enterPressed();
void backspacePressed();
void plusMinusPressed();
void shiftPressed();
void commandEntered(string, string);
};
本质上,整个类接口是由对应于用户输入事件的信号定义的。具体来说,我们有一个信号指示任何科学记数法字符的输入,一个信号指示前进命令按钮的点击,以及单独的信号分别指示退格、回车、加/减或 shift 按钮的点击。
如果您查看 GitHub 资源库源代码中的InputWidget.cpp
文件,您会发现一些额外的公共函数和信号。这些额外的函数是实现后续章节中介绍的两个特性所必需的。首先,需要一个addCommandButton()
函数和一个setupFinalButtons()
函数来适应插件按钮的动态添加,这是在第七章中介绍的一个特性。其次,需要一个procedurePressed()
信号来指示用户请求使用存储过程。存储过程在第八章中介绍。
显示器
从概念上讲,计算器有两个显示器:一个用于输入,一个用于输出。这种抽象可以在视觉上实现为两个单独的显示或者一个合并的输入/输出显示。两种设计都完全有效;每个都在图 6-3 中进行了说明。
图 6-3
输入和输出显示选项
选择一种 I/O 风格还是另一种,最终取决于客户的偏好。我对这两种风格都没有特别的偏好,所以选择了合并显示,因为它看起来更像我的 HP48S 计算器。选择了显示风格后,现在让我们来关注这个选择所暗示的设计含义。
如图 6-3a 所示,使用一个独立的屏幕小部件进行输入和输出,选择独立的输入和输出显示类是显而易见的。输入显示将有接收InputWidget
信号的插槽,输出显示将有接收完整数字(来自输入显示)和栈更新的插槽。内聚力会很强,组件的分离会很合适。
然而,我们的设计要求混合输入/输出显示器,如图 6-3b 所示。混合设计极大地改变了使用独立输入和输出显示类的敏感性。虽然将输入和输出显示问题集中到一个类中确实会降低显示的内聚性,但是试图维护两个独立的类都指向同一个屏幕上的小部件会导致笨拙的实现。例如,选择哪个类应该拥有底层的 Qt 小部件是任意的,很可能导致一个共享的小部件设计(也许使用一个shared_ptr
?).然而,在这种情况下,输入或输出显示类应该初始化屏幕上的小部件吗?如果输入显示共享指向单个显示小部件的指针,那么输入显示向输出显示发送信号是否有意义?答案很简单,两个类的设计对于一个合并的 I/O 显示小部件是不成立的,即使我们可能更喜欢将输入和输出显示分开。
上述讨论确定了几个有趣的点。首先,设计在屏幕上的视觉呈现可以合理地改变底层组件的设计和实现。虽然这在一个具体的 GUI 示例中似乎是显而易见的,但间接的含义是,如果屏幕上的小部件只是稍微改变一下,GUI 类的设计可能需要显著改变。第二,当设计与第二章中假设的良好设计要素直接矛盾时,结果会更清晰。显然,第二章中的指导方针是为了帮助设计过程,而不是作为不可违反的规则。也就是说,我的总的建议是保持遵循指导方针的清晰性,但只是明智地违反最佳实践。
既然我们已经决定用一个底层的Display
类来实现一个单一的 I/O 显示,让我们来看看它的设计。
显示类的设计
我承认。我最初对Display
类的设计和实现是无能的。我没有使用适当的分析技术和前期设计,而是有机地发展设计(即,与实现并行)。然而,当我的设计迫使Display
类发出commandEntered()
信号让 GUI 正常工作时,我就知道这个设计有一股“臭味”。负责在屏幕上绘制数字的类可能不应该解释命令。也就是说,实现工作正常,所以我让代码保持原样,并完成了计算器。然而,当我最终开始写这个设计时,我很难为我的设计制定一个基本原理,我最终不得不承认自己的设计有致命的缺陷,迫切需要重写。
显然,在重新设计展示后,我可以简单地选择只描述改进的产品。然而,我认为研究我的第一次被误导的尝试,讨论设计有一些严重问题的迹象,并最终看到经过一夜的重构最终出现的设计是有启发性的。可能,这里最有趣的教训是,糟糕的设计肯定会导致工作代码,所以永远不要认为工作代码是好设计的指标。此外,糟糕的设计,如果本地化,可以重构,有时,重构应该仅仅是为了增加清晰度。当然,重构假设您的项目时间表包含足够的应急时间,可以定期暂停以偿还技术债务。在回到更好的设计之前,我们现在开始简要研究我的错误。
拙劣的设计
根据前面的分析,我们确定计算器应该有一个统一的Display
类来处理输入和输出。我的显示设计中的基本错误源于错误地解释了一个Display
类意味着没有额外的正交关注类。因此,我继续将所有没有被InputWidget
类处理的功能合并到一个单独的Display
类中。让我们沿着这条路开始。然而,我们不是像我以前那样完成设计和实现,而是一看到第一个致命缺陷出现,就停下来重新设计这个类(这是我本来应该做的)。
使用单一的Display
类设计,Display
负责显示来自用户的输入和来自计算引擎的输出。显示输出是微不足道的。Display
类观察stackChanged()
事件(间接的,因为它不是 GUI 外部接口的一部分)并用新的栈值更新屏幕显示小部件(在本例中是一个QLabel
)。从概念上讲,显示输入也是微不足道的。Display
直接接收InputWidget
类(如characterEntered()
)发出的信号,并用当前输入更新屏幕显示小工具。这种交互的简单性掩盖了这种设计的根本问题,即输入不是为了显示而自动输入的。相反,它是通过独立输入几个字符,并按下 enter 按钮来完成输入,从而将多个信号组合在一起。输入的这种顺序结构意味着计算器必须保持一个活动的输入状态,而输入状态与显示小部件无关。
此时,您可能会问,除了意识形态上的厌恶之外,Display
类保持输入状态还有什么问题。难道我们不能把状态简单地看作一个显示输入缓冲区吗?让我们继续这个设计,看看它为什么有缺陷。例如,考虑退格按钮,它的操作基于输入状态被重载。如果当前输入缓冲区不为空,退格键将从该缓冲区中删除一个字符。但是,如果当前输入缓冲区为空,按 backspace 按钮会导致发出从栈中删除顶部数字的命令。因为在这种设计下,Display
拥有输入状态并且是backspacePressed()
信号的接收器,所以Display
必须是来自栈命令的丢弃号的源。一旦Display
开始发布命令,我们已经完全放弃了内聚力,是时候去寻找意大利面酱了,因为意大利面条代码随之而来。从这里开始,我不再只是放弃设计,而是加倍努力,我原来的设计实际上变得更差了。然而,与其在这条错误的道路上走得更远,不如让我们继续研究一种更好的方法。
改进的展示设计
在讨论糟糕的显示设计的早期,我指出致命的错误来自于假设统一的显示需要单一的类设计。然而,正如我们已经看到的,这个假设是无效的。计算器中状态的出现意味着至少需要两个类:一个用于可视显示,一个用于状态。
这是否让你想起了我们已经见过的模式?GUI 需要维护一个内部状态(一个模型)。我们目前正在设计一个展示(一个视图)。我们已经设计了一个类InputWidget
,用于接受输入和发布命令(一个控制器)。显然,GUI 本身只不过是一种熟悉的模式——模型-视图-控制器(MVC)的体现。注意,相对于图 2-2 中的 MVC 原型,GUI 可以用间接通信来代替控制器和模型之间的直接通信。Qt 的信号和插槽机制促进了这一微小的变化,从而降低了耦合度。
我们现在将注意力集中在新引入的模型类的设计上。模型完成后,我们将返回到Display
类来完成它现在更简单的设计和界面。
6.3.4 模型
我恰当地称之为GuiModel
的模型类负责 GUI 的状态。为了正确地实现这一目标,模型必须是导致系统状态改变的所有信号的接收器,并且它必须是指示系统状态已经改变的所有信号的源。自然,模型也是系统状态的存储库,它应该为 GUI 的其他组件提供查询模型状态的工具。我们来看看GuiModel
的界面:
class GuiModel : public QObject
{
Q_OBJECT
public:
enum class ShiftState { Unshifted, Shifted };
struct State { /* discussed below */ };
GuiModel(QObject* parent = nullptr);
~GuiModel();
void stackChanged(const vector<double>& v);
const State& getState() const;
public slots:
// called to toggle the calculator's shift state
void onShift();
// paired to InputWidget's signals
void onCharacterEntered(char c);
void onEnter();
void onBackspace();
void onPlusMinus();
void onCommandEntered(string primaryCmd, string secondaryCmd);
signals:
void modelChanged();
void commandEntered(string s);
void errorDetected(string s);
};
GuiModel
类的六个槽都对应于InputWidget
类发出的信号。GuiModel
解释这些请求,适当地改变内部状态,并发出一个或多个自己的信号。特别值得注意的是commandEntered()
信号。鉴于GuiModel
的onCommandEntered()
插槽接受两个参数,即对应于被按下的CommandButton
的原始主命令和辅助命令,而GuiModel
负责解释 GUI 的转换状态,并且仅重新发射带有活动命令的commandEntered()
信号。
GuiModel
界面的其余部分涉及 GUI 的状态。我们首先讨论嵌套的State
结构背后的基本原理。比起在GuiModel
中将模型状态的每一部分声明为一个单独的成员,我发现将所有的状态参数放在一个结构中要干净得多。这种设计通过允许使用一个函数调用通过常量引用返回整个系统状态,而不是要求逐段访问各个状态成员,从而方便了模型状态的查询。我选择嵌套State
结构,因为它是GuiModel
的固有部分,没有独立的用途。因此,State
结构自然属于GuiModel
的范围,但是它的声明必须公开声明,以便 GUI 的其他组件能够查询状态。
结构的组成部分定义了 GUI 的整个状态。特别地,这个State
结构包括一个数据结构,该数据结构保存栈上最大数量的可见数字的副本、当前输入缓冲区、定义系统移位状态的枚举以及定义输入缓冲区有效性的 Qt 枚举。声明如下:
struct State
{
vector<double> curStack;
string curInput;
ShiftState shiftState;
QValidator::State curInputValidity;
};
一个有趣的问题是,为什么GuiModel
的State
缓冲来自栈顶的可见数字?鉴于Stack
类是单例的,Display
可以直接访问Stack
。然而,Display
仅观察到GuiModel
中的变化(通过modelChanged()
插槽)。因为与栈变化无关的状态变化频繁出现在 GUI 中(例如,字符输入),由于Display
不是stackChanged()
事件的直接观察者,因此Display
将被迫在每个modelChanged()
事件上浪费地查询Stack
。另一方面,GuiModel
是stackChanged()
事件的观察者(间接通过MainWindow
的函数调用)。因此,有效的解决方案是让GuiModel
仅在计算器的栈实际改变时更新栈缓冲区,并让Display
类访问该缓冲区,这通过构造保证是当前的,用于更新屏幕。
显示冗余
我们现在准备将注意力返回到Display
类。将所有的状态和状态交互放在GuiModel
类中后,Display
类可以简化为一个对象,它监视模型的变化并在屏幕上显示计算器的当前状态。除了构造器之外,Display
类的接口只包含两个函数:模型改变时调用的 slot 和在状态区域显示消息时调用的成员函数。后一个函数调用用于显示在 GUI 中检测到的错误(例如无效输入)以及在命令调度器中检测到的错误(通过UserInterface
的postMessage()
发送)。下面给出了Display
类的整个接口:
class Display : public QWidget
{
Q_OBJECT
public:
explicit Display(const GuiModel& g, QWidget* parent = nullptr,
int nLinesStack = 6, int minCharWide = 25);
void showMessage(const string& m);
public slots:
void onModelChanged();
};
Display
类的构造器的可选参数只是指示栈在屏幕上的可视外观。具体来说,Display
类的客户端可以灵活控制要显示的栈行数和屏幕显示的最小宽度(以固定宽度字体字符为单位)。
6.3.6 结合在一起:主窗口
主窗口是一个相当小的类,服务于一个大的目的。准确地说,它在我们的应用中有三个目的。首先,像在大多数基于 Qt 的 GUI 中一样,我们需要提供一个从QMainWindow
中公开继承的类,它自然地充当应用程序的主 GUI 窗口。特别是,这是在启动 GUI 的函数中实例化和显示的类。遵循我典型的创造性命名风格,我将这个类称为MainWindow
。其次,MainWindow
作为计算器视图模块的接口类。也就是说,MainWindow
也必须公开继承我们的抽象UserInterface
类。最后,MainWindow
类拥有前面讨论的所有 GUI 组件,并在必要时将这些组件粘合在一起。实际上,将组件粘合在一起只需要将信号连接到相应的插槽。这些简单的实现细节可以在MainWindow.cpp
源代码文件中找到。我们将在本节的剩余部分讨论MainWindow
的设计和界面。
我们已经编写了一个 Qt 应用程序;很明显我们会在某个地方有一个QMainWindow
的后代。这本身并不十分有趣。然而,有趣的是决定使用多重继承来使同一个类也作为 pdCalc 的其余部分的UserInterface
。也就是说,这真的是一个有趣的决定吗,或者只是因为一些开发人员对多重继承有强烈的厌恶而显得有些挑衅?
事实上,我本可以将QMainWindow
和UserInterface
分成两个独立的类。在主窗口装饰有菜单、工具栏和多个底层小部件的 GUI 中,我可能会将两者分开。然而,在我们的 GUI 中,QMainWindow
除了为我们的 Qt 应用程序提供一个入口点之外,没有其他用途。在QMainWindow
的角色中,MainWindow
实际上什么也不做。因此,创建一个单独的MainWindow
类,唯一的目的是包含一个UserInterface
类的具体专门化,除了避免多重继承之外,没有任何其他目的。虽然有些人可能不同意,但我认为在这种情况下,缺乏多重继承实际上会使设计复杂化。
前面描述的情况实际上是多重继承是最佳选择的一个典型例子。特别是,多重继承在派生类中表现出色,这些派生类的多个基类表现出正交功能。在我们的例子中,一个基类充当 Qt 的 GUI 入口点,而另一个基类充当 pdCalc 的 GUI 视图的UserInterface
专门化。请注意,两个基类都不共享功能、状态、方法或祖先。在至少有一个基类是纯抽象的(没有状态,只有纯虚函数的类)的情况下,多重继承特别有意义。使用纯抽象基类的多重继承的场景非常有用,以至于在不允许多重继承的编程语言(例如,C#和 Java 中的接口)中允许使用它。
MainWindow
的接口简单地由一个构造器、UserInterface
类中两个纯虚函数的覆盖和一些用于动态添加命令的函数组成(当我们设计插件时,我们将在第七章中遇到这些函数)。为了完整起见,MainWindow
的界面如下:
class MainWindow : public QMainWindow, public UserInterface
{
class MainWindowImpl;
public:
MainWindow(int argc, char* argv[], QWidget* parent = nullptr);
void postMessage(string_view m) override;
void stackChanged() override;
// plugin functions...
};
外观和感觉
在我们用一些执行 GUI 的示例代码来结束本章之前,我们必须简单地回到 GUI 的最后一个组件,即LookAndFeel
类。LookAndFeel
类只是管理 GUI 的可动态定制的外观,比如字体大小和文本颜色。界面简单。对于每个定制点,都有一个函数返回请求的设置。例如,为了获得显示的字体,我们提供了一个函数:
class LookAndFeel
{
public:
// one function per customizable setting, e.g.,
const QFont& getDisplayFont() const;
// ...
}
因为我们在计算器中只需要一个LookAndFeel
对象,所以这个类是作为单例实现的。
一个很好的问题是,“我们到底为什么需要这个类?”答案是,它让我们有机会根据当前环境动态修改计算器的外观,并且它集中了对 pdCalc 外观的内存访问。例如,假设我们想要让我们的 GUI DPI 知道并相应地选择字体大小(我在源代码中没有,但是您可能想要)。对于静态配置文件(或概念上的等效物,注册表设置),我们必须在安装过程中为每个平台定制设置。要么我们必须在安装程序中为每个平台构建定制,要么我们必须编写在安装期间执行的代码,以动态创建适当的静态配置文件。如果我们必须写代码,为什么不把它放在它应该在的地方呢?作为一个实现决策,LookAndFeel
类可以简单地设计为读取一个配置文件并在内存中缓冲外观属性(一个外观代理对象)。这才是LookAndFeel
级的真正威力。它集中了外观属性的位置,因此只需要更改一个类就可以实现全局外观更改。也许更重要的是,LookAndFeel
类将单个 GUI 组件与定义 GUI 如何发现(并可能适应)特定平台上的设置的实现细节隔离开来。
在LookAndFeel.cpp
文件中可以找到LookAndFeel
类的完整实现。当前的实现非常简单。LookAndFeel
类提供了一种标准化 GUI 外观的机制,但是没有实现允许用户定制应用程序。第八章简要地建议了一些可能的扩展,你可以对LookAndFeel
类进行扩展,使 pdCalc 用户可定制。
6.4 工作计划
我们以一个用于启动 GUI 的功能main()
来结束这一章。由于我们将在第七章中遇到的额外要求,pdCalc 的实际main()
函数比下面列出的更复杂。但是,简化版本值得列出来,以说明如何将 pdCalc 的组件与 GUI 结合在一起,以创建一个正常运行的独立可执行文件。
int main(int argc, char* argv[])
{
QApplication app{argc, argv};
MainWindow gui{argc, argv};
CommandInterpreter ci{gui};
RegisterCoreCommands(gui);
gui.attach(UserInterface::CommandEntered(),
make_unique<CommandIssuedObserver>(ci) );
Stack::Instance().attach(Stack::StackChanged(),
make_unique<StackUpdatedObserver>( gui ) );
gui.execute();
return app.exec();
}
注意前面提到的用于执行 GUI 的main()
函数与第五章结尾列出的用于执行 CLI 的main()
函数之间的相似之处。这些相似之处并非偶然,而是 pdCalc 模块化设计的结果。
与 CLI 一样,为了让您快速入门,在构建可执行文件pdCalc-simple-gui
的存储库源代码中包含了一个项目,使用前面的main()
函数作为应用程序的驱动程序。该可执行文件是一个独立的 GUI,包括本书到目前为止讨论的所有特性。
6.5 Microsoft Windows 内部版本说明
pdCalc 被设计为既是 GUI 又是 CLI。在 Linux 中,控制台应用程序(CLI)和窗口应用程序(GUI)在编译时没有区别。对于这两种风格,可以用相同的构建标志来编译统一的应用程序。然而,在 Microsoft Windows 中,创建一个既作为 CLI 又作为 GUI 的应用程序并不容易,因为操作系统要求应用程序在编译期间声明控制台或 Windows 子系统的使用。
为什么子系统的声明在 Windows 上很重要?如果一个应用程序被声明为窗口应用程序,如果从命令提示符启动,该应用程序将简单地返回而没有输出(即,该应用程序将看起来好像从未执行过)。但是,当双击应用程序的图标时,应用程序会在没有后台控制台的情况下启动。另一方面,如果一个应用程序被声明为控制台应用程序,则当从命令提示符启动时,GUI 将会出现,但是如果通过双击该应用程序的图标来打开,GUI 将与后台控制台一起启动。
通常,Microsoft Windows 应用程序是为一个子系统或另一个子系统设计的。在少数同时使用 GUI 和 CLI 开发应用程序的情况下,开发人员已经创建了避免上述问题的技术。一种这样的技术创建了两个应用程序:一个. com 和一个. exe,操作系统可以根据通过命令行参数选择的选项适当地调用它们。
为了保持 pdCalc 的代码简单和跨平台,我忽略了这个问题,简单地使用控制台子系统构建了 GUI(然而,pdCalc-simple-gui
没有 CLI,是在窗口模式下构建的)。事实上,这意味着如果通过双击 pdCalc 的图标来启动应用程序,将会在后台出现一个额外的控制台窗口。如果您打算将应用程序专门用作 GUI,可以通过使用 windows 子系统构建程序来解决这个问题。如果您进行此更改,请记住,pdCalc 的 CLI 实际上将被禁用。构建窗口应用程序可以通过在负责构建 pdCalc 可执行文件的CMakeLists.txt
文件的add_executable()
命令中添加WIN32
选项来完成(参见pdCalc-simple-gui
的CMakeLists.txt
文件)。如果您需要访问 CLI 和 GUI,而外部控制台让您抓狂,您有两个现实的选择。首先,在互联网上搜索前面讨论过的技术之一,并尝试一下。就我个人而言,我从未走过那条路。其次,构建两个独立的可执行文件(可能称为 pdCalc 和 pdCalc-cli ),而不是一个能够基于命令行参数切换模式的可执行文件。应用程序灵活的架构支持这两种选择。
七、插件
你可能已经读过这一章的标题,所以你已经知道这一章是关于插件的,特别是它们的设计和实现。此外,插件将为我们提供探索设计技术的机会,以隔离平台特定的功能。然而,在我们深入细节之前,让我们先来定义什么是插件。
7.1 什么是插件?
插件是一种软件组件,在程序初次编译后,它可以将新功能添加到程序中。在这一章中,我们将专门关注运行时插件,即作为共享库构建的插件(例如,POSIX。所以还是 Windows。dll 文件),它们在运行时是可发现和可加载的。
插件在应用程序中有用的原因有很多。这里只是几个例子。首先,插件对于允许最终用户在不需要重新编译的情况下向现有程序添加特性是有用的。通常,这些新特性是最初的应用程序开发人员完全没有预料到的。第二,从架构上来说,插件可以将一个程序分成多个可选的部分,这些部分可以单独与程序一起发布。例如,考虑一个程序(例如 web 浏览器),它附带一些基本功能,但允许用户添加专业功能(例如广告拦截器)。第三,插件可以用于设计一个可以为特定客户定制的应用程序。例如,考虑一个电子健康记录系统,它需要不同的功能,这取决于软件是部署在医院还是医生的个人诊所。插入核心系统的不同模块可以捕获必要的定制。当然,人们可以为插件想出许多额外的应用。
在 pdCalc 的上下文中,插件是提供新的计算器命令和可选的新 GUI 按钮的共享库。这项任务会有多难?在第四章中,我们创建了许多命令,并看到添加新的命令是相当简单的。我们简单地继承了Command
类(或它的一个派生类,如UnaryCommand
或BinaryCommand
),实例化了命令,并用CommandFactory
注册了它。例如,以正弦命令为例,该命令在CoreCommands.m.cpp
中声明如下:
class Sine : public UnaryCommand
{
// implement Command virtual members
};
并由线路登记在CommandFactory.m.cpp
cf.registerCommand( "sin", MakeCommandPtr<Sine>() );
其中cf
是一个CommandFactory
参考。事实证明,除了一个关键步骤之外,插件命令几乎完全可以遵循这个配方。由于 pdCalc 在编译时不知道插件命令的类名,所以我们不能使用插件类名进行分配。
不知道插件命令的类名这个看似简单的困境导致了我们需要为插件解决的第一个问题。具体来说,我们需要建立一个抽象接口,通过它插件命令可以被发现并在 pdCalc 中注册。一旦我们就插件接口达成一致,我们将很快遇到第二个基本的插件问题,那就是如何动态加载一个插件,甚至使共享库中的名称对 pdCalc 可用。让我们的生活变得更复杂的是,第二个问题的解决方案依赖于平台,所以我们将寻求一种设计策略来最小化平台依赖性的痛苦。我们将遇到的最后一个问题是更新我们现有的代码来动态添加新的命令和按钮。也许令人惊讶的是,这最后一个问题是最容易解决的。然而,在我们开始解决这三个问题之前,我们需要考虑一些 C++ 插件的规则。
7 . 1 . 1 c++ 插件的规则
插件在概念上不是 C++ 语言的一部分。更确切地说,插件是操作系统如何动态加载和链接共享库的一种表现形式(因此插件具有平台特定的性质)。对于任何规模不小的项目,应用程序通常分为一个可执行文件和几个共享库(传统上。所以 Unix 中的文件。麦克 OS X 的 dylib 文件。MS Windows 中的 dll 文件)。
通常,作为 C++ 程序员,我们很高兴地没有意识到这种结构的微妙之处,因为可执行文件和库是在同构的构建环境中构建的(即,相同的编译器和标准库)。然而,对于一个实用的插件接口,我们没有这样的保证。相反,我们必须防御性地编程,并假设最坏的情况,即插件构建在与主应用程序不同但兼容的环境中。这里,我们将做一个相对较弱的假设,即这两个环境至少共享相同的对象模型。具体来说,我们要求两个环境使用相同的布局来处理虚函数指针(vptr
)。如果你不熟悉虚函数指针的概念,所有血淋淋的细节都可以在[18]中找到。虽然原则上,C++ 编译器作者可以选择不同的vptr
布局,但实际上,编译器通常使用兼容的布局,尤其是同一编译器的不同版本。如果没有这个共享对象模型的假设,我们将被迫开发一个 C 语言的–
纯插件结构。注意,我们还必须假设sizeof(T)
对于主应用程序和插件中的所有类型T
都是相同的大小。例如,这消除了 32 位应用程序和 64 位插件,因为这两个平台具有不同的指针大小。
异构环境中的编程如何影响我们可以使用的编程技术?在最坏的情况下,主应用程序可能是用不同的编译器和不同的标准库构建的。这一事实有几个严重的影响。首先,我们不能假设插件和应用程序之间的内存分配和释放是兼容的。这意味着在一个插件中的任何内存必须在同一个插件中。其次,我们不能假设来自标准库的代码在任何插件和主应用程序之间都是兼容的。因此,我们的插件接口不能包含任何标准容器。虽然标准库的不兼容性看起来很奇怪(这是标准的库,对吗?),记住标准指定的是接口,而不是实现(受一些限制,比如vector
s 占用连续内存)。例如,不同的标准库实现经常有不同的string
实现。一些人喜欢小字符串优化,而另一些人喜欢使用写时复制。第三,虽然我们已经为对象中的vptr
假设了一个兼容的布局,但是我们不能假设完全相同的对齐。因此,如果主应用程序中使用了基类中定义的成员变量,插件类就不应该从主应用程序类继承。这是因为如果每个编译器使用不同的对齐方式,主应用程序的编译器可能会对成员变量使用不同于插件编译器定义的内存偏移量。第四,由于不同编译器之间的名称混淆差异,导出的接口必须指定extern "C"
链接。链接要求是双向的。插件不应调用没有extern "C"
链接的应用程序函数,应用程序也不应调用没有extern "C"
链接的插件函数。请注意,因为非非线性、非虚拟成员函数需要跨编译单元的链接(与虚函数相反,虚函数是通过虚函数表中的偏移量通过vptr
调用的),所以应用程序应该只通过虚函数调用插件代码,插件代码不应该调用在主应用程序中编译的基类非非线性、非虚拟函数。第五,异常很少能在主程序和插件之间的二进制接口上移植,所以我们不能在插件中抛出异常并试图在主应用程序中捕捉它们。最后,C++20 模块可以跨插件边界移植,但是它们的编译模块接口(CMI)却不能。插件所需的由 C++20 模块封装的任何主要应用程序代码必须向该插件提供其模块接口文件。这本质上与给插件提供一个头文件没有什么不同,除了,根据构建系统,模块接口文件可能需要单独编译或预编译,而不是简单地包含。
那是一口。让我们通过列举 C++ 插件的规则来回顾一下:
-
在一个插件中分配的内存必须在同一个插件中释放。
-
标准库组件不能在插件接口中使用。
-
假设不兼容对齐。如果主应用程序中使用了成员变量,避免插件从主应用程序类继承。
-
从插件导出的函数(将由主应用程序调用)必须指定
extern "C"
链接。从主应用程序导出的函数(将被插件调用)必须指定extern "C"
链接。 -
主应用程序应该只通过虚函数与插件派生类通信。插件派生类不应该调用非非线性、非虚拟的主应用程序基类函数。
-
不要让插件中抛出的异常传播到主应用程序。
-
模块 CMI 不是可分发的工件;分发模块接口文件。
记住这些规则,让我们回到设计插件必须解决的三个基本问题。
7.2 问题 1:插件接口
插件接口负责几个项目。首先,它必须能够发现新的命令和新的 GUI 按钮。我们将看到,通过类接口可以最有效地实现这一功能。第二,插件必须支持一个 C 链接接口来分配和释放前面提到的插件类。第三,pdCalc 应该提供一个从Command
派生的PluginCommand
类来帮助正确编写插件命令。从技术上讲,PluginCommand
类是可选的,但是提供这样一个接口有助于用户遵守插件规则第三和第六条。第四,值得插件接口提供查询一个插件支持的 API 版本的功能。最后,pdCalc 必须为插件调用的任何函数提供 C 链接。具体来说,插件命令必须能够访问栈。我们将从发现命令的界面开始依次解决这些问题。
7.2.1 发现命令的界面
我们面临的第一个问题是如何从插件中分配命令,我们既不知道插件提供什么命令,也不知道我们需要实例化的类的名称。我们将通过创建一个抽象接口来解决这个问题,所有插件都必须遵循这个接口来导出命令及其名称。首先,让我们解决我们将需要什么功能。
回想一下第四章,为了将新命令加载到计算器中,我们必须用CommandFactory
注册它。按照设计,CommandFactory
是专门为允许命令的动态分配而构建的,这正是我们插件命令所需要的功能。现在,我们假设插件管理系统可以访问 register 命令(我们将在 7.4 节解决这个缺陷)。CommandFactory
的注册功能需要一个string
命令名和一个unique_ptr
作为命令的原型。由于 pdCalc 对插件中的命令名一无所知,插件接口必须首先使名称可被发现。第二,由于 C++ 缺乏反射这一语言特性,插件接口必须提供一种方法来创建一个与每个发现的名字相关联的原型命令。同样,通过设计,抽象的Command
接口通过clone()
虚拟成员函数支持原型模式。让我们看看这两个先前的设计决策是如何有效地启用插件的。
基于前面提到的 C++ 插件规则,我们实现命令发现的唯一方法是将其封装成一个所有插件都必须遵守的纯虚拟接口。理想情况下,我们的虚函数将返回一个由string
s 键入的unique_ptr<CommandPtr>
值的关联容器。然而,我们的 C++ 插件规则也规定我们不能使用标准容器,因此排除了string
、map
、unordered_map
和unique_ptr
。与其(糟糕地)重新实现这些容器的定制版本,我们将只使用一个通常被避免的、低级的可用工具,指针数组。
前面的设计是通过创建一个所有插件都必须符合的Plugin
类来实现的。这个抽象类的目的是标准化插件命令发现。类声明由以下内容给出:
// module code will be omitted from additional plugin listings
export class pdCalc.plugins;
export class Plugin
{
public:
Plugin();
virtual ~Plugin();
struct PluginDescriptor
{
int nCommands;
char** commandNames;
Command** commands;
};
virtual const PluginDescriptor& getPluginDescriptor() const = 0;
};
我们现在有了一个抽象插件接口,当它被专门化时,需要一个派生类来返回一个描述符,该描述符提供了可用命令的数量、这些命令的名称以及命令本身的原型。显然,命令名的顺序必须与命令原型的顺序相匹配。另一种设计是将CommandDescriptor
定义如下:
struct CommandDescriptor
{
char* commandName;
Command* command;
};
并让PluginDescriptor
保存一个CommandDescriptor
的数组,而不是单独的名称和命令数组。这种选择是典型的数组结构或数组结构难题。在这种情况下,两种选择都是有效的,我选择前者作为 pdCalc 的实现,这有点武断。
不幸的是,对于原始指针和原始数组,谁拥有命令名和命令原型的内存会产生歧义。我们无法使用标准容器,这迫使我们进入了一个不幸的设计:通过注释签约。因为我们的规则规定在一个插件中分配的内存必须由同一个插件释放,所以最好的策略是规定插件负责释放PluginDescriptor
及其组成部分。如前所述,内存契约是通过注释“执行”的。
太好了,我们的问题解决了。我们创建一个插件;姑且称之为MyPlugin
,继承自Plugin
。我们将在 7.3 节看到如何分配和释放插件。在MyPlugin
内部,我们像往常一样通过继承Command
来创建新的命令。因为插件知道它自己的命令名,不像主程序,插件可以用new
操作符分配它的命令原型。然后,为了注册所有插件的命令,我们简单地分配一个带有命令名和命令原型的插件描述符,通过覆盖getPluginDescriptor()
函数返回描述符,并让 pdCalc 注册命令。由于Command
必须每个都实现一个clone()
函数,pdCalc 可以通过这个虚拟函数复制插件命令原型,并向CommandFactory
注册它们。很简单,用于注册的字符串名称可以从commandNames
数组中创建。对于已经分配的Plugin* p
,pdCalc 中的以下代码可以实现注册:
const auto& d = p->getPluginDesciptor();
for(int i = 0; i < d.nCommands; ++i)
CommandFactory::Instance().registerCommand( d.commandNames[i],
MakeCommandPtr(d.commands[i]->clone()) );
在这一点上,你可能会意识到我们的插件所面临的困境。命令在插件中分配,通过插件的clone()
函数分配,在CommandRegistry
注册时复制到主程序中,然后当CommandRegistry
的析构函数执行时,最终被主程序删除。更糟糕的是,每次执行命令时,CommandRegistry
都会克隆它的原型,通过Command
的clone()
函数触发插件中的new
语句。这个已执行命令的生命周期由CommandManager
通过其撤销和重做栈来管理。具体来说,当一个命令从其中一个栈中被清除时,当保存该命令的unique_ptr
被销毁时,在主程序中调用delete
。至少,它是这样工作的,不需要任何调整。正如第四章提到的,CommandPtr
不仅仅是unique_ptr<Command>
的简单别名。现在让我们最后描述一下CommandPtr
别名和MakeCommandPtr()
函数背后的机制,它们允许正确的插件命令内存管理。
基本上,我们首先需要一个函数在适当的编译单元中调用delete
。这个问题最简单的解决方案是在Command
类中添加一个deallocate()
虚函数。这个函数的职责是当Command
被销毁时,在正确的编译单元中调用delete
。对于所有核心命令,正确的行为是简单地delete
主程序中的类。因此,我们没有使deallocate()
函数成为纯虚拟的,我们给它以下默认实现:
void Command::deallocate()
{
delete this;
}
对于插件命令,deallocate()
的覆盖具有相同的定义,只有定义出现在插件的编译代码中(比如,在特定插件中命令使用的基类中)。因此,当在主应用程序的Command
指针上调用deallocate()
时,虚拟函数调度确保从正确的编译单元调用delete
。现在,我们只需要一个机制来确保当Command
s 被回收时,我们调用deallocate()
而不是直接调用delete
。幸运的是,似乎标准委员会在设计unique_ptr
时就完美地预见到了我们的需求。让我们回到CommandPtr
别名,看看如何使用unique_ptr
来解决我们的问题。
定义一个CommandPtr
别名和实现一个能够调用deallocate()
而不是delete
的MakeCommandPtr()
函数只需要非常少的几行代码。这段代码利用了unique_ptr
的 deleter 对象(见侧栏),当调用unique_ptr
的析构函数时,它允许调用一个定制例程来回收unique_ptr
持有的资源。让我们看看代码:
inline void CommandDeleter(Command* p)
{
if(p) p->deallocate();
return;
}
using CommandPtr = unique_ptr<Command, decltype(&CommandDeleter)>;
inline auto MakeCommandPtr(Command* p)
{
return CommandPtr{p, &CommandDeleter};
}
对前面的密集代码的简要解释是有保证的。一个CommandPtr
只是一个unique_ptr
的别名,它包含一个Command
指针,通过在析构时调用CommandDeleter()
函数来回收这个指针。由unique_ptr
调用的CommandDeleter()
函数是一个简单的内联函数,它调用先前定义的虚拟deallocate()
函数。为了减轻创建CommandPtr
的语法负担,我们引入了一个内联的MakeCommandPtr()
助手函数,它从一个Command
指针构造一个CommandPtr
。就这样。现在,就像以前一样,unique_ptr
s 自动为Command
s 管理内存。但是,unique_ptr
的析构函数调用CommandDeleter
函数,该函数调用deallocate()
,该函数在正确的编译单元中对底层Command
发出delete
,而不是直接调用底层Command
上的delete
。
如果您查看MakeCommandPtr()
的源代码,除了之前看到的采用Command
指针参数的函数版本之外,您会看到一个非常不同的重载,它使用可变模板和完美转发。由于在存储过程的构造中MakeCommandPtr()
的不同语义用法,这个重载函数一定存在。我们将在第八章中重温这两种函数形式背后的推理。如果悬念太多,可以直接跳到第 8.1.2 节。
Modern C++ Design Note: UNIQUE_PTR Destruction Semantics
unique_ptr<T,D>
类模板是一个智能指针,它对资源的唯一所有权进行建模。最常见的用法是只指定第一个模板参数T
,它声明了所拥有的指针的类型。第二个参数D
指定了一个定制的删除可调用对象,该对象在unique_ptr
的销毁过程中被调用。让我们来看看unique_ptr
的析构函数的概念模型:
template<typename T, typename D = default_delete<T>>
class unique_ptr
{
T* p_;
D d_;
public:
~unique_ptr()
{
d_(p_);
}
};
unique_ptr
的析构函数没有直接调用delete
,而是使用函数调用语义将所拥有的指针传递给删除器。从概念上来说,default_delete
实现如下:
template<typename T>
struct default_delete
{
void operator()(T* p)
{
delete p;
}
};
也就是说,default_delete
仅仅是由unique_ptr
包含的底层指针。然而,通过在构造期间指定一个定制的删除器可调用对象(D
模板参数),可以使用unique_ptr
来释放需要定制的解除分配语义的资源。举个简单的例子,unique_ptr
的删除语义允许我们创建一个简单的 RAII(资源获取是初始化)容器类,MyObj
,由malloc()
分配:
MyObj* m = static_cast<MyObj*>( malloc(sizeof(MyObj) ) );
auto p = unique_ptr<MyObj, decltype(&free)>{m, &free};
当然,我们对 pdCalc 的设计展示了自定义删除语义unique_ptr
有用性的另一个实例。应该注意的是,shared_ptr
也以类似的方式接受自定义删除器。
7.2.2 添加新 GUI 按钮的界面
从概念上讲,动态添加按钮与动态添加命令没有太大区别。主应用程序不知道需要从插件中导入什么按钮,所以Plugin
接口必须提供一个提供按钮描述符的虚函数。然而,与命令不同,插件实际上不需要分配按钮本身。回想一下第六章中的 GUI CommandButton
小部件只需要文本来构造。特别是,它需要按钮的显示文本(可选地,转换状态文本)和与clicked()
信号一起发出的命令文本。因此,即使对于插件命令,相应的 GUI 按钮本身也完全驻留在主应用程序中;插件必须只提供文本。这导致了Plugin
类中的如下简单接口:
class Plugin
{
public:
struct PluginButtonDescriptor
{
int nButtons;
char** dispPrimaryCmd; // primary command label
char** primaryCmd; // primary command
char** dispShftCmd; // shifted command label
char** shftCmd; // shifted command
};
virtual const PluginButtonDescriptor* getPluginButtonDescriptor() const = 0;
};
同样,由于插件必须遵循的规则,接口必须由低级的字符数组组成,而不是高级的 STL 结构。同样,我们也可以提供一个使用结构数组而不是数组的结构的接口。
getPluginButtonDescriptor()
函数相对于getPluginDescriptor()
的一个有趣的方面是决定返回一个指针而不是一个引用。这种选择背后的基本原理是,插件作者可能希望编写一个插件,该插件导出没有相应 GUI 按钮的命令(即,仅 CLI 命令)。当然,相反的情况是荒谬的。也就是说,我无法想象为什么有人会编写一个插件,为不存在的命令输出按钮。这种实用性体现在两个描述符函数的返回类型中。由于这两个函数都是纯虚拟的,Plugin
专门化必须实现它们。因为getPluginDescriptor()
返回一个引用,所以它必须导出一个非空描述符。然而,通过返回一个指向描述符的指针,getPluginButtonDescriptor()
被允许返回一个nullptr
,表明插件没有导出任何按钮。有人可能会争辩说,getPluginButtonDescriptor()
函数不应该是纯虚拟的,而应该提供一个返回nullptr
的默认实现。这个决定在技术上是可行的。然而,通过坚持插件作者手动实现getPluginButtonDescriptor()
,界面强制明确做出决定。
7.2.3 插件分配和释放
我们最初的问题是主程序不知道插件命令的类名,因此不能通过调用new
来分配它们。我们通过创建一个抽象的Plugin
接口来解决这个问题,该接口负责导出命令原型、命令名和 GUI 创建按钮的足够信息。当然,要实现这个接口,插件必须从Plugin
类派生,从而创建一个专门化,其名称主应用程序不能提前知道。表面上,我们没有取得任何进展,又回到了原来的问题。
我们的新问题虽然可能与原来的问题相似,但实际上要容易解决得多。这个问题是通过在每个插件中创建一个单独的extern "C"
分配/解除分配函数对来解决的,这个函数对具有预先指定的名字,通过基类指针来分配/解除分配Plugin
专门化类。为了满足这些需求,我们向插件接口添加了以下两个函数:
extern "C" void* AllocPlugin();
extern "C" void DeallocPlugin(void*);
显然,AllocPlugin()
函数分配了Plugin
特殊化并将其返回给主应用程序,而一旦主应用程序使用完插件,DeallocPlugin()
函数就会解除插件的分配。奇怪的是,AllocPlugin()
和DeallocPlugin()
函数使用void
指针而不是Plugin
指针。这个接口对于保持 C 链接是必要的,因为extern "C"
接口必须符合 C 类型。保持 C 连接的一个不幸后果是必须进行强制转换。主应用程序在使用void*
之前必须将它强制转换为Plugin*
,共享库在调用delete
之前必须将void*
强制转换回Plugin*
。然而,请注意,我们不需要具体的Plugin
的类名。因此,AllocPlugin()/DeallocPlugin()
函数对解决了我们的问题。
插件命令界面
从技术上讲,不需要特殊的插件命令接口。然而,提供这样的接口有助于编写遵循 C++ 插件规则的插件命令。具体来说,通过创建一个PluginCommand
接口,我们向插件开发者保证了两个关键特性。首先,我们提供了一个接口,保证插件命令不会从具有任何状态的命令类继承(以避免对齐问题)。从结构上看,这一特性是显而易见的。其次,我们修改了checkPreconditionsImpl()
函数来创建一个跨越插件边界的无异常接口。考虑到这一点,我们给出了PluginCommand
界面:
class PluginCommand : public Command
{
public:
virtual ~PluginCommand();
private:
virtual const char* checkPluginPreconditions() const noexcept = 0;
virtual PluginCommand* clonePluginImpl() const noexcept = 0;
void checkPreconditionsImpl() const override final;
PluginCommand* cloneImpl() const override final;
};
虽然在第四章中只简单提到过,但是除了checkPreconditionsImpl()
和cloneImpl()
之外,Command
类中所有的纯虚函数都被标记为noexcept
(参见关键字noexcept
的侧栏)。因此,为了确保插件命令不会引发异常,我们只需在层次结构的PluginCommand
级别实现checkPreconditionsImpl()
和cloneImpl()
函数,并为其派生类创建新的、无异常的纯虚函数来实现。checkPreconditionsImpl()
和cloneImpl()
在PluginCommand
类中都被标记为final
,以防止专门化无意中覆盖这些函数中的任何一个。checkPreconditionsImpl()
的实现可以简单地写成如下:
void PluginCommand::checkPreconditionsImpl() const
{
if( const char* p = checkPluginPreconditions() )
throw Exception(p);
return;
}
注意,上述实现背后的关键思想是,PluginCommand
类的实现驻留在主应用程序的编译单元中,而该类的任何专门化驻留在插件的编译单元中。因此,通过虚拟调度,对checkPreconditionsImpl()
的调用在主应用程序的编译单元中执行,这个函数又调用驻留在插件的编译单元中的无异常的checkPluginPreconditions()
函数。如果出现错误,checkPreconditionsImpl()
函数通过指针返回值接收错误,随后从主应用程序的编译单元而不是插件的编译单元产生异常。如果没有前提条件失败,checkPluginPreconditions()
返回一个nullptr
,不抛出异常。
在Command.cpp
中可以找到类似的cloneImpl()
的简单实现。继承自PluginCommand
而不是Command
、UnaryCommand
或BinaryCommand
的插件命令更有可能避免违反任何 C++ 插件规则,因此不太可能产生难以诊断的特定于插件的运行时错误。
Modern C++ Design Note: Noexcept
C++98 标准允许使用异常规范。例如,下面的规范表明函数foo()
不抛出任何异常(规范throw
为空):
void foo() throw();
不幸的是,C++98 异常规范存在许多问题。虽然它们在指定函数可能抛出的异常方面是一种高尚的尝试,但它们通常不会像预期的那样运行。例如,编译器在编译时从不保证异常规范,而是通过运行时检查来强制执行该约束。更糟糕的是,声明不抛出异常规范可能会影响代码性能。出于这些原因以及更多原因,许多编码标准被编写成声明应该简单地避免异常规范(例如,参见[34]中的标准 75)。
虽然指定一个函数可以抛出哪些规范被证明不是非常有用,但是指定一个函数不能抛出任何异常可能是一个重要的接口考虑事项。幸运的是,C++11 标准通过引入noexcept
关键字弥补了异常规范的混乱。关于noexcept
说明符用法的深入讨论,参见[24]中的第 14 项。对于我们的讨论,我们将集中在关键字在设计中的有用性。
撇开性能优化不谈,在函数规范中选择使用noexcept
很大程度上是一个偏好问题。对于大多数函数来说,没有异常规范是正常的。即使函数代码本身不发出异常,也很难静态地确保函数中的嵌套函数调用不发出任何异常。因此,noexcept
是在运行时强制执行的,而不是在编译时保证。因此,我个人的建议是将noexcept
说明符的用法保留到那些需要对函数意图做出强有力声明的特殊情况中。pdCalc 的Command
层次结构说明了不抛出异常对于正确操作非常重要的几种情况。这一要求在接口中进行了编码,以通知开发人员抛出异常将导致运行时错误。
API 版本控制
毫无疑问,在一个长期应用程序的生命周期中,插件的规范可能会改变。这意味着在某个时间点编写的插件可能不再适用于更新的 API 版本。对于作为单个单元交付的应用程序,组成整体的组件(即多个共享库)由开发计划同步。对于一个完整的应用程序,版本控制用于向外部世界表达整个应用程序已经发生了变化。然而,因为插件被设计成独立于主应用程序的开发,所以将插件版本与应用程序版本同步可能是不可能的。此外,插件 API 可能会也可能不会随着每个应用程序版本而改变。因此,为了确保兼容性,我们必须将插件 API 的版本与主应用程序分开。虽然您可能不希望将来改变插件 API,但是如果您没有预先将查询插件支持的 API 版本的能力作为 API 本身的一部分,您将不得不引入一个突破性的改变来在以后添加这个特性。根据您的需求,这样的突破性改变可能是不可行的,并且您将永远无法添加 API 版本控制。因此,即使最初没有使用,在插件接口中添加一个函数来查询插件支持的 API 版本也应该被认为是一个隐式需求。很明显,API 版本不同于应用程序版本。
实际的 API 版本编号方案可以简单,也可以复杂,视情况而定。简单来说,可以是单个整数。在更复杂的方面,它可以是一个包含几个整数的结构,用于主版本、次版本等。对于 pdCalc,我选择了一个简单的结构,只使用了一个主版本号和一个次版本号。接口代码如下所示:
class Plugin
{
public:
struct ApiVersion
{
int major;
int minor;
};
virtual ApiVersion apiVersion() const = 0;
};
主应用程序在调用插件函数之前简单地调用apiVersion()
函数,以确保插件是兼容的。如果检测到不兼容,会显示一条错误消息,并且可以忽略或拒绝不兼容的插件。或者,主应用程序可以支持多个插件版本,并且ApiVersion
信息通知主应用程序插件支持什么功能。
7.2.6 提供栈
插件接口的一部分包括使插件及其命令可被 pdCalc 发现。pdCalc 插件接口的另一部分包括使 pdCalc 功能的必要部分对插件可用。具体来说,新命令的实现需要访问 pdCalc 的栈。
正如我们在开发核心命令时所看到的,命令只需要对栈进行非常基本的访问。具体来说,他们需要能够将元素推到栈上,从栈中弹出元素,并可能检查栈中的元素(以实现前提条件)。我们让这个功能对核心命令可用的策略是将Stack
类实现为一个单独的类,带有一个公共接口,包括 push、pop 和 inspection 成员函数。然而,这种设计无法扩展到插件命令,因为它违反了 C++ 插件的两条规则。也就是说,我们当前的接口不符合 C 链接(栈提供了一个 C++ 类接口),当前的检查函数通过 STL vector
返回栈元素。
这个问题的解决方法很简单。我们只需在栈中添加一个新的接口(最好是在一个特别指定的头文件中),该接口由一组全局(在pdCalc
名称空间之外)extern "C"
函数组成,这些函数在 C 链接和 C++ 类链接(又是适配器模式)之间进行转换。回想一下,由于Stack
类是作为单例实现的,插件和全局帮助函数都不需要拥有Stack
引用或指针。助手函数通过Instance()
函数直接访问Stack
。我选择在单独的StackPluginInterface.m.cpp
模块接口文件中实现以下五个功能:
export module pdCalc.stackinterface;
extern "C" void StackPush(double d, bool suppressChangeEvent);
extern "C" double StackPop(bool suppressChangeEvent);
extern "C" size_t StackSize();
extern "C" double StackFirstElement();
extern "C" double StackSecondElement();
为了简单起见,由于我的示例插件不需要比顶部两个元素更深入地访问栈,所以我只创建了两个检查函数,StackFirstElement()
和StackSecondElement()
,用于获取栈的顶部两个元素。如果需要,可以实现将栈元素返回到任意深度的函数。为了维护extern "C"
链接,这样一个函数的实现者需要记住使用一个double
的原始数组,而不是 STL vector
。
前面五个函数的完整、简单的实现出现在StackPluginInterface.cpp
文件中。例如,StackSize()
函数的实现如下所示:
size_t StackSize()
{
return pdCalc::Stack::Instance().size();
}
7.3 问题 2:加载插件
如前所述,插件是特定于平台的,插件的加载本质上需要特定于平台的代码。在本节中,我们将考虑两个主题。首先,我们将讨论加载库和它们各自的符号所必需的特定于平台的代码。这里,我们将关注两个平台接口:POSIX (Linux、UNIX、Mac OS X)和 win32 (MS Windows)。其次,我们将探索一种设计策略,以减轻由于使用特定于平台的代码而导致的源代码混乱。
7.3.1 特定平台插件加载
为了使用插件,我们只需要三个特定于平台的函数:一个打开共享库的函数,一个关闭共享库的函数,一个从打开的共享库中提取符号的函数。表 7-1 按平台列出了这些函数及其相关的头文件。让我们看看这些函数是如何使用的。
表 7-1
不同平台的插件功能
| |可移植性操作系统接口
|
win32
|
| --- | --- | --- |
| 页眉 | dl fcn . h .- | windows.h |
| 加载库 | 运行中() | LoadLibrary() |
| 关闭库 | dlclose() | 免费库() |
| 获取库符号 | dlsym() | GetProcAddress() |
7.3.2 加载、使用和关闭共享库
使用插件的第一步是要求运行时系统打开库,并使其可导出的符号对当前的工作程序可用。每个平台上的 open 命令都需要打开共享库的名称(POSIX 还需要一个标志来指定所需的符号绑定,可以是惰性的,也可以是立即的),它返回一个不透明的库句柄,用于在后续的函数调用中引用该库。在 POSIX 系统上,句柄类型是一个void*
,而在 win32 系统上,句柄类型是一个HINSTANCE
(经过一些分解后,它是一个void*
的 typedef)。例如,下面的代码在 POSIX 系统上打开一个插件库libPlugin.so
:
void* handle = dlopen("libPlugin.so", RTLD_LAZY);
其中,RTLD_LAZY
选项只是告诉运行时系统执行惰性绑定,在执行引用符号的代码时解析符号。替代选项是RTLD_NOW
,在dlopen()
返回之前解析库中所有未定义的符号。如果打开失败,则返回空指针。一个简单的错误处理方案跳过从空插件加载任何功能,警告用户打开插件失败。
除了不同的函数名之外,打开插件的主要平台特定的差异是不同平台采用的规范命名约定。例如,在 Linux 上,共享库以lib
开头,并有一个.so
文件扩展名。在 Windows 上,共享库(通常称为动态链接库,简称 dll)没有特定的前缀和一个.dll
文件扩展名。在 Mac OS X 上,共享库通常以lib
开头,扩展名为.dylib
。本质上,这种命名约定只在两个地方有关系。首先,构建系统应该为各自的平台创建具有适当名称的插件。其次,打开插件的调用应该使用正确的格式指定名称。由于插件名称是在运行时指定的,我们需要确保插件名称是由提供插件的用户正确指定的。
一旦插件被打开,我们需要从共享库中导出符号来调用插件中包含的函数。这个导出是通过调用dlopen()
或LoadLibrary()
(取决于平台)来完成的,这两者都使用插件函数的字符串名称将插件函数绑定到函数指针。然后,通过这个获得的函数指针,在主应用程序中间接调用绑定的插件函数。
为了绑定到共享库中的一个符号,我们需要有一个插件句柄(打开一个插件的返回值),要知道我们要调用的插件中的函数名,要知道我们要调用的函数的签名。对于 pdCalc,我们需要调用的第一个插件函数是AllocPlugin()
来分配嵌入的Plugin
类(见 7.2.3 节)。因为这个函数被声明为插件接口的一部分,所以我们知道它的名字和签名。举个例子,在 Windows 上,对于一个已经加载的由HINSTANCE handle
指向的插件,我们用下面的代码将插件的AllocPlugin()
函数绑定到一个函数指针:
// function pointer of AllocPlugin's type:
extern "C" { typedef void* (*PluginAllocator)(void); }
// bind the symbol from the plugin
auto alloc = GetProcAddress(handle, "AllocPlugin");
// cast the symbol from void* (return of GetProcAddress)
// to the function pointer type of AllocPlugin
PluginAllocator allocator{ reinterpret_cast<PluginAllocator>(alloc) };
随后,插件的Plugin
特殊化由以下内容分配:
// only dereference if the function was bound properly
if(allocator)
{
// dereference the allocator, call the function,
// cast the void* return to a Plugin*
auto p = static_cast<Plugin*>((*allocator)());
}
具体的Plugin
现在可以通过抽象的Plugin
接口使用(例如,加载插件命令,查询支持的插件 API)。
在插件解除分配时,需要一个类似的代码序列来绑定和执行插件的DeallocPlugin()
函数。感兴趣的读者可以参考 GitHub 资源库中特定于平台的代码来了解详细信息。记住,在释放插件之前,由于插件分配的命令驻留在主应用程序的内存中(但是必须在插件中回收),所以在释放所有命令之前,插件不能关闭。驻留在主应用程序内存空间的插件命令的例子有CommandFactory
中的命令原型和CommandManager
中撤销/重做栈上的命令。
因为一个插件是一个获得的资源,我们应该在用完它的时候释放它。这个动作在 POSIX 平台上通过调用dlclose()
来执行,在 win32 平台上通过调用FreeLibrary()
来执行。例如,POSIX 系统的以下代码关闭了用dlopen()
打开的共享库(handle
):
// only try to close a non-null library
if(handle) dlclose(handle);
既然我们已经讨论了特定于平台的打开、使用和关闭插件的机制,我们将注意力转向一种设计策略,这种策略减轻了使用多平台源代码所固有的复杂性。
7.3.3 多平台代码的设计
对于任何软件项目来说,跨平台的可移植性都是一个值得称赞的目标。然而,在维护可读代码库的同时实现这一目标需要大量的预先考虑。在这一节中,我们将研究一些在保持可读性的同时实现平台可移植性的设计技术。
显而易见的解决方案:库
对于可移植性问题,显而易见的(也是首选的)解决方案是使用一个为您抽象平台依赖性的库。在任何开发场景中使用高质量的库总是可以节省您设计、实现、测试和维护项目所需功能的精力。使用库进行跨平台开发还有一个额外的好处,就是将特定于平台的代码隐藏在独立于平台的 API 后面。当然,这样的 API 允许您维护一个可以跨多个平台无缝工作的单一代码库,而不用在源代码中添加预处理指令。尽管我们在第六章中没有明确讨论这些优点,但是 Qt 的工具包抽象为构建 GUI 提供了一个平台无关的 API,否则这将是一个平台相关的任务。在 pdCalc 中,我们使用 Qt 构建了一个可以在 Windows 和 Linux 上编译和执行的 GUI(可能还有 OS X,尽管我还没有验证这个事实),而不需要在平台之间修改任何一行源代码。
唉,显而易见的解决方案并不总是可用的。不将库合并到项目中有许多原因。首先,许多库不是免费的,一个库的成本可能高得令人望而却步,尤其是如果许可证除了开发费之外还有使用费的话。第二,库的许可可能与项目的许可不兼容。例如,也许您正在构建一个封闭的源代码,但是唯一可用的库有一个不兼容的开源许可证(反之亦然)。第三,库经常没有源代码。缺少源代码使得扩展库的功能变得不可能。第四,您可能需要对库的支持,但是供应商可能不提供任何支持。第五,库可能会附带与您的升级周期不兼容的升级周期。第六,一个库可能与你的工具链不兼容。最后,对于您正在寻找的功能,库可能根本不存在。因此,虽然使用库通常是实现可移植性的第一选择,但是有足够多的使用库的反例值得讨论如何在没有库的情况下实现可移植性。
原始预处理器指令
使用原始预处理器指令无疑是尝试实现跨平台代码的第一种方法。几乎所有编写过可移植代码的人都是这样开始的。简单地说,平台相关代码出现的任何地方,特定于平台的部分都被预处理器#ifdef
指令所包围。让我们以 Linux 和 Windows 中共享库的运行时加载为例:
#ifdef POSIX
void* handle = dlopen("libPlugin.so", RTLD_LAZY);
#elif WIN32
HINSTANCE handle = LoadLibrary("Plugin.dll");
#endif
不要忘记头文件周围的预处理器指令:
#ifdef POSIX
#include <dlfcn.h>
#elif WIN32
#include <windows.h>
#endif
对于少数平台或极少数实例,使用原始预处理器指令是可以接受的。然而,这种技术扩展性很差。一旦平台数量或需要平台相关代码的代码位置数量增加,使用原始预处理器指令很快就会变得一团糟。代码变得难以阅读,并且在添加新平台时找到所有依赖于平台的位置变成了一场噩梦。即使在一个中等规模的项目中,在代码中散布#ifdef
很快就变得站不住脚了。
(稍微)更聪明的预处理器指令
在平台 API 名称不同但函数调用参数相同的情况下(比您想象的更常见,因为相似的功能需要相似的定制,这不足为奇),我们可以更聪明地使用预处理器。我们可以创建平台相关的宏名,并在一个集中的位置定义它们,而不是将预处理指令放在每个平台相关的函数调用和类型声明的位置。这个想法用一个例子更好解释。让我们来看看关闭 Linux 和 Windows 上的共享库:
// some common header defining all platform dependent analogous symbols
#ifdef POSIX
#define HANDLE void*
#define CLOSE_LIBRARY dlclose
#elif WIN32
#define CLOSE_LIBRARY FreeLibrary
#define HANDLE HINSTANCE
#endif
// in the code, for some shared library HANDLE handle
CLOSE_LIBRARY(handle);
这种技术比在每次函数调用时都洒上#ifdef
的幼稚方法要干净得多。然而,它受到严格的限制,只能处理具有相同参数的函数调用。显然,我们在调用点仍然需要一个#ifdef
来打开一个共享库,因为 POSIX 调用需要两个参数,而 Windows 调用只需要一个。当然,有了 C++ 的抽象能力,我们可以做得更好。
构建系统解决方案
一个有趣的想法是将特定于平台的代码分成特定于平台的源文件,然后使用构建系统根据平台选择正确的文件。让我们考虑一个例子。将所有特定于 Unix 的代码放在名为UnixImpl.cpp
的文件中,将所有特定于 Windows 的代码放在名为WindowsImpl.cpp
的文件中。在每个相应的平台上,编写构建脚本,只编译适当的特定于平台的文件。使用这种技术,不需要平台预处理器指令,因为任何给定的源文件只包含一个平台的源代码。
前面的方案有两个明显的缺点。首先,只有在所有平台上的所有特定于平台的文件之间保持与您自己的源代码相同的接口(例如,函数名、类名、参数列表)时,该方法才有效。这个壮举说起来容易做起来难,特别是如果你有独立的团队在每个平台上工作和测试。使问题复杂化的是,因为编译器在任何给定时间仅看到单个平台的代码,所以没有语言机制(例如,类型系统)来实施这些跨平台接口约束。其次,实现跨平台兼容性的机制对于任何在单一平台上检查源代码的开发人员来说都是完全不透明的。在任何一个平台上,许多依赖于平台的源文件中只有一个有效地存在,并且这个源文件不提供其他文件存在的任何提示。当然,后一个问题加剧了前一个问题,因为缺乏跨平台的源代码透明性,加上缺乏对该技术的语言支持,使得维护接口一致性几乎是不可能的。由于这些原因,一个纯粹的构建系统解决方案是难以处理的。
注意到这种技术的缺点,我们必须小心不要把婴儿和洗澡水一起倒掉,因为我们最终解决方案的核心在于预处理程序和构建系统解决方案并列的语言支持机制。这种设计技术将在下一节中讨论。
平台工厂功能
将预处理器宏分散到代码中需要特定于平台的功能的地方,类似于使用整数标志和switch
语句来执行特定于类型的代码。并非巧合的是,这两个问题有相同的解决方案,即构建一个抽象的类层次结构,并通过多态性执行特定的功能。
我们将分两步构建设计通用跨平台架构的解决方案。首先,我们将设计一个处理动态加载的平台层次结构。其次,我们将把这个特定的解决方案扩展成一个框架,将平台依赖性抽象成一个独立于平台的接口。在这两个步骤中,我们将采用一种混合解决方案,通过最少使用特定于平台的预处理器指令,以类型安全的方式利用构建系统。在这个过程中,我们会遇到一个重要的设计模式,抽象工厂。让我们从检查插件的平台无关的动态加载开始。
为了解决我们的具体问题,我们首先为一个DynamicLoader
基类定义一个独立于平台的抽象接口。我们的DynamicLoader
只需要做两件事:分配和解除分配插件。因此,基类被简单地定义如下:
class DynamicLoader
{
public:
virtual ~DynamicLoader();
virtual Plugin* allocatePlugin(const string& pluginName) = 0;
virtual void deallocatePlugin(Plugin*) = 0;
};
前面的基类的设计意图是层次结构将由平台特殊化。
注意,接口本身是独立于平台的。平台相关的分配和取消分配是一个实现细节,由该接口的平台特定的派生类通过虚函数来处理。此外,因为每个特定于平台的实现都完全包含在派生类中,所以通过将每个派生类放在一个单独的文件中,我们可以使用构建系统来有选择地只编译与每个平台相关的文件,从而避免在层次结构中的任何地方需要平台预处理器指令。更好的是,一旦分配了一个DynamicLoader
,接口就抽象出插件加载的特定于平台的细节,插件的消费者不需要关心插件加载的细节。装就行了。对于DynamicLoader
的派生类的实现者,编译器可以使用类型信息来加强跨平台的接口一致性,因为每个派生类必须符合抽象基类指定的接口,这对于所有平台都是通用的。该设计在图 7-1 中进行了图示总结。pdCalc 包含的源代码为 POSIX 兼容系统和 Windows 实现了特定于平台的加载器。
前面的设计将特定于平台的细节隐藏在抽象接口之后,减轻了插件消费者理解插件是如何加载的需要。当然,这是假设插件消费者实例化了正确的特定于平台的派生类,这是不能由DynamicLoader
层次结构自动处理的。这里,我们可以使用熟悉的设计模式,即工厂函数,来解决实例化正确的派生类的问题。回想一下,工厂函数是一种将类型创建与实例化的逻辑点分开的模式。
图 7-1
用于独立于平台的插件分配和释放的动态加载器层次结构
在第四章中,我们定义了一个工厂函数,它基于一个string
参数返回一个特定类型的Command
。在这里,工厂函数更加简单。由于我们的层次结构是由平台特殊化的,而不是传入一个string
来选择适当的派生类,我们只是通过使用预处理器指令来进行选择:
unique_ptr<DynamicLoader> dynamicLoaderFactory()
{
#ifdef POSIX
return make_unique<PosixDynamicLoader>();
#elif WIN32
return make_unique<WindowsDynamicLoader>();
#else
return nullptr;
#endif
}
Listing 7-1A dynamic loader factory function
通过将dynamicLoaderFactory()
函数编译成它自己的源文件,我们可以通过在一个源文件中隔离一组预处理器指令来实现独立于平台的插件创建。然后调用工厂函数在需要插件分配或释放的站点返回正确类型的DynamicLoader
。通过让工厂返回一个unique_ptr
,我们不必担心内存泄漏。下面的代码片段说明了DynamicLoader
的独立于平台的用法:
// Question: What platform?
auto loader = dynamicLoaderFactory();
// Answer: Who cares?
auto plugin = (loader ? loader->allocatePlugin(pluginName) : nullptr);
出于 pdCalc 的目的,我们可以停止使用DynamicLoader
层次结构和简单的工厂函数。我们只需要抽象一个平台相关的特性(插件的分配和释放),前面的代码就足够了。然而,我们已经走了这么远,值得再走一步来看看平台独立性的通用实现,它适用于需要许多不同的平台相关特性的情况,即使它不是我们的案例研究特别需要的。
通用平台无关代码的抽象工厂
作为软件开发人员,我们经常面临平台依赖性带来的设计挑战。下面是一个 C++ 开发人员的常见平台特定编程任务的不完整列表:插件加载、进程间通信、文件系统导航(在 C++17 中标准化)、图形、线程(在 C++11 中标准化)、持久设置、二进制序列化、sizeof()
内置数据类型、定时器(在 C++11 中标准化)和网络通信。这个列表中的大部分功能(如果不是全部的话)都可以通过 boost 或 Qt 之类的库中独立于平台的 API 获得。对我个人来说,引起最大麻烦的平台特定特性是不起眼的目录分隔符(POSIX 系统上的'/'
和 Windows 系统上的'\'
)。
假设我们的计算器需要读取、写入和保存持久自定义设置的能力(参见第八章了解为什么这对计算器是必要的)。通常,Linux 系统将设置保存在文本文件中(例如,在 Ubuntu 上,用户设置保存在。在 Windows 系统上,持久设置保存在系统注册表中。实际上,最好的解决方案是使用已经实现了这个抽象的现有库(例如,Qt 的QSettings
类)。出于教学目的,我们将假设没有可用的外部库,我们将检查一个在现有动态加载器旁边添加持久设置(或任何数量的平台相关功能)的设计。我们的重点将放在抽象上,而不是每个平台上设置实现的细节。
简单的解决方案是搭载我们的动态加载器,简单地将必要的设置接口直接添加到DynamicLoader
类中。当然,我们需要将这个类重命名为更通用的名字,比如OsSpecificFunctionality
,并使用LinuxFuntionality
和WindowsFunctionality
这样的派生类。这种方法简单、快速,并且很快难以处理;它与凝聚力是对立的。对于任何相当大的代码,这种技术最终会导致不可控制的膨胀,因此,完全缺乏界面的可维护性。尽管项目有时间压力,但我建议总是避免这种快速解决方案,因为它只会增加您的技术债务,并在未来导致比现在使用适当的解决方案更长的延迟。
我们没有膨胀现有的DynamicLoader
类,而是从它的设计中获得灵感,创建了一个独立的、类似的设置层次,如图 7-2 所示。
同样,我们有在每个独特的平台上实例化特定于平台的派生类的问题。然而,我们没有添加一个额外的settingsLoaderFactory()
函数来镜像现有的dynamicLoaderFactory()
函数,而是寻求一个通用的解决方案,在为平台选择保留单个代码点的同时实现无限的功能扩展。正如所料,我们不是第一个遇到这个特殊问题的程序员,并且已经有了一个解决方案,抽象工厂模式。
图 7-2
独立于平台的持久设置的设置层次结构
根据 Gamma 等人[11]的说法,抽象工厂“提供了一个创建相关或依赖对象系列的接口,而无需指定它们的具体类。”本质上,该模式可以分两步构建:
-
为每个相关对象创建独立的层次结构(族)(例如,动态加载器层次结构和设置层次结构,通过它们的平台依赖性相关)。
-
创建一个专门针对依赖关系(例如平台)的层次结构,为每个系列提供工厂功能。
我发现如果没有具体的例子,前面的抽象很难理解;因此,让我们考虑一下我们在 pdCalc 中试图解决的问题。当我们浏览这个例子时,我们将参考图 7-3 中的(过于复杂的)类图。回想一下,这种抽象的总体目标是创建一个单一的源位置,它能够为创建任意数量的特定于平台的专门化提供一种独立于平台的机制。
正如我们已经看到的,依赖于平台的功能可以抽象成并行、独立的层次结构。这些层次结构使得依赖于平台的实现可以通过多态性通过独立于平台的基类接口来访问。对于 pdCalc,这种模式转化为提供平台不可知的Settings
和DynamicLoader
层次结构来分别抽象持久设置和动态加载。例如,我们可以通过抽象的DynamicLoader
接口多态地分配和释放一个插件,只要系统基于平台实例化正确的底层派生类(PosixDynamicLoader
或WindowsDynamicLoader
)。抽象工厂的这一部分由图 7-3 中的DynamicLoader
层次表示。
图 7-3
应用于 pdCalc 的抽象工厂模式
问题现在简化为基于当前平台实例化正确的派生类。我们不是提供单独的工厂函数来实例化DynamicLoader
和Settings
对象(一种分散的方法,要求每个工厂中有单独的平台#ifdef
,而是创建一个层次结构,提供一个抽象接口来提供创建DynamicLoader
和Settings
对象所必需的工厂函数。然后,这个抽象工厂层次结构(图 7-3 中的PlatformFactory
层次结构)在平台上专门化,这样我们就有了工厂层次结构的平台特定的派生类,它们创建了功能层次结构的平台特定的派生类。该方案将平台依赖性集中到一个工厂函数中,该工厂函数实例化正确的PlatformFactory
专门化。在 pdCalc 的实现中,我选择将PlatformFactory
设为单例,从而将PlatformFactory
的工厂函数“隐藏”在Instance()
函数中。
抽象工厂模式可能仍然没有太多意义,所以让我们来看一些示例代码,以自顶向下的方式查看抽象。最终,抽象工厂模式使我们能够在 pdCalc 中编写以下独立于平台的高级代码:
// PlatformFactory Instance returns either a PosixFactory or a
// WindowsFactory instance (based on the platform), which in turn
// creates the correct derived DynamicLoader
auto loader = PlatformFactory::Instance().createDynamicLoader();
// The correctly instantiated loader provides platform specific
// dynamic loading functionality polymorphically through a platform
// independent interface
auto plugin = loader->allocatePlugin(pName);
// ...
loader->deallocatePlugin(plugin);
// Same principle for settings...
auto settings = PlatformFactory::Instance().createSettings();
settings->readSettingsFromDisk();
// ...
settings->commitSettingsToDisk();
向下钻取,我们将检查的第一个函数是PlatformFactory
的Instance()
函数,它根据平台返回一个PosixFactory
或一个WindowsFactory
。
PlatformFactory& PlatformFactory::Instance()
{
#ifdef POSIX
static PosixFactory instance;
#elif WIN32
static WindowsFactory instance;
#endif
return instance;
}
前面的函数做了一些微妙但聪明的事情,这是一个值得了解的技巧。从客户端的角度来看,PlatformFactory
看起来像一个普通的单例类。一个调用Instance()
函数,返回一个PlatformFactory
引用。然后,客户端使用PlatformFactory
的公共接口,就像使用任何其他单例类一样。然而,因为Instance()
成员函数返回一个引用,我们可以自由地多态使用实例。因为PosixFactory
和WindowsFactory
都是从PlatformFactory
派生的,所以被实例化的instance
变量是与实现中的#ifdef
所定义的平台相匹配的专门化。我们巧妙地对类的用户隐藏了一个实现细节,抽象工厂模式的机制。除非客户注意到PlatformFactory
中的工厂函数是纯虚拟的,否则他们可能不会意识到他们正在使用面向对象的层次结构。当然,我们的目标并不是在一个邪恶的阴谋中向用户隐藏任何东西来掩盖实现。更确切地说,信息隐藏的这种使用被用来减少PlatformFactory
的客户端的认知负担。
我们接下来检查PosixFactory
和WindowsFactory
类中createDynamicLoader()
函数的简单实现(注意函数的协变返回类型):
unique_ptr<DynamicLoader> PosixFactory::createDynamicLoader()
{
return make_unique<PosixDynamicLoader>();
}
unique_ptr<DynamicLoader> WindowsFactory::createDynamicLoader()
{
return make_unique<WindowsDynamicLoader>();
}
之前,我们简单地用类层次结构替换了动态加载器工厂函数(见清单 7-1 ),用多态替换了平台#ifdef
。由于只有一部分功能依赖于平台,用抽象工厂替换工厂功能无疑是大材小用。然而,对于我们的例子,我们有独立的DynamicLoader
和Settings
家族,它们都依赖于相同的平台标准(原则上,我们可以有任意数量的这种层次),抽象工厂模式允许我们将平台依赖性集中在一个位置(这里,在PlatformFactory
的Instance()
函数中),而不是分散在多个独立的工厂函数中。从维护的角度来看,价值主张类似于偏好多态来切换语句。
拼图的最后一块是DynamicLoader
和Settings
层次结构的实现。幸运的是,这些实现与 7.3.3 节中概述的思想是相同的,我们不需要在这里重复它们的实现。使用抽象工厂模式确实不会给平台相关功能的实现增加任何固有的复杂性。该模式只是通过单个工厂层次结构而不是一系列工厂函数来增加这些类实例化的机制。
在 pdCalc 的存储库中的源代码中,不存在Settings
层次结构(或其关联的readSettingsFromDisk()
和PlatformFactory
中的commitSettingsToDisk()
函数)实现,因为 pdCalc 不需要持久设置抽象。Settings
层次结构仅仅是作为一个看似合理的例子制造出来的,用来具体展示抽象工厂模式的机制和相关性。也就是说,我确实选择在 pdCalc 的代码中单独包含一个完整的抽象平台工厂实现,只是为了说明抽象工厂模式的实际实现,即使更简单的单个工厂函数对于生产代码来说已经足够了,也是更好的选择。
7.4 问题 3:改装 pdCalc
我们现在转向最后一个插件问题,即改造已经开发的类和接口,以适应动态添加计算器功能。这个问题不是关于插件管理的。相反,我们在这里解决的问题是扩展 pdCalc 的模块接口以接受插件特性。本质上,第 7.2 节定义了如何发现插件中的命令和按钮,本节描述了如何将这些新发现的命令合并到 pdCalc 中。
注入命令
让我们首先创建一个接口来实现新发现的插件命令的注入。回想一下第四章,当应用程序启动时,核心命令被加载到CommandFactory
中。首先,主应用程序调用RegisterCoreCommands()
函数。其次,在这个函数中,为每个核心命令调用CommandFactory
类的registerCommand()
函数,用CommandFactory
注册命令的名称和命令原型。在 7.2 节中,我们开发了一个从插件导出命令名和命令原型的接口。显然,要注册这些插件命令,我们只需扩展命令调度器模块的接口,使其包含现有的registerCommand()
函数,这样负责加载插件的代码也可以注册它们的命令。因为已经导出了commandDispatcher
模块的CommandFactory
分区(为了导出RegisterCoreCommands()
函数),所以只需从CommandFactory
分区导出CommandFactory
类,就可以实现对命令分派器模块的所需接口更改。真的就这么简单。
事后看来,导出CommandFactory
类和RegisterCoreCommands()
函数的需求解释了为什么将RegisterCoreCommands()
放在了CommandFactory
分区中。最初,我在CoreCommands
分区中实现了RegisterCoreCommands()
,但是当我意识到CoreCommands
分区不需要从commandDispatcher
模块中导出时,我移动了它。
7.4.2 向 GUI 添加插件按钮
回想一下,在本节开始时,我们概述了在为插件改装 pdCalc 时需要解决的两个问题。我们刚刚解决的第一个问题是,如何在插件加载后将插件命令添加到CommandFactory
中。由于我们已经编写了必要的函数,只需要扩展模块定义的公共接口,所以解决方案变得非常简单。第二个问题涉及改装 pdCalc,以便能够在 GUI 中添加对应于插件命令的按钮。
根据我们的命令调度器的设计,一旦命令被注册,它就可以被任何引发一个commandEntered()
事件的用户界面执行,事件的参数是命令的名称。因此,对于 CLI,用户可以通过键入插件名称来执行插件命令。也就是说,插件命令一经注册,CLI 就可以立即访问它们。当然,让一个插件命令在 GUI 中可访问要稍微复杂一些,因为必须为每个发现的插件命令创建一个可以引发commandEntered()
事件的按钮。
在第 7.2 节中,我们定义了一个用于标记CommandButton
的接口。每个插件都提供了一个PluginButtonDescriptor
来定义主要命令标签、次要命令标签以及与这些标签相关联的底层命令。因此,为了添加一个与插件命令相对应的新 GUI 按钮,我们必须简单地扩展 GUI 的MainWindow
类的接口,以包括一个基于按钮标签添加按钮的功能:
class MainWindow : public QMainWindow, public UserInterface
{
public:
// Existing interface plus the following:
void addCommandButton(const string& dispPrimaryCmd,
const string& primaryCmd, const string& dispShftCmd,
const string& shftCmd);
};
当然,这个功能也需要基于一些合适的算法来布局按钮。我的简单算法只是将四个按钮从左到右排成一行。
与CommandRegistry
的registerCommand()
函数不同,addCommandButton()
不是MainWindow
类预先存在的公共函数。因此,我们必须添加并实现这个新功能。十有八九,GUI 的模块化实现在 GUI 模块的某个地方已经有了类似的功能,因为已经需要这个功能来创建核心命令的按钮。因此,addCommandButton()
函数的实现可能只是将这个调用从MainWindow
转发到适当的内部 GUI 类,而这个函数可能已经存在了。由于 Qt 目前的局限性,我们没有使用 C++ 模块来封装 GUI。将addCommandButton()
添加到MainWindow
类的公共部分足以扩展逻辑模块。
7.5 合并插件
到目前为止,我们已经讨论了 C++ 插件的指南、插件接口、插件命令内存管理、加载和卸载插件、接口后抽象平台相关代码的设计模式,以及改进 pdCalc 以支持插件命令和 GUI 注入。然而,我们还没有讨论任何发现插件的机制,实际上从磁盘加载和卸载插件,管理插件的生命周期,或者将插件功能注入 pdCalc。这些操作是由应用程序的PluginLoader
类和main()
函数执行的,现在将对这两者进行描述。
加载插件
加载插件是由一个PluginLoader
类完成的。PluginLoader
负责查找插件动态库文件,将插件加载到内存中,并按需为 pdCalc 提供具体的Plugin
专门化。PluginLoader
还负责在适当的时候释放插件资源。正如我们之前看到的,一个好的设计会通过 RAII 实现自动释放。
加载插件的第一步是确定应该加载哪些插件以及何时加载。事实上,只有两个可行的选择来回答这个问题。当程序启动时,插件由 pdCalc 自动加载(例如,配置文件中指定的文件或特定目录中的所有 dll),或者插件由用户直接请求按需加载。当然,这些选项并不相互排斥,并且可以设计一个包含这两个选项的PluginLoader
类,可能具有让用户指示将来应该自动加载哪些手动加载的插件的能力。插件如何加载没有对错之分。这个决定必须由计划的需求来解决。
为了简单起见,我选择实现一个插件加载器,在 pdCalc 启动时自动加载插件。PluginLoader
通过读取由多行文本组成的 ASCII 配置文件来找到这些插件,每一行都单独列出了插件的文件名。配置文件被任意命名为plugins.pdp
,这个文件必须位于当前的可执行路径中。在plugins.pdp
中列出的插件文件可以使用相对或绝对路径来指定。更复杂的插件加载器实现可能会将插件文件的位置存储在操作系统–
特定的配置位置(例如,Windows 注册表)中,并使用更好的文件格式,例如 XML 或 JSON。一个好的库,比如 Qt,可以帮助您解析文件,并使用独立于平台的抽象找到特定于系统的配置文件。
正如前面在 7.1.1 节中提到的,插件传统上在不同的平台上有不同的命名约定。对于 pdCalc,我选择要求用户在plugins.pdp
文件中指定每个插件的依赖于平台的文件名。另一种方法是要求用户为每个插件指定一个独立于平台的根名称,并让 pdCalc 在每个平台上重新构建依赖于平台的名称。如果我们选择了后一种方法,我们将能够通过用插件名称构建器层次结构扩展抽象平台工厂来干净地实现这个特性。
考虑到前面的插件加载器设计约束,PluginLoader
接口相当简单:
export module pdCalc.pluginManagement;
export class PluginLoader
{
public:
void loadPlugins(UserInterface& ui, const string& pluginFileName);
const vector<const Plugin*> getPlugins();
};
loadPlugins()
函数将配置文件的名称作为输入,将每个库加载到内存中,并分配每个库的Plugin
类的一个实例。UserInterface
参考仅用于错误报告。当main()
函数准备好注入插件的命令时,getPlugins()
函数被调用来返回加载的Plugin
的集合。当然,loadPlugins()
和getPlugins()
函数可以合并,但是我更喜欢这样的设计,它能让程序员保留对插件加载时间和插件使用的更好的控制。我的PluginLoader
实现使用了一些巧妙的技术,通过 RAII 来管理插件的自动释放。由于这里的实现与设计是正交的,感兴趣的读者可以参考PluginLoader.m.cpp
源文件了解详细信息。
注入功能
已经决定插件应该从配置文件中自动加载,插件加载最合理的位置是在main()
函数中的某个地方(或者由main()
调用的帮助函数)。本质上,这个loadPlugins()
函数只是把我们之前讨论过的所有部分放在一起:加载插件库,加载插件,从插件描述符中提取命令和按钮,并将这些命令和按钮注入到 pdCalc 中。当然,正确的实现也会对插件进行错误检查。例如,错误检查可能包括检查插件 API 版本,确保命令尚未注册,以及确保 GUI 按钮对应于命令工厂中的命令。下面的代码片段是加载插件的函数的框架。它的输入是一个用于报告错误的UserInterface
参考和一个PluginLoader
参考。
void setupPlugins(UserInterface& ui, PluginLoader& loader)
{
loader.loadPlugins(ui, "plugins.pdp");
auto plugins = loader.getPlugins();
for(auto p : plugins)
{
auto apiVersion = p->apiVersion();
// verify plugin API at correct level
// inject plugin commands into CommandFactory - recall
// the cloned command will auto release in the plugin
auto descriptor = p->getPluginDescriptor();
for( auto i : views::iota(0, descriptor.nCommands) )
{
registerCommand(ui, descriptor.commandNames[i],
MakeCommandPtr(descriptor.commands[i]->clone()) );
}
// if gui, setup buttons
auto mw = dynamic_cast<MainWindow*>(&ui);
if(mw)
{
auto buttonDescriptor = p->getPluginButtonDescriptor();
if(buttonDescriptor)
{
for( auto i : views::iota(0, buttonDescriptor->nButtons) )
{
auto b = *buttonDescriptor;
// check validity of button commands
mw->addCommandButton(b.dispPrimaryCmd[i], b.primaryCmd[i],
b.dispShftCmd[i], b.shftCmd[i]);
}
}
}
}
return;
}
在花了很长一章描述如何实现 C++ 插件之后,结局有点虎头蛇尾,因为大多数机制都是在更深的抽象层处理的。当然,这种“乏味”,正如我们在本书中了解到的,只有通过精心的设计才能实现。简单性总是比代码本身所表明的更难实现。在这个高层次的抽象中,如果有任何复杂的东西泄露出来,这肯定意味着一个低劣的设计。
7.6 具体插件
在解释如何将原生 C++ 插件整合到 pdCalc 中的长时间讨论之后,我们终于到了可以实现一个具体插件的时候了。基于我们在第一章中的需求,我们需要编写一个插件,为自然对数、其反指数算法和双曲三角函数添加命令。当然,你可以随意添加包含任何你喜欢的功能的插件。例如,两个有趣的插件可能是概率插件和统计插件。概率插件可以计算排列、组合、阶乘和随机数,而统计插件可以计算平均值、中位数、众数和标准差。然而现在,我们将简单地考虑我们的双曲线自然对数插件的设计和实现。
插件接口
HyperbolicLnPlugin
的实现实际上非常简单。我们将从该类的接口开始,然后一反常态地研究一些实现细节。选择用于进一步检查的代码突出了与原生 C++ 插件相关的特定细节。
HyperbolicLnPlugin
的接口由专门化Plugin
类的类定义和所需的插件分配和解除分配函数给出:
class HyperbolicLnPlugin : public pdCalc::Plugin
{
public:
HyperbolicLnPlugin();
~HyperbolicLnPlugin();
private:
const PluginDescriptor& getPluginDescriptor() const override;
const PluginButtonDescriptor* getPluginButtonDescriptor()
const override;
pdCalc::Plugin::ApiVersion apiVersion() const;
};
extern "C" void* AllocPlugin();
extern "C" void DeallocPlugin(void*);
正如所料,该类实现了Plugin
类中的三个纯虚函数。AllocPlugin()
和DeallocPlugin()
函数有它们明显的实现。AllocPlugin()
简单地返回一个新的HyperbolicLnPlugin
实例,而DeallocPlugin()
函数将其void*
参数转换为Plugin*
,随后在这个指针上调用delete
。注意,根据定义,插件不是主程序的一部分,因此也不应该是pdCalc
名称空间的一部分。因此,在一些位置有明确的名称空间限定。
HyperbolicLnPlugin
类的职责只是按需提供插件描述符,并管理描述符所需对象的生命周期。PluginDescriptor
提供命令名和插件实现的相应的Command
。第 7.6.3 节对这些Command
进行了描述。插件的PluginButtonDescriptor
简单地列出了由PluginDescriptor
定义的Command
的名称以及出现在 GUI 按钮上的相应标签。因为HyperbolicLnPlugin
中的命令都有自然的反转,我们简单地给每个按钮贴上一个前进命令的标签,并把第二个(移位的)命令附加到反转上。我对插件提供的命令使用了明显的标签:sinh
、asinh
、cosh
、acosh
、tanh
、atanh
、ln
和exp
。您是选择ln
作为主服务器,选择exp
作为辅助服务器,还是相反,这只是一个偏好问题。
由于已经讨论过的原因,插件描述符不使用 STL 容器来传输内容。我们通常更喜欢在界面中使用vector
和unique_ptr
来管理资源,但是我们被迫使用原始数组。当然,我们的类提供的封装使我们能够在实现中使用我们想要的任何内存管理方案。对于HyperbolicLnPlugin
,我选择了一个使用string
、unique_ptr
和vector
的复杂的自动内存管理方案。使用 RAII 内存管理方案的优点是,我们可以确保插件在出现异常时不会泄漏内存(即,在构建期间抛出的内存不足异常)。实际上,我不希望计算器在低内存环境中执行,即使是这样,插件分配期间的内存泄漏也不会有多大影响,因为用户在这种情况下的下一个动作可能是重启计算机。因此,回想起来,在构造器中使用裸的new
而在析构函数中使用delete
的更简单的内存管理方案可能会更实用。
7.6.2 源代码依赖倒置
令人惊讶的是,HyperbolicLnPlugin
前面的类声明确实是插件的完整接口。我说令人惊讶是因为,乍一看,人们可能会惊讶于插件的界面与插件提供的功能没有任何关系。当然,这种情况正是应该的。插件提供的计算器功能实际上只是一个实现细节,可以完全包含在插件的实现文件中。
前面的微妙之处,即 pdCalc 只知道插件的接口,而不知道功能本身,不应该被忽略。事实上,这种源代码依赖倒置是插件设计的全部要点。到底什么是源代码依赖倒置,为什么它很重要?要回答这个问题,我们必须先上一堂简短的历史课。
传统上(想想 20 世纪 70 年代的 Fortran),通过简单地编写新的函数和子程序来扩展代码。这种方法的主要设计问题是,要求主程序调用新函数会将主程序绑定到任何扩展的具体接口上。因此,主程序变得依赖于扩展作者突发奇想定义的接口变化。也就是说,每一个新的扩展都定义了一个新的接口,主程序必须遵循这个接口。这种设置非常脆弱,因为主程序需要不断修改才能跟上扩展接口的变化。因为每个新的扩展都需要对主程序的源代码进行独特的修改,所以主程序处理扩展的代码的复杂性随着扩展的数量而线性增长。如果这还不够糟糕的话,添加新功能总是需要重新编译和重新链接主程序。具体来说,想象一个 pdCalc 的设计,每次添加新的插件命令时,都需要修改、重新编译和重新链接 pdCalc 的源代码。
前面的问题可以在没有面向对象编程的情况下通过函数指针和回调来解决,尽管这种方式有些不雅和麻烦。然而,随着面向对象编程的兴起,特别是继承和多态,依赖问题的解决方案在语言支持下以类型安全的方式得到了解决。这些技术使得源代码依赖倒置的普及成为可能。具体来说,源代码依赖倒置声明主程序定义了一个接口(例如,我们在本章中学习的插件接口),所有扩展都必须符合这个接口。在这种策略下,扩展从属于主程序的接口,而不是相反。因此,主程序可以通过插件进行扩展,而无需修改、重新编译或重新链接主程序的源代码。然而,更重要的是,接口的可扩展性是由应用程序而不是其插件决定的。具体来说,pdCalc 提供了Plugin
接口类来定义新功能的添加,但是 pdCalc 从来不知道其扩展的实现细节。一个不符合 pdCalc 接口的插件根本无法注入新的Command
7.6.3 实现双曲线插件的功能
到游戏的这个阶段,我们知道HyperbolicLnPlugin
将通过为每个操作实现一个命令类来提供它的功能。在实现了其中的一些类之后,你会很快注意到插件中的所有命令都是一元命令。不幸的是,基于我们 C++ 插件的第三条规则(假设不兼容对齐),我们不能从UnaryCommand
类继承,而是必须从PluginCommand
类继承。注意,我们的对齐假设甚至排除了通过多重继承使用UnaryCommand
类,我们必须在HyperbolicLnPluginCommand
基类中重新实现一元命令功能。虽然这确实感觉有点重复,但 C++ 插件的规则让我们别无选择(虽然我们可以为一个UnaryPluginCommand
和一个UnaryBinaryCommand
提供源代码,但这些必须用每个插件单独编译)。
因此,我们最终得到了接口类,所有的命令都来自这个接口类:
class HyperbolicLnPluginCommand : public pdCalc::PluginCommand
{
public:
HyperbolicLnPluginCommand() = default; // ???? see sidebar
explicit HyperbolicLnPluginCommand(const HyperbolicLnPluginCommand&
rhs);
virtual ~HyperbolicLnPluginCommand() = default;
void deallocate() override;
protected:
const char* checkPluginPreconditions() const noexcept override;
private:
void executeImpl() noexcept override;
void undoImpl() noexcept override;
HyperbolicLnPluginCommand* clonePluginImpl() const noexcept override;
virtual HyperbolicLnPluginCommand* doClone() const = 0;
virtual double unaryOperation(double top) const = 0;
double top_;
};
与UnaryCommand
类一样,HyperbolicLnPluginCommand
类实现了纯虚拟的executeImpl()
和undoImpl()
命令,将命令操作委托给纯虚拟的unaryOperation()
函数。此外,HyperbolicLnPluginCommand
类实现了checkPluginPreconditions()
函数,以确保在调用命令之前,栈中至少有一个数字。该函数是protected
,因此如果子类必须实现任何额外的前提条件,但仍然调用基类的checkPluginPreconditions()
来进行一元命令前提条件检查,它可以直接覆盖前提条件函数。
deallocate()
和clonePluginImpl()
函数有明显的实现,但在插件中扮演着关键的角色。deallocate()
函数简单地实现为
void HyperbolicLnPluginCommand::deallocate()
{
delete this;
}
回想一下,deallocate()
函数的作用是在插件的编译单元中强制释放插件命令的内存。当持有命令的unique_ptr
被销毁时,通过CommandDeleter()
函数调用。
clonePluginImpl()
函数由下式给出
HyperbolicLnPluginCommand*
HyperbolicLnPluginCommand::clonePluginImpl() const noexcept
{
HyperbolicLnPluginCommand* p;
try
{
p = doClone();
}
catch(...)
{
return nullptr;
}
return p;
}
该函数的唯一目的是调整插件命令的克隆,以确保异常不会跨越插件和主应用程序之间的内存边界。
完成HyperbolicLnPlugin
剩下的工作就是为插件中需要的每个数学运算子类化HyperbolicLnPluginCommand
,并实现剩下的几个纯虚函数(unaryOperation()
、doClone()
和helpMessageImpl()
)。一旦我们达到这一点,这些函数的实现与第四章的一元函数的实现没有什么不同。感兴趣的读者可以参考HyperbolicLnPlugin.cpp
中的源代码了解详情。
Modern C++ Design Note: Defaulting Special Functions
有些成员函数是特殊的,如果你不提供实现,编译器可以为你提供一个。唯一允许这样做的成员函数是默认构造器、复制构造器、移动构造器、默认析构函数、复制赋值、移动赋值和比较操作。编译器可以自动提供这些操作,为什么我们还需要= default
语法?实际上,有两个主要原因。首先,有时默认实现会被抑制。例如,只有在没有提供其他构造器的情况下,才会自动生成默认构造器。因此,如果您提供了替代构造器,但仍希望编译器自动为您生成默认构造器,您必须手动指示它这样做。default
关键字有用的第二个原因是为了清晰。以前,在编译器会为您实现一个特殊成员函数的情况下,您可以让它安静地实现(可能会让新手感到困惑),或者手动实现它,不必编写编译器会为您编写的相同代码。新语法通过声明特殊函数的存在为您提供了明确的能力,但通过= default
实现它也是高效的。
7.7 后续步骤
在对 C++ 插件进行了相当长时间的讨论之后,随着双曲三角函数和自然对数插件的实现,我们已经完成了第一章中提出的 pdCalc 的要求。正如最初描述的那样,计算器已经完成,我们准备发布了!然而,作为有经验的软件开发人员,我们知道任何“成品”都只是客户要求新特性之前的一个暂时的里程碑。下一章处理这种确切的情况,我们将修改我们的设计,以纳入计划外的扩展。
八、新需求
这是一个美丽的周一早晨,你刚刚在轻松的周末步入工作岗位。毕竟你周五刚做完 pdCalc,现在准备出货了。在你坐下来喝咖啡之前,你的项目经理走进你的办公室说,“我们还没完。客户要求一些新功能。”
前面的场景在软件开发中太常见了。虽然新功能可能不会在上线时被请求,但是在您完成大部分设计和实现之后,新功能几乎不可避免地会被请求。因此,我们应该尽可能实际地进行防御性开发,以预测可扩展性。我说尽可能实际地防御,而不是尽可能地防御,因为过于抽象的代码和过于具体的代码一样对开发有害。通常,如果需要的话,简单地重写一个不灵活的代码比毫无理由地维护一个高度灵活的代码要容易得多。在实践中,我们寻求在代码的简单性、可维护性和一定程度的可扩展性之间取得平衡。
在这一章中,我们将探索修改我们的代码来实现超出原始需求设计的特性。本章中介绍的新功能的讨论范围从完整的设计和实现到仅为自我探索而提出的建议。让我们从两个扩展开始,从需求一直到实现。
8.1 完全设计的新功能
在这一节中,我们将研究两个新特性:计算器的批处理操作和存储过程的执行。我们将从批量操作开始。
批量操作
对于那些不熟悉这个术语的人,让我们来定义批处理操作。任何程序的批处理操作都只是程序的执行,从开始到结束,一旦程序启动,没有用户的交互。大多数桌面程序不在批处理模式下运行。然而,批处理操作在编程的许多分支中仍然非常重要,例如科学计算。对于那些受雇于大公司的人来说,可能更感兴趣的是,你的工资单可能是由一个程序以批处理方式运行的。
实话实说吧。pdCalc 的批处理操作,除了测试之外,并不是一个非常有用的扩展。我之所以包括它,主要是因为它演示了如何简单地扩展一个设计良好的 CLI 来添加批处理模式。
回想一下第五章,pdCalc 的 CLI 具有以下公共接口:
class Cli : public UserInterface
{
public:
Cli(istream& in, ostream& out);
~Cli();
void execute(bool suppressStartupMessage = false, bool echo = false);
};
要使用 CLI,该类是用cin
和cout
作为参数构造的,而execute()
是用空参数调用的:
Cli cli{cin, cout};
// setup other parts of the calculator
cli.execute();
我们如何修改Cli
类来启用批处理操作?令人惊讶的是,我们根本不需要修改这个类的代码!按照设计,CLI 本质上是一个解析器,它只是从输入流中提取空格分隔的字符输入,通过计算器处理数据,并生成字符输出到输出流。因为我们预先考虑过不要将这些输入和输出流硬编码为cin
和cout
,所以我们可以通过将输入和输出流转换为文件流来将 CLI 转换为批处理处理器,如下所示:
ifstream fin{inputFile};
ofstream fout{outputFile};
Cli cli{fin, fout};
// setup other parts of the calculator
cli.execute(true, true);
其中inputFile
和outputFile
是可以通过 pdCalc 的命令行参数获得的文件名。回想一下,execute()
函数的参数只是抑制了启动横幅,并将命令回显到输出中。
是的,确实是这样(但是参见main.cpp
中的一些实现技巧)。我们的 CLI 最初是这样构建的,只需更改其构造器参数,就可以将其转换为批处理程序。当然,你可以争辩说,作为作者,我是故意这样设计Cli
类的,因为我知道计算器会以这种方式扩展。然而,事实是,我只是用流输入而不是硬编码输入来构造我的所有 CLI 接口,因为这种设计使 CLI 更加灵活,几乎没有额外的认知负担。
在离开这一部分之前,我将很快注意到,实际情况是,在操作系统的帮助下,pdCalc 的 CLI 已经有了批处理模式。通过在命令行重定向输入和输出,我们可以获得相同的结果:
my_prompt> cat inputFile | pdCalc --cli > outputFile
对于 Windows,只需用 Windows type
命令替换 Linux cat
命令。
存储过程
无可否认,向 pdCalc 添加批处理模式是一个有点做作的例子。增加的功能并不十分有用,代码变化也很小。在这一节中,我们将研究一个更有趣的特性扩展——存储过程。
什么是存储过程?在 pdCalc 中,存储过程是在当前栈上操作的存储的、可重复的操作序列。存储过程提供了一种技术,通过从现有的计算器原语创建用户定义的函数来扩展计算器的功能。您可以将执行存储过程视为类似于为计算器运行一个非常简单的程序。理解这个概念最简单的方法是考虑一个例子。
假设你需要经常计算三角形的斜边。对于图 8-1 中描绘的直角三角形,我们可以用毕达哥拉斯公式计算出斜边的长度 c 。
图 8-1
直角三角形
假设我们有一个边长为 a = 4、 b = 3 的三角形,这些值被输入到 pdCalc 的栈中。在 CLI 中,您会看到以下内容:
Top 2 elements of stack (size = 2):
2: 3
1: 4
为了计算这个三角形的 c ,我们将执行下面的指令序列:dup * swap dup * + 2 root
。按 enter 键后,最终结果将是
Top element of stack (size = 1):
1: 5
如果一次输入一个命令,那么每次按 enter 键时,我们都会看到中间结果栈。如果我们在一行中输入所有命令,然后按 enter 键,pdCalc 将在显示最终结果之前显示每个中间栈。当然,请注意,这个命令序列不是唯一的。使用例如命令序列2 pow swap 2 pow + 2 root
可以获得相同的结果。
如果你和我一样,如果你不得不用 pdCalc 反复计算斜边,你可能会想在第一次手工计算后自动操作。这正是存储过程所允许的。自动化不仅节省时间,而且更不容易出错,因为封装了许多连续命令的存储过程可以被编写、测试和随后重用。如果操作可以由 pdCalc 原语(包括插件函数)组装,存储过程可以扩展计算器的功能,以计算简单的公式,而无需编写任何 C++ 代码。现在我们只需要设计和实现这个新特性。
用户界面
pdCalc 既有 GUI 又有 CLI,因此添加任何面向用户的功能都需要对这两个用户界面组件进行一些修改。对于存储过程,对用户界面的修改非常小。首先,存储过程只是一个包含有序的 pdCalc 指令序列的文本文件。因此,用户可以使用任何纯文本编辑器创建存储过程。因此,除非您想为存储过程文本编辑器提供语法突出显示,否则存储过程的用户界面只能从 CLI 和 GUI 执行。
让我们首先解决在 CLI 中合并存储过程的问题。如前所述,存储过程只是文件系统中的文本文件。回想一下,CLI 的工作方式是将空格分隔的输入标记化,然后通过引发事件将每个标记单独传递给命令调度器。因此,访问存储过程的一个简单方法就是将存储过程文件的名称传递给 CLI。然后,这个文件名将像任何其他命令或数字一样进行标记化,并传递给命令调度器进行处理。为了确保文件名被命令调度器解释为存储过程而不是命令,我们只需在文件名前面加上符号proc:
,并更改命令调度器的解析器。例如,对于一个名为hypotenuse.psp
的存储过程,我们可以向 CLI 发出命令proc:hypotenuse.psp
。我采用文件扩展名psp
作为 pdCalc 存储过程的简写。自然,文件本身是一个普通的 ASCII 文本文件,包含一系列用于计算直角三角形斜边的命令,如果您愿意,可以使用txt
扩展名。
回想一下,GUI 被设计为像 CLI 一样将命令传递给命令调度器。因此,为了使用存储过程,我们添加一个按钮,打开一个对话框来导航文件系统以找到存储过程。一旦选择了存储过程,我们就在文件名前面加上proc:
并引发一个CommandEntered
事件。显然,您可以让您的存储过程选择对话框尽可能的漂亮。我选择了一个简单的设计,允许在一个可编辑的组合框中输入文件名。为了便于使用,组合框中预先填充了当前目录中扩展名为.psp
的任何文件。
对命令调度器模块的更改
下面是CommandInterpreter
的executeCommand()
函数的简短列表,包括解析存储过程所需的逻辑。代码中省略的部分出现在第 4.5.2 节。
void CommandInterpreter::CommandInterpreterImpl::executeCommand(const
string& command)
{
string_view sv{command};
// handle numbers, undo, redo, help in nested if
// ...
else if( command.size() > 6 && sv.starts_with("proc:") )
{
string filename{sv.substr(5, command.size() - 5)};
handleCommand( MakeCommandPtr<StoredProcedure>(ui_, filename) );
}
// else statement to handle Commands from CommandFactory
// ...
return;
}
从前面的代码清单中,我们看到实现只是从 string 命令参数中剥离出proc:
来创建存储过程文件名,创建一个新的StoredProcedure
类,并执行这个类。现在,我们假设让StoredProcedure
类成为Command
类的子类是最佳设计。我们将讨论为什么这种策略是首选,并在接下来的小节中检查它的实现。然而,在我们到达那里之前,让我们讨论一下这个MakeCommandPtr()
函数的新重载。
在第 7.2.1 节中,我们首先看到了由以下实现给出的MakeCommandPtr
的版本:
inline void CommandDeleter(Command* p)
{
if(p) p->deallocate();
return;
}
using CommandPtr = std::unique_ptr<Command, decltype(&CommandDeleter)>;
inline auto MakeCommandPtr(Command* p)
{
return CommandPtr{p, &CommandDeleter};
}
前面的函数是一个帮助函数,用于从原始的Command
指针创建CommandPtr
。这种形式的函数用于从现有Command
的克隆中创建一个CommandPtr
(例如,在CommandFactory::allocateCommand()
):
auto p = MakeCommandPtr( command->clone() );
然而语义上,在CommandInterpreterImpl::executeCommand()
中,我们看到了完全不同的用法,那就是构造一个从Command
派生的类的命名实例。当然,我们可以用现有的MakeCommandPtr
原型来满足这个用例。例如,我们可以如下创建一个StoredProcedure
:
auto c = MakeCommandPtr(new StoredProcedure{ui, filename});
然而,只要有可能,我们不希望用裸露的new
来污染高级代码。因此,我们寻求实现一个重载的 helper 函数来为我们执行这个构造。其实现如下所示:
template<typename T, typename... Args>
auto MakeCommandPtr(Args&&... args)
requires std::derived_from<T, Command>
{
return CommandPtr{new T{std::forward<Args>(args)...}, &CommandDeleter};
}
Listing 8-1Generic perfect forwarding constructor
在 C++11 之前,不存在简单有效的技术来构造具有可变数目构造器参数的泛型类型,这对于创建从Command
类派生的任何一个可能的类都是必要的,每个类都具有不同的构造器参数。然而,现代 C++ 使用可变模板和完美转发为这个问题提供了一个优雅的解决方案。这个构造是下面侧栏的主题。
Modern C++ Design Note: Variadic Templates and Perfect Forwarding
可变模板和完美转发各自解决了 C++ 中不同的问题。可变模板支持使用未知数量的类型化参数进行类型安全的泛型函数调用。完美转发支持将参数正确类型地转发到模板函数内部的底层函数。这些技术的机制可以在你最喜欢的 C++11 参考文本中学习(例如,[30])。在侧栏中,我们将研究一种类型安全的通用设计技术,用于构造需要不同数量构造器参数的具体对象。这种技术是通过可变模板和完美转发的组合来实现的。由于缺乏命名创意,我将这种模式命名为通用完美转发构造器(GPFC)。让我们从介绍 GPFC 解决的根本问题开始。
让我们考虑一下每个作者最喜欢的过度简化的面向对象编程的例子,形状层次结构:
class Shape
{
public:
virtual double area() const = 0;
};
class Circle : public Shape
{
public:
Circle(double r) : r_{r} {}
double area() const override { return 3.14159 * r_ * r_; }
private:
double r_;
};
class Rectangle : public Shape
{
public:
Rectangle(double l, double w) : l_{l}, w_{w} {}
double area() const override { return l_ * w_; }
private:
double l_, w_;
};
在 C++ 中,实现为虚拟分派的可替换性解决了需要使用基类保证的接口通过基类指针调用派生类型的特定实现的问题。在形状示例中,可替换性意味着能够按如下方式计算面积:
double area(const Shape& s)
{
return s.area();
}
对于任何从Shape
派生的类。虚拟函数的确切接口是完全指定的,包括任何函数参数的数量和类型(即使在空的情况下,如本例中的area()
函数)。然而,问题是对象构造永远不能以这种方式“虚拟化”,即使可以,也不会起作用,因为构造一个对象所必需的信息(它的参数)在不同的派生类之间经常是不同的。
进入一般的完美转发构造器模式。在这个模式中,我们使用可变模板来提供一个类型安全的接口,该接口可以接受任意数量的不同类型的构造器参数。第一个模板参数总是我们想要构造的类型。然后,使用完全转发来保证参数以正确的类型传递给构造器。准确地说,为什么在这种情况下完美转发是必要的,这源于模板中类型是如何推导出来的,超出了本讨论的范围(详见[24])。对于我们的形状示例,应用 GPFC 模式会导致以下实现:
template<typename T, typename... Args>
auto MakeShape(Args&&... args)
{
return make_unique<T>(forward<Args>(args)...);
}
下面的代码说明了如何使用MakeShape()
函数创建具有不同数量构造器参数的不同类型:
auto c = MakeShape<Circle>(4.0);
auto r = MakeShape<Rectangle>(3.0, 5.0);
注意,GPFC 模式也适用于在继承层次结构中创建彼此不相关的类。事实上,GPFC 模式被标准库中的make_unique()
函数用来以一种高效、通用的方式生成unique_ptr
,而不需要裸的new
。虽然严格地说,它们是不同的,但我喜欢把 GPFC 模式看作工厂方法的一般模拟。
敏锐的读者会注意到清单 8-1 中函数声明后面的特殊的requires
子句。对于 C++20 来说,requires
子句引入了一个约束,指定了对模板参数的编译时要求。在清单 8-1 的情况下,我们要求类型T
必须从Command
派生。约束是概念的组成部分,是 C++20 的一个新的语言特性。在下面的边栏中简要介绍了一些概念。
Modern C++ Design Note: Concepts
概念是 C++20 的一个非常突出的新特性,用于向模板类型添加需求。概念在本书中并不突出,因为 pdCalc 在其实现中并没有包含很多模板。然而,由于新标准中概念的重要性,我选择在侧栏中简要提及它们,以说明为什么您可能会使用它们。
模板在 C++ 语言中已经存在了几十年,在它们存在的整个过程中,程序员都有同样的抱怨:模板错误会导致冗长的、难以理解的编译器错误消息。为什么不同编译器的错误信息都是一样的?答案基本上是因为类型错误通常是在调用栈深处发现的,而不是在使用该类型的第一行。在错误发生的地方,失去了太多的上下文来给出简洁的信息。
概念通过在使用时约束可接受的模板类型来解决前面的问题。这些需求既可以通过一个requires
子句作为约束添加,也可以通过使用一个requires
子句(或其连接词)来构建一个称为概念的受限模板类型。如果您选择在模板代码中使用概念(您可能应该这样做),不要从头开始。C++20 概念库预定义了许多概念,比如清单 8-1 中使用的derived_from
概念。
让我们从概念和requires
子句的上下文中重新检查清单 8-1 中的代码。在这本书的第一版中,因为概念还不是一种语言特征,所以完全相同的功能被无限制地呈现出来。也就是说,清单 8-1 是相同的,只是没有出现requires
子句。对于两个版本的源代码,当一个从Command
派生的类被传递给MakeCommandPtr()
时,会生成相同的编译代码。概念的真正好处是在犯错误的时候。
考虑一个不是从Command
派生的默认可构造哑类A
,并进一步考虑以下错误的函数调用:
auto p = MakeCommandPtr<A>();
使用 gcc,调用受约束版本的MakeCommandPtr()
会导致以下“友好的”错误:
note: 'pdCalc::Command' is not a base of 'A'
我在引号中写了 friendly,因为 gcc 给出了一个巨大的模板扩展错误,但至少包含了有用的注释,即Command
不是A
的基,作为错误末尾的注释,这正是这段代码无法编译的原因。现在让我们对比一下同样的呼叫,但是是无约束版本的MakeCommandPtr()
。这一次,最后出现的错误是
note: candidate expects 0 arguments, 2 provided
return CommandPtr{new T{std::forward<Args>(args)...}, &CommandDeleter};
这实际上并没有解释这个函数调用失败的原因。在向语言引入概念之前,有经验的程序员只是习惯了糟糕的模板错误消息。我个人诊断模板错误的策略是使用编译器找到令人不快的源代码行,忽略编译器产生的所有消息,并认真思考该行为什么会导致错误。这样的技术对新手来说是不可能的;概念是一个很大的进步。
在结束这篇边栏之前,值得注意的是,在清单 8-1 中,我们没有使用requires
子句,而是创建了一个概念并将模板参数约束为MakeCommandPtr()
。这种策略会产生以下替代代码:
template<typename T>
concept PdCalcCommand = std::derived_from<T, Command>;
template<PdCalcCommand T, typename... Args>
auto MakeCommandPtr(Args&&... args)
{
return CommandPtr{new T{std::forward<Args>(args)...}, &CommandDeleter};
}
我选择直接使用requires
子句,而不是创建一个概念,因为这个概念没有在代码中的其他地方使用过。也就是说,代码没有从概念上受益于PdCalcCommand
;它只需要对MakeCommandPtr
有一个约束。因此,requires
子句的使用看起来更简单,再次遵从了奥卡姆剃刀的设计。
设计 StoredProcedure 类
我们现在回到设计StoredProcedure
类的棘手问题。我们问的第一个问题是,我们到底需要一个类吗?我们已经有了解析单个命令、执行它们并将其放入撤销/重做栈的设计。也许正确的答案是以类似于处理批量输入的方式来处理存储过程。也就是说,在交互式会话(GUI 或 CLI)期间,通过读取存储过程文件、解析它并批量执行命令(就像我们在 CLI 中使用多个命令的一长行命令一样)来处理存储过程,而不引入新的StoredProcedure
类。
在考虑了下面这个非常简单的例子后,几乎可以立即放弃上述设计。假设您实现了一个计算三角形面积的存储过程。存储过程的输入将是栈上三角形的底和高。triangleArea.psp
由下式给出:
*
0.5
*
如果我们没有一个StoredProcedure
类,那么triangleArea.psp
中的每个命令都将被执行并按顺序进入撤销/重做栈。对于 I/O 栈上的值4
和5
,存储过程的正向执行将产生正确的结果10
,以及如图 8-2 所示的撤销栈。基于这个撤销栈,如果用户试图撤销,而不是撤销三角形区域存储过程,用户将只撤销栈上的顶层操作,即最后的乘法。I/O 栈将读取
4
5
0.5
图 8-2
没有StoredProcedure
类的撤销栈
(撤销栈在5
和0.5
之间会有一个*
,而不是
4
5
要完全撤销一个存储过程,用户需要按撤销 n 次,其中 n 等于存储过程中的命令数。重做操作也存在同样的缺陷。在我看来,撤销存储过程的预期行为应该是撤销整个过程,并让 I/O 栈保持在执行存储过程之前的状态。因此,不使用StoredProcedure
类来处理存储过程的设计无法正确地实现撤销和重做,因此必须放弃。
复合模式
本质上,为了解决存储过程的撤销/重做问题,我们需要一个特殊的命令,它封装了多个命令,但表现为单个命令。幸运的是,复合模式解决了这个难题。根据 Gamma 等人[11]的说法,复合模式“让客户可以一致地对待单个对象和对象的组合。”通常,复合模式指的是树形数据结构。我更喜欢一个更宽松的定义,这种模式可以应用于任何允许对复合对象进行统一处理的数据结构。
图 8-3 显示了复合模式的一般形式。Component
类是一个抽象类,需要执行一些动作。这个动作可以由一个Leaf
节点单独执行,也可以由一组被称为Composite
的Component
来执行。客户端通过Component
接口与组件层次结构中的对象进行多形态的交互。从客户端的角度来看,Leaf
节点和Composite
节点都不加区分地处理doSomething()
请求。通常,Composite
s 通过简单地调用它持有的Component
s(或者Leaf
s 或者嵌套Composite
s)的doSomething()
命令来实现doSomething()
。
图 8-3
复合模式的一般形式
在我们特定的具体例子中,Command
类扮演Component
的角色,具体的命令如Add
或Sine
扮演Leaf
节点的角色,而StoredProcedure
类是复合的。doSomething()
命令由一对纯虚函数executeImpl()
和undoImpl()
代替。我怀疑以这种方式组合命令和复合模式是相当普遍的。
以前,我们了解到为了正确地实现存储过程的撤销/重做策略,类设计是必要的。如前所述,复合模式的应用促使从Command
类派生出StoredProcedure
类。现在让我们设计一个StoredProcedure
类,并作为复合模式的一个具体应用来检查它的实现。
第一次尝试
实现复合模式的一种常见方法是通过递归。Composite
类保存一个Component
的集合,通常通过简单的vector
或者更复杂的方式,比如二叉树中的节点。Composite
的doSomething()
函数简单地迭代这个集合,为集合中的每个Component
调用doSomething()
。Leaf
节点的doSomething()
函数实际上做了一些事情并终止递归。虽然不是必需的,但是Component
类中的doSomething()
函数通常是纯虚拟的。
让我们考虑前面的方法,在 pdCalc 中为StoredProcedure
s 实现复合模式。我们已经确定,pdCalc 的Command
类是Component
,具体的命令类,比如Add
,是Leaf
类。因此,我们只需要考虑StoredProcedure
类本身的实现。注意,由于当前的Component
和Leaf
类的实现可以按原样使用,复合模式可以很容易地应用于扩展现有代码库的功能。
考虑以下StoredProcedure
级的骨架设计:
class StoredProcedure : public Command
{
private:
void executeImpl() noexcept override;
void undoImpl() noexcept override;
vector<unique_ptr<CommandPtr>> components_;
};
executeImpl()
命令将按如下方式执行:
void StoredProcedure::executeImpl()
{
for(auto& i : components_)
i->execute();
return;
}
undoImpl()
将以类似的方式实现,但是对component_
集合进行反向迭代。
前面的设计是否解决了以前在没有StoredProcedure
类的情况下将存储过程命令直接输入撤销/重做栈时遇到的撤销/重做问题?考虑图 8-4 中所示的撤销栈,这是我们之前检查过的triangleArea.psp
示例。如图中的SP
所示,存储过程在撤销栈中显示为单个对象,而不是代表其组成命令的单个对象。因此,当用户发出撤销命令时,CommandManager
将通过调用存储过程的undoImpl()
函数来撤销存储过程。这个存储过程的undoImpl()
函数反过来通过对其容器Command
s 的迭代来撤销单个命令,这种行为正是我们所期望的,复合模式的应用确实解决了眼前的问题。
图 8-4
使用StoredProcedure
类的撤销栈
为了完成StoredProcedure
类的实现,我们需要解析存储过程文件的字符串命令(带有错误检查),并使用它们来填充StoredProcedure
的components_ vector
。这个操作可以写在StoredProcedure
的构造器中,并且实现将是有效和完整的。我们现在将拥有一个StoredProcedure
类,它可以将字符串命令转换成Command
命令,并将它们存储在一个容器中,并且能够根据需要执行和撤销这些存储的Command
命令。换句话说,我们基本上重写了CommandInterpreter
!相反,让我们考虑一种替代设计,它通过重用CommandInterpreter
类来实现StoredProcedure
类。
StoredProcedure
级的最终设计
这个设计的目标是按原样重用CommandInterpreter
类。放松这个约束并修改CommandInterpreter
的代码可以稍微清理一下实现,但是设计的本质是一样的。考虑下面的StoredProcedure
级改进骨架设计:
class StoredProcedure : public Command
{
private:
void executeImpl() noexcept override;
void undoImpl() noexcept override;
std::unique_ptr<Tokenizer> tokenizer_;
std::unique_ptr<CommandInterpreter> ci_;
bool first_ = true;
};
目前的设计与我们之前的设计几乎相同,除了components_ vector
已经被CommandInterpreter
所取代,并且已经明确了对记号赋予器的需求。好在我们在第五章中编写了可重用的记号赋予器!
我们现在准备好看到executeImpl()
和undoImpl()
的完整实现。注意,虽然下面的实现没有使用前面看到的模式的规范版本,但是这个StoredProcedure
类的实现仍然只是复合模式的一个应用。首先,我们来看看executeImpl()
:
void StoredProcedure::executeImpl() noexcept
{
if(first_)
{
ranges::for_each( *tokenizer_,
this{ci_->commandEntered(c);} );
first_ = false;
}
else
{
for(auto i = 0u; i < tokenizer_->nTokens(); ++i)
ci_->commandEntered("redo");
}
return;
}
第一次调用executeImpl()
时,令牌必须从令牌化器中提取出来,并由StoredProcedure
自己的CommandInterpreter
执行。对executeImpl()
的后续调用仅仅是请求StoredProcedure
的CommandInterpreter
重做每个StoredProcedure
命令的向前执行。记住,StoredProcedure
的executeImpl()
函数本身会被 pdCalc 的CommandInterpreter
调用;因此,我们的设计要求嵌套CommandInterpreter
的。图 8-5 显示了三角形区域存储过程示例的设计,其中CI
代表CommandInterpreter
。
图 8-5
使用嵌套CommandInterpreter
的撤销栈
StoredProcedure
的undoImpl()
的实现很简单:
void StoredProcedure::undoImpl() noexcept
{
for(auto i = 0u; i < tokenizer_->nTokens(); ++i)
ci_->commandEntered("undo");
return;
}
撤销是通过请求底层的CommandInterpreter
撤销存储过程中的一些命令来实现的。
在结束对最后一个StoredProcedure
类的讨论之前,我们应该考虑一下StoredProcedure
类中命令的标记化。StoredProcedure
的标记化过程包括两个步骤。必须打开并读取存储过程文件,然后对文本流进行实际的标记化。这个过程只需要在初始化时,每个StoredProcedure
实例化执行一次。因此,标记化的自然位置是在StoredProcedure
的构造器中。然而,在StoredProcedure
的构造器中放置标记化会与 pdCalc 的命令错误处理程序产生不一致。特别是,pdCalc 假设命令可以被构造,但不一定被执行,而不会失败。如果一个命令不能被执行,期望通过检查命令的前提条件来处理这个错误。标记化会失败吗?当然可以。例如,如果存储过程文件无法打开,标记化就会失败。因此,为了保持错误处理的一致性,我们在StoredProcedure
的checkPreconditionsImpl()
函数中实现了标记化,当 pdCalc 的CommandInterpreter
第一次尝试执行存储过程时会调用这个函数。由于标记化需要执行一次,所以我们只在第一次执行checkPreconditionsImpl()
函数时执行操作。完整的实现可以在StoredProcedure.cpp
文件中找到。
8.2 设计更有用的计算器
到目前为止,所有关于 pdCalc 的讨论都集中在可从 GitHub 下载的完整代码的设计和实现上。然而,本章的其余部分标志着对这种风格的背离。此后,我们将只讨论扩展的想法和如何修改 pdCalc 以适应新特性的建议。不仅没有提供工作代码,而且在写这些部分之前也没有创建工作代码。因此,我们将要讨论的设计还没有经过测试,选择完成这些扩展的有冒险精神的读者可能会发现将要讨论的想法是次优的,或者,我敢说,是错误的。欢迎来到从空白开始设计功能的狂野西部!将需要试验和反复。
8.2.1 复数
计算器的原始设计规范要求双精度数字,我们设计和实现的计算器明确地只处理双精度数字。然而,需求是变化的。假设您的同事是一名电气工程师,他路过您的办公室,爱上了您的计算器,但需要一台处理复数(虚数)的计算器。这是一个合理的要求,所以让我们看看如何重构我们的计算器来满足这个新特性。
添加复数需要对 pdCalc 进行四项主要修改:在内部使用复数表示,而不是将数字表示为double
s,更改输入和输出(以及,通过扩展,解析)以适应复数,修改 pdCalc 的栈以存储复数而不是double
s,以及修改命令以对复数而不是实值输入执行计算。第一个变化,寻找一个复数的 C++ 表示,是微不足道的;我们将使用std::complex<double>
。一个只有实部的数将简单地存储为一个虚部设置为0
的complex<double>
。其他三个变化不那么微不足道。现在让我们更深入地看看一些能够适应这些变化的设计选项。
修改输入和输出
在所有需要的改变中,修改 I/O 例程实际上是最容易的。要解决的第一个问题是如何解释和表示复数。例如,我们是否希望一个复数c
被表示为c = re + im * i
(也许虚数应该是j
,因为特性请求来自一个电气工程师)?也许我们更喜欢使用c = (re, im)
或使用尖括号或方括号的变体。这个问题没有正确答案。尽管有些选择可能比其他选择更容易实现,但由于这种选择只是一种约定,在实践中,我们会将解决方案委托给客户。对于我们的案例研究,我们将简单地采用约定c = (re, im)
。
我们将只讨论修改 I/O 的命令行版本。一旦为 CLI 准备好了处理复数的基础设施,修改 GUI 应该相当简单。我们遇到的第一个问题是Tokenizer
类。这个类的最初设计只是通过在空白上分割输入来标记。然而,对于复数,这种方案是不够的。例如,根据逗号后是否插入空格,复数的标记方式会有所不同。
在某种程度上,输入变得足够复杂,以至于您需要使用一种语言语法,并将简单的输入例程迁移到“真正的”扫描器和解析器(可能使用 lex 和 yacc 之类的库)。有些人可能会说,通过增加复数,我们已经达到了这种复杂程度。然而,我认为,如果我们修改tokenize()
例程来扫描'(
'标记,并为左括号和右括号之间(包括左括号和右括号)的任何内容创建一个“number”标记,我们可能可以勉强使用现有的简单输入标记器。显然,我们需要执行一些基本的错误检查来确保正确的格式。另一种方法是基于正则表达式匹配分解输入流。这就是 lex 的基本操作方式,在从头开始编写复杂的扫描器之前,我会使用 lex 或类似的库进行研究。
我们遇到的下一个输入问题是解析CommandInterpreterImpl
的executeCommand()
函数中的数字。目前,一个字符串参数(令牌)被传递给这个函数,该字符串被解析以确定它是一个数字还是一个命令。通过检查,我们可以看到,如果我们修改isNum()
来识别和返回复数而不是浮点数,executeCommand()
将适用于复数。最后,需要更新EnterNumber
命令来接受和存储一个complex<double>
。
这就负责修改输入例程,但是我们如何修改输出例程呢?回想一下,Cli
类是Stack
的stackChanged()
事件的(间接)观察者。每当Stack
引发该事件时,就会调用Cli
的stackChanged()
函数将当前栈输出到命令行。让我们来看看Cli::stackChanged()
是如何实现的。本质上,CLI 使用以下函数调用回调栈,用顶部的nElements
填充容器:
auto v = Stack::Instance().getElements(nElements);
然后创建一个string
、s
和相应的back_inserter
、bi
,首先用一些栈元数据填充,然后用以下代码填充栈元素:
for( auto j = v.size(); auto i : views::reverse(v) )
{
std::format_to(bi, "{}:\t{:.12g}\n", j--, i);
}
最后,s
被发送到 CLI 的输出例程,该例程将其输出到 CLI 的输出流。一旦Stack
的getElements()
函数被修改为返回一个vector<complex<double>>
,并且输出格式被相应地改变,Cli
的stackChanged()
函数将按预期工作。实际上只需要很少的改变——这就是设计良好和实现良好的代码的美妙之处。
修改栈
在第三章中,我们最初设计计算器的栈只对双精度变量进行操作。显然,这个限制意味着现在必须重构Stack
类来处理复数。当时,我们质疑为栈硬编码目标数据类型的逻辑,我建议不要设计通用的Stack
类。我的建议是,一般来说,在第一个重用案例明确建立之前,不要设计通用接口。设计好的通用接口通常比设计特定类型更难,从我的个人经验来看,我发现代码的意外重用很少会有结果。然而,对于我们的Stack
类来说,将这种数据结构重新用于另一种数据类型的时候到了,在这一点上,谨慎的做法是将Stack
的接口转换为通用接口,而不仅仅是重构该类以针对复数进行硬编码。
使Stack
类通用化几乎和你想象的一样简单。第一步是通过用我们的通用类型T
替换double
的显式使用,使接口本身通用化。界面变成了
template<typename T>
class Stack : private Publisher
{
public:
static Stack& Instance();
void push(T, bool suppressChangeEvent = false);
T pop(bool suppressChangeEvent = false);
void swapTop();
std::vector<T> getElements(size_t n) const;
void getElements(size_t n, std::vector<T>&) const;
using Publisher::attach;
using Publisher::detach;
};
一般来说,所需的实现更改很简单。double
的使用被替换为T
,显然,pdCalc 中的Stack
类的使用必须被重构,以使用泛型而不是非模板化接口。
需要修改的接口的最后一部分是第七章中添加的五个全局extern "C"
帮助函数,用于将栈命令导出到插件。因为这些函数必须有 C 链接,我们不能让它们成为模板,它们也不能返回 C++ complex
类型来代替double
。第一个问题并不像乍看上去那么可怕。虽然我们的目标是使Stack
类通用和可重用,但是栈的插件接口不需要通用。对于任何特定版本的 pdCalc,无论是对实数进行操作的版本还是对复数进行操作的版本,系统中只存在一个特定的Stack<T>
实例,并且这个实例将有一个特定的T
实现。因此,用于 pdCalc 的栈的 C 链接接口只需要反映计算器中使用的T
的选择。也就是说,容器被设计成通用的和可重用的,但是插件的接口不需要这种灵活性,因为一旦为计算器选择了数据格式,它就不再被重用。
将 C 链接接口中的complex<double>
表示替换到栈中很简单。我们有几个选择。首先,我们可以用两个doubles
的序列替换每个double
:一个代表实数部分,一个代表复数部分。当然,由于 C 函数本身不能返回两个doubles
,我们必须修改返回栈值的函数。一种选择是在参数列表中使用指针参数,通过这些参数“返回”复杂的值。第二种选择是通过数组返回复数。另一个解决方案,也是我的首选,是简单地定义一个struct
:
struct Complex
{
double re;
double im;
};
为了补充接口功能,将当前使用的double
替换为Complex
。虽然这个新的Complex struct
复制了标准complex
类的存储,但是我们不能在纯 C 接口中使用标准的complex
类。
修改命令
修改命令来处理复数真的很容易,因为 C++ 库为我们的计算器所需的所有数学运算提供了重载。除了用Stack<complex<double>>
替换Stack
(希望我们在某处给它起了别名)和在BinaryCommand
和UnaryCommand
中用complex<double>
替换double
的语法变化之外,大多数命令保持不变。例如,清除一堆实数和清除一堆复数是一样的。给定运算符重载,将两个复数相加与将两个实数相加是相同的。当然,我们可能想要添加额外的命令,比如 complex conjugate,但是即使是这个功能也是由 C++ complex
类提供的。如果您创建的命令使用了complex
类本身不支持的算法,那么在修改命令以支持复数时,您可能会遇到比编程更多的数学困难。
变量
在本章的前面,我们实现了存储过程作为一种存储简单指令序列的方法。虽然存储过程适用于只使用每个输入一次的琐碎操作(例如,毕达哥拉斯定理),但是在尝试实现多次使用每个输入的更复杂的公式(例如,二次公式)时,很快就会遇到问题。为了克服这个困难,您需要实现在命名变量中存储参数的能力。
在 pdCalc 中实现变量需要对现有组件进行一些修改,包括添加一个重要的新组件,即符号表。为了简单起见,在示例代码中,我恢复使用实数表示 pdCalc。然而,使用复数不会增加额外的设计复杂性。现在让我们探索一些实现变量的可能的设计思想。
输入和新命令
显然,使用变量需要一些提供符号名的方法。目前,我们的计算器只接受数字和命令作为输入。输入任何在CommandFactory
中找不到的字符串都会导致错误。然而,回想一下,这个错误是在CommandInterpreter
中产生的,而不是在标记器中产生的。因此,我们需要修改CommandInterpreter
,使其不拒绝字符串,而是以某种方式将它们放入栈。现在,我们假设栈除了接受数字还可以接受字符串。我们将在接下来的章节中讨论对Stack
类的必要修改。同样,我们将讨论限制在命令行界面。图形用户界面带来的唯一额外的复杂性是提供了一种机制来输入除数字之外的字符串(可能是虚拟数字小键盘附带的虚拟键盘)。
从技术上讲,我们可以允许任何字符串代表一个变量。然而,将允许的语法限制在字符串的某个子集可能会更好,可能用符号分隔以区分变量名和命令。因为这种选择仅仅是惯例,所以您可以自由选择适合您或您的用户口味的任何规则。就个人而言,我可能会选择像变量名必须以字母开头,可以包含字母、数字的任意组合,还可能包含一些特殊符号,如下划线。为了消除变量名和命令之间的混淆,我将变量用单引号或双引号括起来。
既然我们已经建立了变量的语法,我们仍然需要一种机制来从栈中取出一个数字并将其存储到变量中。完成这项任务最简单的方法是提供一个新的二进制命令store
,从栈中删除一个数字和一个字符串,并创建一个符号表条目,将这个变量名链接到这个数字。例如,考虑栈
4.5
2.9
"x"
发出store
命令应导致进入 x → 2 。符号表中的 9 和的一个结果栈
4.5
隐式地,变量应该被转换成在计算中使用的数字,但在栈中显示为名称。我们还应该提供一个明确的命令eval
,将一个符号名称转换成一个数字。例如,给定栈
"x"
发出eval
命令应该会导致栈
2.9
这样的命令应该有一个相当明显的实现:用符号表中的值替换栈顶的变量。显然,请求对不在符号表中的变量求值会导致错误。计算一个数字可能会导致错误,或者更好的是,只返回数字。您可能会想到许多处理变量的奇特命令(例如,列出符号表)。然而,store
和eval
命令包含使用变量所需的最小命令集。
数字表示和栈
到目前为止,我们的栈只需要表示一个单一的、唯一的类型,可以是实数,也可以是复数。然而,由于变量和数字都可以存储在栈中,我们需要栈能够同时存储这两种类型。我们立即摒弃了可以同时处理两种不同类型的栈的概念,因为这将很快导致混乱。相反,我们寻求一种能够通过单一接口处理数字和变量类型的统一表示。很自然,我们转向了等级制度。
考虑图 8-6 中类图表达的设计。这种层次结构使得Variable
和Number
可以作为Value
互换使用。这种多态设计解决了我们已经遇到的三个问题。首先,Variable
s 和Number
s 可以统一存储在Stack<Value*>
中(可能使用更合适的智能指针存储方案)。第二,当Add
或Sine
等命令需要一个数来执行一个操作时,可以从栈中弹出Value
s,并通过虚拟的evaluate()
函数请求double
s。显然,a Number
直接存储它所代表的double
,而 a Variable
存储变量的名称,可以通过在变量符号表中查找转换成数值。最后,Value
的子类可以返回其底层值的string
表示(或者是Number
的数值,或者是Variable
的名称)。这种字符串转换对于在 I/O 栈上显示是必要的。
图 8-6
一种能够统一表示数字和变量的层次结构
符号表
从本质上讲,符号表只是一种数据结构,它允许通过将一个键与一个值(关联数组)配对来进行符号查找。在这种情况下,变量名作为键,数值作为值。C++ 标准库直接通过map
或unordered_map
提供这种服务,这取决于所需的底层数据结构。然而,正如在第三章中,我强烈建议不要在程序中直接使用标准库容器作为外部接口。相反,应该使用适配器模式将库容器封装在由应用程序本身定义的接口之后。这种模式没有给类的用户增加任何限制,但是它允许设计者独立于底层库容器的接口来限制、扩展或修改组件的接口。
因此,符号表的推荐设计是创建一个SymbolTable
类来包装一个unordered_map<string, double>
。这个底层哈希表提供了一种存储类型,用于在作为string
的变量名和底层数值之间进行映射。SymbolTable
类的公共接口提供了从符号表中添加和删除变量的成员函数,可选地(我们没有指定清除变量的命令)。由于我们在计算器中只需要一个符号表,所以SymbolTable
可能应该作为单例来实现。
一个简单的扩展:数字常量
一旦我们建立了存储用户定义变量的机制,我们就可以进行简单的扩展来提供用户定义的常量。常量是简单的变量,一旦设定就不能改变。常量可以在 pdCalc 中硬编码,在程序启动时通过读取常量文件添加,或者在计算器执行期间动态添加。
显然,为了存储一个常量,我们将需要添加一个新的命令;姑且称之为cstore
。cstore
的工作方式与store
相同,除了该命令通知符号表正在存储的变量不能被改变。我们有两个显而易见的实施方案。首先,在SymbolTable
类中,我们添加了第二个映射,指示给定的名称是代表变量还是常量。这种方法的优点是,添加一个额外的映射只需要对现有代码进行最少的实现更改。缺点是这种方法需要对符号表的每次调用进行两次独立的查找。更好的方法是修改原始映射,将值类型存储为Entry
而不是double
,其中Entry
定义为
struct Entry
{
double val;
bool isConst;
};
当然,为了避免硬编码double
类型,我们当然可以模板化SymbolTable
和Entry
。
由变量启用的功能
让我们来看看哪些变量使我们能够做到这一点。考虑根由
给定的二次方程ax2+bx+c= 0
以前我们不能编写存储过程来计算两个根,现在我们可以编写存储过程:
"c" store "b" store "a" store "b" 2 pow 4 "a" "c" * * - sqrt "root" store
"b" neg "root" + 2 a * / "b" neg "root" - 2 a * /
它将从栈中取出代表系数 a,b,c 的三个条目,并返回代表二次方程的根的两个条目。现在,我们的计算器有所进展了!
8.3 一些有趣的自我探索扩展
本章最后一节列出了一些有趣的 pdCalc 扩展,您可以考虑自己尝试一下。与上一节不同,我没有提供任何设计思路来帮助您开始。我只提供了每个挑战的简要描述。
8.3.1 高 DPI 缩放
像素分辨率极高的显示器越来越成为标准配置。考虑如何修改 pdCalc 的 GUI 来正确处理此类显示的缩放。这个特性是独立于操作系统的,还是我们对第七章中的PlatformFactory
有另外的用途?从版本 5.6 开始,Qt 通过一个高 DPI 缩放的接口来帮助你完成这个任务。
动态蒙皮
在第六章中,引入了一个类来管理 GUI 的外观。然而,所提供的实现仅仅集中了外观和感觉。它不允许用户定制。
用户经常想要定制他们的应用程序的外观和感觉。允许这种变化的应用程序被认为是“可换肤的”,每个不同的外观和感觉被称为一个皮肤。考虑对LookAndFeel
类进行必要的接口和适当的实现更改,以启用 pdCalc 的皮肤。一些可能的选项包括用于定制单个小部件的对话框或从皮肤配置文件中选择皮肤的机制。用一个集中的类来处理应用程序的外观应该会使这种改变变得简单明了。不要忘了给LookAndFeel
添加一个信号,这样其他 GUI 元素就知道什么时候需要用新的外观重新绘制它们自己了!
流量控制
有了变量,我们大大增强了存储过程的灵活性。对于计算大多数公式,这个框架应该是足够的。然而,如果我们想实现一个递归公式,比如计算一个数的阶乘,该怎么办呢?虽然我们有能力通过插件来执行如此复杂的计算,但如果能将这种功能扩展到那些没有 C++ 编程经验的计算器用户,那就更好了。为了完成这项任务,我们需要为流控制设计一个语法。最简单的设计至少能够处理循环和条件操作。就增加的功能和实现工作而言,将流控制添加到 pdCalc 将是一个相当显著的增强。可能是时候转向真正的扫描器和解析器了!
8.3.4 另一种图形用户界面布局
受 HP48S 计算器的启发,pdCalc GUI 目前采用垂直方向。然而,现代屏幕分辨率往往宽于高,使得垂直方向不是最佳选择。对水平方向进行硬编码并不比最初的垂直方向更具挑战性。相反,考虑如何重新设计 pdCalc,以便能够在运行时切换方向。也许垂直方向只是一个不同的皮肤选项?
一个图形计算器
HP48 系列计算器不仅仅是科学计算器;他们在绘制计算器。尽管在复杂的独立绘图程序存在的情况下,为计算机实现绘图计算器可能不太实际,但这种练习可能会被证明是非常有趣的。从 5.7 版本开始,Qt 现在包含了一个绘图模块,使得这个任务比以前简单多了。考虑到这个图形化小部件集,最大的挑战可能只是设计一个图形化输入的方法。如果你想回到 20 世纪 70 年代,考虑为 CLI 实现一个 ASCII 图形计算器!
8.3.6 插件管理系统
目前,插件是在 pdCalc 启动时加载的,加载哪些插件是通过从文本文件中读取共享库名称来确定的。插件一旦加载,就不能卸载。考虑实现一个动态插件管理系统,以便插件可以在运行时被选择、加载和卸载。您甚至可以扩展插件接口来支持插件描述的动态查询。我认为真正的问题在于如何处理一个插件的卸载,这个插件的一个命令当前在撤销/重做栈上。
移动设备接口
在我创作这本书的最初构思中,我设想了一章来描述如何将 pdCalc 扩展到 iOS 或 Android 平板电脑。Qt 库可以再次帮助您完成这项任务。我没有在本书中包括这一章的原因是我没有任何平板电脑编程的实践经验。从我第一次涉足设计领域开始,我就觉得试图教别人如何设计平板电脑界面是不真诚的。嗯,这可能是一个糟糕设计的绝佳例子!尽管如此,将 pdCalc 扩展到平板电脑或智能手机界面仍是一个有价值的挑战。
8.3.8 云中的 pdCalc
假设您想扩展 pdCalc 以在云中执行。本书中介绍的 pdCalc 的设计可能会被归类为模块化整体结构。大多数大规模云程序都被设计成分布式服务。因此,我建议的第一个设计变更是将 pdCalc 重组为微服务架构。我们不是将模块构建为共享库,而是将模块实现为独立的服务,每个模块运行在自己的容器中。模块将通过 RESTful APIs 而不是函数调用进行通信。从现有的 c++ API 设计 RESTful APIs 应该是一项简单的任务。根据程序的预期负载,您可以通过一个容器编排服务(如 Kubernetes)添加动态伸缩。然而,我怀疑我们的低级计算器不太可能看到每个服务需要一个以上的容器。一个合适的构建系统甚至应该包括一个自动化测试套件和一个用于持续集成和持续部署的管道。
微服务架构的一个方便的特性是,由于不同的组件通过 API 进行通信,所以用不同的编程语言编写不同的模块是很简单的。基于云的 pdCalc 的 GUI 可能是一个网页,我们可能希望用 JavaScript(或 TypeScript)编写该组件,可能使用 Angular 之类的库。后端可以用 C++ 设计,但 Python 可能是更合适的语言。根据会话状态和栈的设计,pdCalc 甚至可能需要一个数据库。
将 pdCalc 移植到云听起来不仅仅是一个小的设计修改。事实上,它的设计可能足够独特,足以成为它自己的一本书——如果你想合写,请随时给我发电子邮件。既然你已经看完了这本书,你和我都需要一个新的项目!