软件是复杂的,是人类有史以来最复杂的工作之一。当你第一次阅读一个大型编程项目的需求文件时,你可能会感到不知所措,这是意料之中的事,这项任务是压倒性的!由于这个原因,大规模的编程项目通常从分析开始。

项目的分析阶段包括探索问题领域的时间,以便完全理解问题,澄清需求,并解决客户和开发人员领域之间的任何歧义。如果不完全理解问题,作为架构师或开发人员,您绝对没有机会开发可维护的设计。但是,对于为本书选择的案例研究,应该熟悉该领域(如果不熟悉,您可能希望在这里暂停并参与分析练习)。因此,我们将跳过一个正式的、单独的分析阶段。也就是说,分析的各个方面永远不能被完全跳过,我们将在设计的构建过程中探索几种分析技术。这种分析和设计的有意耦合强调了这两种活动之间的相互作用,以说明即使对于最简单的问题领域,产生良好的设计也需要一些正式的技术来分析问题。

作为软件设计者,我们拥有的解决固有问题复杂性的最重要技术之一是分层分解。大多数人倾向于以两种方式之一来分解问题:自上而下或自下而上。自上而下的方法是先看全局,然后对问题进行细分,直到达到最底层。在软件设计中,绝对的最底层是各个功能的实现。然而,自上而下的设计可能在实现之前就停止了,最后是设计对象和它们的公共接口。自下而上的方法会从单个功能或对象的层面开始,反复组合组件,直到最终包含整个设计。

对于我们的案例研究,自上而下和自下而上的方法都将在设计的不同阶段使用。我发现,以自顶向下的方式开始组合是可行的,直到定义了批量模块及其接口,然后从底部向上实际设计这些模块。在处理计算器的分解之前,让我们首先检查一个好的分解的元素。

2.1 良好分解的要素

是什么让分解变得很好?很明显,我们可以随意地将功能分成不同的模块,并将完全不相干的组件分组。以计算器为例,我们可以把算术运算符和图形用户界面放在一个模块中,而把三角函数与Stack和错误处理放在另一个模块中。这是一种分解,只是不是非常有用的分解。

一般来说,一个好的设计会显示出模块化、封装、高内聚和低耦合等属性。许多开发者已经在面向对象的设计中看到了许多良好的分解原则。毕竟,将代码分解成对象本身就是一个分解过程。让我们首先在一个抽象的环境中考察这些原则。随后,我将通过将这些原则应用于pdCalc来进行讨论。

模块化将组件分解成独立互动的部分(模块)是很重要的。有几个原因:首先,它允许人们立即将一个大的、复杂的问题分割成多个更小的、更易操作的组件。虽然试图一次实现整个计算器的代码会很困难,但实现一个独立运作的Stack是相当合理的。第二,一旦组件被分割成不同的模块,就可以定义单元测试来验证各个模块,而不是要求在测试开始前完成整个程序。第三,对于大型项目来说,如果定义了具有明确边界和接口的模块,开发工作就可以在多个程序员之间进行分配,防止他们因为需要修改相同的源文件而不断干扰对方的进度。

其余的良好设计原则、封装、高内聚和低耦合都描述了模块应该具备的特征。基本上,它们可以防止意大利面条式的代码。封装,或者说信息隐藏,是指一旦定义了一个模块,它的内部实现(数据结构和算法)对其他模块是隐藏的。相应地,一个模块不应该使用任何其他模块的私有实现。这并不是说,模块之间不应该相互影响。相反,封装坚持认为,模块之间只能通过明确定义的,最好是有限的接口进行交互。这种明显的分离确保了内部模块的实现可以独立修改,而不必担心破坏外部的、依赖性的代码,只要接口保持固定,并且满足接口所保证的契约。

内聚性指的是模块内的代码应该是自洽的,或者说是凝聚力。也就是说,一个模块中的所有代码应该在逻辑上适合在一起。回到我们这个糟糕的计算器设计的例子,一个混合了算术代码和用户界面代码的模块将缺乏内聚性。没有逻辑上的联系将这两个概念结合在一起(除了它们都是计算器的组成部分)。虽然像我们的计算器这样的小代码,如果缺乏内聚性,也不至于完全无法穿透,但一般来说,一个大的、没有内聚性的代码库是非常难以理解、维护和扩展的。

