第10章 柔性设计

第10章 柔性设计 - 图1

软件的最终目的是为用户服务。但首先它必须为开发人员服务。在强调重构的软件开发过程中尤其如此。随着程序的演变,开发人员将重新安排并重写每个部分。他们会把原有的领域对象集成到应用程序中,也会让它们与新的领域对象进行集成。甚至几年以后,负责维护的程序员还将修改和扩充代码。人们必须要做这些工作,但他们是否愿意呢?

当具有复杂行为的软件缺乏良好的设计时,重构或元素的组合会变得很困难。一旦开发人员不能十分肯定地预知计算的全部含意,就会出现重复。当设计元素都是整块的而无法重新组合的时候,重复就是一种必然的结果。我们可以对类和方法进行分解,这样可以更好地重用它们,但这些小部分的行为又变得很难跟踪。如果软件没有一个条理分明的设计,那么开发人员不仅不愿意仔细地分析代码,他们更不愿意修改代码,因为修改代码会产生问题——要么加重了代码的混乱状态,要么由于某种未预料到的依赖而破坏了某些东西。在任何一种系统中(除非是一些非常小的系统),这种不稳定性使我们很难开发出丰富的功能,而且限制了重构和迭代式的精化。

为了使项目能够随着开发工作的进行加速前进,而不会由于它自己的老化停滞不前,设计必须要让人们乐于使用,而且易于做出修改。这就是柔性设计(supple design)。

柔性设计是对深层建模的补充。一旦我们挖掘出隐式概念,并把它们显示地表达出来之后,就有了原料。通过迭代循环,我们可以把这些原料打造成有用的形式:建立的模型能够简单而清晰地捕获主要关注点;其设计可以让客户开发人员真正使用这个模型。在设计和代码的开发过程中,我们将获得新的理解,并通过这些理解改善模型概念。我们一次又一次回到迭代循环中,通过重构得到更深刻的理解。但我们究竟要获得什么样的设计呢?在这个过程中应该进行哪些实验?这正是本章要讨论的内容。

很多过度设计(overengineering)借着灵活性的名义而得到合理的外衣。但是,过多的抽象层和间接设计常常成为项目的绊脚石。看一下真正为用户带来强大功能的软件设计,你常常会发现一些简单的东西。简单并不容易做到。为了把创建的元素装配到复杂系统中,而且在装配之后仍然能够理解它们,必须坚持模型驱动的设计方法,与此同时还要坚持适当严格的设计风格。要创建或使用这样的设计,可能需要我们掌握相对熟练的设计技巧。

开发人员扮演着两个角色,而设计必须要为这两个角色服务。同一个人可能会同时承担这两种角色,甚至在几分钟之内来回变换角色,但角色与代码之间的关系是不同的。一个角色是客户开发人员,负责将领域对象组织成应用程序代码或其他领域层代码,以便发挥设计的功能。柔性设计能够揭示深层次的底层模型,并把它潜在的部分明确地展现出来。客户开发人员可以灵活地使用一个最小化的、松散耦合的概念集合,并用这些概念来表示领域中的众多场景。设计元素非常自然地组合到一起,其结果也是健壮的,可以被清晰地刻画出来,而且也是可以预知的。

同样重要的是,设计也必须为那些修改代码的开发人员服务。为了便于修改,设计必须易于理解,必须把客户开发人员正在使用的同一个底层模型表示出来。我们必须按照领域深层模型的轮廓进行设计,以便大部分修改都可以灵活地完成。代码的结果必须是完全清晰明了的,这样才容易预见到修改的影响。

早期的设计版本通常达不到柔性设计的要求。由于项目的时间期限和预算的缘故,很多设计一直就是僵化的。我也从未见过有哪个大型程序自始至终都是柔性的。但是,当复杂性阻碍了项目的前进时,就需要仔细修改最关键、最复杂的地方,使之变成一个柔性设计,这样才能突破复杂性带给我们的限制,而不会陷入遗留代码维护的麻烦中。

设计这样的软件并没有公式,但我精选了一组模式,从我自己的经验来看,这些模式如果运用得当的话,就有可能获得柔性设计。这些模式和示例展示了一个柔性设计应该是什么样的,以及在设计中所采取的思考方式。

10.1 模式:INTENTION-REVEALING INTERFACES

在领域驱动的设计中,我们希望看到有意义的领域逻辑。如果代码只是在执行规则后得到结果,而没有把规则显式地表达出来,那么我们就不得一步一步地去思考软件的执行步骤。那些只是运行代码然后给出结果的计算——没有显式地把计算逻辑表达出来,也有同样的问题。如果不把代码与模型清晰地联系起来,我们很难理解代码的执行效果,也很难预测修改代码的影响。前一章深入探讨了对规则和计算进行显式的建模。实现这样的对象要求我们深入理解计算或规则的大量细节。对象的强大功能是它能够把所有这些细节封装起来,如此一来,客户代码就能够很简单,而且可以用高层概念来解释。

第10章 柔性设计 - 图2 图10-1 一些有助于获得柔性设计的模式

但是,客户开发人员要想有效地使用对象,必须知道对象的一些信息,如果接口没有告诉开发人员这些信息,那么他就必须深入研究对象的内部机制,以便理解细节。阅读客户代码的人也需要做同样的事情。这样就失去了封装的大部分价值。我们需要避免出现“认识过载”的问题。如果客户开发人员必须总是思考组件工作方式的大量细节,那么就无暇理清思路来解决客户设计的复杂性。即便一个人同时扮演两种角色(既开发代码,也使用他自己的代码)的时候也是如此,因为他即使不必去了解那些细节,也不可能一次就把所有的因素都考虑全面。

如果开发人员为了使用一个组件而必须要去研究它的实现,那么就失去了封装的价值。当某个人开发的对象或操作被别人使用时,如果使用这个组件的新的开发者不得不根据其实现来推测其用途,那么他推测出来的可能并不是那个操作或类的主要用途。如果这不是那个组件的用途,虽然代码暂时可以工作,但设计的概念基础已经被误用了,两位开发人员的意图也是背道而驰。

当我们把概念显式地建模为类或方法时,为了真正从中获取价值,必须为这些程序元素赋予一个能够反映出其概念的名字。类和方法的名称为开发人员之间的沟通创造了很好的机会,也能够改善系统的抽象。

Kent Beck曾经提出通过INTENTION-REVEALING SELECTOR(释意命名选择器)来选择方法的名称,使名称表达出其目的[Beck 1997]。设计中的所有公共元素共同构成了接口,每个元素的名称都提供了揭示设计意图的机会。类型名称、方法名称和参数名称组合在一起,共同形成了一个INTENTION-REVEALING INTERFACE(释意接口)。

因此:

在命名类和操作时要描述它们的效果和目的,而不要表露它们是通过何种方式达到目的的。这样可以使客户开发人员不必去理解内部细节。这些名称应该与UBIQUITOUS LANGUAGE保持一致,以便团队成员可以迅速推断出它们的意义。在创建一个行为之前先为它编写一个测试,这样可以促使你站在客户开发人员的角度上来思考它。

所有复杂的机制都应该封装到抽象接口的后面,接口只表明意图,而不表明方式。

在领域的公共接口中,可以把关系和规则表述出来,但不要说明规则是如何实施的;可以把事件和动作描述出来,但不要描述它们是如何执行的;可以给出方程式,但不要给出解方程式的数学方法。可以提出问题,但不要给出获取答案的方法。

示例 重构:调漆应用程序

一家油漆商店的程序能够为客户显示出标准调漆的结果。下面是初始的设计,它有一个简单的领域类。

第10章 柔性设计 - 图3 图10-2

paint(paint)方法的行为根本猜不出,想知道它的唯一方法就是阅读代码。

第10章 柔性设计 - 图4

从代码上看,这个方法是把两种油漆(Paint)混合到一起,结果是油漆的体积增加了,并变为混合颜色。

为了换个角度来看问题,我们为这个方法编写一个测试(这段代码基于JUnit测试框架)。

