面向对象软件设计原则(三) —— 软件实体的设计原则
提起面向对象,大家也许觉得自己已经非常“精通”了,起码也到了“灵活运用”的境界。面向对象设计不就是OOD吗?不就是用C++、Java、Smalltalk等面向对象语言写程序吗?不就是封装+继承+多态吗?
很好!大家已经掌握了不少对面向对象设计的基本要素:开发语言、基本概念、机制。Java是一种纯面向对象语言,是不是用Java写程序就等于面向对象了呢?我先列举一下面向对象设计的11个原则,测试一下大家对面向对象设计的理解程度~^_^~
- 单一职责原则(The Single Responsibility Principle,简称SRP)
- 开放-封闭原则(The Open-Close Principle,简称OCP)
- Liskov替换原则(The Liskov Substitution,简称LSP)
- 依赖倒置原则(The Dependency Inversion Principle,简称DIP)
- 接口隔离原则(The Interface Segregation Principle,简称ISP)
- 重用发布等价原则(The Reuse-Release Equivalence Principle,简称REP)
- 共同重用原则(The Common Reuse Principle,简称CRP)
- 共同封闭原则(The Common Close Principle,简称CCP)
- 无环依赖原则(The No-Annulus Dependency Principle,简称ADP)
- 稳定依赖原则(The Steady Dependency Principle,简称SDP)
- 稳定抽象原则(The Steady Abstract Principle,简称SAP)
其中1-5的原则关注所有软件实体(类、模块、函数等)的结构和耦合性,这些原则能够指导我们设计软件实体和确定软件实体的相互关系;6-8的原则关注包的内聚性,这些原则能够指导我们对类组包;9-11的原则关注包的耦合性,这些原则帮助我们确定包之间的相互关系。
1 单一职责原则(SRP)
就一个类而言,应该仅有一个引起它变化的原因。
在SRP中,我们把职责定义为“变化的原因”。如果你能够想到多于一个动机去改变一个类,那么这个类就具有多于一个的职责。有时,我们很难注意到这一点,我们习惯于以组的形式去考虑职责。
1.1 Rectangle类
例如,图2.1-1,Rectangle类具有两个方法,一个方法把矩形绘制在屏幕上,另一个方法计算矩形面积。
图2.1-1 多于一个的职责
有两个不同的应用程序使用Rectangle类。一个是有关计算几何学方面的,Rectangle类会在几何形状计算方面为它提供帮助,它从来不会在屏 幕上绘制矩形。另一个应用程序是有关图形绘制方面的,它可能进行一些几何学方面的工作,但是它肯定会在屏幕上绘制矩形。
这个设计违反了SRP。Rectangle类具有两个职责。第一个职责提供了矩形几何形状数学模型;第二个职责是把矩形在一个图形用户界面上绘制出来。
对于SRP的违反导致了一些严重的问题。首先,我们必须在计算几何应用程序中包含GUI代码。如果这是一个C++程序,就必须要把GUI代码链接进来,这会浪费链接时间、编译时间以及内存占用。如果是一个JAVA程序,GUI的.class文件必须要部署到目标平台。
其次,如果Graphical Application的改变由于一些原因导致了Rectangle的改变,那么这个改变会迫使我们重新构建、测试已经部署Computational Geometry Application。如果忘记了这样作,Computational Geometry Application可能会以不可预测的方式失败。
一个较好的设计是把这两个职责分离到图2.1-2中所示的两个完全不同的类中。这 个设计把Rectangle类中进行计算的部分移到GeometryRectangle类中,现在矩形绘制方式 的改变不会对Computational Geometry Application造成影响。
图2.1-2 分离的职责
1.2 结论
SRP是所有原则中最简单的原则之一,也是最难正确运用的原则之一。我们会自然地把职责结合在一起。软件设计真正要做到的许多内容,就是发现职责,并把那些职责相互分离。事实上,我们要论述的其余原则都会以这样或那样的方式回到这个问题上。
2 开放-封闭原则(OCP)
软件实体(类、模块、函数等)应该是可以扩展的,但是不可修改的。
遵循OCP设计出的模块具有两个主要的特征:
1、 对于扩展是开放的(Open for extension)
这意味着模块的行为是可以扩展的。当应用的需求变化时,我们可以对模块进行扩展,使其具有满足那些改变的新行为。换句话说,我们可以改变模块的功能。
2、 对于更改是封闭的(Closed for modification)
对模块行为进行扩展时,不必改动模块的源代码或者二进制代码。模块的二进制可执行版本,无论是共享库、dll或者Java的jar文件,都无需改动。
这两个特征好像是相互矛盾的。扩展模块行为的通常方式就是修改模块的源代码。不允许修改的模块常常都被认为是具有固定的行为。怎样可能在不改动模块源代码的情况下去更改它的行为呢?怎样才能在无需对模块进行改动的情况下就改变它的功能呢?——关键是抽象!
2.1 Shape应用程序
我们有一个需要在标准GUI上绘制圆和正方形的应用程序。
2.1.1 违反OCP
程序2.2.1.1-1 Square/Circle问题的过程化解决方案
------------------------------shape.h------------------------------
enum ShapeType {circle, square };
struct Shape
{
ShapeType itsType;
}
------------------------------circle.h------------------------------
#include shape.h
struct Circle
{
ShapeType itsType;
double itsRadius;
Point itsCenter;
};
------------------------------square.h------------------------------
#include shape.h
struct Aquare
{
ShapeType itsType;
double itsSide;
Point itsTopLeft;
};
------------------------------drawAllShapes.c------------------------------
#include shape.h
#include circle.h
#include square.h
typedef struct Shape* ShapePointer;
Void DrawAllShapes(ShapePointer list[], int n)
{
int i;
for (i = 0; i < n; i++)
{
struct Shape* s = list[i];
switch (s->itsType)
{
case square:
DrawSquare((struct Square*) s );
Break;
case circle:
DrawCircle((struct Circle*) s );
Break;
}
}
}
DrawAllShapes函数不符合OCP,因为它对于新的形状类型的添加不是封闭的。如果希望这个函数能够绘制包含有三角形的列表,就必须更改这个函数。事实上每增加一种新的形状类型,都必须要更改这个函数。
同样,在进行上述改动时,我们必须要在ShapeType enum中添加一个新的成员。由于所有不同种类的形状都依赖于这个enum的声明,所有我们必须要重新编译所有的形状模块。并且也必须要重新编译所有依赖于Shape类的模块。
程序2.2.1.1-1中的解决方案是僵化的,这是因为增加Triangle会导致Shape、Square、Circle以及 DrawAllShapes的重新编译和重新部署。该方法是脆弱的,因为很可能在程序的其他地方也存在类似的既难以查找又难以理解的 switch/case或者if/else语句。该方法是牢固的,因为想在另一个程序中复用DrawAllShapes时,都必须附带上Square和 Circle,即使那个新程序不需要它们。因此该程序展示了许多糟糕设计的臭味。
2.1.2 遵循OCP
程序2.2.1.2-1 Square/Circle问题的OOD解决方案
class Shape
{
public:
virtual void Draw() const = 0;
};
class Square : public Shape
{
public:
virtual void Draw() const;
};
class Circle : public Shape
{
public:
virtual void Draw() const;
};
void DrawAllShapes(vector<Shape*>& list)
{
vector<Shape*>::iterator i;
for (i == list.begin(); i != list.end(); i++)
(*i)->Draw();
}
可以看到,如果我们要扩展程序2.2.1.2-1中 DrawAllShapes函数的行为,使之能够绘制一种新的形状,我们只需增加一个新的Shape派生类。DrawAllShapes函数并不需要改 动。这样DrawAllShapes就符合了OCP。无需改动自身的代码就可以扩展它的行为。实际上,增加一个Triangle类对于这里展示的任何模块 完全没有影响。很明显,为了能够处理Triangle类,必须改动系统中的某些部分,但是这里展示的所有代码都无需改动。
这个程序是符合OCP的。对它的改动是通过增加新代码进行的,而不是更改现有的代码。因此,它就不会引起像不遵循OCP的程序那样的连锁改动。所需要的改动仅仅是增加新的模块,以及为了能够实例化新类型的对象而进行的围绕main的改动。
2.1.3 是的,我说谎了
上面的例子其实并非是100%封闭的!如果我们要求所有的圆必须在正方形之前绘制,那么程序2.2.1.2-1中DrawAllShapes函数无法对这种变化做到封闭。
这就导致一个麻烦的结果,一般而言,无论模块是多么的封闭,都会存在一些无法对之封闭的变化。没有对于所有的情况都贴切的模型。
既然不可能完全封闭,那么就必须有策略地对待这个问题。也就是说,设计人员必须对于他设计的模块应该对哪种变化封闭作出选择。他必须先猜测出最有可能发生的变化种类,然后构造抽象来隔离那些变化。
有句古老的谚语说:“愚弄我一次,应感羞愧的是你。再次愚弄我,应感羞愧的是我。”这也是一种有效的对待软件设计的态度。为了防止软件背负着不必要的复 杂性 ,我们会允许自己被愚弄一次。这意味着在我们最初编写代码时,假设变化不会发生。当变化发生时,我们就创建抽象来隔离以后发生的同类变化。简而言之,我们 愿意被第一颗子弹击中,然后我们会确保自己不再被同一支枪发射的其他任何子弹击中。
2.2 结论
在许多方面,OCP是面向对象设计的核心所在。遵循这个原则可以带来面向对象技术所声称的巨大好处(也就是:灵活性、可重用性以及可维护性)。然而,并 不是说只要使用一种面向对象语言就是遵循了这个原则。对于应用程序中的每个部分都肆意地进行抽象同样不是一个好主意。正确的做法是,开发人员应该仅仅对程 序中呈现出频繁变化的那些部分做出抽象。拒绝不成熟的抽象和抽象本身一样重要。
3 Liskov替换原则(LSP)
子类型(subtype)必须能够替换掉它们的基类型(base type)。
Barbara Liskov首次写下这个原则是在1988年。她说道:
这里需要如下替换性质:若对每个类型S的对象o1,都存在一个类型T的对象o2,使得在所有针对T编写的程序P中,o1替换o2后,程序P行为和功能不变,则S是T的子类型。
想想违反该原则的后果,LSP的重要性就不言而喻了。假设有一个函数f,它的参数为指向某个基类型B的指针或引用。同样假设某个B的派生类D,如果把D的对象作为B类型传递给f,会导致f出现错误行为。那么D就违反了LSP。显然D对f来说是脆弱的。
f的编写者会想去对D进行一些测试,以便于在把D的对象传递给f时,可以使f具有正确的行为。这个测试违反了OCP,因为此f对于B的所有不同的派生类 都不再是封闭的。这样的测试是一种代码的臭味,它是缺乏经验的开发人员(或者,更糟的,匆忙的开发人员)在违反了LSP时所产生的结果。
3.1 正方形和矩形,微妙的违规
程序2.3.1-1 Rectangle类和Square类
class Rectangle
{
public:
void SetWidth(double w) {itsWidth = w;}
void SetHeight(double h) {itsHeight = h;}
double GetWidth() {return itsWidth;}
double GetHeight() {return itsHeight;}
private:
Point itsTopLeft;
double itsWidth;
double itsHeight;
};
class Square : public Rectangle
{
public:
void SetWidth(double w)
{
Rectangle::SetWidth(w);
Rectangle::SetHeight(w);
}
void SetHeight(double h)
{
Rectangle::SetWidth(h);
Rectangle::SetHeight(h);
}
};
从一般意义上讲一个正方形就是一个矩形。因此,把Square类视为从Rectangle类派生是合乎逻辑的。
IS-A关系的这种用法有时被认为是面向对象分析(OOA)的基本技术之一。一个正方形是一个矩形,所以Square类就派生自Rectangle类。不 过这个想法会带来一些微妙但极为严重的问题。一般来说,这些问题是难以预见的,直到我们编写代码时才会发现它们。
我们 首先注意到出问题的地方是,Square类并不同时需要成员变量itsHeight和itsWidth。但是Square类仍会在Rectangle类中 继承它们。显然这是个浪费。在许多情况下,这种浪费是无关紧要的。但是,如果我们必须创建成百上千个Square对象,浪费的程度则是巨大的。
假设目前我们并不十分关心内存效率。从Rectangle类派生Square类也会产生其他一些问题。请考虑下面这个函数:
void f (Rectangle& r)
{
r.SetWidth(32); // Calls Rectangle::SetWideth()
}
如果我们向这个函数传递一个指向Square对象的引用,这个Square对象就会被破坏,因为他们的长并不会改变。这显然违反了LSP。以 Rectangle派生类的对象作为参数传入是,函数f不能正确运行。错误的原因是在Rectangle中没有把SetWidth和SetHeight声 明为虚函数,因此它们不是多态的。
这个错误很容易修正。然而,如果派生类的创建会导致我们改变基类,这就常常意味着设 计是有缺陷的。当然也违反了OCP。也许有人会反驳说,真正的设计缺陷是忘记把SetWidth和SetHeight声明为虚函数,而我们已经作了修正。 可是,这很难让人信服,因为设置一个长方形的长和宽是非常基本的操作。如果不是预见到Square类的存在,我们凭什么要把这两个函数声明为虚函数呢?
尽管如此,假设我们接受这个理由并修正这些类。
程序2.3.1-2 修正后的Rectangle类
class Rectangle
{
public:
virtual void SetWidth(double w) {itsWidth = w;}
virtual void SetHeight(double h) {itsHeight = h;}
double GetWidth() {return itsWidth;}
double GetHeight() {return itsHeight;}
private:
Point itsTopLeft;
Double itsWidth;
Double itsHeight;
};
3.1.1 真正的问题
现在Square和Rectangle看起来都能够正常工作。无论Square对象进行什么样的操作,它都和数学意义上的正方形保持一致。无论 Rectangle对象进行什么样的操作,它都和数学意义上的长方形保持一致。此外,可以向接受指向Rectangle的指针或引用的函数传递 Square,而Square依然保持正方形的特性,与数学意义上的正方形一致。
这样看来,设计似乎是自相容的、正确的。可是,这个结论是错误的。一个自相容的设计未必就和所有的用户程序自相容。考虑下面的函数g:
void g (Rectangle& r)
{
r.SetWidth(5);
r.Setheight(4);
assert(r.Area() == 20);
}
这个函数认为所传递进来的一定是Rectangle,并调用了其成员函数SetWidth和SetHeight。对于Rectangle来说,此函数运 行正确,但如果传递进来的是Square对象就发生断言错误(assertion error)。所以,真正的问题是:函数g的编写者假设改变Rectangle的宽不会导致其长的改变。
很显然,改变 一个长方形的宽不会影响它的长的假设是合理的!然而,并不是所有可以作为Rectangle传递的对象都满足这个假设。如果把一个Square类的实例传 递给g这样做了假设的函数,那么这个函数就会出现错误的行为。函数g对于Square/Rectangle层次结构来说是脆弱的。
函数g的表现说明有一些使用指向Rectangle对象的指针或者引用的函数,不能正确地操作Square对象。对于这些函数来说,Square不能替换Rectangle,因此Square和Rectangle之间的关系是违反LSP的。
3.1.2 IS-A是关于行为的
那么究竟是怎么会使?Square和Rectangle这个显然合理的模型为什么会有问题呢?毕竟,Square应该就是Rectangle。难道他们之间不存在IS-A关系吗?
对于那些不是g的编写这而言,正方形可以是长方形,但是从g的角度来看,Square对象绝对不是Rectangle对象。为什么!?因为Square 对象的行为方式和函数g所期望的Rectangle对象的行为方式不相容。从行为方式的角度来看,Square不是Rectangle,对象的行为方式才 是软件真正所关注的问题。LSP清楚地指出,OOD中IS-A关系是就行为方式而言的,行为方式是可以进行合理假设的,是客户程序所依赖的。
3.2 从派生类中抛出异常
另一种LSP的违规形式是在派生类的方法中添加了其他基类不会抛出的异常。如果基类的使用者不期望这些异常,那么把它们添加到派生类的方法中就会导致不可替换性。此时要遵循LSP,要么就必须改变使用者的期望,要么派生类就不应该抛出这些异常。
3.3 有效性并非本质属性
在考虑一个特定设计是否恰当时,不能完全孤立地来看这个解决方案。必须要根据设计的使用者做出的合理假设来审视它。
有谁知道设计的使用者会做出什么样的合理假设呢?大多数这样的假设都很难预测。事实上,如果试图去预测所有这些假设,我们所得到的系统很可能会充满不必要的复杂性的臭味。因此,像所有其他原则一样,通常最好的方法只预测那些最明显的对于LSP的违反情况而推迟所有其他的预测,知道出现相关的脆弱性的臭味时,才去处理它们。
3.4 结论
OCP是OOD中很多说法的核心。如果这个原则应用得有效,应用程序就会具有更多的可维护性、可重用性以及健壮性。LSP是使OCP成为可能的主要原则之一。正是子类型的可替换性才使得使用基类类型的模块在无需修改的情况下就可以扩展。这种可替换性必须使开发人员可以隐式依赖的东西。因此,如果没有显式地强制基类类型的契约,那么代码就必须良好地并且明显地表达出这一点。
俗语“IS-A”的含义过于宽泛以至于不能作为子类型的定义。子类型的正确定义是“可替换性的”,这里的可替换性可以通过显式或隐式的契约来定义。
4 依赖倒置原则(DIP)
A、 高层模块不应该依赖于低层模块,两者都应该依赖于抽象。
B、 抽象不应该依赖于细节,细节应该依赖于抽象。
这条原则的名字中使用“倒置”这个词,是由于许多传统的软件开发方法,例如结构化分析和设计,总是倾向于创建一些高层模块依赖于低层模块,策略 (policy)依赖于细节的软件结构。实际上这些方法的目的之一就是要定义子程序层次结构,该层次结构描述了高层模块怎样调用低层模块。第一章中1.2 节的Copy程序的初始设计就是这种层次结构的一个典型示例。一个设计良好的面向对象的程序,其依赖程序结构相对于传统的过程式方法设计的通常结构而言就 是被“倒置”了。
请考虑一下高层模块依赖于低层模块时意味着什么。高层模块包含了一个应用程序的重要的策略选择和业务 模型。正是这些高层模块才使得其所在的应用程序区别于其他。然而,如果这些高层模块依赖于低层模块,那么对低层模块的改动就会直接影响到高层模块,从而迫 使它们依次做出改动。
这种情形是非常荒谬的!本应该是高层的策略设置模块去影响低层的细节实现模块的。包含业务规则的模块应该优先于并独立于包含实现细节的模块。无论如何高层模块都不应该依赖于低层模块。
此外,我们更希望能够重用的是高层的策略设置模块。我们已经非常擅长于通过子程序库的形式来重用低层模块。如果高层模块依赖于低层模块,那么在不同的上 下文中重用高层模块就会变得非常困难。然而,如果高层模块独立于低层模块,那么高层模块就可以非常容易的被重用。该原则是框架(framework)设计 的核心原则。
4.1 层次化
请看图2.4.1-1的层次化方案:
图2.4.1-1 简单的层次化方案
图中,高层的Policy Layer使用了低层的Mechanism Layer,而Mechanism Layer又使用了更细节的层Utility Layer。这看起来似乎是正确的,然而它存在一个隐伏的错误特征,那就是:Policy Layer对于其下一直到Utility Layer的改动都是敏感的。这种依赖关系是传递的。Policy Layer依赖于某些依赖于Utility Layer的层次;因此Policy Layer传递性的依赖于Utility Layer。这是非常糟糕的。
图 2.4.1-2展示了一个更为适合的模型。每个较高层次都为它所需的服务声明一个抽象接口,较低的层次实现了这个抽象接口,每个高层类都通过该抽象接口使 用下一层,这样,高层就不依赖于低层。低层反而依赖于在高层中声明的抽象服务接口。这不仅解除了Policy Layer对于Utility Layer的传递依赖关系,甚至也解除了Policy Layer对Mechanism Layer的依赖关系。
图2.4.1-2 倒置的层次
请注意这里的倒置不仅仅是依赖关系的倒置,它也是接口所有权的倒置。我们通常会认为工具库应该拥有它们自己的接口。但是当应用了DIP时,我们发现,往往是客户端拥有抽象接口,而它们的服务者这从这些抽象接口派生。
4.1.1 倒置接口所有权
这就是著名的Hollywood原则:“Don’t call us, we’ll call you.”(不要调用我们,我们会调用你。)低层模块实现了在高层模块中声明并被高层模块调用的接口。
通过倒置接口所有权,对于Mechanism Layer或者Utility Layer的任何改动都不会在影响到Policy Layer。而且,Policy Layer可以在实现了Policy Service Interface的任何上下文中重用。这样,通过倒置这些依赖关系,我们创建了一个更灵活、更持久、更易改变的结构。
4.1.2 依赖于抽象
一个稍微简单但仍然非常有效的对于DIP的解释,是这样一个简单的启发式规则:“依赖于抽象 ”。这是一个简单的陈述,该启发式规则建议不应该依赖于具体类——也就是说,程序中所有的依赖关系都应该终止于抽象类或者接口。
根据启发式规则:
- 任何变量都不应该持有一个指向具体类的指针或者引用
- 任何类都不应该从具体类派生
- 任何方法都不应该覆写它的任何基类中已经实现了的方法
当然,每个程序都会有违反该规则的情况。有时必须要创建具体类的实例,而创建这些实例的模块将会依赖于它们。此外,该启发规则对于那些虽然是具体但却稳定(nonvolatile)的类来说似乎不太合理。如果一个具体类不太会改变,并且也不会创建其他类似的派生类,那么依赖于它并不会造成损害。
例如,在大多数系统中,描述字符串的类都是具体的(如Java中的String类),而该类有时稳定的,也就是说,它不太会改变。因此,直接依赖于它不会造成损害。
然而,我们在应用程序中所编写的大多数具体类都是不稳定的。我们不想直接依赖于这些不稳定的具体类。通过把它们隐藏在抽象接口的后面,可以隔离它们的不稳定性。
这不是一个完美的解决方案。常常,如果不稳定类的接口必须变化时,这个变化一定会影响到该类的抽象接口。这种变化破坏了抽象接口维系的隔离性。
由此可知,该启发规则对问题的考虑有点简单了。另一方面,如果看得远一点,认为是由客户来声明它需要的服务接口,那么仅当客户需要时才会对接口进行改变。这样,改变实现抽象接口的类就不会影响到客户。
4.2 结论
使用传统的过程化程序设计所创建出来的依赖关系结构,策略是依赖于细节的。这是糟糕的,因为这样会使策略受到细节改变的影响。面向对象的程序设计倒置了依赖关系结构,使得细节和策略都依赖于抽象,并且常常是客户拥有服务接口。
事实上,这种依赖关系的倒置正好是面向对象设计的标志所在。使用何种语言来编写程序是无关紧要的。如果程序的依赖关系是倒置的,它就是面向对象的设计。否则,它就是过程化的设计。
DIP是实现许多面向对象技术所宣称的好处的基本低层机制。它的正确应用对于实现可重用的框架来说是必须的。同时它对构建在变化面前富有弹性的代码也是非常重要的。由于抽象和细节彼此隔离,所以代码也非常容易维护。
5 接口隔离原则(ISP)
不应该强迫客户依赖于它们不要的方法。接口属于客户,不属于它所在的类层次结构。
这个原则用来处理“胖”接口所具有的缺点。如果类的接口不是内聚的(cohesive),
就表示该类具有“胖”接口。换句话说,类的“胖”接口可以分解成多组方法。每一组方法都服务于一组不同的客户程序。这样,一些客户程序可以使用一组成员函数,而其他客户程序可以使用其他组的成员函数。
ISP承认存在有一些对象,它们确实不需要内聚的接口:但是ISP建议客户程序不应该看到它们作为单一的类存在。相反,客户程序看到的应该是多个具有内聚接口的抽象基类。
如果强迫客户程序依赖于那些它们不使用的方法,那么这些客户程序就面临着由于这些没使用的方法的改变所带来的变更。这无意中导致了所有客户程序之间的耦 合。换种说法,如果一个客户程序依赖于一个含有它不使用的方法的类,但是其他客户程序却要使用该方法,那么当其他客户要求这个类改变时,就会影响到这个客 户程序。我们希望尽可能地避免这种耦合,因此我们希望分离接口。
5.1 ATM用户界面的例子
现在我们考虑一下这样一个例子:传统的自动取款机(ATM)问题。ATM需要一个非常灵活的用户界面。它的输出信息需要被转换成许多不同的语言。输出信 息可能被显示在屏幕上,或者布莱叶盲文书写板上,或者通过语音合成器说出来。显然,通过创建一个抽象基类,其中具有用来处理所有不同的、需要被该界面呈现 的消息的抽象方法,就可以实现这种需求。如图2.5.1-1所示:
图2.5.1-1 ATM界面层次结构
同样,可以把每个ATM可以执行的不同操作封装为类Transaction的派生类。这样,我们可以得到类DepositTransaction、 WithdrawalTransaction以及TransferTransaction。每个类都调用UI的方法。例如,为了要求用户输入希望存储的金 额,DepositTransaction对象会调用UI类中的RequestDepositAmount方法。同样,为了要求用户输入想要转帐的金 额,TransferTransaction对象会调用UI类中的RequestTransferAmount方法。图2.5.1-2为相应的类图。
图2.5.1-2 ATM操作层次结构
请注意,这正好是ISP告诉我们应该避免的情形。每个操作所使用的UI的方法,其他的操作类都不会使用。这样,对于任何一个Transaction的派 生类的改动都会迫使对UI的相应改动,从而也影响了其他所有Transaction的派生类以及其他所有依赖于UI接口的类。这样的设计就具有了僵化性以 及脆弱性的臭味。
例如,如果要增加一种操作PayGasBillTransaction,为了处理该操作想要显示的特 定消息,就必须在UI中加入新的方法,糟糕的是,由于DepositTransaction、WithdrawalTransaction以及 TransferTransaction全部都依赖于UI接口,所以它们都需要重新编译。更糟糕的是,如果这些操作都作为不同的DLL或者共享库部署的 话,那么这些组件必须得重新部署,即使它们的逻辑没有做过任何改动。你闻到粘滞性的臭味了吗?
通过将UI接口分解成像DepositUI、WithdrawalUI以及TransferUI这样的单独接口,可以避免这种不合适的耦合。最终的UI接口可以去多重继承这些单独的接口。图2.5.1-3展示了这个模型。
图2.5.1-3 分离的ATM UI接口
每次创建一个Transaction类的新派生类时,抽象接口UI就需要增加一个相应的基类并且因此UI接口以及所有他的派生类都必须改变。不过,这些 类并没有被广泛使用。事实上,它们可能仅被main或者那些启动系统并创建具体UI实例之类的过程所使用。因此,增加新的UI基类所带来的影响被减至最 小。
5.2 结论
胖类(fat class)会导致它们的客户程序之间产生不正常的并且有害的耦合关系。当一个客户程序要求该胖类进行一个改动时,会影响到所有其他的客户程序。因此,客 户程序应该仅仅依赖于它们实际调用的方法。通过把胖类的接口分解成多个特定于客户程序的接口,可以实现这个目标。每个特定于客户程序的接口仅仅声明它的特 定客户或者客户组调用的那些函数。接着,该胖类就可以继承所有特定于客户程序的接口,并实现它们。这就解决了客户程序和它们没有调用的方法间的依赖关系, 并使客户程序之间互不依赖。