糟糕的内聚性可以表现为两种方式之一:要么是不应该在一起的代码被挤在一起,要么是应该在一起的代码被拆开。在第一种情况下,代码功能几乎不可能被分解成精神上可管理的抽象概念,因为逻辑子组件之间没有明确的界限。在后一种情况下,阅读或调试不熟悉的代码(尤其是第一次)会非常令人沮丧,因为典型的代码执行路径会以一种看似随机的方式从一个文件跳到另一个文件。无论哪种表现形式都会产生反作用,因此我更喜欢有内聚性的代码。

最后,我们将研究耦合问题。耦合表示组件的相互连接,无论是功能耦合还是数据耦合。当一个模块的逻辑流程需要调用另一个模块来完成其动作时,就会发生功能耦合。相反,数据耦合是指各个模块之间通过直接共享(例如,一个或多个模块指向一些共享数据集)或通过数据传递(例如,一个模块向另一个模块返回一个内部数据结构的指针)来共享数据。争论零耦合显然是荒谬的,因为这种状态将意味着没有模块可以以任何方式与其他模块进行交流。然而,在好的设计中,我们确实要争取低耦合度。低到什么程度才算低呢?简单的答案是在保持必要的功能的情况下,尽可能的低。现实情况是,在不使代码复杂化的情况下将耦合度降到最低是一种随着经验积累而获得的技能。就像封装一样,低耦合性是通过确保模块之间只通过干净的、有限的接口进行通信来实现的。高度耦合的代码是很难维护的,因为一个模块的设计的微小变化可能会导致许多不可预见的、通过看似不相关的模块的级联变化。请注意,封装保护模块A不受模块B的内部实现变化的影响,而低耦合保护模块A不受模块B的接口变化的影响。

2.2 选择一个架构

虽然现在很想遵循上述准则,简单地开始将我们的计算器分解成看似合理的组成成分,但最好先看看别人是否已经解决了我们的问题。因为类似的问题在编程中经常出现,所以软件架构师已经创建了一个解决这些问题的模板目录;这些原型被称为模式。模式通常有多个种类,本书将研究的两类模式是设计模式[6]和架构模式。

设计模式是用来解决软件设计过程中出现的类似问题的概念模板;它们通常被应用于局部决策。在本书中,我们将在计算器的详细设计过程中反复遇到设计模式。然而,我们的第一个顶层分解需要一个全局范围的模式,它将定义总体的设计策略或者软件架构。这样的模式自然被称为架构模式。

架构模式在概念上与设计模式类似;两者的区别主要在于其适用范围。设计模式通常适用于特定的类或相关的类集,而架构模式则通常概述了整个软件系统的设计。请注意,我指的是一个软件系统而不是一个程序,因为架构模式可以超越简单的程序边界,包括与硬件的接口或多个独立程序的耦合。在我们的案例研究中,有两种架构模式特别值得关注,即多层架构和模型-视图-控制器(MVC)架构。在将这两种模式应用于pdCalc之前,我们将分别对其进行抽象的研究。架构模式在我们的案例研究中的成功应用将代表计算器的第一层分解。

2.2.1 多层次的结构

在多层或n层体系结构中,组件按层顺序排列。通过相邻层进行通信是双向的,但不允许非相邻层直接通信。n层架构如图2-1所示。
image.png
图2-1. 用箭头表示通信的多层体系结构

多层架构的最常见形式是三层架构。第一层是表现层,它由所有的用户界面代码组成。第二层是逻辑层,它捕捉应用程序的所谓 “业务逻辑”。第三层是数据层,顾名思义,它封装了系统的数据。很多时候,三层架构被用作企业级平台,其中每一层不仅可以代表不同的本地进程,而且可能代表在不同机器上运行的不同进程。在这样的系统中,表现层将是客户端界面,无论是传统的桌面应用程序还是基于浏览器的界面。程序的逻辑层可以运行在应用程序的客户端或服务器端,或者可能同时运行在这两个地方。最后,数据层将由一个可以在本地或远程运行的数据库表示。 然而,正如你将在pdCalc中看到的那样,三层结构也可以应用于一个单一的桌面应用程序。