第10章 柔性设计 - 图5

通过这个测试只是一个起点,这无法令我们满意,因为这段测试代码并没有告诉我们这个方法都做了什么。让我们来重新编写这个测试,看一下如果我们正在编写一个客户应用程序的话,将以何种方式来使用Paint对象。最初,这个测试会失败。实际上,它甚至不能编译。我们编写它的目的是从客户开发人员的角度来研究一下Paint对象的接口设计。

第10章 柔性设计 - 图6

花时间编写这样的测试是非常必要的,因为它可以反映出我们希望以哪种方式与这些对象进行交互。在这之后,我们重构Paint类,使它通过测试,如图10-3所示。

新的方法名称可能不会告诉读者有关混合另一种油漆(Paint)的效果的所有信息(要达到这个目的需要使用断言,接下来我们就会讨论它)。但这个名称为读者提供了足够多的线索,使读者可以开始使用这个类,特别是从测试提供的示例开始。而且它还使客户代码的阅读者能够理解客户的意图。在本章接下来的几个示例中,我们将再次重构这个类,使它更清晰。

第10章 柔性设计 - 图7 图10-3

整个子领域可以被划分到独立的模块中,并用一个表达了其用途的接口把它们封装起来。这种方法可以使我们把注意力集中在项目上,并控制大型系统的复杂性,这些内容将在第15章中的COHESIVE MECHANISM和GENERIC SUBDOMAIN部分进行更多的讨论。

在接下来的两个模式中,我们将介绍如何令一个方法的执行结果变得易于预测。复杂的逻辑可以在SIDE-EFFECT-FREE FUNCTION中安全地执行,而改变系统状态的方法可以用ASSERTION来刻画。

10.2 模式:SIDE-EFFECT-FREE FUNCTION

我们可以宽泛地把操作分为两个大的类别:命令和查询。查询是从系统获取信息,查询的方式可能只是简单地访问变量中的数据,也可能是用这些数据执行计算。命令(也称为修改器)是修改系统的操作(举一个简单的例子,设臵变量)。在标准英语中,“副作用”这个词暗示着“意外的结果”,但在计算机科学中,任何对系统状态产生的影响都叫副作用。这里为了便于讨论,我们把它的含义缩小一下,任何对未来操作产生影响的系统状态改变都可以称为副作用。

为什么人们会采用“副作用”这个词来形容那些显然是有意影响系统状态的操作呢?我推测这大概是来自于复杂系统的经验。大多数操作都会调用其他的操作,而后者又会调用另外一些操作。一旦形成这种任意深度的嵌套,就很难预测调用一个操作将要产生的所有后果。第二层和第三层操作的影响可能并不是客户开发人员有意为之的,于是它们就变成了完全意义上的副作用。在一个复杂的设计中,元素之间的交互同样也会产生无法预料的结果。副作用这个词强调了这种交互的不可避免性。

多个规则的相互作用或计算的组合所产生的结果是很难预测的。开发人员在调用一个操作时,为了预测操作的结果,必须理解它的实现以及它所调用的其他方法的实现。如果开发人员不得不“揭开接口的面纱”,那么接口的抽象作用就受到了限制。如果没有了可以安全地预见到结果的抽象,开发人员就必须限制“组合爆炸”[3],这就限制了系统行为的丰富性。

返回结果而不产生副作用的操作称为函数。一个函数可以被多次调用,每次调用都返回相同的值。一个函数可以调用其他函数,而不必担心这种嵌套的深度。函数比那些有副作用的操作更易于测试。由于这些原因,使用函数可以降低风险。

显然,在大多数软件系统中,命令的使用都是不可避免的,但有两种方法可以减少命令产生的问题。首先,可以把命令和查询严格地放在不同的操作中。确保导致状态改变的方法不返回领域数据,并尽可能保持简单。在不引起任何可观测到的副作用的方法中执行所有查询和计算[Meyer 1988]。

第二,总是有一些替代的模型和设计,它们不要求对现有对象做任何修改。相反,它们创建并返回一个VALUE OBJECT,用于表示计算结果。这是一种很常见的技术,在接下来的示例中我们就会演示它的使用。VALUE OBJECT可以在一次查询的响应中被创建和传递,然后被丢弃——不像ENTITY,实体的生命周期是受到严格管理的。

VALUE OBJECT是不可变的,这意味着除了在创建期间调用的初始化程序之外,它们的所有操作都是函数。像函数一样,VALUE OBJECT使用起来很安全,测试也很简单。如果一个操作把逻辑或计算与状态改变混合在一起,那么我们就应该把这个操作重构为两个独立的操作[Fowler 1999,p.279]。但从定义上来看,这种把副作用隔离到简单的命令方法中的做法仅适用于ENTITY。在完成了修改和查询的分离之后,可以考虑再进行一次重构,把复杂计算的职责转移到VALUE OBJECT中。通过派生出一个VALUE OBJECT(而不是改变现有状态),或者通过把职责完全转移到一个VALUE OBJECT中,往往可以完全消除副作用。

因此:

尽可能把程序的逻辑放到函数中,因为函数是只返回结果而不产生明显副作用的操作。严格地把命令(引起明显的状态改变的方法)隔离到不返回领域信息的、非常简单的操作中。当发现了一个非常适合承担复杂逻辑职责的概念时,就可以把这个复杂逻辑移到VALUE OBJECT中,这样可以进一步控制副作用。

SIDE-EFFECT-FREE FUNCTION,特别是在不变的VALUE OBJECT中,允许我们安全地对多个操作进行组合。当通过INTENTION-REVEALING INTERFACE把一个FUNCTION呈现出来的时候,开发人员就可以在无需理解其实现细节的情况下使用它。

示例 再次重构调漆应用程序

一家油漆商店的程序能够为客户显示出标准调漆的结果。我们继续前面的例子,下面是上次重构后得到的领域类:

第10章 柔性设计 - 图8

第10章 柔性设计 - 图9 图10-4

第10章 柔性设计 - 图10 图10-5 mixIn()方法的副作用

mixIn()方法中发生了很多事情,但这个设计确实遵循了“修改和查询分离”这条原则。有一点需要注意(下面会具体讨论),这里并没有对paint 2对象(mixIn()方法的一个参数)的体积做过多的考虑。操作不改变Paint 2的体积,在这个概念模型的上下文中,这看起来并不是十分合乎逻辑。就我们所知,这在原来的开发人员看来并不是问题,因为他们对操作之后的paint 2对象不感兴趣,但我们很难预测副作用会产生什么后果。在接下来要讨论的ASSERTION中我们很快会回头再讨论这个问题。现在,我们先来看一下颜色。

在这个领域中,颜色是一个重要的概念。让我们试着把它变成一个显式的对象。它应该叫什么名字呢?首先想到的就是Color(颜色),但我们通过先前的知识消化已经认识到了一个重要的知识,即油漆的调色与我们所熟悉的RGB调色是不同的。名称必须反映出这一点。

第10章 柔性设计 - 图11 图10-6

把Pigment Color(颜料颜色)分离出来之后,确实比先前表达了更多信息,但计算还是相同的,仍然是在mixIn()方法中进行计算。当把颜色数据移出来后,与这些数据有关的行为也应该一起移出来。但是在做这件事之前,要注意Pigment Color是一个VALUE OBJECT。因此,它应该是不可变的。当我们调漆时,Paint对象本身被改变了,它是一个具有生命周期的实体。相反,表示某个色调(如黄色)的Pigment Color则一直表示那种颜色。调漆的结果是产生一个新的Pigment Color对象,用于表示新的颜色。

第10章 柔性设计 - 图12 图10-7

第10章 柔性设计 - 图13

第10章 柔性设计 - 图14 图10-8

现在,Paint中的代码已经尽可能简单了。新的Pigment Color类捕获了知识,并显式地把这些知识表达出来,而且它还提供了一个SIDE-EFFECT-FREE FUNCTION,这个函数的计算结果很容易理解,也很容易测试,因此可以安全地使用或与其他操作进行组合。由于它的安全性很高,因此复杂的调色逻辑真正被封装起来了。使用这个类的开发人员不必理解其实现。

