OOD设计原则之其他

在面向对象大师Robert C. Martin的《Agile software development:
Principles, Patterns and Practices》一书中,只把前面提到的 OCP LSP SRP DIP ISP 5种 )列为OOD的设计原则。确实,这几个原则是面向对象设计中基石性的原
则。在我看来,不能明了这些原则的本义,是根本谈不到OOD的。而后面的 LoD CARP (2种 也被广泛传播,尤其是CARP因为《Desgin Pattern》的影响力而被普遍接受。

在Martin的《Agile..》一书中,还提高了包设计 的几个原则,列举在这
里,只为了时时看看提个醒:(读者按:
这里包是指一个二进制的可发布文件,比如.jar文件、或dll文件,而不是Java包或是C++的命名空间)

REP,重用发布等价原则,重用的粒度就是发布的粒度。
CCP,共同封闭原则,包中的所有类对于同一类性质的变化应该是共同封闭的。 
CRP,共同重用原则,一个包中的所有类应该是共同重用的。
(读者按:
头三项包原则是关于包内聚性的,它们会告诉我们该把什么划分到包中

ADP,无环依赖原则,在包的依赖关系图中不允许存在环。
SDP,稳定依赖原则,朝着稳定的方向进行依赖。
SAP,稳定抽象原则,包的抽象程度应该和其稳定程度一致
(读者按: 后三项原则是关于包之间的耦合性原则的,并且论述了评价系统中包结构优良与否的评判标准)

 

 

 

 

 

 

合成 / 聚合复用原则( Composite/Aggregate Reuse
Principle, CARP
)也可以简称为合成复用原则( Composite
Reuse Principle, CRP
)。合成( Composition )和聚合( Aggregation )都是对象建模中关联( Association )关系的的一种(读者按:保留意见 )。聚合表示的是整体和部分的关系,表示“含有”,整体由部分组合而成,部分可以脱离部分作为一个独立的个体而存在。合成则是一种更强的“拥有”,部分组成整体,而且不可分割,部分不能脱离整体而单独存在。合成关系中,部分和整体的生命周期一样 ,合成的新的对象完全支配其组成部分,包括它们的创建和湮灭等。一个合成关系的成分对象是不能与另一个合成关系共享的。

合成 / 聚合和
继承是实现复用的两个基本途径
。假设我们已有一个类
A ,现在又一个新类 B ,想要复用 A 已经具有的功能,我们有两个方法:

方法一,可以通过组合或者聚合关系使 A 成为 B 的一部分,从而可以使用 A 的功能;

方法二,通过让 B 继承 A ,同样可以获得 A 已经具有的功能。那么,这两种方式孰优孰劣呢?

 

其实根据前面我们提到的其他 OOD 原则,也应当有一个基本的判断。我们前面提到过两点:面向接口(而不是实现)编程;类应该依赖于抽象而不依赖于具体。因此,方
法二的继承关系会带来依赖性和耦合性方面的问题。(读者按:继承是一种强依赖关系)

 

因此,合成 / 聚合复用原则简单的说,就是: 尽量使用合成 / 聚合,而不是使用继承。

 

CARP 最先是由谁提起的我无法考证,不过在 GoF 的经典著作《 Design
Patterns
》中,是这样描述这条原则的:

Favor object composition over class
inheritance.
优先使用对象组合,而不是类继承 .

 

 

 

 

 

迪米特法则(Law of Demeter, LoD)又叫最少知识原则(Least Knowledge Principle,
LKP)。1987年秋天由美国Northeastern University的Ian Holland
出,被UML的创始者之一Booch等普及。后来,因为在经典著作《 The Pragmatic Programmer》而广为人知。

 迪米特法则可以简单说成:talk only to your immediate friends。  对于面向OOD来说,又被解释为下面几种方式:
一个软件实体应当尽可能少的与其他实体
发生相互作用。
每一个软件单位对其他的单位都只有最少的知识,而且局限于那些与本单位密切相关的软件单位。

迪米特法则的初衷在于降低类之间的耦合 。由于每个类尽量减少对其他
类的依赖,因此,很容易使得系统的功能模块功能独立,相互之间不存在(或很少有)依赖关系。

迪米特法则不希望类直接建立直接的接触。如果真的有需要建立联系,
也希望能通过它的友元类来转达。因此,应用迪米特法则有可能造成的一个后果就是:系统中存在大量的中介类,这些类之所以存在完全是为了传递类之间的相互调
用关系——这在一定程度上增加了系统的复杂度。

有兴趣可以研究一下设计模式的门面模式(Facade)和中介模式
(Mediator),都是迪米特法则应用的例子。

值得一提的是,虽然Ian
Holland对计算机科学的贡献也仅限于这一条法则,其他方面的建树不多,但是,这一法则却不仅仅局限于计算机领域,在其他领域也同样适用。比如,美国
人就在航天系统的设计中采用这一法则。

 

 

 

 

 

接口隔离原则( Interface
Seg
regation Principle )是 Robert C. Martin 1996 年为《 C++ Reporter 》所写的专栏 Engineering
Notebook
的第四篇 ,这个原则说的是如何处理
接口“臃肿”带来的麻烦。这个原则概括起来只有一句话:
Clients should not be forced to
depend upon
methods that they do not use . 不应该强迫客户依赖于它们不用的方法。

现在广为流行的 ISP 的表述一般是这样的:使用多个专门的接口比使用单一的总接口要好;从一个客户类的角度来讲,一个类对另外一个类的依赖性应当是建立在最小的接口上的。这都是一个意思。

客户类只列出了它确实实现的方法。实际上,很多语言都要求继承关系中,子类要实现父类所有的虚方法,除非这个方法在父类中以及提供了默认实现方式。

接口 BaseInterface 定义了 5 个方法,有三个客户依赖于这个接口。其中, C onsumerA 只依赖于 BaseInterface A1() A2() 方法; C onsumerB 只依赖于 BaseInterface B1() B2() 方法; C onsumerC 只依赖于 BaseInterface C1() 。这是一个典型的违背 ISP 原则的设计。这样的设计有什么问题呢?假设 A1() 方法的定义需要改变,或者 C onsumerA 要求增加 A3() 方法,出现什么结果呢?很显然,因为这个改变需要修改 BaseInterface ,那么所有依赖于 BaseInterface 的客户( C onsumerA/B/C )都要受到影响(需要重新编译、部署、测试等等)! ConsmerB ConsumerC 岂不是很冤枉?

我们可以检讨一下:这样的接口是怎么设计出来的呢?一种情况是设计的时候根本没考虑接口的功能或职责问题,
一股脑的把所有可能的方法统统罗列;另一种更常见的情况是:开始的时候,只有一个客户
C onsumerA ,需要两个方法 A1() A2() 于是,根据面向抽象的要求,抽象出来一个接口 Ba seInferface ,还有这两个方法。后来,又有新的客户 ConsumerB ,提出了新的方法要求 B1() B2() ,于是这两个也被加入 BaseInferface 中。这个过程持续下去,随着一步步积累, BaseInterface 变得越来越臃肿。

正确的设计应该是把接口进行拆分。拆分的原则可以参照 SRP 的要求,按照职责和功能对方法进行分组。每一组方法抽象成一个接口,每一个客户都只依赖于特定的接口。这时候,如果 InterfaceA 进行了修改,添加了新方法 A3()
只需要修改
C onsumerA 就可以了,其他客户根本就不受影响!

 

 

 

 

 

 

依赖倒置原则( D ependency-Inversion Principle )是 Robert C. Martin 1996 年为《 C++ Reporter 》所写的专栏 Engineering Notebook 的第三篇 ,后来加入到他在 2002 年出版的经典著作《Agile Software
Development Principles Patterns and Practices》
中提到的,它由两条构成:

A High level modules should
not depend upon low level modules. Both should depend upon abstractions.
高层模块不应该依赖于低层模块。它
们都应该依赖于抽象。

B Abstractions should not
depend upon details. Details should depend upon
abstractions.
抽象不应该依赖于具体。具体应该依赖于抽象。

 

(读者按:简洁而美,像是E=mc2)


这里的高层模块和低层模块的概念可以参照标准的 MVC 分层架构。低层模块包含数据持久化等低层次的功能,在整个系统中处于支撑的作用;高层模块包含通常包含重要的业务逻辑,是系统核心功能所在。如果高层模块依赖于低层模块 ,那么,当我们需要修改低层模块的时候,所有层次在它之上的模块都需要进行修改。而且,由于对低层模块的依赖,高层模块无法在没有低层模块的上下文环境中独立运行。高层模块无法实现复用,而恰恰是这些高层模块才是系统的核心,是最具复用价值的。

M artin 的文章中,举了 Copy 程序的例子,我使用类重新构造了一下。这个例子很简单,实现的功能就是从键盘读取字符,然后输出到打印机中。这个功能由 Copy
完成。一个违反
DIP 原则的设计如下:

“Do Copy” 方法的代码可能是这个样子的:

public
class Copy

{

   public int DoCopy(Keyboard keyboard, Printer printer)

   {

      char c;

      while(c=(keyboard.Read())!=EOF)

      {

         printer.Print(c);

      }

   }

}

Copy 依赖于 Key board Printer 两个类。如果想在没有 Keyboard Printer 的上下文环境中复用 Copy ,比如实现键盘读取字符写入一个文件,则是无法办到的。

一个更合理的的设计是这样的:把 Copy Keyboard Printer 的这种关系中解放出来,Copy类既不依赖于Keyboard也不依赖于Printer,而只依赖于抽象类 Reader Writer 。因此,依赖性已经被倒置;Copy类依赖于抽象,而具体的读取器和写出器也依赖相同的抽象。这就是一个符合 DIP 的设计。以后如果需要复用 Copy 的功能,比如实现从一个文件读取,写入打印机,完全不需要修改 Copy ,只需要增加一个继承抽象类 Reader 的具体实现 FileReader 就可以了——这又符合了开闭原则的要求!


那么,为什么要称之为依赖倒置呢?实际上,高层模块和低层模块共同依赖的这个抽象层,基本上是由高层模块决定的。也就是说,抽象有那些功能,是由系统的功能和业务逻辑决定的,所以从某种程度上来说,虽然高层模块的具体功能要藉由低层实现(调用低层),但要实现哪些由高层说了算! ——这有点儿 H ollywood 的味道了: Don’t call us, we’ll call you!

 

 

 

 

 

单一职责原则( S ingle
Responsibility Principle
SRP )最初是谁提出的我没有考证,不过大师 Robert C. Martin SRP 的解释是: E ach class should have one and
only one
re ason to change. 一个类只能因为一个因素而改变

SRP 说的其实是类设计时的职责划分和粒度问题。每个类都是因为具有一定的职责才会存在,但是一个类也不应该分担过多的职责,每一个职责的变化都会引起类的变化。这就好比一个人身兼数职,这些职责可能互不相干。但是一旦有一个
职责发生变化,他就必须重新安排自己的工作。类也一样。过多互不相关(或相关性不强)的职责集中在一个类中就会造成高耦合性,代码僵化。因此,应该把不同的职责分开到不同的类中。

OOD 中,一个最典型最常用的符合 SRP 的设计就是分层结构:数据层专门负责数据持久化,处理和数据库
(或其他数据源)的交互;业务逻辑层专门负责业务运作逻辑;表现层处理用户界面。一个层的变化不会对其他层造成影响,比如修改数据源只需要对数据层进行改动,其他两层完全不必知道。这是一个理想的结果。不过,实际情况下更多的是尽可能降低这种影响,而不能完全消除。(读者按:追求完美任何时候都是不现实的)

举个例子,我们在设计一个电子商务系统的时候,通常需要计算订单总价。订单总价的计算通常包含计算货物总价和运费两部分。成熟的设计中, Sale 类用来计算货物总价,不应该再拿来计算运费。因为对于不同的产品、店铺、运送方式,运费的计算规则可能完全不同。

早期脚本语言编写的 Web 应用对 SRP 的破坏比比皆是:一个 htm 的页面呈现一个 form ,用户提交之后交给一个脚本程序去处理(比如 asp/jsp )。这个脚本处理一切的东西:获取表单数据、进行必要的检查和处理、连接数据库进行存储,最后向用户进行反馈!一旦表单有变化,或者存储的数据库有修改,怎么办?每一个细节的变化都要修改这个脚本。因此,这根本就不是符合 SRP 的,甚至不能称之为 OO 的。

不过,我也见过另一个极端,整个程序非常简单,只有不到 10 个操作,而程序结构却异常复杂:分了 4 个层次,上百个大大小小的类。通常,一个类里只有一两个方法。这种设计方法也不能说是符合 SRP 的。 SRP 要求对每一个可能变化的职责都进行分开固然是正确的,但是这个变化一定要是现实中“真的可能”发生的。我们要专注于那些有较大可能发生变化的地方,而忽略那些不太可能变化或变化概率极小的细枝末节 。比如说,你的数据访
问逻辑是固定的,而中途变化数据库的可能也不大,因此,就没有必要把数据访问单独出来。一句话,不能为了“
Everything is possible ”,就把类结构设计的异常复杂。

所以,什么样的设计是符合 SRP 的,还需要根据实际的需要认真考虑,过犹不及, OOD 也是需要中庸的。(读者按:什么时候能到大道无形的境界?)

 

 

 

 

 

里氏替换原则(Liskov Substitution
Principle,LSP)被称作继承复用 的基石,它的提出甚至要早于OCP。不过遗憾的是,由于对这一原则的理解各不相同,经过多次的翻译、转述,LSP成了OOD设计原则中争议最多的话题之一。

其实早在1987年的OOPSLA大会上,麻省理工学院(MIT)计算机科学实验室的Liskov女士就发表了经典文章Data
Abstraction and Hierarchy,其中提出了以她名字命名的Liskov替换原则(The Liskov Substitution
Principle),简称LSP。该原则说明了什么时候该使用继承,什么时候不该使用以及为什么。一年后此文发表在ACM的SIGPLAN
Notices杂志上,更是影响深远。Liskov在这篇文章中写到:

A type hierarchy is composed of subtypes and supertypes. The
intuitive idea of a subtype is one whose objects provide all the
behavior of objects of another type (the supertype) plus something
extra.What is wanted here is something like the following substitution
property: If for each object o1 of type S there is an object o2 of type T
such that for all programs P defined in terms of T, the behavior of P
is unchanged when o1 is substituted for o2 then S is a subtype of T.

意思是:“类型层次由子类型和超类型(也就是父类)组成,直觉告诉我们,子类型的含义就是该类型的对象提供了另外一个类型(超类型)的对象的所有行
为功能,并有所扩充。这里需要如下的替换性质:若对于每一个类型S的对象o1,都存在一个类型T的对象o2,使得在所有针对T编写的程序P中,用o1替换
o2后,程序P的行为功能不变,则S是T的子类型。”这就是LSP的最初含义。

而著名技术作家Robert Martin(Uncle Bob)在1996年为《C++ Reporter》写了一篇题为《The The Liskov
Substitution Principle》的文章,专门介绍LSP。在Martin的文章中,他给了LSP一个解释:

Functions
that use pointers or references to base classes must be able to use
objects of derived classes without knowing it.

意思是:“使
用指向基类的指针或引用的函数,必须能够在不知道具体派生类对象类型的情况下使用它们。”在2002年,Martin在他出版的《Agile  
Software   Development   Principles   Patterns   and  
Practices》一书中,又进一步简化为:

Subtypes
  must   be   substitutable   for   their   base   types。子类必须能替换成它们的父类

也是正是由于
这许多版本的存在在加上翻译,转述等等问题,最终导致了LSP的多种理解(不深究了)。按照我的理解,上面Liskov的表述虽然拗口,但是也不难明白。
再加上Martin的解释,LSP原则就很清楚了。

LSP说的其实是在构建类的继承结构的过程中需要遵循的基本原则,什么时候该用,什么时候不能用,避免继承的滥用 。LSP和OCP是相关联的,也可以说是OCP的基本保证。试想,如果某个函数使用了指向基类的指针或引用,但是类的设计却违背了LSP原则,那么这个函数就必须了解该基类的所有派生类。这个函数显然违背开
放-封闭原则OCP,因为一旦新构建该基类的子类,此函数就需要修改。

我也来说那个经典的”正方形不是矩形“的问题。在数学的世界里,正方形当然是矩形。用OO的数据,正方形和矩形之间是IS-A的关系——这个关系正好是OO初级教程里说的确定继承关系的依据。因此,理所当然的,正方形类然Square应该继承长方形类Rectangle:

 

public   class  Rectangle
{
    
private   long  width;
    
private   long  height;
    
public   void  setWidth( long  width) {
         
this .width = width;
     }

     
public   void  setHeight( long  height) {
          
this .height = height;
     }

     
public   long  getWidth() {
         
return  width;
     }

     
public   long  getHeight() {
          
return  height;
     }

     
}
;

public   class  Square : Rectangle
{
    
public   void  setWidth( long  width) {
          
this .width = width;
          
this .height = width;
     }

     
public   void  setHeight( long  height) {
          
this .width = width;
          
this .height = width;
     }

}
;

假设有这么一个函数:

public  Rectangle IncreaseHeight(Rectangle r)
{
    
while (r.getHeight() < r.getWidth()))
    
{
        r.setHeight(r.getHeight()
++ )
    }

    
    
return  r;
}

如果传递给IncreaseHeight的是一个Rectangle(长宽不同)的对象的话,没问题;如果传递一个Square对象。。。谁都知道 会是个什么结果!出现这个问题的原因就是这个继承结构的设计违反了LSP原则:Square类对Height和Weight的处理和Rectangle逻 辑不同不同,Rectangle单独改变Widtht和Height,而Square必须同时改变Width和Height。所以,Square和
Rectangle之间的继承关系是不能成立的。可以增加一个抽象类Quadrangle,定义四边形的公共方法,Square和Rectangle都从
Quadrangle继承这些方法,同时可以添加自己特有的方法:

其实,不只是这个“经典”的问题反映了现实世界中概念和 OO 概念的区别,很多情况都需要在做 OOD 的时候仔细考虑。只有符合了 LSP 的规定,才可能实现 OCP 。以下几条可作为实践经验:

1. 从抽象类继承,不要从实体类继承 。因为实体类会具有和特定实体相关的方法,这些方法在子类中可能不会有用。

2. 使用契约式编程方法 。DBC(Design by
Contract)把类和其客户之间的关系看作是一个正式的协议,明确各方的权利和义务。
在父类里定义好子类需要实现的功能,而子类只要实现这些功能即可。

3. 所有派生类的行为功能必须和客户程序对其基类所期望的保持一致 。上面的例子就是违背了这一条。其实,这是 OO 在继承和重写时的基本要求。

 

 

 

 

开闭原则OCP(Open-Close
Principle)被称作是OOD的基石,是OOD最重要的原则之一。

这个原则由大师Bertrand Meyer在1988年提出(汗,那个时候恐怕国内还很少人知道OO,甚至计算机为何物): Software entities should be open for extension,but closed for
modification。多简单啊?!这个原则的意思大概是说:软件对扩展应该是开发的,对修改应该是关闭的。说的更通俗点儿,就是说我们开发了一个软
件,应该可以对它进行功能扩展(开放),而在进行这些扩展的时候,不需要对原来的程序进行修改(关闭)!

为什么会有这样的要求呢?如果一个软件是符合OCP原则的,那么至少,我们有两个极大的好处:
1.在软件可用性上,非常灵活。你可以在软件完成对软件进行扩展,加入新的功能。这样,这个软件就可以通过不断的增加新模块满足不断
变化的新需求!
2.由于对软件原来的模块不能修改,因此不用担心软件的稳定性。

目前,对OCP的实现,主要的一条就是抽象 ,就是我们常常挂在嘴边的要面向抽象
(接口)。把系统的所有可能的行为抽象成一个抽象底层,这个抽象底层规定出所有的具体类必须提供的方法的特征作为系统设计的抽象层,这个抽象层要预见所有
可能的扩展,从而使得在任何扩展情况下,系统的抽象层不需修改 ;同时由于可以从抽象层导出一个或多个新的具体类可改变系统的行为,因此对于可变的部分,系统设计对扩展是开放的。

关于系统可变的部分,还有一个更具体的对可变性封装原则(Principle of Encapsulation
of Variation, 
EVP),从工程实现的角度对开闭原则进行了进一步的解释。EVP要求在做系统设计的时候,对系统所有可能(或允许)发生变化的部分进行评估和分类,每一个可变的因素都单独进行封装。

我们很容易就可以想到,在设计的开始就罗列系统所有可能的行为加入到抽象底层是不可能的(实际上也是不合算的),对所有的可变因素进行预计和封装也不太现实,因此,开闭原则很难被完全实现 ,只能在某些模块、某种程度上、某个限度内符合OCP的要求。所以可以说,OCP
具有理想主义的色彩,是OOD的终极目标。因此,针对OCP的实现方法,许多OOD的大师都费尽心机,研究OCP的实现方式。后面要提到的里氏代换原则、合成复用原则、依赖倒转原则、接口隔离原则、抽象类、迪米特法则等,都可以看作是OCP的实现方法。

 


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值