让我们来看看三层架构是如何遵守我们的一般分解原则的。首先,也是最重要的,在最高层的分解中,该架构是模块化的。至少有三个模块,每个层级都有一个。然而,三层架构并不排除在每一层存在多个模块。如果系统足够大,每个主要模块都值得细分。 第二,这种架构鼓励封装,至少在层与层之间。虽然人们可以愚蠢地设计一个三层架构,让相邻的层访问相邻层的私有方法,但这样的设计将是反直觉的,而且非常脆弱。 也就是说,在各层共存于同一进程空间的应用中,很容易使各层交织在一起,必须注意确保这种情况不会出现。 这种分离是通过明确的接口清楚地划分每一层来实现的。 第三,三层架构是有内聚性的。架构的每一层都有一个独特的任务,不会与其他层的任务混在一起。最后,三层架构作为有限耦合的一个例子,真正发挥了作用。通过明确定义的接口来分离每一层,每一层都可以独立于其他层而发生变化。这个特点对于那些必须在多个平台上执行的应用(只有表现层在平台之间变化)或在其生命周期中对某一特定层进行不可预见的替换的应用(例如,由于可扩展性问题必须改变数据库)特别重要。

2.2.2 模型-视图-控制器架构(MVC)

在模型-视图-控制器架构中,组件被分解成三个不同的元素,被恰当地命名为模型、视图和控制器。模型抽象了领域数据,视图抽象了用户界面,而控制器则管理着模型和视图之间的交互。通常情况下,MVC模式被应用于框架层面上的单个GUI部件,其设计目标是在多个不同的视图可能与相同的数据相关联的情况下,将数据与用户界面分开。例如,考虑一个日程安排的应用程序,该应用程序必须能够存储约会的日期和时间,但用户可以在日历中查看这些约会,可以按天、周或月查看。应用MVC,约会数据被一个模型模块(可能是面向对象框架中的一个类)抽象出来,每个日历样式被一个不同的视图(可能是三个独立的类)抽象出来。一个控制器被引入来处理由视图产生的用户事件,并操作模型中的数据。

乍一看,MVC似乎与三层架构没有什么不同,模型取代了数据层,视图取代了表现层,而控制器取代了业务逻辑层。然而,这两种架构模式在其交互模式上是不同的。在三层架构中,各层之间的通信是僵化的线性的。也就是说,表现层和数据层只与逻辑层双向交流,而不是相互交流。在MVC中,通信是三角形的。虽然不同的MVC实现在其确切的通信模式上有所不同,但图2-2中描述了一个典型的实现。在这个图中,视图既可以产生由控制器处理的事件,又可以直接从模型中获得要显示的数据。控制器处理来自视图的事件,但它也可以直接操作模型或控制器。最后,模型可以被视图或控制器直接操作,但它也可以产生事件由视图处理。一个典型的事件是一个状态改变事件,它将导致视图更新它对用户的展示。
image.png

图2-2. 一个MVC架构,箭头表示通信。 实线表示直接通信。虚线表示间接通信(例如,通过事件处理)[30]。
正如我们对三层架构所做的那样,现在让我们来看看MVC是如何遵守一般的分解原则的。首先,一个MVC架构通常会被分解成至少三个模块:模型、视图和控制器。然而,与三层架构一样,一个更大的系统会接纳更多的模块,因为模型、视图和控制器中的每一个都需要细分。其次,这种架构也鼓励封装。模型、视图和控制器应该只通过明确定义的接口相互作用,其中事件和事件处理被定义为接口的一部分。第三,MVC架构是有内聚性的。每个组件都有一个独特的、定义明确的任务。最后,我们问MVC架构是否是松散耦合的。通过检查,这种架构模式比三层架构更紧密地耦合,因为表现层和数据层被允许有直接的依赖关系。在实践中,这些依赖关系通常通过松散耦合的事件处理或通过抽象基类的多态性来限制。然而,通常情况下,这种额外的耦合通常会使MVC模式被限制在一个内存空间中的应用。这种限制与三层架构的灵活性形成了直接的对比,三层架构可以将应用程序跨越多个内存空间。

2.2.3 应用于计算器的架构模式

现在让我们回到案例研究,并将上面讨论的两种体系结构模式应用到pdCalc。最终,我们将选择一个作为应用程序的体系结构。如前所述,三层体系结构由表示层、逻辑层和数据层组成。对于计算器,这些层被清楚地标识为输入命令和查看结果(通过图形或命令行用户界面)、命令的执行和Stack。对于MVC架构,我们将Stack作为模型,用户界面作为视图,命令分派器作为控制器。图2-3描述了这两种计算器架构。注意,在三层和MVC架构中,表示层或视图的输入方面只负责接受命令,而不负责解释或执行命令。这种区分减轻了开发人员为自己造成的一个常见问题:表示层与逻辑层的混合。
image.png
图2-3. 计算器体系结构的选择