10.3 模式:ASSERTION

把复杂的计算封装到SIDE-EFFECT-FREE FUNCTION中可以简化问题,但实体仍然会留有一些有副作用的命令,使用这些ENTITY的人必须了解使用这些命令的后果。在这种情况下,使用ASSERTION(断言)可以把副作用明确地表示出来,使它们更易于处理。

确实,一条不包含复杂计算的命令只需查看一下就能够理解。但是,在一个软件设计中,如果较大的部分是由较小部分构成的,那么一个命令可能会调用其他命令。开发人员在使用高层命令时,必须了解每个底层命令所产生的后果,这时封装也就没有什么价值了。而且,由于对象接口并不会限制副作用,因此实现相同接口的两个子类可能会产生不同的副作用。使用它们的开发人员需要知道哪个副作用是由哪个子类产生的,以便预测后果。这样,抽象和多态也就失去了意义。

如果操作的副作用仅仅是由它们的实现隐式定义的,那么在一个具有大量相互调用关系的系统中,起因和结果会变得一团糟。理解程序的唯一方式就是沿着分支路径来跟踪程序的执行。封装完全失去了价值。跟踪具体的执行也使抽象失去了意义。

我们需要在不深入研究内部机制的情况下理解设计元素的意义和执行操作的后果。INTENTION-REVEALING INTERFACE可以起到一部分作用,但这样的接口只能非正式地给出操作的用途,这常常是不够的。“契约式设计”(design by contract)向前推进了一步,通过给出类和方法的“断言”使开发人员知道肯定会发生的结果。[Meyer 1988]中详细讨论了这种设计风格。简言之,“后臵条件”描述了一个操作的副作用,也就是调用一个方法之后必然会发生的结果。“前臵条件”就像是合同条款,即为了满足后臵条件而必须要满足的前臵条件。类的固定规则规定了在操作结束时对象的状态。也可以把AGGREGATE作为一个整体来为它声明固定规则,这些都是严格定义的完整性规则。

所有这些断言都描述了状态,而不是过程,因此它们更易于分析。类的固定规则在描述类的意义方面起到帮助作用,并且使客户开发人员能够更准确地预测对象的行为,从而简化他们的工作。如果你确信后臵条件的保证,那么就不必考虑方法是如何工作的。断言应该已经把调用其他操作的效果考虑在内了。

因此:

把操作的后置条件和类及AGGREGATE的固定规则表述清楚。如果在你的编程语言中不能直接编写ASSERTION,那么就把它们编写成自动的单元测试。还可以把它们写到文档或图中(如果符合项目开发风格的话)。

寻找在概念上内聚的模型,以便使开发人员更容易推断出预期的ASSERTION,从而加快学习过程并避免代码矛盾。

尽管很多面向对象的语言目前都不支持直接使用ASSERTION,但ASSERTION仍然不失为一种功能强大的设计方法。自动单元测试在一定程度上弥补了缺乏语言支持带来的不足。由于ASSERTION只声明状态,而不声明过程,因此很容易编写测试。测试首先设臵前臵条件,在执行之后,再检查后臵条件是否被满足。

把固定规则、前臵条件和后臵条件清楚地表述出来,这样开发人员就能够理解使用一个操作或对象的后果。从理论上讲,如果一组断言之间互不矛盾,那么就可以发挥作用。但人的大脑并不会一丝不苟地把这些断言编译到一起。人们会推断和补充模型的概念,因此找到一个既易于理解又满足应用程序需求的模型是至关重要的。

示例 回到调漆应用程序

在前面的示例中,我们曾注意到:在Paint类中mixIn(paint)操作的参数到底会发生什么变化,这还存在着一些不明之处。

接受者(即被混合的油漆)的所增加的体积就是参数的体积。根据我们对油漆的了解,这个混合过程应该使另一种油漆减少同样的体积,把它的体积减为零或完全删除。目前的实现并没有修改这个参数,而修改参数无疑是有产生副作用的风险的。

第10章 柔性设计 - 图15 图10-9

第一步,我们先把mixIn()方法的后臵条件声明如下:

在 之后:增加 的量不变

问题在于开发人员将会犯错,因为这些属性与实际概念不符。简单的修改方法是让另一种油漆的体积变为零。虽然修改参数不是一种好的行为,但这里的修改简单而直观。我们可以声明一个固定规则:

混合之后油漆的总体积保持不变。

但先等一下!当开发人员考虑这种选择时,他们有了一个新发现。最初的设计人员这样设计原来是有充分理由的。程序在最后会报告被混合之前的油漆清单。毕竟,这个程序的最终目的是帮助用户弄清楚把哪几种油漆混合到一起。

因此,如果要使体积模型的逻辑保持一致,那么它就无法满足这个应用程序的需求了。这看上去是一种进退两难的境况。我们是否仍使用这个不合常理的后臵条件,并为了弥补这个不足而清楚地说明这样做的理由呢?世界上并不是一切事物都是直观的,有时那就是最好的答案。但在这个例子中,这种尴尬局面似乎是由于丢失概念而造成的。让我们去寻找一个新的模型。

寻找更清晰的模型

我们在寻找更好的模型的时候,会比原来的设计人员更有优势,因为我们在研究的过程中消化了更多知识,而且通过重构得到了更深层的理解。例如,我们用一个VALUE OBJECT上的SIDE-EFFECT-FREE FUNCTION来计算颜色。这意味着可以在任何需要的时候重复进行这个计算。我们应该利用这种优势。

我们似乎为Paint分配了两种不同的基本职责。让我们试着把它们分开。

现在只有一个命令,即mixIn()。从对模型的直观理解可以看出,它只是把一个对象加入到一个集合中。所有其他操作都是SIDE-EFFECT-FREE FUNCTION。

下面的测试方法(使用了JUnit测试框架)用来确认图10-10中列出的一个ASSERTION是否满足:

第10章 柔性设计 - 图17 图10-10

第10章 柔性设计 - 图18

这个模型捕捉并传递了更多领域知识。固定规则和后臵条件符合常识,这使得它们更易于维护和使用。

INTENTION-REVEALING INTERFACE清楚地表明了用途,SIDE-EFFECT-FREE FUNCTION和ASSERTION使我们能够更准确地预测结果,因此封装和抽象更加安全。

可重组元素的下一个因素是有效的分解……

10.4 模式:CONCEPTUAL CONTOUR

有时,人们会对功能进行更细的分解,以便灵活地组合它们,有时却要把功能合成大块,以便封装复杂性。有时,人们为了使所有类和操作都具有相似的规模而寻找一种一致的粒度。这些方法都过于简单了,并不能作为通用的规则。但使用这些方法的动机都来自于一系列基本的问题。

如果把模型或设计的所有元素都放在一个整体的大结构中,那么它们的功能就会发生重复。外部接口无法给出客户可能关心的全部信息。由于不同的概念被混合在一起,它们的意义变得很难理解。

而另一方面,把类和方法分解开也可能是毫无意义的,这会使客户更复杂,迫使客户对象去理解各个细微部分是如何组合在一起的。更糟的是,有的概念可能会完全丢失。铀原子的一半并不是铀。而且,粒度的大小并不是唯一要考虑的问题,我们还要考虑粒度是在哪种场合下使用的。

菜谱式的规则是没有用的。但大部分领域都深深隐含着某种逻辑一致性,否则它们就形不成领域了。这并不是说领域就是绝对一致的,而且人们讨论领域的方式肯定也不一样。但是领域中一定存在着某种十分复杂的原理,否则建模也就失去了意义。由于这种隐藏在底层的一致性,当我们找到一个模型,它与领域的某个部分特别吻合时,这个模型很可能也会与我们后续发现的这个领域的其他部分一致。有时,新的发现可能与模型不符,在这种情况下,就需要对模型进行重构,以便获取更深层的理解,并希望下一次新发现能与模型一致。

