- 2.1 The Elements of a Good Decomposition(良好分解的要素)
- 2.2 Selecting an Architecture(选择一个架构)
- 2.3 Interfaces(接口)
- 2.3.1 Calculator Use Cases(计算器用例)
- 2.3.1.1 Use Case: User Enters a Floating Point Number onto the Stack(用户在堆栈中输入一个浮点数字)
- 2.3.1.2 Use Case: User Undoes Last Operation 用户撤消最后一次操作
- 2.3.1.3 Use Case: User Redoes Last Operation 用户重做最后一次操作
- 2.3.1.4 Use Case: User Swaps Top Stack Elements 用户调换堆栈顶部元素
- 2.3.1.5 Use Case: User Drops the Top Stack Element 用户丢弃最上面的堆栈元素
- 2.3.1.6 Use Case: User Clears the Stack 用户清除堆栈
- 2.3.1.7 Use Case: User Duplicates the Top Stack Element 用户复制了顶层堆栈元素
- 2.3.1.8 Use Case: User Negates the Top Stack Element 用户否定了堆栈顶部的元素
- 2.3.1.9 Use Case: User Performs an Arithmetic Operation 用户执行算术操作
- 2.3.1.10 Use Case: User Performs a Trigonometric Operation
- 2.3.2 Analysis of Use Cases(用例分析)
- 2.3.3 A Quick Note on Actual Implementation(关于实际执行情况的简要说明)
- 2.3.1 Calculator Use Cases(计算器用例)
- 2.4 Assessment of Our Current Design(对我们当前设计的评估 )
- 2.5 Next Steps(下一步)
Software is complex, one of the most complex endeavors humankind has ever undertaken. When you first read the requirements document for a large-scale programming project, you may feel overwhelmed. That’s expected; the task is overwhelming! For this reason, largescale programming projects typically begin with analysis.
软件是复杂的,是人类有史以来最复杂的工作之一。当你第一次阅读一个大型编程项目的需求文件时,你可能会感到不知所措,这是意料之中的事,这项任务是压倒性的!由于这个原因,大规模的编程项目通常从分析开始。
The analysis phase of a project consists of the time spent exploring the problem domain in order to understand the problem completely, clarify the requirements, and resolve any ambiguities between the client’s and developer’s domains. Without fully understanding the problem, you, as the architect or developer, have absolutely no chance of developing a maintainable design. For the case study chosen for this book, however, the domain should be familiar (if not, you may wish to pause here and partake in an analysis exercise). Therefore, we will skip a formal, separate analysis phase. That said, aspects of analysis can never be skipped entirely, and we will explore several analysis techniques during the construction of our design. This intentional coupling of analysis and design emphasizes the interplay between these two activities to demonstrate that even for the simplest of problem domains, producing a good design requires some formal techniques for analyzing the problem.
项目的分析阶段包括探索问题领域的时间,以便完全理解问题,澄清需求,并解决客户和开发人员领域之间的任何歧义。如果不完全理解问题,作为架构师或开发人员,您绝对没有机会开发可维护的设计。但是,对于为本书选择的案例研究,应该熟悉该领域(如果不熟悉,您可能希望在这里暂停并参与分析练习)。因此,我们将跳过一个正式的、单独的分析阶段。也就是说,分析的各个方面永远不能被完全跳过,我们将在设计的构建过程中探索几种分析技术。这种分析和设计的有意耦合强调了这两种活动之间的相互作用,以说明即使对于最简单的问题领域,产生良好的设计也需要一些正式的技术来分析问题。
One of the most important techniques we have as software designers for addressing inherent problem complexity is hierarchical decomposition. Most people tend to decompose a problem in one of two ways: top down or bottom up. A top-down approach starts by looking at the whole picture and subsequently subdividing the problem until reaching the bottom-most level. In software design, the absolute bottom-most level is individual function implementations. However, a top-down design might stop short of implementation and conclude by designing objects and their public interfaces. A bottom-up approach would start at the individual function or object level and combine components repeatedly until eventually encompassing the entire design.
作为软件设计者,我们拥有的解决固有问题复杂性的最重要技术之一是分层分解。大多数人倾向于以两种方式之一来分解问题:自上而下或自下而上。自上而下的方法是先看全局,然后对问题进行细分,直到达到最底层。在软件设计中,绝对的最底层是各个功能的实现。然而,自上而下的设计可能在实现之前就停止了,最后是设计对象和它们的公共接口。自下而上的方法会从单个功能或对象的层面开始,反复组合组件,直到最终包含整个设计。
For our case study, both top-down and bottom-up approaches will be used at various stages of the design. I find it practical to begin ecomposition in a top-down fashion until bulk modules and their interfaces are defined, and then actually design these modules from the bottom up. Before tackling the decomposition of our calculator, let’s first begin by examining the elements of a good decomposition.
对于我们的案例研究,自上而下和自下而上的方法都将在设计的不同阶段使用。我发现,以自顶向下的方式开始组合是可行的,直到定义了批量模块及其接口,然后从底部向上实际设计这些模块。在处理计算器的分解之前,让我们首先检查一个好的分解的元素。
2.1 The Elements of a Good Decomposition(良好分解的要素)
What makes a decomposition good? Obviously, we could just randomly split functionality into different modules and group completely unconnected components. Using the calculator as an example, we could place arithmetic operators and the GUI in one module while placing trigonometric functions with the stack and error handling in another module. This is a decomposition, just not a very useful one.
是什么让分解变得很好?很明显,我们可以随意地将功能分成不同的模块,并将完全不相干的组件分组。以计算器为例,我们可以把算术运算符和图形用户界面放在一个模块中,而把三角函数与堆栈和错误处理放在另一个模块中。这是一种分解,只是不是非常有用的分解。
In general, a good design will display attributes of modularity, encapsulation, cohesion, and low coupling. Many developers will have already seen many of the principles of a good decomposition in the context of object-oriented design. After all, breaking code into objects is itself a decomposition process. Let’s first examine these principles in an abstract context. Subsequently, I’ll ground the discussion by applying these principles to pdCalc.
一般来说,一个好的设计会显示出模块化、封装、内聚和低耦合等属性。许多开发者已经在面向对象的设计中看到了许多良好的分解原则。毕竟,将代码分解成对象本身就是一个分解过程。让我们首先在一个抽象的环境中考察这些原则。随后,我将通过将这些原则应用于pdCalc来进行讨论。
Modularity, or breaking components into independently interacting parts (modules), is important for several reasons. First, it immediately allows one to partition a large, complex problem into multiple, smaller, more tractable components. While trying to implement code for the entire calculator at once would be difficult, implementing an independently functioning stack is quite reasonable. Second, once components are split into distinct modules, unit tests can be defined that validate individual modules instead of requiring the entire program to be completed before testing commences. Third, for large projects, if modules with clear boundaries and interfaces are defined, the development effort can be divided between multiple programmers, preventing them from constantly interfering with each others’progress by needing to modify the same source files.
模块化,或将组件分解成独立互动的部分(模块),是很重要的,有几个原因。首先,它允许人们立即将一个大的、复杂的问题分割成多个更小的、更易操作的组件。虽然试图一次实现整个计算器的代码会很困难,但实现一个独立运作的堆栈是相当合理的。第二,一旦组件被分割成不同的模块,就可以定义单元测试来验证各个模块,而不是要求在测试开始前完成整个程序。第三,对于大型项目来说,如果定义了具有明确边界和接口的模块,开发工作就可以在多个程序员之间进行分配,防止他们因为需要修改相同的源文件而不断干扰对方的进度。
The remaining principles of good design, encapsulation, cohesion, and low coupling all describe characteristics that modules should possess. Basically, they prevent spaghetti code. Encapsulation, or information hiding, refers to the idea that once a module is defined, its internal implementation (data structures and algorithms) remains hidden from other modules. Correspondingly, a module should not make use of the private implementation of any other module. That is not to say that modules should not interact with each other. Rather, encapsulation insists that modules interact with each other only through clearly defined, and, preferably, limited interfaces. This distinct separation ensures that internal module implementation can be independently modified without concern for breaking external, dependent code, provided the interfaces remain fixed and the contracts guaranteed by the interfaces are met.
其余的良好设计原则、封装、内聚和低耦合都描述了模块应该具备的特征。基本上,它们可以防止意大利面条式的代码。封装,或者说信息隐藏,是指一旦定义了一个模块,它的内部实现(数据结构和算法)对其他模块是隐藏的。相应地,一个模块不应该使用任何其他模块的私有实现。这并不是说,模块之间不应该相互影响。相反,封装坚持认为,模块之间只能通过明确定义的,最好是有限的接口进行交互。这种明显的分离确保了内部模块的实现可以独立修改,而不必担心破坏外部的、依赖性的代码,只要接口保持固定,并且满足接口所保证的契约。
Cohesion refers to the idea that the code inside a module should be self-consistent or, as the name implies, cohesive. That is, all of the code within a module should logically fit together. Returning to our example of a poor calculator design, a module mixing arithmetic code with user interface code would lack cohesion. No logical ties bind the two concepts together (other than that they are both components of a calculator). While a small code, like our calculator, would not be completely impenetrable if it lacked cohesion, in general, a large, noncohesive code base is very difficult to understand, maintain, and extend.
凝聚力指的是模块内的代码应该是自洽的,或者说,顾名思义,凝聚力。也就是说,一个模块中的所有代码应该在逻辑上适合在一起。回到我们这个糟糕的计算器设计的例子,一个混合了算术代码和用户界面代码的模块将缺乏内聚性。没有逻辑上的联系将这两个概念结合在一起(除了它们都是计算器的组成部分)。虽然像我们的计算器这样的小代码,如果缺乏内聚力,也不至于完全无法穿透,但一般来说,一个大的、没有内聚力的代码库是非常难以理解、维护和扩展的。
Poor cohesion can manifest in one of two ways: either code that should not be together is crammed together or code that should be together is split apart. In the first instance, code functionality is almost impossible to decompose into mentally manageable abstractions because no clear boundaries exist between logical subcomponents. In the latter situation, reading or debugging unfamiliar code (especially for the first time) can be very frustrating because a typical execution path through the code jumps from file to file in a seemingly random fashion. Either manifestation is counterproductive, and I thus prefer cohesive code.
糟糕的内聚力可以表现为两种方式之一:要么是不应该在一起的代码被挤在一起,要么是应该在一起的代码被拆开。在第一种情况下,代码功能几乎不可能被分解成精神上可管理的抽象概念,因为逻辑子组件之间没有明确的界限。在后一种情况下,阅读或调试不熟悉的代码(尤其是第一次)会非常令人沮丧,因为典型的代码执行路径会以一种看似随机的方式从一个文件跳到另一个文件。无论哪种表现形式都会产生反作用,因此我更喜欢有凝聚力的代码。
Finally, we’ll examine coupling. Coupling represents the interconnections of components, be it functional coupling or data coupling. Functional coupling occurs when the logical flow of one module requires calling another module to complete its action. Conversely, data coupling is when data is shared between individual modules either via direct sharing (e.g., one or more modules point to some set of shared data) or via passing of data (e.g., one module returning a pointer to an internal data structure to another module). To argue for zero coupling is clearly absurd because this state would imply that no module could communicate in any way with any other module. However, in good design, we do strive for low coupling. How low should low be? The glib answer is as low as possible while still maintaining the ability to function as necessary. The reality is that minimizing coupling without detrimentally complicating code is a skill acquired with experience. As with encapsulation, low coupling is enabled by ensuring that modules communicate with each other only through cleanly defined, limited interfaces. Code that is highly coupled is difficult to maintain because small changes in one module’s design may lead to many unforeseen, cascading changes through seemingly unrelated modules. Note that whereas encapsulation protects module A from internal implementation changes to module B, low coupling protects module A from changes to the interface of module B.
最后,我们将研究耦合问题。耦合表示组件的相互连接,无论是功能耦合还是数据耦合。当一个模块的逻辑流程需要调用另一个模块来完成其动作时,就会发生功能耦合。相反,数据耦合是指各个模块之间通过直接共享(例如,一个或多个模块指向一些共享数据集)或通过数据传递(例如,一个模块向另一个模块返回一个内部数据结构的指针)来共享数据。争论零耦合显然是荒谬的,因为这种状态将意味着没有模块可以以任何方式与其他模块进行交流。然而,在好的设计中,我们确实要争取低耦合度。低到什么程度才算低呢?简单的答案是在保持必要的功能的情况下,尽可能的低。现实情况是,在不使代码复杂化的情况下将耦合度降到最低是一种随着经验积累而获得的技能。就像封装一样,低耦合性是通过确保模块之间只通过干净的、有限的接口进行通信来实现的。高度耦合的代码是很难维护的,因为一个模块的设计的微小变化可能会导致许多不可预见的、通过看似不相关的模块的级联变化。请注意,封装保护模块A不受模块B的内部实现变化的影响,而低耦合保护模块A不受模块B的接口变化的影响。
2.2 Selecting an Architecture(选择一个架构)
Although it is now tempting to follow the above guidelines and simply start decomposing our calculator into what seem like sensible constituent components, it’s best to first see if someone else has already solved our problem. Because similar problems tend to arise frequently in programming, software architects have created a catalog of templates for solving these problems; these archetypes are called patterns. Patterns typically comein multiple varieties. Two categories of patterns that will be examined in this book are design patterns [6] and architectural patterns.
虽然现在很想遵循上述准则,简单地开始将我们的计算器分解成看似合理的组成成分,但最好先看看别人是否已经解决了我们的问题。因为类似的问题在编程中经常出现,所以软件架构师已经创建了一个解决这些问题的模板目录;这些原型被称为模式。模式通常有多个种类。本书将研究的两类模式是设计模式[6]和架构模式。
Design patterns are conceptual templates used to solve similar problems that arise during software design; they are typically applied to local decisions. We will encounter design patterns repeatedly throughout this book during the detailed design of our calculator. Our first top level of decomposition, however, requires a pattern of global scope that will define the overarching design strategy, or, software architecture. Such patterns are naturally referred to as architectural patterns.
设计模式是用来解决软件设计过程中出现的类似问题的概念模板;它们通常被应用于局部决策。在本书中,我们将在计算器的详细设计过程中反复遇到设计模式。然而,我们的第一个顶层分解需要一个全局范围的模式,它将定义总体的设计策略或者软件架构。这样的模式自然被称为架构模式。
Architectural patterns are conceptually similar to design patterns; the two differ primarily in their domains of applicability. Whereas design patterns are typically applied to particular classes or sets of related classes, architectural patterns typically outline the design for an entire software system. Note that I refer to a software system rather than a program because architectural patterns can extend beyond simple program boundaries to include interfaces to hardware or the coupling of multiple independent programs. Two architectural patterns of particular interest for our case study are the multi-tiered architecture and the model-view-controller (MVC) architecture. We’ll examine each of these two patterns in the abstract before applying them to pdCalc. The successful application of an architectural pattern to our case study will represent the first level of decomposition for the calculator.
架构模式在概念上与设计模式类似;两者的区别主要在于其适用范围。设计模式通常适用于特定的类或相关的类集,而架构模式则通常概述了整个软件系统的设计。请注意,我指的是一个软件系统而不是一个程序,因为架构模式可以超越简单的程序边界,包括与硬件的接口或多个独立程序的耦合。在我们的案例研究中,有两种架构模式特别值得关注,即多层架构和模型-视图-控制器(MVC)架构。在将这两种模式应用于pdCalc之前,我们将分别对其进行抽象的研究。架构模式在我们的案例研究中的成功应用将代表计算器的第一层分解。
2.2.1 Multi-Tiered Architecture( 多层次的结构)
In a multi-tiered, or n-tiered, architecture, components are arranged sequentially in tiers. Communication is bidirectional via adjacent tiers, but nonadjacent tiers are not permitted to communicate directly. An n-tiered architecture is depicted in Figure 2-1.
在多层或n层体系结构中,组件按层顺序排列。通过相邻层进行通信是双向的,但不允许非相邻层直接通信。n层架构如图2-1所示。
Figure 2-1. A multi-tiered architecture with arrows indicating communication
Figure 2-1. 用箭头表示通信的多层体系结构
The most common form of the multi-tiered architecture is the three-tiered architecture. The first tier is the presentation layer, which consists of all of the user interface code. The second tier is the logic layer, which captures the so-called “business logic” of the application. The third tier is the data layer, which, as the name implies, encapsulates the data for the system. Very often, the three-tiered architecture is applied as an enterprise-level platform, where each tier could represent not only a different local process, but possibly a different process operating on a different machine. In such a system, the presentation layer would be the client interface, whether it be a traditional desktop application or a browser-based interface. The logic layer of the program could run on either the client or server side of the application or, possibly, on both. Finally, the data layer would be represented by a database that could be running locally or remotely. However, as you shall see with pdCalc, the three-tiered architecture can also be applied to a single desktop application.
多层架构的最常见形式是三层架构。第一层是表现层,它由所有的用户界面代码组成。第二层是逻辑层,它捕捉应用程序的所谓 “业务逻辑”。第三层是数据层,顾名思义,它封装了系统的数据。很多时候,三层架构被用作企业级平台,其中每一层不仅可以代表不同的本地进程,而且可能代表在不同机器上运行的不同进程。在这样的系统中,表现层将是客户端界面,无论是传统的桌面应用程序还是基于浏览器的界面。程序的逻辑层可以运行在应用程序的客户端或服务器端,或者可能同时运行在这两个地方。最后,数据层将由一个可以在本地或远程运行的数据库表示。 然而,正如你将在pdCalc中看到的那样,三层结构也可以应用于一个单一的桌面应用程序。
Let’s examine how the three-tiered architecture obeys our general decomposition principles. First and foremost, at the highest level of decomposition, the architecture is modular. At least three modules, one for each tier, exist. However, the three-tiered architecture does not preclude multiple modules from existing at each tier. If the system were large enough, each of the primary modules would warrant subdivision. Second, this architecture encourages encapsulation, at least between tiers. While one could foolishly design a three-tiered architecture where adjacent tiers accessed private methods of neighboring tiers, such a design would be counterintuitive and very brittle. That said, in applications where the tiers coexist in the same process space, it is very easy to intertwine the layers, and care must be taken to ensure this situation does not arise. This separation is achieved by clearly delineating each layer via definitive interfaces. Third, the three-tiered architecture is cohesive. Each tier of the architecture has a distinct task, which is not commingled with the tasks of the other tiers. Finally, the three-tiered architecture truly shines as an example of limited coupling. By separating each of the tiers via clearly defined interfaces, each tier can change independently of the others. This feature is particularly important for applications that must execute on multiple platforms (only the presentation layer changes platform to platform) or applications that undergo unforeseen replacement of a given tier during their lifetimes (e.g., the database must be changed due to a scalability problem).
让我们来看看三层架构是如何遵守我们的一般分解原则的。首先,也是最重要的,在最高层的分解中,该架构是模块化的。至少有三个模块,每个层级都有一个。然而,三层架构并不排除在每一层存在多个模块。如果系统足够大,每个主要模块都值得细分。 第二,这种架构鼓励封装,至少在层与层之间。虽然人们可以愚蠢地设计一个三层架构,让相邻的层访问相邻层的私有方法,但这样的设计将是反直觉的,而且非常脆弱。 也就是说,在各层共存于同一进程空间的应用中,很容易使各层交织在一起,必须注意确保这种情况不会出现。 这种分离是通过明确的接口清楚地划分每一层来实现的。 第三,三层架构是有凝聚力的。架构的每一层都有一个独特的任务,不会与其他层的任务混在一起。最后,三层架构作为有限耦合的一个例子,真正发挥了作用。通过明确定义的接口来分离每一层,每一层都可以独立于其他层而发生变化。这个特点对于那些必须在多个平台上执行的应用(只有表现层在平台之间变化)或在其生命周期中对某一特定层进行不可预见的替换的应用(例如,由于可扩展性问题必须改变数据库)特别重要。
2.2.2 Model-View-Controller (MVC) Architecture(模型-视图-控制器架构)
In the Model-View-Controller architecture, components are decomposed into three distinct elements aptly named the model, the view, and the controller. The model abstracts the domain data, the view abstracts the user interface, and the controller manages the interaction between the model and the view. Often, the MVC pattern is applied locally to individual GUI widgets at the framework level where the design goal is to separate the data from the user interface in situations where multiple distinct views may be associated with the same data. For example, consider a scheduling application with the requirement that the application must be able to store dates and times for appointments, but the user may view these appointments in a calendar that can be viewed by day, week, or month. Applying MVC, the appointment data is abstracted by a model module (likely a class in an object-oriented framework), and each calendar style is abstracted by a distinct view (likely three separate classes). A controller is introduced to handle user events generated by the views and to manipulate the data in the model.
在模型-视图-控制器架构中,组件被分解成三个不同的元素,被恰当地命名为模型、视图和控制器。模型抽象了领域数据,视图抽象了用户界面,而控制器则管理着模型和视图之间的交互。通常情况下,MVC模式被应用于框架层面上的单个GUI部件,其设计目标是在多个不同的视图可能与相同的数据相关联的情况下,将数据与用户界面分开。例如,考虑一个日程安排的应用程序,该应用程序必须能够存储约会的日期和时间,但用户可以在日历中查看这些约会,可以按天、周或月查看。应用MVC,约会数据被一个模型模块(可能是面向对象框架中的一个类)抽象出来,每个日历样式被一个不同的视图(可能是三个独立的类)抽象出来。一个控制器被引入来处理由视图产生的用户事件,并操作模型中的数据。
At first glance, MVC seems no different than the three-tiered architecture with the model replacing the data layer, the view replacing the presentation layer, and the controller replacing the business logic layer. The two architectural patterns are different, however, in their interaction pattern. In the three-tiered architecture, the communication between layers is rigidly linear. That is, the presentation and data layers talk only bidirectionally to the logic layer, never to each other. In MVC, the communication is triangular. While different MVC implementations differ in their exact communication patterns, a typical implementation is depicted in Figure 2-2. In this figure, the view can both generate events to be handled by the controller and get the data to be displayed directly from the model. The controller handles events from the view, but it can also directly manipulate either the model or the controller. Finally, the model can be acted upon directly by either the view or the controller, but it can also generate events to be handled by the view. A typical such event would be a state change event that would cause the view to update its presentation to the user.
乍一看,MVC似乎与三层架构没有什么不同,模型取代了数据层,视图取代了表现层,而控制器取代了业务逻辑层。然而,这两种架构模式在其交互模式上是不同的。在三层架构中,各层之间的通信是僵化的线性的。也就是说,表现层和数据层只与逻辑层双向交流,而不是相互交流。在MVC中,通信是三角形的。虽然不同的MVC实现在其确切的通信模式上有所不同,但图2-2中描述了一个典型的实现。在这个图中,视图既可以产生由控制器处理的事件,又可以直接从模型中获得要显示的数据。控制器处理来自视图的事件,但它也可以直接操作模型或控制器。最后,模型可以被视图或控制器直接操作,但它也可以产生事件由视图处理。一个典型的事件是一个状态改变事件,它将导致视图更新它对用户的展示。
Figure 2-2. An MVC architecture with arrows indicating communication. Solid lines indicate direct communication. Dashed lines indicate indirect communication (e.g., via eventing) [30].
Figure 2-2. 一个MVC架构,箭头表示通信。 实线表示直接通信。虚线表示间接通信(例如,通过事件处理)[30]。
As we did with the three-tiered architecture, let’s now examine how MVC obeys the general decomposition principles. First, an MVC architecture will usually be broken into at least three modules: model, view, and controller. However, as with the threetiered architecture, a larger system will admit more modules because each of the model, view, and controller will require subdivision. Second, this architecture also encourages encapsulation. The model, view, and controller should only interact with each other through clearly defined interfaces, where events and event handling are defined as part of an interface. Third, the MVC architecture is cohesive. Each component has a distinct, well-defined task. Finally, we ask if the MVC architecture is loosely coupled. By inspection, this architectural pattern is more tightly coupled than the three-tiered architecture because the presentation layer and the data layer are permitted to have direct dependencies. In practice, these dependencies are often limited either through loosely coupled event handling or via polymorphism with abstract bases classes. Typically, however, this added coupling does usually relegate the MVC pattern to applications in one memory space. This limitation directly contrasts with the flexibility of the three-tiered architecture, which may span applications over multiple memory spaces.
正如我们对三层架构所做的那样,现在让我们来看看MVC是如何遵守一般的分解原则的。首先,一个MVC架构通常会被分解成至少三个模块:模型、视图和控制器。然而,与三层架构一样,一个更大的系统会接纳更多的模块,因为模型、视图和控制器中的每一个都需要细分。其次,这种架构也鼓励封装。模型、视图和控制器应该只通过明确定义的接口相互作用,其中事件和事件处理被定义为接口的一部分。第三,MVC架构是有凝聚力的。每个组件都有一个独特的、定义明确的任务。最后,我们问MVC架构是否是松散耦合的。通过检查,这种架构模式比三层架构更紧密地耦合,因为表现层和数据层被允许有直接的依赖关系。在实践中,这些依赖关系通常通过松散耦合的事件处理或通过抽象基类的多态性来限制。然而,通常情况下,这种额外的耦合通常会使MVC模式被限制在一个内存空间中的应用。这种限制与三层架构的灵活性形成了直接的对比,三层架构可以将应用程序跨越多个内存空间。
2.2.3 Architectural Patterns Applied to the Calculator (应用于计算器的架构模式)
Let’s now return to our case study and apply the two architectural patterns discussed above to pdCalc. Ultimately we’ll select one as the architecture for our application. As previously described, a three-tiered architecture consists of a presentation layer, a logic layer, and a data layer. For the calculator, these tiers are clearly identified as entering commands and viewing results (via either a graphical or command line user interface), the execution of the commands, and the stack, respectively. For the MVC architecture, we have the stack as the model, the user interface as the view, and the command dispatcher as the controller. Both calculator architectures are depicted in Figure 2-3. Note that in both the three-tiered and MVC architectures, the input aspects of the presentation layer or view are responsible only for accepting the commands, not interpreting or executing them. Enforcing this distinction alleviates a common problem developers create for themselves: the mixing of the presentation layer with the logic layer.
现在让我们回到案例研究,并将上面讨论的两种体系结构模式应用到pdCalc。最终,我们将选择一个作为应用程序的体系结构。如前所述,三层体系结构由表示层、逻辑层和数据层组成。对于计算器,这些层被清楚地标识为输入命令和查看结果(通过图形或命令行用户界面)、命令的执行和堆栈。对于MVC架构,我们将堆栈作为模型,用户界面作为视图,命令分派器作为控制器。图2-3描述了这两种计算器架构。注意,在三层和MVC架构中,表示层或视图的输入方面只负责接受命令,而不负责解释或执行命令。这种区分减轻了开发人员为自己造成的一个常见问题:表示层与逻辑层的混合。
Figure 2-3. Calculator architecture options
Figure 2-3. 计算器体系结构的选择
2.2.4 Choosing the Calculator’s Architecture(选择计算器的架构)
From Figure 2-3, one quickly identifies that the two architectures partition the calculator into identical modules. In fact, at the architectural level, these two competing architectures differ only in their coupling. Therefore, in selecting between these two architectures, we only need to consider the design tradeoffs between their two communication patterns. Obviously, the main difference between the three-tiered architecture and the MVC architecture is the communication pattern between the user interface (UI) and the stack. In the three-tiered architecture, the UI and stack are only allowed to communicate indirectly through the command dispatcher. The biggest benefit of this separation is a decrease in coupling in the system. The UI and the stack need to know nothing about the interface of the other. The disadvantage, of course, is that if the program requires significant direct UI and stack communication, the command dispatcher will be required to broker this communication, which decreases the cohesion of the command dispatcher module. The MVC architecture has the exact opposite tradeoff. That is, at the expense of additional coupling, the UI can directly exchange messages with the stack, avoiding the awkwardness of the command dispatcher performing added functionality unrelated to its primary purpose. Therefore, the architecture decision reduces to examining whether or not the UI frequently needs a direct connection to the stack.
从图2-3中,我们可以很快发现,这两种架构将计算器划分为相同的模块。事实上,在架构层面上,这两种相互竞争的架构只在其耦合度上有区别。因此,在选择这两种体系结构时,我们只需要考虑它们两种通信模式之间的设计权衡。很明显,三层架构和MVC架构之间的主要区别在于用户界面(UI)和堆栈之间的通信模式。在三层架构中,用户界面和堆栈只允许通过命令调度器进行间接通信。这种分离的最大好处是减少了系统中的耦合性。UI和堆栈不需要知道对方的接口。当然,缺点是如果程序需要大量的UI和堆栈的直接通信,命令调度器将被要求作为这种通信的中介,这就降低了命令调度器模块的凝聚力。MVC架构则有完全相反的权衡。也就是说,以额外的耦合为代价,用户界面可以直接与堆栈交换消息,避免了命令调度器执行与它的主要目的无关的附加功能的尴尬局面。因此,架构的决定简化为检查用户界面是否经常需要与堆栈直接连接。
In an RPN calculator, the stack acts as the repository for both the input and output for the program. Frequently, the user will wish to see both the input and output exactly as it appears on the stack. This situation favors the MVC architecture with its direct interaction between the view and the data. That is, the calculator’s view does not require the command dispatcher to translate the communication between the data and the user because no transformation of the data is required. Therefore, I selected the model-view-controller as the architecture for pdCalc. The advantages of the MVC architecture over the three-tiered architecture are, admittedly, small for our case study. Had I instead chosen to use the three-tiered architecture, pdCalc still would have had a perfectly valid design.
在RPN计算器中,堆栈作为程序的输入和输出的存放处。通常情况下,用户会希望看到输入和输出都准确地出现在堆栈中。这种情况有利于MVC架构,它在视图和数据之间有直接的交互。也就是说,计算器的视图不需要命令调度器来翻译数据和用户之间的通信,因为不需要对数据进行转换。因此,我选择了模型-视图-控制器作为pdCalc的架构。诚然,对于我们的案例研究来说,MVC架构比三层架构的优势很小。如果我选择使用三层架构,pdCalc仍然会有一个完全有效的设计。
2.3 Interfaces(接口)
Although it might be tempting to declare the first level of decomposition complete with the selection of the MVC architecture, we cannot yet declare victory. While we have defined our three highest level modules, we must also define their public interfaces. However, without utilizing some formal method for capturing all the data flows in our problem, we are very likely to miss key necessary elements of our interface. We therefore turn to an object-oriented analysis technique, the use case.A use case is an analysis technique that generates a description of a specific action a user has with a system. Essentially, a use case defines a workflow. Importantly, a use case does not specify an implementation. The customer should be consulted during use case generation, particularly in instances where a use case uncovers an ambiguity in the requirements. Details concerning use cases and use case diagrams can be found in Booch et al [4].For the purpose of designing interfaces for pdCalc’s high-level modules, we will first define the use cases for an end user interacting with the calculator. Each use case should define a single workflow, and we should provide enough use cases to satisfy all of the technical requirements for the calculator. These use cases can then be studied to discover the minimal interactions required between the modules. These communication patterns will define the modules’ public interfaces. An added benefit of this use case analysis is that if our existing modules are insufficient to implement all of the workflows, we will have uncovered the need for additional modules in our top-level design.
尽管我们可能很想宣布第一层的分解已经完成,选择了MVC架构,但我们还不能宣布胜利。虽然我们已经定义了三个最高级别的模块,但我们还必须定义它们的公共接口。然而,如果不利用一些正式的方法来捕捉我们问题中的所有数据流,我们很可能会错过接口的关键必要元素。因此,我们求助于一种面向对象的分析技术—用例。用例是一种分析技术,它生成了对用户在系统中的特定行为的描述。本质上,用例定义了一个工作流程。重要的是,一个用例并不指定一个实现。在用例生成过程中应该咨询客户,特别是在用例发现需求中的模糊性的情况下。关于用例和用例图的细节可以在Booch等人[4]中找到。为了设计pdCalc高级模块的接口,我们将首先定义终端用户与计算器交互的用例。每个用例应该定义一个工作流程,我们应该提供足够的用例来满足计算器的所有技术要求。然后,这些用例可以被研究,以发现模块之间所需的最小交互。这些通信模式将定义各模块的公共接口。这种用例分析的另一个好处是,如果我们现有的模块不足以实现所有的工作流程,我们将在顶层设计中发现对额外模块的需求。
2.3.1 Calculator Use Cases(计算器用例)
Let’s create the use cases for our requirements. For consistency, use cases are created in the order in which they appear in the requirements.
让我们为我们的需求创建用例。为了保持一致性,用例是按照它们在需求中出现的顺序来创建的。
2.3.1.1 Use Case: User Enters a Floating Point Number onto the Stack(用户在堆栈中输入一个浮点数字)
Scenario: The user enters a floating point number onto the stack. After entry, the user can see the number on the stack.
Exception: The user enters an invalid floating point number. An error condition is displayed.
情景:用户在堆栈中输入一个浮点数字。输入后,用户可以看到堆栈上的数字。
异常:用户输入了一个无效的浮点数,显示一个错误条件。
2.3.1.2 Use Case: User Undoes Last Operation 用户撤消最后一次操作
Scenario: The user enters the command to undo the last operation. The system undoes the last operation and displays the previous stack.
Exception: There is no command to undo. An error condition is displayed.
情景:用户输入命令撤销上一次的操作。系统撤销上一次的操作,并显示上一次的堆栈。
异常:没有要撤销的命令。显示一个错误条件。
2.3.1.3 Use Case: User Redoes Last Operation 用户重做最后一次操作
Scenario: The user enters the command to redo the last operation. The system redoes the last operation and displays the new stack.
Exception: There is no command to redo. An error condition is displayed.
情景:用户输入命令撤销上一次的操作。用户输入命令,重做上一次的操作。系统重做了上次的操作,并显示新的堆栈。
异常:没有重做的命令,显示一个错误条件。
2.3.1.4 Use Case: User Swaps Top Stack Elements 用户调换堆栈顶部元素
Scenario: The user enters the command to swap the top two elements on the stack. The system swaps the top two elements on the stack and displays the new stack.
Exception: The stack does not have at least two numbers. An error condition is displayed.
情景:用户输入命令,交换堆栈中的前两个元素。系统交换了堆栈中的前两个元素,并显示新的堆栈。
异常:堆栈中没有至少两个数字,显示一个错误条件。
2.3.1.5 Use Case: User Drops the Top Stack Element 用户丢弃最上面的堆栈元素
Scenario: The user enters the command to drop the top element from the stack. The system drops the top element from the stack and displays the new stack.
Exception: The stack is empty. An error condition is displayed.
情景:用户输入命令,从堆栈中删除最上面的元素。系统从堆栈中丢掉最上面的元素,并显示新的堆栈。
异常情况:堆栈是空的,显示一个错误条件。
2.3.1.6 Use Case: User Clears the Stack 用户清除堆栈
Scenario: The user enters the command to clear the stack.The system clears the stack and displays the empty stack.
Exception: None. Let clear succeed even for an empty stack (by doing nothing).
情景:用户输入了清除堆栈的命令,系统清除了堆栈并显示空的堆栈。
异常:没有。即使是空的堆栈,也要让清空成功(什么都不做)。
2.3.1.7 Use Case: User Duplicates the Top Stack Element 用户复制了顶层堆栈元素
Scenario: The user enters the command to duplicate the top element on the stack. The system duplicates the top element on the stack and displays the new stack.
Exception: The stack is empty. An error condition is displayed.
情景:用户输入命令,复制堆栈上的顶级元素。系统复制了堆栈中最顶端的元素,并显示新的堆栈。
异常:堆栈是空的,显示一个错误条件。
2.3.1.8 Use Case: User Negates the Top Stack Element 用户否定了堆栈顶部的元素
Scenario: The user enters the command to negate the top element on the stack. The system negates the top element on the stack and displays the new stack.
Exception: The stack is empty. An error condition is displayed.
情景:用户输入命令,否定堆栈中的顶层元素。系统否定了堆栈中的顶层元素,并显示新的堆栈。
异常:堆栈是空的,显示一个错误条件。
2.3.1.9 Use Case: User Performs an Arithmetic Operation 用户执行算术操作
Scenario: The user enters the command to add, subtract, multiply, or divide. The system performs the operation and displays the new stack.
Exception: The stack size is insufficient to support the operation. An error condition is displayed.
Exception: Division by zero is detected. An error condition is displayed.
情景:用户输入加、减、乘、除的命令,系统执行该操作并显示新的堆栈。
异常:堆栈大小不足以支持该操作,显示一个错误条件。
异常:检测到除以0,显示一个错误条件。
2.3.1.10 Use Case: User Performs a Trigonometric Operation
Scenario: The user enters the command for sin, cos, tan, arcsin, arccos, or arctan. The system performs the operation and displays the new stack.
Exception: The stack size is insufficient to support the operation. An error condition is displayed.
Exception: The input for the operation is invalid (e.g., arctan(−50) produces an imaginary result). An error condition is displayed.
情景:用户输入sin、cos、tan、arcsin、arccos或arctan的命令,系统执行该操作并显示新的堆栈。
异常:堆栈大小不足以支持该操作,显示一个错误的条件。
异常:操作的输入是无效的(例如,arctan(-50)产生一个虚数的结果),显示一个错误条件。
2.3.2 Analysis of Use Cases(用例分析)
We will now analyze the use cases for the purpose of developing C++ interfaces for pdCalc’s modules. Keep in mind that the C++ language does not formally define a module concept. Therefore, think of an interface conceptually as the publicly facing function signatures to a collection of classes and functions grouped logically to define a module. For the sake of brevity, the std namespace prefix is omitted in the text.
现在我们将分析这些用例,以便为pdCalc的模块开发C++接口。请记住,C++语言并没有正式定义一个模块的概念。因此,从概念上讲,把接口看作是类和函数集合的公开函数签名,这些类和函数在逻辑上被分组,以定义一个模块。为了简洁起见,文中省略了std命名空间的前缀。
Let’s examine the use cases in order. As the public interface is developed, it will be entered into Table 2-2. The exception will be for the first use case, whose interface will be described in Table 2-1. By using a separate table for the first use case, we’ll be able to preserve the errors we’ll make on the first pass for comparison to our final product. By the end of this section, the entire public interface for all of the modules will have been developed and cataloged.
让我们按顺序审查这些用例。随着公共接口的开发,它将被输入表2-2中。第一个用例将是个异常,其接口将在表2-1中描述。通过为第一个用例使用一个单独的表格,我们将能够保留我们在第一遍时的错误,以便与我们的最终产品进行比较。在本节结束时,所有模块的整个公共接口将被开发和编目。
We begin with the first use case: entering a floating point number. The implementation of the user interface will take care of getting the number from the user into the calculator. Here, we are concerned with the interface required to get the number from the UI onto the stack.
我们从第一个用例开始:输入一个浮点数字。用户界面的实现将负责把数字从用户那里输入计算器。在这里,我们关注的是把数字从用户界面拿到堆栈中所需要的接口。
Regardless of the path the number takes from the UI to the stack, we must eventually have a function call for pushing numbers onto the stack. Therefore, the first part of our interface is simply a function on the stack module, push(), for pushing a double precision number onto the stack. We enter this function into Table 2-1. Note that the table contains the complete function signature, while the return type and argument types are omitted in the text.
无论数字从用户界面到堆栈的路径如何,我们最终都必须有一个函数调用来将数字推入堆栈。因此,我们接口的第一部分只是堆栈模块上的一个函数push(),用于将一个双精度的数字推入堆栈。我们把这个函数输入到表2-1中。注意,表中包含了完整的函数签名,而返回类型和参数类型在文中被省略了。
Now, we must explore our options for getting the number from the user interface module to the stack module. From Figure 2-3b, we see that the UI has a direct link to the stack. Therefore, the simplest option would be to push the floating point number onto the stack directly from the UI using the push() function we just defined. Is this a good idea?
现在,我们必须探索从用户界面模块到堆栈模块获取数字的方案。从图2-3b中,我们看到用户界面与堆栈有一个直接的联系。因此,最简单的选择是使用我们刚刚定义的push()函数直接从用户界面将浮点数推到堆栈上。这是个好主意吗?
By definition, the command dispatcher module, or the controller, exists to process commands the user enters. Should entering a number be treated differently than, for example, the addition command? Having the UI bypass the command dispatcher and directly enter a number onto the stack module violates the principle of least surprise (also referred to as the principle of least astonishment). Essentially, this principle states that when a designer is presented with multiple valid design options, the correct choice is the one that conforms to the user’s intuition. In the context of interface design, the user is another programmer or designer. Here, any programmer working on our system would expect all commands to be handled identically, so a good design will obey this principle.
根据定义,命令调度器模块或控制器的存在是为了处理用户输入的命令。输入一个数字是否应该和例如加法命令区别对待?让用户界面绕过命令调度器,直接在堆栈模块中输入数字,违反了最小惊讶原则(也被称为最小惊奇原则)。从本质上讲,这个原则指出,当设计者面临多个有效的设计选项时,正确的选择是符合用户直觉的那一个。在界面设计的背景下,用户是另一个程序员或设计师。在这里,任何在我们的系统上工作的程序员都希望所有的命令都能得到相同的处理,所以一个好的设计会遵守这个原则。
To avoid violating the principle of least surprise, we must build an interface that routes a newly entered number from the UI through the command dispatcher. We again refer to Figure 2-3b. Unfortunately, the UI does not have a direct connection to the command dispatcher, making direct communication impossible. It does, however, have an indirect pathway. Thus, our only option is for the UI to raise an event (you’ll study events in detail in Chapter 3). Specifically, the UI must raise an event indicating that a number has been entered, and the command dispatcher must be able to receive this event (eventually, via a function call in its public interface). Let’s add two more functions to Table 2-1, one for the numberEntered() event raised by the UI and one for the numberEntered() event handling function in the command dispatcher.
为了避免违反最小惊讶原则,我们必须建立一个接口,将新输入的号码从用户界面通过命令调度器传送出去。我们再次参考图2-3b。不幸的是,用户界面没有与命令调度器的直接连接,因此不可能进行直接通信。然而,它确实有一个间接的途径。因此,我们唯一的选择是让用户界面引发一个事件(你将在第三章详细研究事件)。具体来说,用户界面必须引发一个事件,表明有一个数字被输入,而命令调度器必须能够接收这个事件(最终,通过其公共接口中的一个函数调用)。让我们在表2-1中再添加两个函数,一个用于由用户界面引发的numberEntered()
事件,另一个用于命令调度器中的numberEntered()
事件处理函数。
Once the number has been accepted, the UI must display the revised stack. This is accomplished by the stack signaling that it has changed, and the view requesting n elements from the stack and displaying them to the user. We must use this pathway because the stack only has an indirect communication channel to the UI. We add three more functions to Table 2-1: a stackChanged() event on the stack module, a stackChanged() event handler on the UI, and a getElements() function on the stack module (see the “Modern C++ Design Note” sidebar on move semantics to see options for the getElements() function signature). Unlike the entering of the number itself, it is reasonable to have the UI directly call the stack’s function for getting elements in response to the stackChanged() event. This is, in fact, precisely how we want a view to interact with its data in the MVC pattern.
一旦这个数字被接受,用户界面必须显示修改后的堆栈。这是通过堆栈发出它已经改变的信号,以及视图从堆栈中请求n个元素并将它们显示给用户来完成的。我们必须使用这个途径,因为堆栈只有一个与用户界面的间接通信渠道。我们在表2-1中又增加了三个函数:堆栈模块上的stackChanged()
事件,UI上的stackChanged()
事件处理程序,以及堆栈模块上的getElements()
函数(参见 “现代C++设计说明 “边栏中的移动语义,查看getElements()
函数的签名选项)。与输入数字本身不同,让UI直接调用堆栈的函数来获取元素以响应stackChanged()
事件是合理的。事实上,这正是我们希望视图在MVC模式中与它的数据交互的方式。
Of course, the aforementioned workflow assumes the user entered a valid number. For completeness, however, the use case also specifies that error checking must be performed on number entry. Therefore, the command dispatcher should actually check the validity of the number before pushing it onto the stack, and it should signal the user interface if an error has occurred. The UI should correspondingly be able to handle error events. That’s two more functions for Table 2-1: an error() event on the command dispatcher, and a function, displayError(), on the UI, for handling the error event. Note that we could have selected an alternative error handling design by leaving the UI to perform its own error checking and only raise a number entered event for valid numbers. However, for improved cohesion, I prefer placing the “business logic” of error checking in the controller rather than in the interface.
当然,上述工作流程假设用户输入了一个有效的数字。然而,为了完整起见,该用例还规定,在数字输入时必须进行错误检查。因此,命令调度器应该在将数字推入堆栈之前实际检查其有效性,如果发生了错误,它应该向用户界面发出信号。UI应该相应地能够处理错误事件。这就为表2-1增加了两个函数:一个是命令调度器上的error()
事件,另一个是用户界面上的函数displayError()
,用于处理错误事件。请注意,我们可以选择另一种错误处理设计,让用户界面执行自己的错误检查,只对有效的数字提出一个数字输入事件。然而,为了提高凝聚力,我倾向于将错误检查的 “业务逻辑 “放在控制器中,而不是界面中。
Phew! That completes our analysis of the first use case. In case you got lost, remember that all of the functions and events just described are summarized in Table 2-1. Now just 12 more exciting use cases to go to complete our interface analysis! Don’t worry, the drudgery will end shortly. We will soon derive a design that can consolidate almost all of the use cases into a unified interface.
这就完成了我们对第一个用例的分析。如果你忘记了,请记住,刚才描述的所有功能和事件都总结在表2-1中。现在只需要再做12个令人兴奋的用例,就可以完成我们的界面分析了!别担心,这种苦差事很快就会结束。我们很快就会得出一个设计,可以将几乎所有的用例整合到一个统一的界面中。
Table 2-1. Public Interfaces Derived from the Analysis of the Use Case for Entering a Floating Point Number onto the Stack
Table 2-1. 从分析输入浮点数字到堆栈的用例中得到的公共接口
Before proceeding immediately to the next use case, let’s pause for a moment and discuss two decisions we just implicitly made about error handling. First, the user interface handles errors by catching events rather than by catching exceptions. Because the user interface cannot directly send messages to the command dispatcher, the UI can never wrap a call to the command dispatcher in a try block. This communication pattern immediately eliminates using C++ exceptions for inter-module error handling (note that it does not preclude using exceptions internally within a single module). In this case, since number entry errors are trapped in the command dispatcher, we could have notified the UI directly using a callback. However, this convention is not sufficiently general, for it would break down for errors detected in the stack since the stack has no direct communication with the UI. Second, we have decided that all errors, regardless of cause, will be handled by passing a string to the UI describing the error rather than making a class hierarchy of error types. This decision is justified because the UI never tries to differentiate between errors. Instead, the UI simply serves as a conduit to display error messages verbatim from other modules.
在立即进行下一个用例之前,让我们停顿一下,讨论一下我们刚才隐含的关于错误处理的两个决定。首先,用户界面通过捕捉事件而不是捕捉异常来处理错误。因为用户界面不能直接向命令分配器发送消息,所以用户界面永远不能将对命令分配器的调用包裹在一个尝试块中。这种通信模式立即消除了使用C++异常来处理模块间的错误(注意,它并不排除在单个模块内部使用异常)。在这种情况下,由于数字输入错误被捕获在命令调度器中,我们可以使用回调直接通知用户界面。然而,这个惯例并不充分通用,因为它对于在堆栈中检测到的错误来说会被打破,因为堆栈没有与用户界面直接通信。第二,我们决定所有的错误,不管是什么原因,都将通过向用户界面传递一个描述错误的字符串来处理,而不是制定一个错误类型的等级制度。这个决定是合理的,因为用户界面从来没有试图区分不同的错误。相反,用户界面只是作为一个渠道,逐字逐句地显示来自其他模块的错误信息。
MODERN C++ DESIGN NOTE: MOVE SEMANTICS
现代C++设计说明:移动语义
In Table 2-1, the stack has the function void getElements(n, vector
在表2-1中,堆栈有一个函数 void getElements(n, vector
Beginning with C++11, however, the above interface ambiguity can be resolved semantically by the language itself. Rvalue references and move semantics allow us to make this interface decision very explicit. We can now efficiently (that is, without copying the vector or relying on the compiler to implement the return value optimization) implement the function vector
然而,从C++11开始,上述接口的模糊性可以由语言本身在语义上解决。R值引用和移动语义使我们可以非常明确地做出这个接口决定。我们现在可以有效地(也就是说,不需要复制向量或依靠编译器来实现返回值的优化)实现函数vector
To not bloat the interface in the text, both variants of the function do not explicitly appear in the tables defining the interface. However, both variants do appear in the source code. This convention will often be used in this book. Where multiple helper calls performing the same operation are useful in the implementation, both appear there, but only one variant appears in the text. This omission is acceptable for the illustrative purposes of this book, but this omission would not be acceptable for a detailed design specification for a real project.
为了不使文本中的接口臃肿,该函数的两个变体都没有明确出现在定义接口的表格中。然而,这两种变体确实出现在源代码中。本书将经常使用这一惯例。当执行相同操作的多个辅助调用在实现中很有用时,两个都出现在那里,但只有一个变体出现在文本中。对于本书的说明性目的来说,这种省略是可以接受的,但对于一个真实项目的详细设计规范来说,这种省略是不能接受的。
The next two use cases, undo and redo of operations, are sufficiently similar that we can analyze them simultaneously. First, we must add two new events to the user interface: one for undo and one for redo. Correspondingly, we must add two event handling functions in the command dispatcher for undo and redo, respectively. Before simply adding these functions to Table 2-2, let’s take a step back and see if we can simplify.
接下来的两个用例,即操作的撤销和重做,有足够的相似性,我们可以同时对它们进行分析。首先,我们必须在用户界面上增加两个新的事件:一个用于撤销操作,一个用于重做操作。相应地,我们必须在命令调度器中为撤销和重做分别添加两个事件处理函数。在简单地将这些函数添加到表2-2之前,让我们退一步,看看是否可以简化。
Table 2-2. Public Interfaces for the Entire First Level Decomposition
Table 2-2. 整个第一层分解的公共接口
At this point, you should begin to see a pattern emerging from the user interface events being added to the table. Each use case adds a new event of the form commandEntered(), where command has thus far been replaced by number, undo, or redo. In subsequent use cases, command might be replaced with operations such as swap, add, sin, exp, etc. Rather than continue to bloat the interface by giving each command a new event in the UI and a corresponding event handler in the command dispatcher, we instead replace this family of commands with the rather generic sounding UI event commandEntered() and the partner event handler commandEntered() in the command dispatcher. The single argument for this event/handler pair is a string, which encodes the given command. In the case of a number entered, the argument is the ASCII representation of the number.
在这一点上,你应该开始看到从用户界面事件中出现的一个模式被添加到表中。每个用例都会添加一个新的commandEntered()形式的事件,到目前为止,command已经被数字、撤销或重做所取代。在随后的用例中,command可能会被替换成swap、add、sin、exp等操作。与其继续通过在用户界面中给每个命令一个新的事件和在命令调度器中给一个相应的事件处理程序来使界面变得臃肿,我们不如用听起来相当普通的用户界面事件commandEntered()和命令调度器中的伙伴事件处理程序commandEntered()来代替这一系列的命令。这个事件/处理程序对的唯一参数是一个字符串,它对给定的命令进行编码。在输入数字的情况下,该参数是数字的ASCII表示。
Combining all of the UI command events into one event with a string argument instead of issuing each command as an individual event serves several design purposes. First, and most immediately evident, this choice declutters the interface. Rather than needing individual pairs of functions in the UI and the command dispatcher for each individual command, we now need only one pair of functions for handling events from all commands. This includes the known commands from the requirements and any unknown commands that might derive from future extensions. However, more importantly, this design promotes cohesion because now the UI does not need to understand anything about any of the events it triggers. Instead, the deciphering of the command events is placed in the command dispatcher, where this logic naturally belongs. Creating one commandEntered() event for commands even has direct implications on the implementations of commands, graphical user interface buttons, and plugins. I will reserve those discussions for when you encounter those topics in Chapters 4, 6, and 7.
将所有的UI命令事件合并成一个带有字符串参数的事件,而不是将每个命令作为一个单独的事件来发布,有几个设计目的。首先,也是最直观的,这个选择简化了界面。我们现在只需要一对函数来处理来自所有命令的事件,而不是在用户界面和命令调度器中为每个单独的命令提供一对函数。这包括需求中的已知命令和任何可能来自未来扩展的未知命令。然而,更重要的是,这种设计促进了凝聚力,因为现在用户界面不需要了解它所触发的任何事件的情况。相反,对命令事件的解读被放在了命令调度器中,这个逻辑自然属于那里。为命令创建一个commandEntered()
事件甚至对命令、图形用户界面按钮和插件的实现有直接影响。我将把这些讨论留给你在第4、6和7章中遇到这些主题时进行。
We now return to our analysis of the undo and redo use cases. As described above, we will forgo adding new command events in Table 2-2 for each new command we encounter. Instead, we add the commandEntered() event to the UI and the commandEntered() event handler to the command dispatcher. This event/handler pair will suffice for all commands in all use cases. The stack, however, does not yet possess all of the necessary functionality to implement every command. For example, in order to undo pushes onto the stack, we will need to be able to pop numbers from the stack. Let’s add a pop() function to the stack in Table 2-2. Finally, we note that a stack error could occur if we attempted to pop an empty stack. We, therefore, add a generic error() event to the stack to mirror the error event on the command dispatcher.
现在我们回到对撤销和重做用例的分析。如上所述,我们将放弃在表2-2中为我们遇到的每个新命令添加新的命令事件。相反,我们在用户界面上添加commandEntered()
事件,在命令调度器上添加commandEntered()
事件处理程序。这个事件/处理程序对将足以满足所有用例中的所有命令。然而,堆栈还不具备实现每个命令的所有必要功能。例如,为了撤销对堆栈的推送,我们将需要能够从堆栈中弹出数字。让我们在表2-2中为堆栈添加一个pop()
函数。最后,我们注意到,如果我们试图弹出一个空的堆栈,可能会发生一个堆栈错误。因此,我们在堆栈中添加一个通用的error()
事件,以反映命令调度器上的错误事件。
We move to our next use case, swapping the top of the stack. Obviously, this command will reuse the commandEntered() and error() patterns from the previous use cases, so we only need to determine if a new function needs to be added to the stack’s interface. Obviously, swapping the top two elements of the stack could either be implemented via a swapTop() function on the stack or via the existing push() and pop() functions. Somewhat arbitrarily, I chose to implement a separate swapTop() function, so I added it to Table 2-2. This decision was probably subconsciously rooted in my natural design tendency to maximize efficiency (the majority of my professional projects are high-performance numerical simulations) at the expense of reuse. In hindsight, that might not be the better design decision, but this example demonstrates that sometimes design decisions are based on nothing more than the instincts of a designer as colored by his or her individual experiences.
我们转到下一个用例,交换堆栈的顶部。显然,这个命令将重复使用前面用例中的commandEntered()和error()模式,所以我们只需要确定是否需要在栈的接口上添加一个新函数。显然,交换堆栈的前两个元素可以通过堆栈上的swapTop()函数或者通过现有的push()和pop()函数实现。我有些武断地选择了实现一个单独的swapTop()函数,所以我把它添加到表2-2中。这个决定可能是下意识地植根于我的自然设计倾向,即以牺牲重复使用为代价来实现效率最大化(我的大部分专业项目都是高性能数值模拟)。事后看来,这可能不是一个更好的设计决定,但这个例子表明,有时设计决定只不过是基于设计者的本能,是由他或她的个人经验决定的。
At this point, a quick scan of the remaining use cases shows that, other than loading a plugin, the existing module interfaces defined by Table 2-2 are sufficient to handle all user interactions with the calculator. Each new command only adds new functionality internal to the command dispatcher, the logic of which will be detailed in Chapter 4. Therefore, the only remaining use case to examine concerns loading plugins for pdCalc. The loading of plugins, while complex, is minimally invasive to the other modules in the calculator. Other than command and user interface injection (you’ll encounter these topics in Chapter 7), the plugin loader is a standalone component. We therefore defer the design of its interface (and the necessary corresponding changes to the other interfaces) until we are ready to implement plugins.
在这一点上,对其余用例的快速扫描表明,除了加载一个插件外,表2-2定义的现有模块接口足以处理所有用户与计算器的交互。每个新的命令只是在命令调度器的内部增加了新的功能,其逻辑将在第四章中详细介绍。 因此,剩下的唯一要研究的用例就是为pdCalc加载插件。 插件的加载虽然复杂,但对计算器中其他模块的侵入性很小。除了命令和用户界面的注入(你将在第7章遇到这些主题),插件加载器是一个独立的组件。因此,我们将其接口的设计(以及对其他接口的必要的相应修改)推迟到我们准备实现插件的时候。
Deferring the design of a significant portion of the top-level interface is a somewhat risky proposition, and one to which design purists might object. Pragmatically, however, I have found that when enough of the major elements have been designed, you need to start coding. The design will change as the implementation progresses anyway, so seeking perfection by overworking the initial design is mostly futile. Of course, neither should one completely abandon all upfront design in an agile frenzy!
推迟顶层界面的大部分设计是一个有点冒险的提议,设计纯粹主义者可能会反对这样做。然而,从实际情况来看,我发现当主要元素已经设计得足够多时,你就需要开始编码了。无论如何,设计会随着实施的进展而改变,所以通过对初始设计的过度加工来寻求完美是徒劳的。当然,也不应该在敏捷的狂热中完全放弃所有的前期设计。
The above said, a few caveats exist for adopting a strategy of delaying the design of a major component. First, if the delayed portion of the design will materially impact the architecture, the delay may potentially cause significant rework later. Second, delaying parts of the design prolongs the stabilization of the interfaces. Such delays may or may not be problematic on large teams working independently on connected components. Knowing what can and what cannot be deferred comes only with experience. If you are uncertain as to whether the design of a component can be safely deferred or not, you are much better off erring on the side of caution and performing a little extra design and analysis work upfront to minimize the impact to the overall architecture. Poor designs impacting the architecture of a program will impact development for the duration of a project. They cause much more significant rework than poor implementations, and in the worst case scenario, poor design decisions become economically infeasible to fix.
综上所述,采用推迟主要部件设计的策略有一些注意事项。首先,如果推迟设计的部分会对结构产生实质性的影响,那么推迟可能会导致以后的重大返工。第二,推迟部分设计会延长接口的稳定时间。这样的延迟对于独立工作的大型团队来说可能有问题,也可能没有问题。只有通过经验才能知道什么可以推迟,什么不能推迟。如果你不确定一个组件的设计是否可以安全地推迟,你最好谨慎行事,在前期进行一些额外的设计和分析工作,以减少对整个架构的影响。影响程序架构的不良设计将影响项目的开发。它们所造成的返工要比糟糕的实现要多得多,而且在最坏的情况下,糟糕的设计决定在经济上是不可能修复的。
Sometimes, they can only be fixed in a major rewrite, which may never occur.Before completing the analysis of the use cases, let’s compare the interface developed in Table 2-1 for the first use case with the interface developed in Table 2-2 encompassing all of the use cases. Surprisingly, Table 2-2 is only marginally longer than Table 2-1. This is a testament to the design decision to abstract commanding into one generic function instead of individual functions for each command. Simplifying the communication patterns between modules is one of the many time-saving advantages of designing code instead of just hacking away. The only other differences between the first interface and the complete interface are the addition of a few stack functions and the modification of a few function names (e.g., renaming the displayError() function to postMessage() to increase the generality of the operation).
有时,它们只能在重大重写中修复,而这可能永远不会发生。 在完成用例分析之前,让我们将表 2-1 中为第一个用例开发的接口与表 2-2 中开发的包含所有用例的接口进行比较。 令人惊讶的是,表 2-2 仅比表 2-1 稍长。 这证明了将命令抽象为一个通用功能而不是每个命令的单独功能的设计决策。 简化模块之间的通信模式是设计代码而不仅仅是黑客攻击的众多节省时间的优势之一。 第一个接口和完整接口唯一的其他区别是增加了一些堆栈函数和一些函数名称的修改(例如,将displayError()
函数重命名为postMessage()
以增加操作的通用性)。
2.3.3 A Quick Note on Actual Implementation(关于实际执行情况的简要说明)
For the purposes of this text, the interfaces developed, as exemplified by Table 2-2, represent idealizations of the actual interfaces deployed in the code. The actual code may differ somewhat in the syntax, but the semantic intent of the interface will always be preserved. For example, in Table 2-2, we have defined the interface to get n elements as void getElements(n, vector
所开发的接口代表了代码中实际部署的接口的理想化。实际的代码在语法上可能会有些不同,但是接口的语义意图总是被保留的。例如,在表2-2中,我们将获取n个元素的接口定义为void getElements(n, vector<double>&)
,这是一个完全可用的接口。然而,利用现代C++的新特性(见侧边栏的移动语义),该实现通过提供vector<double> getElements(n)
作为一个逻辑上等价的重载接口来使用r值引用和移动构造。
Defining good C++ interfaces is a highly nontrivial task; I know of at least one excellent book dedicated entirely to this subject [20]. Here in this book, I only provide a sufficient level of detail about the interfaces needed to clearly explain the design. The available source code demonstrates the intricacies necessary for developing efficient C++ interfaces. In a very small project, allowing developers some latitude in adapting the interface can usually be tolerated and is often beneficial as it allows implementation details to be delayed until they can be practically determined. However, in a large-scale development, in order to prevent absolute chaos between independent teams, it is wise to finalize the interfaces as soon as practical before implementation begins.
定义好的C++接口是一项非常不简单的任务;我知道至少有一本优秀的书是完全针对这个主题的[20]。在本书中,我只提供了足够的关于接口的细节,以清楚地解释设计。可用的源代码展示了开发高效的C++接口所需的错综复杂的问题。在一个非常小的项目中,允许开发人员在适应接口方面有一定的自由度,通常是可以容忍的,而且往往是有益的,因为它允许将实现细节推迟到可以实际确定的程度。然而,在大规模的开发中,为了防止独立团队之间出现绝对的混乱,在开始实施之前尽快确定接口是明智的做法。
2.4 Assessment of Our Current Design(对我们当前设计的评估 )
Before beginning the detailed design of our three major components, let’s stop and assess our current design against the criteria we identified in the beginning of this chapter. First, having defined three distinct modules, our design is clearly modular. Second, each module acts as a cohesive unit, with each module dedicated to one specific task. User interface code belongs to one module, operational logic belongs to another, and data management belongs to yet another, separate module. Additionally, each module encapsulates all its own features. Finally, the modules are loosely coupled, and where coupling is necessary, it is through a set of clearly defined, concise, public interfaces. Not only does our top-level architecture meet our good design criteria, but it also conforms to a well-known and well-studied architectural design pattern that has been successfully used for decades. At this point, we have reaffirmed the quality of our design and should feel very comfortable proceeding to the next step in our decomposition, the design of the individual components.
在开始详细设计我们的三个主要组件之前,让我们停下来,根据我们在本章开始时确定的标准评估我们目前的设计。首先,在定义了三个不同的模块之后,我们的设计显然是模块化的。 第二,每个模块都是一个凝聚力强的单元,每个模块都专门负责一个特定的任务。用户界面代码属于一个模块,操作逻辑属于另一个模块,而数据管理则属于另一个独立的模块。此外,每个模块都封装了自己的所有功能。最后,这些模块是松散耦合的,在需要耦合的地方,它是通过一组明确定义的、简洁的、公共的接口。我们的顶层架构不仅符合我们的良好设计标准,而且也符合一个众所周知的、经过充分研究的、已经成功使用了几十年的架构设计模式。在这一点上,我们已经重申了我们设计的质量,并且应该感到非常舒服地进入我们分解的下一个步骤,即各个组件的设计。
2.5 Next Steps(下一步)
Where do we go from here? We have now established the overall architecture of our calculator, but how do we tackle the task of choosing which component to design and implement first? In a corporate setting, with a large-scale project, the likelihood would be that many modules would be designed and coded simultaneously. After all, isn’t that one of the primary reasons for creating distinct modules separated cleanly by interfaces? Of course, for our project, the modules will be handled sequentially, with some level of iteration to make a posteriori improvements. Therefore, we must choose one module to design and build first.
我们从哪里开始?我们现在已经建立了计算器的整体架构,但我们如何解决选择先设计和实现哪个组件的任务呢?在企业环境中,对于一个大规模的项目,很可能会有许多模块同时被设计和编码。毕竟,这不正是创建由接口干净地分开的不同模块的主要原因之一吗? 当然,对于我们的项目来说,这些模块将被按顺序处理,通过一定程度的迭代来进行事后改进。因此,我们必须选择一个模块首先进行设计和构建。
Of the three modules, the most logical starting point is the module with the fewest dependencies on the other modules. From Figure 2-3, we see that, in fact, the stack is the only module that has no dependencies on the interfaces of the other modules. The only outward pointing arrow from the stack is dashed, which means that the communication is indirect via eventing. Although the figure makes this decision pictorially obvious, one would likely reach the same conclusion without the architecture diagram. The stack is essentially an independent data structure that is easy to implement and test in isolation. Once the stack has been completed and tested, it can be integrated into the design and testing of the remaining modules. We therefore begin our next level of decomposition by designing and implementing the stack.
在这三个模块中,最合理的起点是对其他模块依赖性最小的模块。从图2-3中,我们看到,事实上,堆栈是唯一一个对其他模块的接口没有依赖的模块。栈的唯一向外的箭头是虚线,这意味着通信是通过事件间接进行的。尽管该图使这一决定在图形上显而易见,但如果没有架构图,人们可能会得出同样的结论。堆栈本质上是一个独立的数据结构,很容易实现和隔离测试。 一旦堆栈完成并经过测试,它就可以被整合到其余模块的设计和测试中。因此,我们通过设计和实现堆栈开始下一级的分解。