2.2.4 选择计算器的架构

从图2-3中,我们可以很快发现,这两种架构将计算器划分为相同的模块。事实上,在架构层面上,这两种相互竞争的架构只在其耦合度上有区别。因此,在选择这两种体系结构时,我们只需要考虑它们两种通信模式之间的设计权衡。很明显,三层架构和MVC架构之间的主要区别在于用户界面(UI)和Stack之间的通信模式。在三层架构中,用户界面和Stack只允许通过命令调度器进行间接通信。这种分离的最大好处是减少了系统中的耦合性。UI和Stack不需要知道对方的接口。当然,缺点是如果程序需要大量的UI和Stack的直接通信,命令调度器将被要求作为这种通信的中介,这就降低了命令调度器模块的内聚性。MVC架构则有完全相反的权衡。也就是说,以额外的耦合为代价,用户界面可以直接与Stack交换消息,避免了命令调度器执行与它的主要目的无关的附加功能的尴尬局面。因此,架构的决定简化为检查用户界面是否经常需要与Stack直接连接。

在RPN计算器中,Stack作为程序的输入和输出的存放处。通常情况下,用户会希望看到输入和输出都准确地出现在Stack中。这种情况有利于MVC架构,它在视图和数据之间有直接的交互。也就是说,计算器的视图不需要命令调度器来翻译数据和用户之间的通信,因为不需要对数据进行转换。因此,我选择了模型-视图-控制器作为pdCalc的架构。诚然,对于我们的案例研究来说,MVC架构比三层架构的优势很小。如果我选择使用三层架构,pdCalc仍然会有一个完全有效的设计。

2.3 接口

尽管我们可能很想宣布第一层的分解已经完成,选择了MVC架构,但我们还不能宣布胜利。虽然我们已经定义了三个最高级别的模块,但我们还必须定义它们的公共接口。然而,如果不利用一些正式的方法来捕捉我们问题中的所有数据流,我们很可能会错过接口的关键必要元素。因此,我们求助于一种面向对象的分析技术—用例。用例是一种分析技术,它生成了对用户在系统中的特定行为的描述。本质上,用例定义了一个工作流程。重要的是,一个用例并不指定一个实现。在用例生成过程中应该咨询客户,特别是在用例发现需求中的模糊性的情况下。关于用例和用例图的细节可以在Booch等人[4]中找到。为了设计pdCalc高级模块的接口,我们将首先定义终端用户与计算器交互的用例。每个用例应该定义一个工作流程,我们应该提供足够的用例来满足计算器的所有技术要求。然后,这些用例可以被研究,以发现模块之间所需的最小交互。这些通信模式将定义各模块的公共接口。这种用例分析的另一个好处是,如果我们现有的模块不足以实现所有的工作流程,我们将在顶层设计中发现对额外模块的需求。

2.3.1 计算器用例

让我们为我们的需求创建用例。为了保持一致性,用例是按照它们在需求中出现的顺序来创建的。

2.3.1.1 用户在Stack中输入一个浮点数字

情景:用户在Stack中输入一个浮点数字。输入后,用户可以看到Stack上的数字。
异常:用户输入了一个无效的浮点数,显示一个错误条件。

2.3.1.2 用户撤消最后一次操作

情景:用户输入命令撤销上一次的操作。系统撤销上一次的操作,并显示上一次的Stack。
异常:没有要撤销的命令。显示一个错误条件。

2.3.1.3 用户重做最后一次操作

情景:用户输入命令撤销上一次的操作。用户输入命令,重做上一次的操作。系统重做了上次的操作,并显示新的Stack。
异常:没有重做的命令,显示一个错误条件。

2.3.1.4 用户调换Stack顶部元素

情景:用户输入命令,交换Stack中的前两个元素。系统交换了Stack中的前两个元素,并显示新的Stack。
异常:Stack中没有至少两个数字,显示一个错误条件。

2.3.1.5 用户丢弃最上面的Stack元素

情景:用户输入命令,从Stack中删除最上面的元素。系统从Stack中丢掉最上面的元素,并显示新的Stack。
异常情况:Stack是空的,显示一个错误条件。

2.3.1.6 用户清除Stack

情景:用户输入了清除Stack的命令,系统清除了Stack并显示空的Stack。
异常:没有。即使是空的Stack,也要让清空成功(什么都不做)。

2.3.1.7 用户复制了顶层Stack元素