通过反复重构最终会实现柔性设计,以上就是其中的一个原因。随着代码不断适应新理解的概念或需求,CONCEPTUAL CONTOUR(概念轮廓)也就逐渐形成了。

从单个方法的设计,到类和MODULE的设计,再到大型结构的设计(参见第16章),高内聚低耦合这一对基本原则都起着重要的作用。这两条原则既适用于代码,也适用于概念。为了避免机械化地遵循它,我们必须经常根据我们对领域的直观认识来调整技术思路。在做每个决定时,都要问自己:“这是根据当前模型和代码中的特定关系做出的权宜之计呢,还是反映了底层领域的某种轮廓?”

寻找在概念上有意义的功能单元,这样可以使得设计既灵活又易懂。例如,如果领域中对两个对象的“相加”(addition)是一个连贯的整体操作,那么就把它作为整体来实现。不要把第10章 柔性设计 - 图19拆分成两个步骤。不要在同一个操作中进行下一个步骤。从稍大的范围来看,每个对象都应该是一个独立的、完整的概念,也就是一个“WHOLE VALUE”(整体值)[4]

出于同样的原因,在任何领域中,都有一些细节是用户不感兴趣的。前面假想的那个调漆应用程序的用户不会添加红色颜料或蓝色颜料,他们只是把已经做好的油漆拿来调,而油漆包含所有3种颜料。把那些没必要分解或重组的元素作为一个整体,这样可以避免混乱,并且使人们更容易看到那些真正需要重组的元素。如果用户的物理设备允许加入颜料,那么领域就改变了,而且我们可能需要分别对每种颜料进行控制。专门研究油漆的化学家将需要更精细的控制,这就需要进行完全不同的分析了,有可能会产生一个比我们的调漆应用程序中的颜料颜色更精细的油漆构成模型。但是这些与我们的调漆应用程序项目中的任何人都无关。

因此:

把设计元素(操作、接口、类和AGGREGATE)分解为内聚的单元,在这个过程中,你对领域中一切重要划分的直观认识也要考虑在内。在连续的重构过程中观察发生变化和保证稳定的规律性,并寻找能够解释这些变化模式的底层CONCEPTUAL CONTOUR。使模型与领域中那些一致的方面(正是这些方面使得领域成为一个有用的知识体系)相匹配。

我们的目标是得到一组可以在逻辑上组合起来的简单接口,使我们可以用UBIQUITOUS LANGUAGE进行合理的表述,并且使那些无关的选项不会分散我们的注意力,也不增加维护负担。但这通常是通过重构才能得到的结果,很难在前期就实现。而且如果仅仅是从技术角度进行重构,可能永远也不会出现这种结果;只有通过重构得到更深层的理解,才能实现这样的目标。

设计即使是按照CONCEPTUAL CONTOUR进行,也仍然需要修改和重构。当连续的重构往往只是做出一些局部修改(而不是对模型的概念产生大范围的影响)时,这就是模型已经与领域相吻合的信号。如果遇到了一个需求,它要求我们必须大幅度地修改对象和方法的划分,那么这就在向我们传递这样一条信息:我们对领域的理解还需要精化。它提供了一个深化模型并且使设计变得更具柔性的机会。

示例 应计项目的CONCEPTUAL CONTOUR

在第9章中,基于对会计概念的更深层理解,我们对一个贷款跟踪系统进行了重构,如图10-11所示。

新模型比原来的模型只多出一个对象,但职责的划分却发生了很大的变化。

Schedule原来是在Calculator类中通过逻辑判断计算的,现在被分散到不同的类中,用于不同类型的手续费和利息计算。另一方面,手续费和利息的支付原来是分开的,现在也被合并到一起了。

由于新发现的显式概念与领域非常吻合,而且Accrual Schedule的层次结构具有内聚性,因此开发人员认为这个模型更符合领域的CONCEPTUAL CONTOUR,如图10-12所示。

新的Accrual Schedule的加入是开发人员早就预料到的,因为有一些需求早已等待它来处理了。这样,她选择的模型除了使现有功能更清晰、简单之外,还很容易引入新的Schedule。但是,她是否找到了一个CONCEPTUAL CONTOUR,使得领域设计可以随着应用程序和业务的演变而改变和发展呢?我们无法确定一个设计如何处理意料之外的改变,但她认为她的设计中一些不合适的地方已经有所改进了。

第10章 柔性设计 - 图20 图10-11

第10章 柔性设计 - 图21 图10-12 这个模型把新的Accrual Schedule添加进来了

一个未预料到的改变

随着项目向前进展,又出现了一个新的需求——需要制定一些详细的规则来处理提早付款和延迟付款。这位开发人员在研究问题的时候,很高兴地发现利息付款和手续费付款实际上使用相同的规则。这意味着新的模型元素可以很自然地使用Payment类。

第10章 柔性设计 - 图22 图10-13

原有的设计导致两个Payment History类之间必然出现重复(这个难题可能使得开发人员认识到Payment类应该被共享,这样就会从另外一条途径得到类似的模型)。新元素之所以很容易就添加进来了,并不是因为她预料到了这个改变,也不是因为她的设计灵活到了足以容纳任何可能修改的程度。真正的原因是经过前面的重构,设计能够很好地与领域的基本概念相契合。

INTENTION-REVEALING INTERFACE使客户能够把对象表示为有意义的单元,而不仅仅是一些机制。SIDE-EFFECT-FREE FUNCTION和ASSERTION使我们可以安全地使用这些单元,并对它们进行复杂的组合。CONCEPTUAL CONTOUR的出现使模型的各个部分变得更稳定,也使得这些单元更直观,更易于使用和组合。

然而,我们仍然会遇到“概念过载”(conceptual overload)的问题——当模型中的互相依赖过多时,我们就必须把大量问题放在一起考虑。

10.5 模式:STANDALONE CLASS

互相依赖使模型和设计变得难以理解、测试和维护。而且,互相依赖很容易越积越多。

当然,每个关联都是一种依赖,要想理解一个类,必须理解它与哪些对象有联系。与这个类有联系的其他对象还会与更多的对象发生联系,而这些联系也是必须要弄清楚的。每个方法的每个参数的类型也是一个依赖,每个返回值也都是一个依赖。

如果有一个依赖关系,我们必须同时考虑两个类以及它们之间关系的本质。如果某个类依赖另外两个类,我们就必须考虑这3个类当中的每一个、这个类与其他两个类之间的相互关系的本质,以及这3个类可能存在的其他相互关系。如果它们之间依次存在依赖关系,那么我们还必须考虑这些关系。如果一个类有3个依赖关系……问题就会像滚雪球一样越来越多。

MODULE和AGGREGATE的目的都是为了限制互相依赖的关系网。当我们识别出一个高度内聚的子领域并把它提取到一个MODULE中的时候,一组对象也随之与系统的其他部分解除了联系,这样就把互相联系的概念的数量控制在一个有限的范围之内。但是,即使把系统分成了各个MODULE,如果不严格控制MODULE内部的依赖的话,那么MODULE也一样会让我们耗费很多精力去考虑依赖关系。

即使是在MODULE内部,设计也会随着依赖关系的增加而变得越来越难以理解。这加重了我们的思考负担,从而限制了开发人员能处理的设计复杂度。隐式概念比显式引用增加的负担更大。

我们可以将模型一直精炼下去,直到每个剩下的概念关系都表示出概念的基本含义为止。在一个重要的子集中,依赖关系的个数可以减小到零,这样就得到一个完全独立的类,它只有很少的几个基本类型和基础库概念。

在每种编程环境中,都有一些非常基本的概念,它们经常用到,以至于已经根植于我们的大脑中。例如,在Java开发环境中,基本类型和一些标准类库提供了数字、字符串和集合等基本概念。从实际来讲,“整数”这个概念是不会增加思考负担的。除此之外,为了理解一个对象而必须保留在大脑中的其他概念都会增加思考负担。

