面向对象设计原则

  与设计模式相关的是设计原则。设计原则更像是理论,而设计模式是这种理论的具体体现。

  每一个模式描述了一个在我们周围不断重复发生的问题,以及该问题解决方案的核心。设计模式描述了软件设计过程中某一类常见问题的一般性的解决方案。面向对象设计模式描述了面向对象设计过程中、特定场景下、类与相互通信的对象之间常见的组织关系,包括它们的角色、职责、协作方式几个方面。
  从编程语言来看,各种面向对象编程语言相互有别,但都能看到它们对 面向对象三大机制的支持,即封装、继承、多态。
  (1)封装:
隐藏内部实现。
   (2)继承:复用现有代码。
   (3)多态:改写对象行为(override覆写/重写)。
  OOPL的三大机制“封装、继承、多态” 可以表达面向对象的所有概念,但这三大机制本身并没有刻画出面向对象的核心精神。换言之,既可以用这三大机制做出“好的面向对象设计”,也可以用这三大机制做出“差的面向对象设计”。不是使用了面向对象的语言(例如Java),就实现了面向对象的设计与开发!因此我们不能依赖编程语言的面向对象机制,来掌握面向对象。
  任何一个严肃的面向对象程序员(例如Java程序员),都需要系统地学习面向对象的知识,单纯从编程语言上获得的面向对象知识,不能够胜任面向对象设计与开发。深刻理解面向对象是学好设计模式的基础,掌握一定的面向对象设计原则才能把握面向对象设计模式的精髓,从而实现灵活运用设计模式。
   一个成功的面向对象设计有三大关键点:
  (1)针对接口编程,而不是针对实现编程:
客户无需知道所使用对象的特定类型,只需要知道对象拥有客户所期望的接口。
   (2)优先使用对象组合,而不是类继承:类继承通常为“白箱复用”,对象组合通常为“黑箱复用”。继承在某种程度上破坏了封装性,子类父类耦合度高;而对象组合则只要求被组合的对象具有良好定义的接口,耦合度低。
   (3)封装变化点:把对象内部或对象之间经常变化的部分独立出来进行封装,使用封装来创建对象之间的分界层,让设计者可以在分界层的一侧进行修改,而不会对另一侧产生不良的影响,从而实现层次间的松耦合。

  一个软件设计的可维护性很差,有四个主要的征兆,即过于僵硬、过于脆弱、复用率低、黏度过高。相反,一个好的系统设计应该是可扩展的、灵活的、可插入的。设计原则就是在提高一个系统的可维护性的同时,提高系统的可复用性。

  1、过于僵硬Rigidity
  Rigidity致使软件难以更改,每一个改动都会造成一连串的互相依靠的模块的改动,项目经理不敢改动,因为他永远也不知道一个改动何时才能完成。
  2、过于脆弱Fragility
  Fragility致使当软件改动时,系统会在许多地方出错。并且错误经常会发生在概念上与改动的地方没有联系的模块中。这样的软件无法维护,每一次维护都使软件变得更加难以维护。(恶性循环)
  3、不可重用性immobility
  immobility致使我们不能重用在其它项目中、或本项目中其它位置中的软件。工程师发现将他想重用的部分分离出来的工作量和风险太大,足以抵消他重用的积极性,因此软件用重写代替了重用。
  4、粘滞性过高viscosity
  viscosity有两种形式:设计的viscosity和环境的viscosity。当需要进行改动时,工程师通常发现有不止一个方法可以达到目的。但是这些方法中,一些会保留原有的设计不变,而另外一些则不会(也就是说,这些人是hacks)。一个设计如果使工程师作错比作对容易得多,那么这个设计的viscosity就会很高。
  环境的viscosity高是指开发环境速度很慢且效率很低。
  这四个征兆都证明一个软件系统的体系结构不好,任何一个展现了这些特征的软件系统从里“腐烂”到外的危险性很高。但是“腐烂”是怎么发生的?该问题中涉及两个方面:变化的需求和依赖性的管理。我们应该拥抱变化和隔离依赖性。
  (1)变化的需求
  设计退化的原因很明显,因为最初的设计并没有预料到后来的需求变化。这些需求通常变化得很频繁,并且通常由不熟悉该软件原始设计原理的工程师来对软件作相应改动。所以,虽然这些改动能满足新提出的需求,但是它有悖于该软件原始设计原理。一点一点,随着这种改动越来越多,量变到质变,“恶性肿瘤”产生了。
  作为软件工程师,我们不能责备需求的变化,如果我们的设计仅仅因为变化的需求时时发生而失败,这只能说明我们的设计不好。我们必须拥抱变化。
  (2)依赖性的管理
  什么样的变化导致设计的“腐烂”。引入新的没计划到的依赖性问题的变化是罪魁祸首。以上提出的四个特征都是直接,或间接的由软件模块之间不合适的依赖性所导致。是依赖性体系结构在退化,而这正是软件所要维持的。
  为了预防依赖性体系结构的退化,软件模块之间的依赖性必须得到管理和控制。这种管理由一系列依赖性“防火墙”组成。跨越这些“防火墙”,依赖性不会增殖。
  面向对象设计充满了关于如何建立这样的“防火墙”以及管理模块之间依赖性的原则和技巧。我们下面所讨论的就是这样的原则和技巧,它们都有助于维持一个软件应用程序的依赖性体系结构。
  面向对象的类设计原则:
  (1)开放关闭原则The Open Closed Principle (OCP):