情景:用户输入命令,复制Stack上的顶级元素。系统复制了Stack中最顶端的元素,并显示新的Stack。
异常:Stack是空的,显示一个错误条件。

2.3.1.8 用户否定了Stack顶部的元素

情景:用户输入命令,否定Stack中的顶层元素。系统否定了Stack中的顶层元素,并显示新的Stack。
异常:Stack是空的,显示一个错误条件。

2.3.1.9 用户执行算术操作

情景:用户输入加、减、乘、除的命令,系统执行该操作并显示新的Stack。
异常:Stack大小不足以支持该操作,显示一个错误条件。
异常:检测到除以0,显示一个错误条件。

2.3.1.10 用户执行三角运算

情景:用户输入sin、cos、tan、arcsin、arccos或arctan的命令,系统执行该操作并显示新的Stack。
异常:Stack大小不足以支持该操作,显示一个错误的条件。
异常:操作的输入是无效的(例如,arctan(-50)产生一个虚数的结果),显示一个错误条件。

2.3.2 用例分析

现在我们将分析这些用例,以便为pdCalc的模块开发C++接口。请记住,C++语言并没有正式定义一个模块的概念。因此,从概念上讲,把接口看作是类和函数集合的公开函数签名,这些类和函数在逻辑上被分组,以定义一个模块。为了简洁起见,文中省略了std命名空间的前缀。

让我们按顺序审查这些用例。随着公共接口的开发,它将被输入表2-2中。第一个用例将是个异常,其接口将在表2-1中描述。通过为第一个用例使用一个单独的表格,我们将能够保留我们在第一遍时的错误,以便与我们的最终产品进行比较。在本节结束时,所有模块的整个公共接口将被开发和编目。

我们从第一个用例开始:输入一个浮点数字。用户界面的实现将负责把数字从用户那里输入计算器。在这里,我们关注的是把数字从用户界面拿到Stack中所需要的接口。

无论数字从用户界面到Stack的路径如何,我们最终都必须有一个函数调用来将数字推入Stack。因此,我们接口的第一部分只是Stack模块上的一个函数push(),用于将一个双精度的数字推入Stack。我们把这个函数输入到表2-1中。注意,表中包含了完整的函数签名,而返回类型和参数类型在文中被省略了。

现在,我们必须探索从用户界面模块到Stack模块获取数字的方案。从图2-3b中,我们看到用户界面与Stack有一个直接的联系。因此,最简单的选择是使用我们刚刚定义的push()函数直接从用户界面将浮点数推到Stack上。这是个好主意吗?

根据定义,命令调度器模块或控制器的存在是为了处理用户输入的命令。输入一个数字是否应该被区别对待,例如,加法命令?让用户界面绕过命令调度器,直接在Stack模块中输入数字,违反了最小惊讶原则(也被称为最小惊奇原则)。从本质上讲,这个原则指出,当设计者面临多个有效的设计选项时,正确的选择是符合用户直觉的那一个。在界面设计的背景下,用户是另一个程序员或设计师。在这里,任何在我们的系统上工作的程序员都希望所有的命令都能得到相同的处理,所以一个好的设计会遵守这个原则。

为了避免违反最小惊讶原则,我们必须建立一个接口,将新输入的号码从用户界面通过命令调度器传送出去。我们再次参考图2-3b。不幸的是,用户界面没有与命令调度器的直接连接,因此不可能进行直接通信。然而,它确实有一个间接的途径。因此,我们唯一的选择是让用户界面引发一个事件(你将在第三章详细研究事件)。具体来说,用户界面必须引发一个事件,表明有一个数字被输入,而命令调度器必须能够接收这个事件(最终,通过其公共接口中的一个函数调用)。让我们在表2-1中再添加两个函数,一个用于由用户界面引发的numberEntered()事件,另一个用于命令调度器中的numberEntered()事件处理函数。

一旦这个数字被接受,用户界面必须显示修改后的Stack。这是通过Stack发出它已经改变的信号,以及视图从Stack中请求n个元素并将它们显示给用户来完成的。我们必须使用这个途径,因为Stack只有一个与用户界面的间接通信渠道。我们在表2-1中又增加了三个函数:Stack模块上的stackChanged()事件,UI上的stackChanged()事件处理程序,以及Stack模块上的getElements()函数(参见 “现代C++设计说明 “边栏中的移动语义,查看getElements()函数的签名选项)。与输入数字本身不同,让UI直接调用Stack的函数来获取元素以响应stackChanged()事件是合理的。事实上,这正是我们希望视图在MVC模式中与它的数据交互的方式。