隐式概念,无论是否已被识别出来,都与显式引用一样会加重思考负担。虽然我们通常可以忽略像整数和字符串这样的基本类型值,但无法忽略它们所表示的意义。例如,在第一个调漆应用程序的例子中,Paint对象包含3个公共的整数,分别表示红、黄、蓝3种颜色值。Pigment Color对象的创建并没有增加所涉及的概念数量,也没有增加依赖关系。但它确实使现有概念更明晰、更易于理解了。另一方面,Collection的size()操作返回一个整数(只是一个简单的合计数),它只表示整数的基本含义,因此并不产生隐式的新概念。

我们应该对每个依赖关系提出质疑,直到证实它确实表示对象的基本概念为止。这个仔细检查依赖关系的过程从提取模型概念本身开始。然后需要注意每个独立的关联和操作。仔细选择模型和设计能够大幅减少依赖关系——常常能减少到零。

低耦合是对象设计的一个基本要素。尽一切可能保持低耦合。把其他所有无关概念提取到对象之外。这样类就变得完全独立了,这就使得我们可以单独地研究和理解它。每个这样的独立类都极大地减轻了因理解MODULE而带来的负担。

当一个类与它所在的模块中的其他类存在依赖关系时,比它与模块外部的类有依赖关系要好得多。同样,当两个对象具有自然的紧密耦合关系时,这两个对象共同涉及的多个操作实际上能够把它们的关系本质明确地表示出来。我们的目标不是消除所有依赖,而是消除所有不重要的依赖。当无法消除所有的依赖关系时,每清除一个依赖对开发人员而言都是一种解脱,使他们能够集中精力处理剩下的概念依赖关系。

尽力把最复杂的计算提取到STANDALONE CLASS(独立的类)中,实现此目的的一种方法是从存在大量依赖的类中将VALUE OBJECT建模出来。

从根本上讲,油漆的概念与颜色的概念紧密相关。但在考虑颜色(甚至是颜料)的时候却与不必去考虑油漆。通过把这两个概念变为显式概念并精炼它们的关系,所得到的单向关联就可以表达出重要的信息,同时我们可以对Pigment Color类(大部分计算复杂性都隐藏在这个类中)进行独立的分析和测试。

低耦合是减少概念过载的最基本办法。独立的类是低耦合的极致。

消除依赖性并不是说要武断地把模型中的一切都简化为基本类型,这样只会削弱模型的表达能力。本章要讨论的最后一个模式CLOSURE OF OPERATION(闭合操作)就是一种在减小依赖性的同时保持丰富接口的技术。

10.6 模式:CLOSURE OF OPERATION

两个实数相乘,结果仍为实数(实数是所有有理数和所有无理数的集合)。由于这一点永远成立,因此我们说实数的“乘法运算是闭合的”:乘法运算的结果永远无法脱离实数这个集合。当我们对集合中的任意两个元素组合时,结果仍在这个集合中,这就叫做闭合操作。

——The Math Forum,Drexel University

当然,依赖是必然存在的,当依赖是概念的一个基本属性时,它就不是坏事。如果把接口精简到只处理一些基本类型,那么会极大地削弱接口的能力。但我们也经常为接口引入很多不必要的依赖,甚至是整个不必要的概念。

大部分引起我们兴趣的对象所产生的行为仅用基本类型是无法描述的。

另一种对设计进行精化的常见方法就是我所说的CLOSURE OF OPERATION(闭合操作)。这个名字来源于最精炼的概念体系,即数学。1 + 1 = 2。加法运算是实数集中的闭合运算。数学家们都极力避免去引入无关的概念,而闭合运算的性质正好为他们提供了这样一种方式,可用来定义一种不涉及其他任何概念的运算。我们都非常熟悉数学中的精炼,因此很难注意到一些小技巧会有多么强大。但是,这些技巧在软件设计中也广为应用。例如,XSLT的基本用法是把一个XML文档转换为另一个XML文档。这种XSLT操作就是XML文档集合中的闭合操作。闭合的性质极大地简化了对操作的理解,而且闭合操作的链接或组合也很容易理解。

因此:

在适当的情况下,在定义操作时让它的返回类型与其参数的类型相同。如果实现者(implementer)的状态在计算中会被用到,那么实现者实际上就是操作的一个参数,因此参数和返回值应该与实现者有相同的类型。这样的操作就是在该类型的实例集合中的闭合操作。闭合操作提供了一个高层接口,同时又不会引入对其他概念的任何依赖。

这种模式更常用于VALUE OBJECT的操作。由于ENTITY的生命周期在领域中十分重要,因此我们不能为了解决某一问题而草率创建一个ENTITY。有一些操作是ENTITY类型之下的闭合操作。我们可以通过查询一个Employee(员工)对象来返回其主管,而返回的将是另一个Employee对象。但是,ENTITY通常不会成为计算结果。因此,大部分闭合操作都应该到VALUE OBJECT中去寻找。

一个操作可能是在某一抽象类型之下的闭合操作,在这种情况下,具体的参数可能是不同的具体类型。例如,加法是实数之下的闭合运算,而实数既可以是有理数,也可以是无理数。

在尝试和寻找减少互相依赖并提高内聚的过程中,有时我们会遇到“半个闭合操作”这种情况。参数类型与实现者的类型一致,但返回类型不同;或者返回类型与接收者(receiver)的类型相同但参数类型不同。这些操作都不是闭合操作,但它们确实具有CLOSURE OF OPERATION的某些优点。当没有形成闭合操作的那个多出来的类型是基本类型或基础库类时,它几乎与CLOSURE OF OPERATION一样减轻了我们的思考负担。

在前面的示例中,Pigment Color的mixedWith()操作是Pigment Color之下的闭合操作,本书中还零星地穿插着几个这样的示例。以下示例显示了即使在没有达到真正CLOSURE OF OPERATION的时候,这种思想也发挥了强大的作用。

示例 从集合中选择子集

在Java中,如果想从Collection(集合)中选择一个元素子集,需要使用Iterator(迭代器)。用迭代器遍历这些元素,测试每个元素,把匹配的元素收集到一个新的Collection中。

第10章 柔性设计 - 图23

从概念上讲,上段代码只是从集合中选择了一个子集。是否真的有必要使用Iterator这个额外的概念以及它所带来的所有机制上的复杂性呢?如果是使用Smalltalk,我将在Collection上调用“select”操作,把测试作为参数传递给它。返回值将是一个新的Collection,其中只包含通过测试的那些元素。

第10章 柔性设计 - 图24

Smalltalk的Collection还提供了其他一些这样的函数,它们返回新生成的Collection(可能是几种不同的具体类)。这些操作并不是闭合操作,因为它们把一个block(块)作为参数。但block在Smalltalk中是一个基础库类型,因此它们并不会增加开发人员的思考负担。由于返回值与实现者的类型相匹配,因此它们可以像一系列过滤器一样被串接在一起。读写代码都变得很容易。它们并没有引入与选择子集无关的外来概念。

本章介绍的模式演示了一个通用的设计风格和一种思考设计的方式。把软件设计得意图明显、容易预测且富有表达力,可以有效地发挥抽象和封装的作用。我们可以对模型进行分解,使得对象更易于理解和使用,同时仍具有功能丰富的、高级的接口。

运用这些技术需要掌握相当高级的设计技巧,甚至有时编写客户端代码也需要掌握高级技巧才能运用这些技术。MODEL-DRIVEN DESIGN的作用受细节设计的质量和实现决策的质量影响很大,而且只要有少数几个开发人员没有弄清楚它们,整个项目就会偏离目标。

尽管如此,团队只要愿意培养这些建模和设计技巧,那么按照这些模式的思考方式就能够开发出可以反复重构的软件,从而最终创建出非常复杂的软件。

10.7 声明式设计