一个软件实体应该对扩展开放,对修改关闭。开闭原则的关键是抽象化,然后从抽象化导出具体实现, 通过抽象基类或接口来提供一个标准调用规范,每个实质子类都继承或者实现这个规范以达到不同的应用操作。在实际使用中我们可以通过工厂模式 (抽象工厂或工厂方法)、动态多态性(从抽象类继承)、静态多态性(模板和范型)等方法,来达到扩展而不修改现有代码的目的。
  (2)里氏替换原则The Liskov Substitution Principle(LSP):子类型必须能替换掉他们的基类型。一个软件实体如果使用的是一个基类的话,那么适用于其子类,而且它根本不能觉察出基类对象和子类对象的区别。这个原则实质上就是多态的一种体现,子类继承或者覆盖基类的方法,具有和基类完全相同的调用规范。
  圆和椭圆的例子可以说明LSP原则的意义。我们知道圆是长短径相等的椭圆,这使得我们通常会把Circle设计成Ellipse的子类。但由于Circle继承了Ellipse,所以它也继承它本身并不需要的、多余的数据。椭圆的一些性质和操作并不适用于圆,如果在圆中也调用这些操作,就会出错。我们说Circle不能替代Ellipse,它违反了LSP。当修改Ellipse,也会导致需要修改Circle,可见违反LSP就是潜在的违反OCP。因此,Circle并不能设计成Ellipse的子类。
  一个子类在下列情况下可以替换其父类:它的前提条件不能比父类的强;它的后置条件不能比父类的弱。换句话说,继承的方法应该要求得更少保证得更多。

  (3)依赖倒转原则The Dependency Inversion Principle (DIP):要针对接口编程,不要针对实现编程。抽象不应该依赖于细节,细节应该依赖于抽象。高层模块不应该依赖于低层模块,二者都应该依赖于抽象。继承时应该从抽象类继承,不应该从具体类继承。越稳定的部分应该越抽象。
  这条原则就是支持组件设计、COM、CORBA、EJB等等的背后力量。例如COM的基石强迫使用这条原则,至少在组件之间是如此。COM组件的唯一可见部分就是它的抽象接口。
  如果开闭原则是面向对象设计的目标的话,依赖倒置原则就是面向对象设计的主要机制。高层模块一般是指那些实现业务规则的单元,而所谓低层模块是具体的一些功能代码。我们在设计的时候,一般高层模块会直接依赖于低层模块来实现其具体功能,这势必会造成相关干扰,任何一个模块的修改都会影响其关联的模块。因此DIP原则建议我们在两个模块之间添加一个抽象,两者通过依赖和实现抽象来达到沟通和调用,从而确保在抽象不变动的情况下,各自隔离而互不影响。抽象本身应该是个干净的协议,它具备完整的独立性,如果依赖于某个功能细节,那么抽象本身就变成不稳定因素,造成以后修改的污染。
  在DIP后面的动机之一就是阻止你依赖多变的模块。DIP假定任何具体的都是多变的。另外,设计依赖具体类最经常发生的地方就是在这些设计创建实例的时候。按照定义,你不能创建抽象类的实例,因此创建了一个实例,你一定是依赖了一个具体类。实例的创建发生在体系结构设计的所有地方。因此,似乎我们在整个体系结构中都不能摆脱依赖具体类的问题。然而,有一个精巧的解决办法:用“抽象工厂”模式可以解决这一问题。
  (4)接口隔离原则The Interface Segregation Principle (ISP):多个和客户相关的接口要好于一个通用接口。一个类对另一个类的依赖应当是建立在最小的接口上的,不应该强迫客户依赖于他们不用的方法。
  我们设计的一个类会提供给多个客户调用,那么势必造成客户会“看到”一些他们根本用不上的方法,这对于客户是一种干扰,会加大他们的负担,甚至造成错误调用而影响系统的功能实现。如果一个类有几个使用者,与其让这个类载入所有使用者需要使用的所有方法,还不如为每一个使用者创建一个特定的接口,并让该类分别实现这些接口。ISP并不是推荐我们每一个Client都要有一个特定的接口,而是说Client应该按照类型的不同进行分类,每一种类型对应一个接口。如果两个或两个以上的Client类型都需要同一个方法,那么这个方法应该同时被加到它们各自的接口中。这样做不会搞乱Client,也不会有有其它任何坏处。
  (5)合成/聚合复用原则Composite/Aggregate Reuse Principle(CARP):在一个新的对象里面使用一些已有的对象,使之成为新对象的一部分;新的对象通过这些向对象的委派达到复用已有功能的目的,这个设计原则有另一个简短的表述:要尽量使用合成/聚合,尽量不要使用继承。
  (6)最少知识原则Least Knowledge Principle(LKP):又叫迪米特法则(Law of Demeter, LOD)。一个对象应当对其他对象有尽可能少的了解。就是说,如果两个类无需彼此直接通信,那么这两个类就不应当发生直接的相互作用,如果其中的一个类需要调用另一个类的某一个方法的话,可以通过第三者转发这个调用,这样可以实现这两个类之间的松耦合。
  (7)单一职责原则Simple Responsibility Pinciple(SRP):就一个类而言,应该仅有一个引起它变化的原因。如果你能想到多于一个的动机去改变一个类,那么这个类就具有多于一个的职责。应该把其它的职责分离出去,分别再创建一些类来完成每一个职责。
  这个原则实质上是对于一个类的粒度划分作出了一个规定。一个具备强大功能的庞大类并不是一个好的做法,不如将其分割成多个小类,每个小类仅提供某个单一的功能,这样我们的编码和测试都会简化,避免了因为复杂而造成潜在的错误,然后使用Facade或者Adapter模式对外提供一个聚合的操作接口就可以了。软件设计上组合和继承一样重要,非类库开发的时候推荐更多的使用组合而不是继承。
  类是必不可少的,但对于组织一个设计来说还不够,粒度更大的包有助于此。但是我们应该怎样协调类和包之间的从属关系?

  面向对象的包设计原则:
  (1)发布重用等价原则The Release Reuse Equivalency Principle(REP):重用的粒度就是发布的粒度。