当然,上述工作流程假设用户输入了一个有效的数字。然而,为了完整起见,该用例还规定,在数字输入时必须进行错误检查。因此,命令调度器应该在将数字推入Stack之前实际检查其有效性,如果发生了错误,它应该向用户界面发出信号。UI应该相应地能够处理错误事件。这就为表2-1增加了两个函数:一个是命令调度器上的error()事件,另一个是用户界面上的函数displayError(),用于处理错误事件。请注意,我们可以选择另一种错误处理设计,让用户界面执行自己的错误检查,只对有效的数字提出一个数字输入事件。然而,为了提高内聚性,我倾向于将错误检查的“业务逻辑”放在控制器中,而不是界面中。

这就完成了我们对第一个用例的分析。如果你忘记了,请记住,刚才描述的所有功能和事件都总结在表2-1中。现在只需要再做12个令人兴奋的用例,就可以完成我们的界面分析了!别担心,这种苦差事很快就会结束。我们很快就会得出一个设计,可以将几乎所有的用例整合到一个统一的界面中。

表2-1. 从分析输入浮点数字到Stack的用例中得到的公共接口

Functions Events
User Interface void displayError(const string&) numberEntered(double)

void stackChanged()
Command Dispatcher void numberEntered(double) error(string)
Stack void push(double) stackChanged()
void getElements(n, vector&)

在立即进行下一个用例之前,让我们停顿一下,讨论一下我们刚才隐含的关于错误处理的两个决定。首先,用户界面通过捕捉事件而不是捕捉异常来处理错误。因为用户界面不能直接向命令分配器发送消息,所以用户界面永远不能将对命令分配器的调用包裹在一个try catch块中。这种通信模式立即消除了使用C++异常来处理模块间的错误(注意,它并不排除在单个模块内部使用异常)。在这种情况下,由于数字输入错误被捕获在命令调度器中,我们可以使用回调直接通知用户界面。然而,这个惯例并不充分通用,因为它对于在Stack中检测到的错误来说会被打破,因为Stack没有与用户界面直接通信。第二,我们决定所有的错误,不管是什么原因,都将通过向用户界面传递一个描述错误的字符串来处理,而不是制定一个错误类型的等级制度。这个决定是合理的,因为用户界面从来没有试图区分不同的错误。相反,用户界面只是作为一个渠道,逐字逐句地显示来自其他模块的错误信息。


现代C++设计说明:移动语义

在表2-1中,Stack有一个函数 void getElements(n, vector<double>&),它使调用者能够用Stack中的前n个元素填充一个向量。然而,该函数的界面没有告诉我们元素是如何被添加到向量中的。它们是在前面加入的吗?是在后面添加的吗?是否假定向量的大小已经正确,新元素是用operator[]输入的?在添加新元素之前,旧的元素是否被从向量中删除?希望这个模棱两可的问题能通过开发者的文档来解决(祝你好运)。在没有进一步信息的情况下,人们可能会得出结论,新元素只是被推到了向量的后面。

然而,从C++11开始,上述接口的模糊性可以由语言本身在语义上解决。R值引用和移动语义使我们可以非常明确地做出这个接口决定。我们现在可以有效地(也就是说,不需要复制向量或依靠编译器来实现返回值的优化)实现函数vector<double> getElements(n)。一个临时的向量在函数内部被创建,它的内容在函数返回时被移到调用者中。现在接口契约是明确的。一个大小为n的新向量将被返回,并被Stack中的前n个元素填充。

为了不使文本中的接口臃肿,该函数的两个变体都没有明确出现在定义接口的表格中。然而,这两种变体确实出现在源代码中。本书将经常使用这一惯例。当执行相同操作的多个辅助调用在实现中很有用时,两个都出现在那里,但只有一个变体出现在文本中。对于本书的说明性目的来说,这种省略是可以接受的,但对于一个真实项目的详细设计规范来说,这种省略是不能接受的。


接下来的两个用例,即操作的撤销和重做,有足够的相似性,我们可以同时对它们进行分析。首先,我们必须在用户界面上增加两个新的事件:一个用于撤销操作,一个用于重做操作。相应地,我们必须在命令调度器中为撤销和重做分别添加两个事件处理函数。在简单地将这些函数添加到表2-2之前,让我们退一步,看看是否可以简化。