使用ASSERTION可以得到更好的设计,虽然我们只是用一些相对非正式的方式来检查这些ASSERTION。但实际上我们无法保证手写软件的正确性。举个简单例子,只要代码还有其他一些没有被ASSERTION专门排除在外的副作用,断言就失去了作用。无论我们的设计多么遵守MODEL-DRIVEN开发方法,最后仍要通过编写过程代码来实现概念交互的结果。而且我们花费了大量时间来编写样板代码,但是这些代码实际上不增加任何意义或行为。这些代码冗长乏味而且易出错,此外还掩盖了模型的意义(虽然有的编程语言会相对好一些,但都需要我们做大量繁琐的工作)。本章介绍的INTENTION-REVEALING INTERFACE和其他模式虽然有一定的帮助作用,但它们永远也不会使传统的面向对象技术达到非常严密的程度。

以上这些正是采用声明式设计的部分动机。声明式设计对于不同的人来说具有不同的意义,但通常是指一种编程方式—把程序或程序的一部分写成一种可执行的规格(specification)。使用声明式设计时,软件实际上是由一些非常精确的属性描述来控制的。声明式设计有多种实现方式,例如,可以通过反射机制来实现,或在编译时通过代码生成来实现(根据声明来自动生成传统代码)。这种方法使其他开发人员能够根据字面意义来使用声明。它是一种绝对的保证。

从模型属性的声明来生成可运行的程序是MODEL-DRIVEN DESIGN的理想目标,但在实践中这种方法也有自己的缺陷。例如,下面就是我多次遇到的两个具体问题:

声明式语言并不足以表达一切所需的东西,它把软件束缚在一个由自动部分构成的框架之内,使软件很难扩展到这个框架之外。

代码生成技术破坏了迭代循环——它把生成的代码合并到手写的代码中,使得代码重新生成具有巨大的破坏作用。

许多声明式设计的尝试带来了意想不到的后果,由于开发人员受到框架局限性的约束,为了交付工作只能先处理重要问题,而搁臵其他一些问题,这导致模型和应用程序的质量严重下降。

基于规则的编程(带有推理引擎和规则库)是另一种有望实现的声明式设计方法。但遗憾的是,一些微妙的问题会影响它的实现。

尽管基于规则的程序原则上是声明式的,但大多数系统都有一些用于性能优化的“控制谓词”(control predicate)。这种控制代码引入了副作用,这样行为就不再完全由声明式规则来控制了。添加、删除规则或重新排序可能导致预料不到的错误结果。因此,编写逻辑的程序员必须确保代码的效果是显而易见的,就像对象程序员所做的那样。

很多声明式方法被开发人员有意或无意忽略之后会遭到破坏。当系统很难使用或限制过多时,就会发生这种情况。为了获得声明式程序的好处,每个人都必须遵守框架的规则。

据我所知,声明式设计发挥的最大价值是用一个范围非常窄的框架来自动处理设计中某个特别单调且易出错的方面,如持久化和对象关系映射。最好的声明式设计能够使开发人员不必去做那些单调乏味的工作,同时又完全不限制他们的设计自由。

领域特定语言

领域特定语言是一种有趣的方法,它有时也是一种声明式语言。采用这种编码风格时,客户代码是用一种专门为特定领域的特定模型定制的语言编写的。例如,运输系统的语言可能包括cargo(货物)和route(路线)这样的术语,以及一些用于组合这些术语的语法。然后,程序通常会被编译成传统的面向对象语言,由一个类库为这些术语提供实现。

在这样的语言中,程序可能具有极强的表达能力,并且与UBIQUITOUS LANGUAGE之间形成最紧密的结合。领域特定语言是一个令人振奋的概念,但就我所见,在基于面向对象技术进行实现时,这种语言也存在自身的缺陷。

为了精化模型,开发人员需要修改语言。这可能涉及修改语法声明和其他语言解释功能,以及修改底层类库。虽然我对学习高级技术和设计概念是完全赞同的,但我们必须冷静地评估团队当前的技术水平,以及将来维护团队可能的技术水平。此外,用同一种语言实现的应用程序和模型之间是“无缝”的,这一点很有价值。另一个缺点是当模型被修改时,很难对客户代码进行重构,使之与修改之后的模型及与其相关的领域特定语言保持一致。当然,也许有人可以通过技术方法来解决重构问题。

一种完全不同的语言

有一种不同的范式能够比对象更好地实现领域特定语言。在Scheme编程语言中(它是“函数式编程”家族的一个代表),有些部分非常类似于标准的编程风格,因此既具有领域特定语言的表达能力,又不会造成系统的分裂。

这种技术也许能在非常成熟的模型中发挥出最大的作用,在这种情况下,客户代码可能是由不同的团队编写的。但一般情况下,这样的设臵会产生有害的结果——团队被分成两部分,框架由那些技术水平较高的人来构建,而应用程序则由那些技术水平较差的人来构建了,但也并不是非得如此。

10.8 声明式设计风格

一旦你的设计中有了INTENTION-REVEALING INTERFACE、SIDE-EFFECT-FREE FUNCTION和ASSERTION,那么你就具备了使用声明式设计的条件。当我们有了可以组合在一起来表达意义的元素,并且使其作用具体化或明朗化,甚或是完全没有明显的副作用,我们就可以获得声明式设计的很多益处。

柔性设计使得客户代码可以使用声明式的设计风格。为了说明这一点,下一节将会把本章介绍的一些模式结合起来使用,从而使SPECIFICATION更灵活,更符合声明式设计的风格。

用声明式的风格来扩展SPECIFICATION

第9章介绍了SPECIFICATION的基本概念、它在程序中扮演的角色,以及它在实现中的意义。现在,让我们来看看几个额外的、有吸引力的技巧,它们在规则很复杂的情况下可能非常有用。

SPECIFICATION是由“谓词”(predicate)这个众所周知的形式化概念演变来的。谓词还有其他一些有用的特性,我们可以对这些特性进行有选择的利用。

使用逻辑运算对SPECIFICATION进行组合

当使用SPECIFICATION时,我们很容易就会遇到需要把它们组合起来使用的情况。正如我们刚刚提到的那样,SPECIFICATION是谓词的一个例子,而谓词可以用“AND”、“OR”和“NOT”等运算进行组合和修改。这些逻辑运算都是谓词这个类别之下的闭合操作,因此SPECIFICATION组合也是CLOSURE OF OPERATION。

随着SPECIFICATION的通用性逐渐提高,创建一个可用于各种类型的SPECIFICATION的抽象类或接口会变得很有用。这需要把参数类型定义为某种高层的抽象类。

第10章 柔性设计 - 图25

这个抽象要求在方法的开始处放臵一条卫语句(guard clause),但是没有卫语句也不影响它的功能。例如,可以对Container Specification(参见图9-16以及后面的相关表格、代码等)做如下修改:

第10章 柔性设计 - 图26

现在,让我们扩展Specification接口,加入3个新操作:

第10章 柔性设计 - 图27

回忆一下,有些Container Specification需要通风性的Container(容器),而有些则需要有防爆性。如果一种化学药品既易挥发又易爆炸,那么它可能同时需要这两种规格。如果使用新的方法,这就很容易实现。

第10章 柔性设计 - 图28

这段声明定义了一个具有期望属性的新的Specification对象。这种组合将需要一个用于某种特殊目的的、更复杂的Container Specification。

假设我们有多种通风容器。对于有些物品来说,把它们放进哪种容器中都没问题。它们可以放在任何一种通风容器中。

第10章 柔性设计 - 图29

如果我们认为把砂存放在特殊容器中是一种浪费,那么可以通过指定一种没有特殊性质的“便宜的”容器来禁止把砂存放在特殊容器中。

第10章 柔性设计 - 图30

这个约束将阻止第9章中所讨论的仓库打包程序原型的某些不优化的行为。

从简单元素构建复杂规格的能力提高了代码的表达能力。以上组合是以声明式的风格编写的。

由于SPECIFICATION实现的方法存在不同,提供这些运算符的难易程度也不同。下面是一个非常简单的实现,在有些情况下它的效率很差,而有些情况下则很实用。举这个例子只是为了起到说明的作用。像任何模式一样,它也有很多实现方式。

第10章 柔性设计 - 图31 图10-14 SPECIFICATION的COMPOSITE(组合)设计

第10章 柔性设计 - 图32

