软件的最终目的是为用户服务。但首先它必须为开发人员服务。在强调重构的软件开发过程中尤其如此。随着程序的演变,开发人员将重新安排并重写每个部分。他们会把原有的领域对象集成到应用程序中,也会让它们与新的领域对象进行集成。甚至几年以后,负责维护的程序员还将修改和扩充代码。人们必须要做这些工作,但他们是否愿意呢?
当具有复杂行为的软件缺乏良好的设计时,重构或元素的组合会变得很困难。一旦开发人员不能十分肯定地预知计算的全部含意,就会出现重复。当设计元素都是整块的而无法重新组合的时候,重复就是一种必然的结果。我们可以对类和方法进行分解,这样可以更好地重用它们,但这些小部分的行为又变得很难跟踪。
如果软件没有一个条理分明的设计,那么开发人员不仅不愿意仔细地分析代码,他们更不愿意修改代码,因为修改代码会产生问题——要么加重了代码的混乱状态,要么由于某种未预料到的依赖而破坏了某些东西。在任何一种系统中(除非是一些非常小的系统),这种不稳定性使我们很难开发出丰富的功能,而且限制了重构和迭代式的精化。
- 为了使项目能够随着开发工作的进行加速前进,而不会由于它自己的老化停滞不前,设计必须要让人们乐于使用,而且易于做出修改。这就是柔性设计(supple design)。
柔性设计是对深层建模的补充。一旦我们挖掘出隐式概念,并把它们显示地表达出来之后,就有了原料。通过迭代循环,我们可以把这些原料打造成有用的形式:建立的模型能够简单而清晰地捕获主要关注点;其设计可以让客户开发人员真正使用这个模型。
很多过度设计(overengineering)借着灵活性的名义而得到合理的外衣。但是,过多的抽象
层和间接设计常常成为项目的绊脚石。看一下真正为用户带来强大功能的软件设计,你常常会发现一些简单的东西。简单并不容易做到。为了把创建的元素装配到复杂系统中,而且在装配之后仍然能够理解它们,必须坚持模型驱动的设计方法,与此同时还要坚持适当严格的设计风格。
开发人员扮演着两个角色,而设计必须要为这两个角色服务。同一个人可能会同时承担这两
种角色,甚至在几分钟之内来回变换角色,但角色与代码之间的关系是不同的。
- 一个角色是客户开发人员,负责将领域对象组织成应用程序代码或其他领域层代码,以便发挥设计的功能。柔性设计能够揭示深层次的底层模型,并把它潜在的部分明确地展现出来。客户开发人员可以灵活地使用一个最小化的、松散耦合的概念集合,并用这些概念来表示领域中的众多场景。设计元素非常自然地组合到一起,其结果也是健壮的,可以被清晰地刻画出来,而且也是可以预知的。
同样重要的是,设计也必须为那些修改代码的开发人员服务。为了便于修改,设计必须易于
理解,必须把客户开发人员正在使用的同一个底层模型表示出来。我们必须按照领域深层模型的轮廓进行设计,以便大部分修改都可以灵活地完成。代码的结果必须是完全清晰明了的,这样才容易预见到修改的影响。
设计这样的软件并没有公式,但我精选了一组模式,从我自己的经验来看,这些模式如果运
用得当的话,就有可能获得柔性设计。这些模式和示例展示了一个柔性设计应该是什么样的,以及在设计中所采取的思考方式。
一、模式:INTENTION-REVEALING INTERFACES 寓意接口
在领域驱动的设计中,我们希望看到有意义的领域逻辑。如果代码只是在执行规则后得到结
果,而没有把规则显式地表达出来,那么我们就不得一步一步地去思考软件的执行步骤。那些只是运行代码然后给出结果的计算—没有显式地把计算逻辑表达出来,也有同样的问题。如果不把代码与模型清晰地联系起来,我们很难理解代码执行效果,也很难预测修改代码的影响。
前一章深入探讨了对规则和计算进行显式的建模。实现这样的对象要求我们深入理解计算或规则的大量细节。对象的强大功能是它能够把所有这些细节封装起来,如此一来,客户代码就能够很简单,而且可以用高层概念来解释。
side effect freefunction Assertion 断言 closure of operation运营成本
standAlone 独立的类 conceptual contours 概念结构
如果开发人员为了使用一个组件而必须要去研究它的实现,那么就失去了封装的价值。当某
个人开发的对象或操作被别人使用时,如果使用这个组件的新的开发者不得不根据其实现来推测其用途,那么他推测出来的可能并不是那个操作或类的主要用途。如果这不是那个组件的用途,虽然代码暂时可以工作,但设计的概念基础已经被误用了,两位开发人员的意图也是背道而驰。
当我们把概念显式地建模为类或方法时,为了真正从中获取价值,必须为这些程序元素赋予
一个能够反映出其概念的名字。类和方法的名称为开发人员之间的沟通创造了很好的机会,也能够改善系统的抽象。
Kent Beck曾经提出通过INTENTION-REVEALING SELECTOR(释意命名选择器)来选择方法的名称,使名称表达出其目的[Beck 1997]。设计中的所有公共元素共同构成了接口,每个元素的名称都提供了揭示设计意图的机会。类型名称、方法名称和参数名称组合在一起,共同形成了一个INTENTION-REVEALING INTERFACE(释意接口)。
因此:总结
-
在命名类和操作时要描述它们的效果和目的,而不要表露它们是通过何种方式达到目的的。
这样可以使客户开发人员不必去理解内部细节。这些名称应该与UBIQUITOUS LANGUAGE保持一致,以便团队成员可以迅速推断出它们的意义。在创建一个行为之前先为它编写一个测试,这样可以促使你站在客户开发人员的角度上来思考它。 -
所有复杂的机制都应该封装到抽象接口的后面,接口只表明意图,而不表明方式。
-
在领域的公共接口中,可以把关系和规则表述出来,但不要说明规则是如何实施的;可以把事件和动作描述出来,但不要描述它们是如何执行的;可以给出方程式,但不要给出解方程式的数学方法。可以提出问题,但不要给出获取答案的方法。
第一条: 提供释意接口。
重构的示例 重构:调漆应用程序
paint
一家油漆商店的程序能够为客户显示出标准调漆的结果。下面是初始的设计,它有一个简单
从代码上看,这个方法是把两种油漆(Paint)混合到一起,结果是油漆的体积增加了,并变
为混合颜色。为了换个角度来看问题,我们为这个方法编写一个测试(这段代码基于JUnit测试框架)。
通过这个测试只是一个起点,这无法令我们满意,因为这段测试代码并没有告诉我们这个方法都做了什么。让我们来重新编写这个测试,看一下如果我们正在编写一个客户应用程序的话,将以何种方式来使用Paint对象。最初,这个测试会失败。实际上,它甚至不能编译。我们编写它的目的是从客户开发人员的角度来研究一下Paint对象的接口设计。
花时间编写这样的测试是非常必要的,因为它可以反映出我们希望以哪种方式与这些对象进行交互。在这之后,我们重构Paint类,使它通过测试,如图10-3所示。
新的方法名称可能不会告诉读者有关混合另一种油漆(Paint)的效果的所有信息(要达到这个目的需要使用断言,接下来我们就会讨论它)。但这个名称为读者提供了足够多的线索,使读者可以开始使用这个类,特别是从测试提供的示例开始。而且它还使客户代码的阅读者能够理解客户的意图。在本章接下来的几个示例中,我们将再次重构这个类,使它更清晰。
在接下来的两个模式中,我们将介绍如何令一个方法的执行结果变得易于预测。复杂的逻辑可以在SIDE-EFFECT-FREE FUNCTION中安全地执行,而改变系统状态的方法可以用ASSERTION来刻画。
二、 模式:SIDE-EFFECT-FREE FUNCTION无副作用的函数
我们可以宽泛地把操作分为两个大的类别:命令和查询。
查询是从系统获取信息,查询的方式可能只是简单地访问变量中的数据,也可能是用这些数据执行计算。
命令(也称为修改器)是修改系统的操作(举一个简单的例子,设置变量)。
任何对未来操作产生影响的系统状态改变都可以称为副作用。为什么人们会采用“副作用”这个词来形容那些显然是有意影响系统状态的操作呢?
我推测这大概是来自于复杂系统的经验。大多数操作都会调用其他的操作,而后者又会调用另外一些操作。一旦形成这种任意深度的嵌套,就很难预测调用一个操作将要产生的所有后果。第二层和第三层操作的影响可能并不是客户开发人员有意为之的,于是它们就变成了完全意义上的副作用。在一个复杂的设计中,元素之间的交互同样也会产生无法预料的结果。副作用这个词强调了这种交互的不可避免性。
- 多个规则的相互作用或计算的组合所产生的结果是很难预测的。开发人员在调用一个操作时,为了预测操作的结果,必须理解它的实现以及它所调用的其他方法的实现。如果开发人员不得不“揭开接口的面纱”,那么接口的抽象作用就受到了限制。如果没有了可以安全地预见到结果的抽象,开发人员就必须限制“组合爆炸”①,这就限制了系统行为的丰富性。
返回结果而不产生副作用的操作称为函数。一个函数可以被多次调用,每次调用都返回相同的值。一个函数可以调用其他函数,而不必担心这种嵌套的深度。函数比那些有副作用的操作更易于测试。由于这些原因,使用函数可以降低风险。
显然,在大多数软件系统中,命令的使用都是不可避免的,但有两种方法可以减少命令产生的问题。首先,可以把命令和查询严格地放在不同的操作中。确保导致状态改变的方法不返回领域数据,并尽可能保持简单。在不引起任何可观测到的副作用的方法中执行所有查询和计算
第二,总是有一些替代的模型和设计,它们不要求对现有对象做任何修改。相反,它们创建并返回一个VALUE OBJECT,用于表示计算结果。这是一种很常见的技术,在接下来的示例中我们就会演示它的使用。VALUE OBJECT可以在一次查询的响应中被创建和传递,然后被丢弃——不像ENTITY,实体的生命周期是受到严格管理的。
-
VALUE OBJECT是不可变的,这意味着除了在创建期间调用的初始化程序之外,它们的所有操作都是函数。像函数一样,VALUE OBJECT使用很安全,测试也很简单。如果一个操作把逻辑或计算与状态改变混合在一起,那么我们就应该把这个操作重构为两个独立的操作。
-
但从定义上来看,这种把副作用隔离到简单的命令方法中的做法仅适用于ENTITY。在完成了修改和查询的分离之后,可以考虑再进行一次重构,把复杂计算的职责转移到VALUE OBJECT中。通过派生出一个VALUE OBJECT(而不是改变现有状态),或者通过把职责完全转移到一个VALUE OBJECT中,往往可以完全消除副作用。
因此:
总结:
- 尽可能把程序的逻辑放到函数中,因为函数是只返回结果而不产生明显副作用的操作。严格地把命令(引起明显的状态改变的方法)隔离到不返回领域信息的、非常简单的操作中。当发现了一个非常适合承担复杂逻辑职责的概念时,就可以把这个复杂逻辑移到VALUE OBJECT中,这样可以进一步控制副作用。
SIDE-EFFECT-FREE FUNCTION,特别是在不变的VALUE OBJECT中,允许我们安全地对多个操作进行组合。当通过INTENTION-REVEALING INTERFACE把一个FUNCTION呈现出来的时候,开发人员就可以在无需理解其实现细节的情况下使用它。
示例:再次重构调漆应用程序
一家油漆商店的程序能够为客户显示出标准调漆的结果。我们继续前面的例子,下面是上次
mixIn()方法中发生了很多事情,但这个设计确实遵循了“修改和查询分离”这条原则。有一点需要注意(下面会具体讨论),这里并没有对paint 2对象( mixIn()方法的一个参数)的体积做过多的考虑。操作不改变Paint 2的体积,在这个概念模型的上下文中,这看起来并不是十分合乎逻辑。就我们所知,这在原来的开发人员看来并不是问题,因为他们对操作之后的paint 2对象不感兴趣,但我们很难预测副作用会产生什么后果。 在这个领域中,颜色是一个重要的概念。让我们试着把它变成一个显式的对象。它应该叫什么名字呢?首先想到的就是Color(颜色),但我们通过先前的知识消化已经认识到了一个重要的知识,即油漆的调色与我们所熟悉的RGB调色是不同的。名称必须反映出这一点。 把Pigment Color(颜料颜色)分离出来之后,确实比先前表达了更多信息,但计算还是相同的,仍然是在mixIn() 方法中进行计算。当把颜色数据移出来后,与这些数据有关的行为也应该一起移出来。但是在做这件事之前,要注意Pigment Color是一个VALUE OBJECT。因此,它应该是不可变的。当我们调漆时,Paint对象本身被改变了,它是一个具有生命周期的实体。相反,表示某个色调(如黄色)的Pigment Color则一直表示那种颜色。调漆的结果是产生一个新的Pigment Color对象,用于表示新的颜色。
现在,Paint中的代码已经尽可能简单了。新的Pigment Color类捕获了知识,并显式地把这些知识表达出来,而且它还提供了一个SIDE-EFFECT-FREE FUNCTION,这个函数的计算结果很容易理解,也很容易测试,因此可以安全地使用或与其他操作进行组合。由于它的安全性很高,因此复杂的调色逻辑真正被封装起来了。使用这个类的开发人员不必理解其实现。
三、模式:ASSERTION 断言;声称,使用
把复杂的计算封装到SIDE-EFFECT-FREE FUNCTION中可以简化问题,但实体仍然会留有一些有副作用的命令,使用这些ENTITY的人必须了解使用这些命令的后果。在这种情况下,使用ASSERTION(断言)可以把副作用明确地表示出来,使它们更易于处理。
如果操作的副作用仅仅是由它们的实现隐式定义的,那么在一个具有大量相互调用关系的系统中,起因和结果会变得一团糟。理解程序的唯一方式就是沿着分支路径来跟踪程序的执行。封装完全失去了价值。跟踪具体的执行也使抽象失去了意义。
我们需要在不深入研究内部机制的情况下理解设计元素的意义和执行操作的后果。INTENTION-REVEALING INTERFACE可以起到一部分作用,但这样的接口只能非正式地给出操作的用途,这常常是不够的。
“契约式设计”(design by contract)向前推进了一步,通过给出类和方法的“断言”使开发人员知道肯定会发生的结果。[Meyer 1988]中详细讨论了这种设计风格。简言之,“后置条件”描述了一个操作的副作用,也就是调用一个方法之后必然会发生的结果。“前置条件”就像是合同条款,即为了满足后置条件而必须要满足的前置条件。类的固定规则规定了在操作结束时对象的状态。也可以把AGGREGATE作为一个整体来为它声明固定规则,这些都是严格定义的完整性规则。
所有这些断言都描述了状态,而不是过程,因此它们更易于分析。类的固定规则在描述类的意义方面起到帮助作用,并且使客户开发人员能够更准确地预测对象的行为,从而简化他们的工作。如果你确信后置条件的保证,那么就不必考虑方法是如何工作的。断言应该已经把调用其他操作的效果考虑在内了。
因此:
- 把操作的后置条件和类及AGGREGATE的固定规则表述清楚。如果在你的编程语言中不能直接编写ASSERTION,那么就把它们编写成自动的单元测试。还可以把它们写到文档或图中(如果符合项目开发风格的话)。寻找在概念上内聚的模型,以便使开发人员更容易推断出预期的ASSERTION,从而加快学习过程并避免代码矛盾。
尽管很多面向对象的语言目前都不支持直接使用ASSERTION,但ASSERTION仍然不失为一种功能强大的设计方法。自动单元测试在一定程度上弥补了缺乏语言支持带来的不足。由于ASSERTION只声明状态,而不声明过程,因此很容易编写测试。测试首先设臵前臵条件,在执行之后,再检查后置条件是否被满足。
把固定规则、前置条件和后置条件清楚地表述出来,这样开发人员就能够理解使用一个操作或对象的后果。从理论上讲,如果一组断言之间互不矛盾,那么就可以发挥作用。但人的大脑并不会一丝不苟地把这些断言编译到一起。人们会推断和补充模型的概念,因此找到一个既易于理解又满足应用程序需求的模型是至关重要的。
示例:回到调漆应用程序
在前面的示例中,我们曾注意到:在Paint类中 操作的参数到底会发生什么变化,这还存在着一些不明之处。接受者(即被混合的油漆)的所增加的体积就是参数的体积。根据我们对油漆的了解,这个混合过程应该使另一种油漆减少同样的体积,把它的体积减为零或完全删除。目前的实现并没有修改这个参数,而修改参数无疑是有产生副作用的风险的。
问题在于开发人员将会犯错,因为这些属性与实际概念不符。简单的修改方法是让另一种油漆的体积变为零。虽然修改参数不是一种好的行为,但这里的修改简单而直观。我们可以声明一个固定规则:混合之后油漆的总体积保持不变。
但先等一下!当开发人员考虑这种选择时,他们有了一个新发现。最初的设计人员这样设计原来是有充分理由的。程序在最后会报告被混合之前的油漆清单。毕竟,这个程序的最终目的是帮助用户弄清楚把哪几种油漆混合到一起。因此,如果要使体积模型的逻辑保持一致,那么它就无法满足这个应用程序的需求了。
这看上去是一种进退两难的境况。我们是否仍使用这个不合常理的后臵条件,并为了弥补这个不足而清楚地说明这样做的理由呢?但在这个例子中,这种尴尬局面似乎是由于丢失概念而造成的。让我们去寻找一个新的模型。
寻找更清晰的模型
我们在寻找更好的模型的时候,会比原来的设计人员更有优势,因为我们在研究的过程中消化了更多知识,而且通过重构得到了更深层的理解。例如,我们用一个VALUE OBJECT上的SIDE-EFFECT-FREE FUNCTION来计算颜色。这意味着可以在任何需要的时候重复进行这个计算。我们应该利用这种优势。
我们似乎为Paint分配了两种不同的基本职责。让我们试着把它们分开。
现在只有一个命令,即mixIn() 。从对模型的直观理解可以看出,它只是把一个对象加入到一个集合中。所有其他操作都是SIDE-EFFECT-FREE FUNCTION。
下面的测试方法(使用了JUnit测试框架)用来确认图10-10中列出的一个ASSERTION是否满足:
constituents 组成
这个模型捕捉并传递了更多领域知识。固定规则和后臵条件符合常识,这使得它们更易于维护和使用。
总结: INTENTION-REVEALING INTERFACE清楚地表明了用途,SIDE-EFFECT-FREE FUNCTION和ASSERTION使我们能够更准确地预测结果,因此封装和抽象更加安全。
在封装以及抽象之后,就是进行分解了
四、模式:CONCEPTUAL CONTOUR
如果把模型或设计的所有元素都放在一个整体的大结构中,那么它们的功能就会发生重复。外部接口无法给出客户可能关心的全部信息。由于不同的概念被混合在一起,它们的意义变得很难理解。
而另一方面,把类和方法分解开也可能是毫无意义的,这会使客户更复杂,迫使客户对象去理解各个细微部分是如何组合在一起的。更糟的是,有的概念可能会完全丢失。铀原子的一半并不是铀。而且,粒度的大小并不是唯一要考虑的问题,我们还要考虑粒度是在哪种场合下使用的。
通过反复重构最终会实现柔性设计,以上就是其中的一个原因。随着代码不断适应新理解的概念或需求,CONCEPTUAL CONTOUR(概念轮廓)也就逐渐形成了。
从单个方法的设计,到类和MODULE的设计,再到大型结构的设计(参见第16章),高内聚\低耦合这一对基本原则都起着重要的作用。这两条原则既适用于代码,也适用于概念。为了避免机械化地遵循它,我们必须经常根据我们对领域的直观认识来调整技术思路。在做每个决定时,都要问自己:“这是根据当前模型和代码中的特定关系做出的权宜之计呢,还是反映了底层领域的某种轮廓?”
寻找在概念上有意义的功能单元,这样可以使得设计既灵活又易懂。例如,如果领域中对两个对象的“相加”(addition)是一个连贯的整体操作,那么就把它作为整体来实现。不要把add()拆分成两个步骤。不要在同一个操作中进行下一个步骤。从稍大的范围来看,每个对象都应该是一个独立的、完整的概念,也就是一个“WHOLE VALUE”(整体值)①。
出于同样的原因,在任何领域中,都有一些细节是用户不感兴趣的。前面假想的那个调漆应用程序的用户不会添加红色颜料或蓝色颜料,他们只是把已经做好的油漆拿来调,而油漆包含所有3种颜料。把那些没必要分解或重组的元素作为一个整体,这样可以避免混乱,并且使人们更容易看到那些真正需要重组的元素。如果用户的物理设备允许加入颜料,那么领域就改变了,而且我们可能需要分别对每种颜料进行控制。专门研究油漆的化学家将需要更精细的控制,这就需要进行完全不同的分析了,有可能会产生一个比我们的调漆应用程序中的颜料颜色更精细的油漆构成模型。
因此:
总结:
- 把设计元素(操作、接口、类和AGGREGATE)分解为内聚的单元,在这个过程中,你对域中一切重要划分的直观认识也要考虑在内。在连续的重构过程中观察发生变化和保证稳定的规律性,并寻找能够解释这些变化模式的底层CONCEPTUAL CONTOUR(概念性轮廓)。使模型与领域中那些一致的方面(正是这些方面使得领域成为一个有用的知识体系)相匹配。
我们的目标是得到一组可以在逻辑上组合起来的简单接口,使我们可以用UBIQUITOUS LANGUAGE进行合理的表述,并且使那些无关的选项不会分散我们的注意力,也不增加维护负担。但这通常是通过重构才能得到的结果,很难在前期就实现。而且如果仅仅是从技术角度进行重构,可能永远也不会出现这种结果;只有通过重构得到更深层的理解,才能实现这样的目标。
设计即使是按照CONCEPTUAL CONTOUR进行,也仍然需要修改和重构。当连续的重构往往只是做出一些局部修改(而不是对模型的概念产生大范围的影响)时,这就是模型已经与领域相吻合的信号。如果遇到了一个需求,它要求我们必须大幅度地修改对象和方法的划分,那么这就在向我们传递这样一条信息:我们对领域的理解还需要精化。它提供了一个深化模型并且使设计变得更具柔性的机会。
示例: 应计项目的CONCEPTUAL CONTOUR
在第9章中,基于对会计概念的更深层理解,我们对一个贷款跟踪系统进行了重构,如图10-11
所示。新模型比原来的模型只多出一个对象,但职责的划分却发生了很大的变化。
Schedule原来是在Calculator类中通过逻辑判断计算的,现在被分散到不同的类中,用于不同类型的手续费和利息计算。另一方面,手续费和利息的支付原来是分开的,现在也被合并到一起了。
由于新发现的显式概念与领域非常吻合,而且Accrual Schedule的层次结构具有内聚性,因此开发人员认为这个模型更符合领域的CONCEPTUAL CONTOUR,如图10-12所示
图 10-11
图10-12
新的Accrual Schedule的加入是开发人员早就预料到的,因为有一些需求早已等待它来处理了。这样,她选择的模型除了使现有功能更清晰、简单之外,还很容易引入新的Schedule。但是,她是否找到了一个CONCEPTUAL CONTOUR,使得领域设计可以随着应用程序和业务的演变而改变和发展呢?我们无法确定一个设计如何处理意料之外的改变,但她认为她的设计中一些不合适的地方已经有所改进了。
随着项目向前进展,又出现了一个新的需求——需要制定一些详细的规则来处理提早付款和延迟付款。这位开发人员在研究问题的时候,很高兴地发现利息付款和手续费付款实际上使用相同的规则。这意味着新的模型元素可以很自然地使用Payment类。原有的设计导致两个Payment History类之间必然出现重复(这个难题可能使得开发人员认识到Payment类应该被共享,这样就会从另外一条途径得到类似的模型)。新元素之所以很容易就添加进来了,并不是因为她预料到了这个改变,也不是因为她的设计灵活到了足以容纳任何可能修改的程度。真正的原因是经过前面的重构,设计能够很好地与领域的基本概念相契合。
小结:
- INTENTION-REVEALING INTERFACE使客户能够把对象表示为有意义的单元,而不仅仅是一些机制。SIDE-EFFECT-FREE FUNCTION和ASSERTION使我们可以安全地使用这些单元,并对它们进行复杂的组合。CONCEPTUALCONTOUR的出现使模型的各个部分变得更稳定,也使得这些单元更直观,更易于使用和组合。
然而,我们仍然会遇到“概念过载”(conceptual overload)的问题——当模型中的互相依赖过多时,我们就必须把大量问题放在一起考虑。
五、模式:STANDALONE CLASS 独立的类
互相依赖使模型和设计变得难以理解、测试和维护。而且,互相依赖很容易越积越多。
当然,每个关联都是一种依赖,要想理解一个类,必须理解它与哪些对象有联系。与这个类有联系的其他对象还会与更多的对象发生联系,而这些联系也是必须要弄清楚的。每个方法的每个参数的类型也是一个依赖,每个返回值也都是一个依赖。
另外两个类,我们就必须考虑这3个类当中的每一个、这个类与其他两个类之间的相互关系的本质,以及这3个类可能存在的其他相互关系。如果它们之间依次存在依赖关系,那么我们还必须考虑这些关系。如果一个类有3个依赖关系……问题就会像滚雪球一样越来越多。
- MODULE和AGGREGATE的目的都是为了限制互相依赖的关系网。当我们识别出一个高度内聚的子领域并把它提取到一个MODULE中的时候,一组对象也随之与系统的其他部分解除了联系,这样就把互相联系的概念的数量控制在一个有限的范围之内。但是,即使把系统分成了各个MODULE,如果不严格控制MODULE内部的依赖的话,那么MODULE也一样会让我们耗费很多精力去考虑依赖关系。
我们应该对每个依赖关系提出质疑,直到证实它确实表示对象的基本概念为止。这个仔细检查依赖关系的过程从提取模型概念本身开始。然后需要注意每个独立的关联和操作。仔细选择模型和设计能够大幅减少依赖关系——常常能减少到零。
-
低耦合是对象设计的一个基本要素。尽一切可能保持低耦合。把其他所有无关概念提取到对象之外。这样类就变得完全独立了,这就使得我们可以单独地研究和理解它。每个这样的独立类都极大地减轻了因理解MODULE而带来的负担。
-
尽力把最复杂的计算提取到STANDALONE CLASS(独立的类)中,实现此目的的一种方法是从存在大量依赖的类中将VALUE OBJECT建模出来。
从根本上讲,油漆的概念与颜色的概念紧密相关。但在考虑颜色(甚至是颜料)的时候却与不必去考虑油漆。通过把这两个概念变为显式概念并精炼它们的关系,所得到的单向关联就可以表达出重要的信息,同时我们可以对Pigment Color类(大部分计算复杂性都隐藏在这个类中)进行独立的分析和测试。
- 低耦合是减少概念过载的最基本办法。独立的类是低耦合的极致。
- 消除依赖性并不是说要武断地把模型中的一切都简化为基本类型,这样只会削弱模型的表达
能力。
六、模式:CLOSURE OF OPERATION 闭合操作
- 两个实数相乘,结果仍为实数(实数是所有有理数和所有无理数的集合)。由于这一点永远成立,因此我们说实数的“乘法运算是闭合的”:乘法运算的结果永远无法脱离实数这个集合。
当我们对集合中的任意两个元素组合时,结果仍在这个集合中,这就叫做闭合操作。
大部分引起我们兴趣的对象所产生的行为仅用基本类型是无法描述的。
另一种对设计进行精化的常见方法就是我所说的CLOSURE OF OPERATION(闭合操作)。
这个名字来源于最精炼的概念体系,即数学。1 + 1 = 2。加法运算是实数集中的闭合运算。数学家们都极力避免去引入无关的概念,而闭合运算的性质正好为他们提供了这样一种方式,可用来定义一种不涉及其他任何概念的运算。我们都非常熟悉数学中的精炼,因此很难注意到一些小技巧会有多么强大。但是,这些技巧在软件设计中也广为应用。例如,XSLT的基本用法是把一个XML文档转换为另一个XML文档。这种XSLT操作就是XML文档集合中的闭合操作。闭合的性质极大地简化了对操作的理解,而且闭合操作的链接或组合也很容易理解。
闭合操作使用
这种模式更常用于VALUE OBJECT的操作。由于ENTITY的生命周期在领域中十分重要,因此我们不能为了解决某一问题而草率创建一个ENTITY。有一些操作是ENTITY类型之下的闭合操作。我们可以通过查询一个Employee(员工)对象来返回其主管,而返回的将是另一个Employee对象。但是,ENTITY通常不会成为计算结果。因此,大部分闭合操作都应该到VALUE OBJECT中去寻找。
因此总结:
- 在适当的情况下,在定义操作时让它的返回类型与其参数的类型相同。如果实现者(implementer)的状态在计算中会被用到,那么实现者实际上就是操作的一个参数,因此参数和返回值应该与实现者有相同的类型。这样的操作就是在该类型的实例集合中的闭合操作。闭合操作提供了一个高层接口,同时又不会引入对其他概念的任何依赖。
一个操作可能是在某一抽象类型之下的闭合操作,在这种情况下,具体的参数可能是不同的具体类型。例如,加法是实数之下的闭合运算,而实数既可以是有理数,也可以是无理数。
在尝试和寻找减少互相依赖并提高内聚的过程中,有时我们会遇到“半个闭合操作”这种情况。参数类型与实现者的类型一致,但返回类型不同;或者返回类型与接收者(receiver)的类型相同但参数类型不同。这些操作都不是闭合操作,但它们确实具有CLOSURE OF OPERATION的某些优点。当没有形成闭合操作的那个多出来的类型是基本类型或基础库类时,它几乎与CLOSURE OF OPERATION一样减轻了我们的思考负担。
运用这些技术需要掌握相当高级的设计技巧,甚至有时编写客户端代码也需要掌握高级技巧才能运用这些技术。MODEL-DRIVEN DESIGN的作用受细节设计的质量和实现决策的质量影响很大,而且只要有少数几个开发人员没有弄清楚它们,整个项目就会偏离目标。
尽管如此,团队只要愿意培养这些建模和设计技巧,那么按照这些模式的思考方式就能够开发出可以反复重构的软件,从而最终创建出非常复杂的软件。
七、声明式设计
使用ASSERTION可以得到更好的设计,虽然我们只是用一些相对非正式的方式来检查这些ASSERTION。但实际上我们无法保证手写软件的正确性。举个简单例子,只要代码还有其他一些没有被ASSERTION专门排除在外的副作用,断言就失去了作用。无论我们的设计多么遵守MODEL-DRIVEN开发方法,最后仍要通过编写过程代码来实现概念交互的结果。而且我们花费了大量时间来编写样板代码,但是这些代码实际上不增加任何意义或行为。这些代码冗长乏味而且易出错,此外还掩盖了模型的意义(虽然有的编程语言会相对好一些,但都需要我们做大量繁琐的工作)。本章介绍的INTENTION-REVEALING INTERFACE和其他模式虽然有一定的帮助作用,但它们永远也不会使传统的面向对象技术达到非常严密的程度。
以上这些正是采用声明式设计的部分动机。声明式设计对于不同的人来说具有不同的意义,但通常是指一种编程方式—把程序或程序的一部分写成一种可执行的规格(specification)。使用声明式设计时,软件实际上是由一些非常精确的属性描述来控制的。声明式设计有多种实现方式,例如,可以通过反射机制来实现,或在编译时通过代码生成来实现(根据声明来自动生成传统代码)。
从模型属性的声明来生成可运行的程序是MODEL-DRIVEN DESIGN的理想目标,但在实践中这种方法也有自己的缺陷。例如,下面就是我多次遇到的两个具体问题:
声明式语言并不足以表达一切所需的东西,它把软件束缚在一个由自动部分构成的框架之内,使软件很难扩展到这个框架之外。
代码生成技术破坏了迭代循环——它把生成的代码合并到手写的代码中,使得代码重新生成具有巨大的破坏作用。
许多声明式设计的尝试带来了意想不到的后果,由于开发人员受到框架局限性的约束,为了交付工作只能先处理重要问题,而搁臵其他一些问题,这导致模型和应用程序的质量严重下降。
基于规则的编程(带有推理引擎和规则库)是另一种有望实现的声明式设计方法。但遗憾的是,一些微妙的问题会影响它的实现。
尽管基于规则的程序原则上是声明式的,但大多数系统都有一些用于性能优化的“控制谓词”(control predicate)。这种控制代码引入了副作用,这样行为就不再完全由声明式规则来控制了。添加、删除规则或重新排序可能导致预料不到的错误结果。因此,编写逻辑的程序员必须确保代码的效果是显而易见的,就像对象程序员所做的那样。
据我所知,声明式设计发挥的最大价值是用一个范围非常窄的框架来自动处理设计中某个特别单调且易出错的方面,如持久化和对象关系映射。最好的声明式设计能够使开发人员不必去做那些单调乏味的工作,同时又完全不限制他们的设计自由。
领域特定语言
领域特定语言是一种有趣的方法,它有时也是一种声明式语言。采用这种编码风格时,客户代码是用一种专门为特定领域的特定模型定制的语言编写的。例如,运输系统的语言可能包括cargo(货物)和route(路线)这样的术语,以及一些用于组合这些术语的语法。然后,程序通常会被编译成传统的面向对象语言,由一个类库为这些术语提供实现。
在这样的语言中,程序可能具有极强的表达能力,并且与UBIQUITOUS LANGUAGE之间形成最紧密的结合。领域特定语言是一个令人振奋的概念,但就我所见,在基于面向对象技术进行实现时,这种语言也存在自身的缺陷。
为了精化模型,开发人员需要修改语言。这可能涉及修改语法声明和其他语言解释功能,以及修改底层类库。虽然我对学习高级技术和设计概念是完全赞同的,但我们必须冷静地评估团队当前的技术水平,以及将来维护团队可能的技术水平。此外,用同一种语言实现的应用程序和模型之间是“无缝”的,这一点很有价值。另一个缺点是当模型被修改时,很难对客户代码进行重构,使之与修改之后的模型及与其相关的领域特定语言保持一致。当然,也许有人可以通过技术方法来解决重构问题。
八、声明式设计风格
一旦你的设计中有了INTENTION-REVEALING INTERFACE、SIDE-EFFECT-FREE FUNCTION和ASSERTION,那么你就具备了使用声明式设计的条件。当我们有了可以组合在一起来表达意义的元素,并且使其作用具体化或明朗化,甚或是完全没有明显的副作用,我们就可以获得声明式设计的很多益处。
柔性设计使得客户代码可以使用声明式的设计风格。
用声明式的风格来扩展SPECIFICATION
使用逻辑运算对SPECIFICATION进行组合
当使用SPECIFICATION时,我们很容易就会遇到需要把它们组合起来使用的情况。正如我们刚刚提到的那样,SPECIFICATION是谓词的一个例子,而谓词可用“AND”、“OR”和“NOT”等
运算进行组合和修改。这些逻辑运算都是谓词这个类别之下的闭合操作,因此SPECIFICATION组合也是CLOSURE OF OPERATION。
回忆一下,有些Container Specification需要通风性的Container(容器),而有些则需要有防爆性。如果一种化学药品既易挥发又易爆炸,那么它可能同时需要这两种规格。如果使用新的方法,这就很容易实现。
这段声明定义了一个具有期望属性的新的Specification对象。这种组合将需要一个用于某种特殊目的的、更复杂的Container Specification。
假设我们有多种通风容器。对于有些物品来说,把它们放进哪种容器中都没问题。它们可以放在任何一种通风容器中。
如果我们认为把砂存放在特殊容器中是一种浪费,那么可以通过指定一种没有特殊性质的
这个约束将阻止第9章中所讨论的仓库打包程序原型的某些不优化的行为。
从简单元素构建复杂规格的能力提高了代码的表达能力。以上组合是以声明式的风格编写的。由于SPECIFICATION实现的方法存在不同,提供这些运算符的难易程度也不同。下面是一个非常简单的实现,在有些情况下它的效率很差,而有些情况下则很实用。举这个例子只是为了起到说明的作用。像任何模式一样,它也有很多实现方式。
网友评论