表2-2. 整个第一层分解的公共接口

Functions Events
User Interface void postMessage(const string&) commandEntered(double)

void stackChanged()
Command Dispatcher void commandEntered(const string&) error(string)
Stack void push(double) stackChanged()
void getElements(n, vector&) error(string)
double pop()
void swapTop()

在这一点上,你应该开始看到从用户界面事件中出现的一个模式被添加到表中。每个用例都会添加一个新的commandEntered()形式的事件,到目前为止,command已经被数字、撤销或重做所取代。在随后的用例中,command可能会被替换成swap、add、sin、exp等操作。与其继续通过在用户界面中给每个命令一个新的事件和在命令调度器中给一个相应的事件处理程序来使界面变得臃肿,我们不如用听起来相当普通的用户界面事件commandEntered()和命令调度器中的伙伴事件处理程序commandEntered()来代替这一系列的命令。这个事件/处理程序对的唯一参数是一个字符串,它对给定的命令进行编码。在输入数字的情况下,该参数是数字的ASCII表示。

将所有的UI命令事件合并成一个带有字符串参数的事件,而不是将每个命令作为一个单独的事件来发布,有几个设计目的。首先,也是最直观的,这个选择简化了界面。我们现在只需要一对函数来处理来自所有命令的事件,而不是在用户界面和命令调度器中为每个单独的命令提供一对函数。这包括需求中的已知命令和任何可能来自未来扩展的未知命令。然而,更重要的是,这种设计促进了内聚性,因为现在用户界面不需要了解它所触发的任何事件的情况。相反,对命令事件的解读被放在了命令调度器中,这个逻辑自然属于那里。为命令创建一个commandEntered()事件甚至对命令、图形用户界面按钮和插件的实现有直接影响。我将把这些讨论留给你在第4、6和7章中遇到这些主题时进行。

现在我们回到对撤销和重做用例的分析。如上所述,我们将放弃在表2-2中为我们遇到的每个新命令添加新的命令事件。相反,我们在用户界面上添加commandEntered()事件,在命令调度器上添加commandEntered()事件处理程序。这个事件/处理程序对将足以满足所有用例中的所有命令。然而,Stack还不具备实现每个命令的所有必要功能。例如,为了撤销对Stack的推送,我们将需要能够从Stack中弹出数字。让我们在表2-2中为Stack添加一个pop()函数。最后,我们注意到,如果我们试图弹出一个空的Stack,可能会发生一个Stack错误。因此,我们在Stack中添加一个通用的error()事件,以反映命令调度器上的错误事件。

我们转到下一个用例,交换Stack的顶部。显然,这个命令将重复使用前面用例中的commandEntered()error()模式,所以我们只需要确定是否需要在栈的接口上添加一个新函数。显然,交换Stack的前两个元素可以通过Stack上的swapTop()函数或者通过现有的push()pop()函数实现。我有些武断地选择了实现一个单独的swapTop()函数,所以我把它添加到表2-2中。这个决定可能是下意识地植根于我的自然设计倾向,即以牺牲重复使用为代价来实现效率最大化(我的大部分专业项目都是高性能数值模拟)。事后看来,这可能不是一个更好的设计决定,但这个例子表明,有时设计决定只不过是基于设计者的本能,是由他或她的个人经验决定的。

在这一点上,对其余用例的快速扫描表明,除了加载一个插件外,表2-2定义的现有模块接口足以处理所有用户与计算器的交互。每个新的命令只是在命令调度器的内部增加了新的功能,其逻辑将在第四章中详细介绍。 因此,剩下的唯一要研究的用例就是为pdCalc加载插件。 插件的加载虽然复杂,但对计算器中其他模块的侵入性很小。除了命令和用户界面的注入(你将在第7章遇到这些主题),插件加载器是一个独立的组件。因此,我们将其接口的设计(以及对其他接口的必要的相应修改)推迟到我们准备实现插件的时候。

推迟顶层界面的大部分设计是一个有点冒险的提议,设计纯粹主义者可能会反对这样做。然而,从实际情况来看,我发现当主要元素已经设计得足够多时,你就需要开始编码了。无论如何,设计会随着实施的进展而改变,所以通过对初始设计的过度加工来寻求完美是徒劳的。当然,也不应该在敏捷的狂热中完全放弃所有的前期设计。