为了便于阅读,上面这段代码写得尽可能简单。如前所述,它在有些情况下是低效的。可能会有一些其他的实现选择,使得对象的数目减至最少,或极大地提高速度,或者与某个项目的特定技术兼容。重要的是模型捕捉到领域的关键概念,同时有一个忠实于该模型的实现。这就为解决性能问题预留了很大的空间。

此外,这样完全的通用性在很多情况下并不需要。特别是AND可能比其他运算用得更多,而且它的实现的复杂程度也较小。如果你只需要AND,那么完全可以只实现它,这没有什么可担心的。

我们回顾一下第2章示例中的对话,开发人员显然没有实现他们SPECIFICATION中的“satisfied by”行为。在他们进行那段讨论的时候,SPECIFICATION只是“根据需要来构建”(building to order)。尽管如此,抽象仍然完整,而且功能添加起来也相对简单。使用模式并不意味着构建你不需要的特性。它们可以过后再添加,只要不引起概念混淆即可。

示例 COMPOSITE SPECIFICATION的另一种实现

有些实现环境不能使用粒度很小的对象。我曾经遇到过一个项目,它有一个对象数据库,这个数据库为每个对象分配一个ID并跟踪这个ID。每个对象都占有很大的内存空间,并且产生很大的性能开销,因此总的地址空间成为一个限制因素。我在领域设计中的一些重要地方使用了SPECIFICATION,当时我认为这是一个很好的决定。但我使用了一个过于细致的实现(像本章中描述的那样),这无疑是个错误。它产生了数百万个粒度非常小的对象,使整个系统的速度变得非常缓慢。

下面的例子给出了一种替代实现,它把组合SPECIFICATION编码为一个字符串或者数组(这个数组对逻辑表达式进行了编码),然后在运行时进行解析。

(即使你没明白它的实现也不要紧,重要的是认识到用逻辑运算符来实现SPECIFICATION的方式有很多。如果最简单的方法不适用于你的情况,可以选择其他的方法。)

“Cheap Container”的SPECIFICATION栈的内容 第10章 柔性设计 - 图33

当我们想测试一种候选方案时,必须解释这个结构,这可以通过把每个元素弹出来并计算它(或者是根据运算符的需要弹出下一个元素)来实现。最后将得到如下结果:

第10章 柔性设计 - 图34

这种设计有一些优点(+)和缺点()

  • 对象个数较少

  • 内存使用效率高

 需要更高级的开发人员

你必须根据自己的实际情况做出权衡,找到一种适合你的实现。基于相同的模式和模型可以创建出完全不同的实现。

包容

最后要讲的这个包容特性并不是经常需要,而且实现起来也很难,但有时它确实能够解决很困难的问题。它还能够表达出一个SPECIFICATION的含义。

再次考虑一下前面的化学仓库打包程序的例子。每个Chemical都有一个Container Specification,而且Packer SERVICE确保当把Drum分配到Container中时,所有这些Container Specification都被满足,一切都没有问题……直到有人改变了规则。

每隔几个月都会发布一组新的规则,我们的用户希望能够生成一个列表,把那些已经有了更严格要求的化学品列出来。

当然,通过运行一个验证,用新实施的规格来检查仓库中的每个Drum,并找到所有不再满足新SPECIFICATION的化学品,这样可以把一部分化学品列出来,而且这可能也是用户需要的。这可以告诉用户现在仓库中有哪些Drum是需要转移的。

但用户要求的是把所有那些存放要求变得更严格的化学品都列出来。或许仓库里目前还没有这样的化学品,或者它们碰巧被装到了一个更严格的容器中。无论是哪种情况,刚才的那个报告都不会列出它们。

我们引入一个用于直接比较两种SPECIFICATION的新操作:

第10章 柔性设计 - 图35

更严格的SPECIFICATION包容不太严格的SPECIFICATION。用更严格的SPECIFICATION来取代不严格的SPECIFICATION不会遗漏掉先前的任何需求,如图10-15所示。

第10章 柔性设计 - 图36 图10-15 汽油容器的SPECIFICATION变严格了

在SPECIFICATION语言中,我们说新的SPECIFICATION包容旧的SPECIFICATION,因为任何满足新SPECIFICATION的对象都将满足旧SPECIFICATION。

如果把每个SPECIFICATION看成一个谓词,那么包容就等于逻辑蕴涵(logical implication)。使用传统的符号,A→B表示声明A蕴涵声明B,因此,如果A为真,则B也为真。

让我们把这个逻辑应用于我们的容器匹配需求。当一个SPECIFICATION被修改时,我们想知道新SPECIFICATION是否满足旧SPECIFICATION的所有条件。

New Spec → Old Spec

也就是说,如果新规格为真,那么旧规格一定也为真。要证明一般情况下的逻辑蕴涵是很难的,但特殊情况就很容易证明。例如,参数化的SPECIFICATION可以定义它们自己的包容规则。

第10章 柔性设计 - 图37

JUnit测试可能包含以下代码:

第10章 柔性设计 - 图38

还有一个有用的特例适用于解决Container Specification问题,它用SPECIFICATION接口把包容与逻辑操作AND结合起来。

第10章 柔性设计 - 图39

证明只有一个AND操作符的涵盖是简单的:

AANDB→A

或者在更复杂的情况中,

AANDBANDC→AANDB

这样,如果Composite Specification能够把所有由“AND”连接起来的叶节点(leaf)SPECIFICATION收集到一起,那么我们要做的事情只是检查包容规格(subsuming SPECIFICATION)是否含有被包容规格的所有叶节点(而且它可能还包含更多的叶节点)——它的叶节点集合是另一个SPECIFICATION的叶节点集合的超集。

第10章 柔性设计 - 图40

我们还可以增强这种交互,对仔细选择的参数化的叶节点SPECIFICATION进行比较或者进行其他一些复杂的比较。遗憾的是,当把OR和NOT也包括进来时,这些证明会变得更复杂。在大多数情况下,最好避免出现这样的复杂性:要么选择放弃一些运算符,要么不使用包容。如果这二者同时需要,那么要慎重考虑这样做的价值是否多过它所带来的麻烦。

受SPECIFICATION约束的亚里士多德 第10章 柔性设计 - 图41

10.9 切入问题的角度

本章展示了一系列技术,它们用于澄清代码意图,使得使用代码的影响变得显而易见,并且解除模型元素的耦合。尽管有这些技术,但要想实现这样的设计还是很难的。我们不能只是看着一个庞大的系统说:“让我们把它设计得灵活点吧。”我们必须选择具体的目标。下面介绍几种主要方法,然后给出一个扩展的示例,它展示了如何把这些模式结合起来使用,并用于处理更大的设计。

10.9.1 分割子领域

我们无法一下子就能处理好整个设计,而需要一步一步地进行。我们从系统的某些方面可以看出适合用哪种方法处理,那么就把它们提取出来加以处理。如果模型的某个部分可以被看作是专门的数学,那么可以把这部分分离出来。如果应用程序实施了某些用来限制状态改变的复杂规则,那么可以把这部分提取到一个单独的模型中,或者提取到一个允许声明规则的简单框架中。随着这些步骤的进行,不仅新模型更整洁了,而且剩下的部分也更小、更清晰了。在剩下的模型中,有的部分是用声明式的风格来编写的——这些可能是根据专门数学或验证框架编写的声明,或者是子领域所采用的任何形式。

重点突击某个部分,使设计的一个部分真正变得灵活起来,这比分散精力泛泛地处理整个系统要有用得多。第15章将更深入地讨论如何选择和管理子领域。

10.9.2 尽可能利用已有的形式

我们不能把从头创建一个严密的概念框架当作一项日常的工作来做。在项目的生命周期中,我们有时会发现并精炼出这样一个框架。但更常见的情况是,可以对你的领域或其他领域中那些建立已久的概念系统加以修改和利用,其中有些系统已经被精化和提炼达几个世纪之久。例如,很多商业应用程序涉及会计学。会计学定义了一组成熟的ENTITY和规则,我们很容易对这些ENTITY和规则进行调整,得到一个深层的模型和柔性设计。