一个可重用的元件(组件、一个类、一组类等),只有在它们被某种发布(Release)系统管理以后,才能被重用。用户不愿意使用那些每次改动以后都要被强迫升级的元件。因此,即使开发者发布了可重用元件的新版本,他也必须支持和维护旧版本,这样才有时间让用户熟悉新版本。
  因此,将什么类放在一个包中的判断标准之一就是重用,并且因为包是发布的最小单元,它们同样也是重用的最小单元。体系结构师应该将可重用的类都放在包中。
  (2)共同封闭原则The Common Closure Principle(CCP):一起变化的类放在一起。一个大的开发项目通常分割成很多网状互联的包。管理、测试和发布这些包的工作可不是微不足道的工作。在任何一个发布的版本中,如果改动的包数量越多,重建、测试和部署也就会越多。因此我们应该尽量减少在产品的发布周期中被改动的包的数量,这就要求我们将一起变化的类放在一起(同一个包)。
  (3)共同重用原则The Common Reuse Principle(CRP):不一起重用的类不应该放在一起。对一个包的依赖就是对包里面所有东西的依赖。当一个包改变时,这个包的所有使用者都必须验证是否还能正常运行,即使它们所用到的没有任何改变也不行。
  比如我们就经常遇到操作系统需要升级。当开发商发布一个新版本以后,我们的升级是迟早的问题,因为开发商将会不支持旧版本,即使我们对新版本没有任何兴趣,我们也得升级。如果把不一起使用的类放在一起,同样的事情我们也会遇到。一个和我们无关的类的改变也产生包的一个新版本,我们被强迫升级和验证这个包是否影响正常的运行。
  上述三条原则实际上是互斥的。它们不能被同时满足,因为每一条原则都只针对某一方面,只对某一部分人有好处。REP和CRP都对重用元件的人有好处,CCP对维护人员有好处。CCP使得包有尽可能大的趋势(毕竟,如果所有的类都属于一个包,那么将只会有一个包变化);CRP尽量使得包更小。
  幸运的是,包并不是一成不变的。实际上,在开发过程中,包的转义和增删都是很正常的。在项目开发的早期,软件建筑师建立包的结构体系,此时CCP占主导地位,维护只是辅助。在体系结构稳定以后,软件建筑师会对包结构进行重构,此时尽可能的运用REP和CRP,从而最大的方便重用元件的人员。

  (4)无依赖回路原则The Acyclic Dependencies Principle(ADP):包与包之间的依赖不能形成回路。因为包是发布的粒度。人们倾向于节省人力资源,所以工程师们通常只编写一个包而不是十几个包。这种倾向由于包聚合原则被放大,后来人们就将相关的类组成一组。因此,工程师发现他们只会改动较少的几个包,一旦这些改动完成,他们就可以发布他们改动的包。但是在发布前,他们必须进行测试。为了测试,他们必须编译和连编他们的包所依赖的所有的包。
  如果一个包A 中的类引用了包B中的类,我们称包A依赖包B。“依赖”在具体的程序语言中表现为,如果A依赖B,C/C++语言则在A包的文件/类中通过#include语句包含B包中的文件/类;Java语言则A包的类中通过import语句引入B包中的类。
  如果存在2个或2个以上的包,它们之间的依赖关系图出现了环状,我们就称包之间存在循环依赖关系。也就是说它们的依赖结构图根据箭头的方向形成了一个环状的闭合图形。依赖结构中,出现在环内的所有包都不得不一起发布。它们形成了一个高耦合体,当项目的规模大到一定程度,包的数目变多时,包与包之间的关系便变得错综复杂,各种测试也将变得非常困难,常常会因为某个不相关的包中的错误而使得测试无法继续。而发布也变得复杂,需要把所有的包一起发布,无疑增加了发布后的验证难度。
  有两种方法可以打破这种循环依赖关系:第一种方法是创建新的包,第二种方法是使用DIP(依赖倒转原则)和ISP(接口隔离原则)设计原则。
  (5)稳定依赖原则Stable Dependencies Principle(SDP):朝稳定的方向依赖。包应该依赖比自己更稳定的包。因为如果依赖一个不稳定的包,那么当这个不稳定的包发生变化时,本身稳定的包也不得不发生变化,变得不稳定了。SDP要求一个包的不稳定性要大于它所依赖的包的不稳定性。一个确切的方法是,让大量其它软件的包依赖它。一个包被很多其他包依赖是非常稳定的,这是因为被依赖的包为了协调其他包必须做很多的工作来对应各种变化(责任的负担者)。
  (6)稳定抽象原则Stable Abstractions Principle(SAP):稳定的包应该是抽象包。我们可以想象应用程序的包结构应该是一个互相联系的包的集合,其中不稳定的包在顶端,稳定的包在底部,所有的依赖方向朝下。那些顶端的包是不稳定而且灵活的,但那些底部的包就很难改动。这就导致一个两难局面:我们想要将包设计为难以改动的吗?明显地,难以改动的包越多,我们整个软件设计的灵活性就越差。但是好像有一点希望解决这个问题,位于依赖网络最底部的高稳定性的包的确难以改动,但是如果遵从OCP,这样的包并不难以扩展。
  实际上,SAP只是DIP的另一种表达方式。它说明最被依赖(最稳定)的包应该是最抽象的包。抽象类或接口通过子类继承扩展行为,这表示抽象类或接口比它们的子类更具有稳定性。总之,为了构成稳定的包,应该提高包内的抽象类或接口的比率;它们的子类可以放在另一个不稳定的包内,该包依赖上述稳定的包,从而也遵循了稳定依赖原则(SDP)。


参考文献:

Design Principles. http://www.objectmentor.com/resources/publishedArticles.html

The Principles of OOD. http://butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod

你可能感兴趣的:(设计模式,面向对象,架构设计)