综上所述,采用推迟主要部件设计的策略有一些注意事项。首先,如果推迟设计的部分会对结构产生实质性的影响,那么推迟可能会导致以后的重大返工。第二,推迟部分设计会延长接口的稳定时间。这样的延迟对于独立工作的大型团队来说可能有问题,也可能没有问题。只有通过经验才能知道什么可以推迟,什么不能推迟。如果你不确定一个组件的设计是否可以安全地推迟,你最好谨慎行事,在前期进行一些额外的设计和分析工作,以减少对整个架构的影响。影响程序架构的不良设计将影响项目的开发。它们所造成的返工要比糟糕的实现要多得多,而且在最坏的情况下,糟糕的设计决定在经济上是不可能修复的。

有时,它们只能在重大重写中修复,而这可能永远不会发生。 在完成用例分析之前,让我们将表2-1 中为第一个用例开发的接口与表2-2 中开发的包含所有用例的接口进行比较。 令人惊讶的是,表2-2 仅比表2-1 稍长。 这证明了将命令抽象为一个通用功能而不是每个命令的单独功能的设计决策。 简化模块之间的通信模式是设计代码而不仅仅是黑客攻击的众多节省时间的优势之一。 第一个接口和完整接口唯一的其他区别是增加了一些Stack函数和一些函数名称的修改(例如,将displayError()函数重命名为postMessage()以增加操作的通用性)。

2.3.3 关于实际执行情况的简要说明

所开发的接口代表了代码中实际部署的接口的理想化。实际的代码在语法上可能会有些不同,但是接口的语义意图总是被保留的。例如,在表2-2中,我们将获取n个元素的接口定义为void getElements(n, vector<double>&),这是一个完全可用的接口。然而,利用现代C++的新特性(见侧边栏的移动语义),该实现通过提供vector<double> getElements(n)作为一个逻辑上等价的重载接口来使用r值引用和移动构造。

定义好的C++接口是一项非常不简单的任务;我知道至少有一本优秀的书是完全针对这个主题的[20]。在本书中,我只提供了足够的关于接口的细节,以清楚地解释设计。可用的源代码展示了开发高效的C++接口所需的错综复杂的问题。在一个非常小的项目中,允许开发人员在适应接口方面有一定的自由度,通常是可以容忍的,而且往往是有益的,因为它允许将实现细节推迟到可以实际确定的程度。然而,在大规模的开发中,为了防止独立团队之间出现绝对的混乱,在开始实施之前尽快确定接口是明智的做法。

2.4 对我们当前设计的评估

在开始详细设计我们的三个主要组件之前,让我们停下来,根据我们在本章开始时确定的标准评估我们目前的设计。首先,在定义了三个不同的模块之后,我们的设计显然是模块化的。 第二,每个模块都是一个高内聚的单元,每个模块都专门负责一个特定的任务。用户界面代码属于一个模块,操作逻辑属于另一个模块,而数据管理则属于另一个独立的模块。此外,每个模块都封装了自己的所有功能。最后,这些模块是松散耦合的,在需要耦合的地方,它是通过一组明确定义的、简洁的、公共的接口。我们的顶层架构不仅符合我们的良好设计标准,而且也符合一个众所周知的、经过充分研究的、已经成功使用了几十年的架构设计模式。在这一点上,我们已经重申了我们设计的质量,并且应该感到非常舒服地进入我们分解的下一个步骤,即各个组件的设计。

2.5 下一步

我们从哪里开始?我们现在已经建立了计算器的整体架构,但我们如何解决选择先设计和实现哪个组件的任务呢?在企业环境中,对于一个大规模的项目,很可能会有许多模块同时被设计和编码。毕竟,这不正是创建由接口干净地分开的不同模块的主要原因之一吗? 当然,对于我们的项目来说,这些模块将被按顺序处理,通过一定程度的迭代来进行事后改进。因此,我们必须选择一个模块首先进行设计和构建。

在这三个模块中,最合理的起点是对其他模块依赖性最小的模块。从图2-3中,我们看到,事实上,Stack是唯一一个对其他模块的接口没有依赖的模块。栈的唯一向外的箭头是虚线,这意味着通信是通过事件间接进行的。尽管该图使这一决定在图形上显而易见,但如果没有架构图,人们可能会得出同样的结论。Stack本质上是一个独立的数据结构,很容易实现和隔离测试。 一旦Stack完成并经过测试,它就可以被整合到其余模块的设计和测试中。因此,我们通过设计和实现Stack开始下一级的分解。