有很多这样的正式概念框架,而我个人最喜欢的框架是数学。数学的强大功能令人惊奇,它可以用基本数学概念把一些复杂的问题提取出来。很多领域都涉及数学,我们要寻找这样的部分,并把它挖掘出来。专门的数学很整齐,可以通过清晰的规则进行组合,并很容易理解。下面我要举一个例子,用它来结束本章,它来自我过去的经历——它就是“股份数学”(Shares Math)。

示例 把各种模式结合起来使用:股份数学

第8章讲述了在银团贷款系统项目上发生的一次模型突破的故事。现在我们将更详细地讨论这个例子,这里我们只集中讨论设计的一个特性,并与原来项目上的特性进行比较。

该应用程序的一个需求是,当借款者偿付本金时,默认是根据放贷方的股份来分配这笔钱。

最初的付款分配设计

随着我们对它进行重构,这段代码会变得越来越容易理解,因此不必过度深究这个版本。

第10章 柔性设计 - 图42 图10-16

*

把命令和SIDE-EFFECT-FREE FUNCTION分开

这个设计已经有了INTENTION-REVEALING INTERFACE。但第10章 柔性设计 - 图44方法做了一件很危险的事情。它计算要分配的股份,并且还修改了Loan。我们通过重构把查询从修改操作中分离出来。

第10章 柔性设计 - 图45 图10-17

*

客户代码现在如下:

第10章 柔性设计 - 图47

这段代码不算太差。方法把大量的复杂性封装在INTENTION-REVEALING INTERFACE背后。但当我们添加applyDrawdon(),calculateFeeoaymentShares()等一些函数之后,代码开始大量增加。每次扩充都使代码变得更复杂,速度也不断减慢。这可能是由于粒度过大造成的。传统的解决方法是把计算方法分解为子例程。这可能是一种不错的解决办法,但我们希望最终看到底层的概念边界,并深化模型。当设计元素具有这种CONCEPT-CONTOURING的粒度时,就可以把这些元素进行组合,得到所需的变体。

把隐式概念变为显式概念

现在我们有足够的条件来探索新模型了。在这个实现中,Share对象是被动的,它们是用一些复杂、底层的方式来操纵的。这是因为大部分与股份有关的规则和计算并不适用于单独的股份,而是用于成组的股份。有一个概念被漏掉了:各个股份在构成整体时互相之间是有关联的。如果能把这个概念显式地表达出来,就能更简洁地表示这些规则和计算。

第10章 柔性设计 - 图48 图10-18

Share Pie表示了一个特定的Loan的总体分布。它是一个ENTITY,其标识位于Loan AGGREGATE的内部。实际的分布计算可以被委托给Share Pie。

第10章 柔性设计 - 图49 图10-19

第10章 柔性设计 - 图50

这样Loan就被简化了,而且Share计算也被集中到了一个VALUE OBJECT中(这个VALUE OBJECT只负责这个计算)。但是,这个计算并没有真正变得通用和易用。

在进一步理解之后,把Share Pie变成一个VALUE OBJECT

通常,在实现一个新设计的过程中,所获得的经验会引导我们对模型本身形成新的认识。在这个例子中,Loan和Share Pie的紧密耦合使Share Pie与Share之间的关系变得模糊不清。如果我们把Share Pie变成一个VALUE OBJECT,会产生什么变化呢?

这意味着不能再使用increase(Map)和decrease(Map)了,因为Share Pie必须是不变的。要更改Share Pie的值,必须整个替换。因此需要使用addshares(Map)这样的方法来返回一个全新的、更大的Share Pie。

让我们再进一步把它变成CLOSURE OF OPERATION。我们不采用“增加”Share Pie或向它添加Share,而只是把两个Share Pie加起来,结果是一个新的、更大的Share Pie。

我们可以先把Share Pie上的prorate()操作变成半个闭合操作,这只需要修改返回类型即可。我们把它重命名为prorated(),以便强调它没有副作用。“股份数学”开始成型了,最初它有4个操作。

第10章 柔性设计 - 图51 图10-20

我们可以为新的VALUE OBJECT(Share Pie)创建一些定义明确的ASSERTION。每个方法都有各自的意义。

第10章 柔性设计 - 图52

总股份等于各股份之和

第10章 柔性设计 - 图53

两个Pie之差等于这两个股东所持股份之差

*

两个Pie的组合就等于把这两个股东所持股份加到一起

总额可以依照所有股东所占的股份按比例划分

新设计的柔性

现在,最重要的Loan类中的方法已经很简单了,如下:

第10章 柔性设计 - 图55

这些简短的方法中的每一个都表达了其自己的含义。本金偿付表示从贷款中按照股份减去偿付额。对已偿付的本金进行分配是指在股份持有者之间按比例分配。Share Pie的设计使我们能够在Loan代码中使用声明式风格,所编写的代码读起来像是业务交易的概念定义,而不像是一种计算。

现在,其他交易类型(由于过于复杂没有在前面列出)也很容易声明了。例如,贷款支取是根据贷方的Facility股份来分配的。新支取的数额被加到未偿贷款(Loan)中。用我们的新领域语言可以描述如下:

第10章 柔性设计 - 图56

要查看每个贷方的原定贷款额与实际贷款额之差,只需计算该贷方在未偿Loan总额中的理论分配值,然后用Loan的实际股份减去这个值即可。

第10章 柔性设计 - 图57

Share Pie设计的一些特性使这种组合变得很容易,也提高了代码的表达能力。

复杂的逻辑通过SIDE-EFFECT-FREE FUNCTION被封装到了专门的VALUE OBJECT中。大部分复杂逻辑都已经被封装到这些不变的对象中。由于Share Pie是VALUE OBJECT,因此数学运算可以创建新实例,我们可以用这些新实例来替换旧实例。

Share Pie的所有方法都不会修改任何现有对象。这使我们在中间计算中能够自由地使用pius()、minus()和pro-rated(),并通过组合它们来实现预期效果,同时又不会产生其他副作用。我们还可以根据这些方法来创建分析功能(以前,只有在执行实际计算的时候才能调用这些方法,因为在每次调用之后数据就改变了)。

修改状态的操作很简单,而且是用ASSERTION来描述的。利用“股份数学”的高层抽象,我们可以用声明式的风格来精确地编写交易的固定规则。例如,差值是实际股份减去根据Facility 的Share Pie按比例分配的Loan额。

模型概念解除了耦合,操作只涉及最少的其他类型。Share Pie上的一些方法显示出它们是CLOSURE OF OPERATION(加、减方法是Share Pie之下的闭合操作)。其他操作以简单的总额作为参数或返回值,它们虽然不是闭合操作,但只增加了极少的概念负担。Share Pie只与一个其他的类——Share有密切交互。这样,Share Pie就非常直截了当,易于理解和测试,也很容易通过组合来产生声明式的交易。这些特性都是从数学形式中继承得来的。

熟悉的形式使我们更容易掌握协议(protocol)。最初用于操作股份的协议本来也是可以用财务术语来设计的,而且从原则上讲,这样的设计也能很灵活。但它有两个缺点。首先,我们必须从头开始设计它,这是一项困难且没有把握完成的任务。其次,每个处理它的人都必须先学会它。而我们现在这种设计的好处是,看到股份数学的人会发现他们对这个早已十分熟悉了,而且由于设计与算术规则保持严格一致,因此人们不会被误导。

把与数学形式有关的那部分问题提取出来之后,我们得到了一个柔性的Share设计,这使得我们可以进一步精炼核心的Loan和Facility方法(参见第15章有关CORE DOMAIN的讨论)。

柔性设计可以极大地提升软件处理变更和复杂性的能力。正如本章的例子所示,柔性设计在很大程度上取决于详细的建模和设计决策。柔性设计的影响可能远远超越某个特定的建模和设计问题。第15章将讨论柔性设计的战略价值,我们将把它作为一种工具,用来精炼领域模型,以便使大型和复杂的项目更易于掌握。