设计模式概述|面向对象设计原则

Author Avatar
罗炜光 3月 18, 2016
  • 在其它设备中阅读本文章

设计模式的4个主要优点

  1. 它们提炼出专家的经验和智慧,为普通开发人员所用。
  2. 它们的名字组成了一个词汇表,有助于开发人员更好地交流。
  3. 系统的文档若记载了该系统所使用的模式,则有助于人们更快地理解系统。
  4. 它们使得对系统进行改造变得更加容易,无论系统原来的设计是否采用了模式。

设计模式用于在特定的条件下为一些重复出现的软件设计问题提供合理的、有效的解决方案

设计模式(Design Pattern)是一套被反复使用、多数人知晓的、经过分类编目的、代码设计经验的总结,使用设计模式是为了可重用代码、让代码更容易被他人理解并且保证代码可靠性。

设计模式一般包含模式名称、问题、目的、解决方案、效果等组成要素,其中关键要素是模式名称、问题、解决方案和效果
模式名称(Pattern Name)
通过一两个词来描述模式的问题、解决方案和效果,以便更好地理解模式并方便开发人员之间的交流,绝大多数模式都是根据其功能或模式结构来命名的(GoF设计模式中没有一个模式用人名命名,微笑);
问题(Problem)
描述了应该在何时使用模式,它包含了设计中存在的问题以及问题存在的原因;
解决方案(Solution)
描述了一个设计模式的组成成分,以及这些组成成分之间的相互关系,各自的职责和协作方式,通常解决方案通过UML类图和核心代码来进行描述;
效果(Consequences)
描述了模式的优缺点以及在使用模式时应权衡的问题。

根据它们的用途,设计模式可分为创建型(Creational)结构型(Structural)行为型(Behavioral)三种。
创建型模式主要用于描述如何创建对象
结构型模式主要用于描述如何实现类或对象的组合
行为型模式主要用于描述类或对象怎样交互以及怎样分配职责

设计模式遵循的七个原则:

  1. 单一职责原则(Single Responsibility Principle)
  2. 里氏替换原则(Liskov Substitution Principle)
  3. 依赖倒置原则(Dependence Inversion Principle)
  4. 接口隔离原则(Interface Segregation Principle)
  5. 迪米特法则(Law Of Demeter)
  6. 开闭原则(Open Close Principle)
  7. 合成/聚合复用原则(Composite/Aggregate Reuse Principle)

单一职责原则

定义:
不要存在多于一个导致类变更的原因。通俗的说,即一个类只负责一项职责。或者说就一个类而言,应该只有一个引起它变化的原因。

问题由来:类T负责两个不同的职责:职责P1,职责P2。当由于职责P1需求发生改变而需要修改类T时,有可能会导致原本运行正常的职责P2功能发生故障。
解决方案:遵循单一职责原则。分别建立两个类T1、T2,使T1完成职责P1功能,T2完成职责P2功能。这样,当修改类T1时,不会使职责P2发生故障风险;同理,当修改T2时,也不会使职责P1发生故障风险。

个人理解:
程序设计其实是对复杂性的管理,当复杂性过高,项目将难以开发与维护,据说C语言的项目当代码超过50K后,项目的复杂度就急剧上升,而采取面向对象语言编程,通过对现实世界理解和抽象的方法,极大的帮助我们理解项目,使我们能够顺利的开发大型项目,而Java的出现,又帮助我们减少了开发健壮代码所需的时间以及困难。

面向对象的基本原则是:多聚合,少继承。低耦合,高内聚.

  • 内聚:内聚是从功能角度来度量模块内的联系,它描述的是模块内的功能联系
  • 耦合:耦合是软件结构中各模块之间相互连接的一种度量,耦合强弱取决于模块间接口的复杂程度、进入或访问一个模块的点以及通过接口的数据。

一个模块的耦合性越强。那么模块的独立性越差,则越难以修改,对一个地方的修改,必然导致其他的地方需要修改。而内聚性越高,则说明模块各个元素的联系越紧密。低内聚的模块说明模块的职责不明确,比较松散,说明对其他模块的依赖程度高。不容易对模块进行修改。而高内聚,低耦合的系统具有更好的重用性,维护性,扩展性。

单一职责原则可以看作是低耦合、高内聚在面向对象原则上的引申,将职责定义为引起变化的原因,以提高内聚性来减少引起变化的原因。职责过多,可能引起变化的原因就越多,这将是导致职责依赖,相互之间就产生影响,从而极大的损伤其内聚性和耦合度。单一职责通常意味着单一的功能,因此不要为类实现过多的功能点,以保证实体只有一个引起它变化的原因

按照职责来控制软件的可维护性和复杂性

单一职责原则的好处:

降低类的复杂性,实现什么样的职责都有清晰的定义
提高可读性
提高可维护性
降低变更引起的风险,对系统扩展性和维护性很有帮助

注意:
职责扩散:职责扩散,就是因为某种原因,职责P被分化为粒度更细的职责P1和P2。

里氏替换原则

定义:
定义1:如果对每一个类型为 T1的对象 o1,都有类型为 T2 的对象o2,使得以 T1定义的所有程序 P 在所有的对象 o1 都代换成 o2 时,程序 P 的行为没有发生变化,那么类型 T2 是类型 T1 的子类型。
定义2:子类型必须能够替换掉它们的父类型。

问题由来:有一功能P1,由类A完成。现需要将功能P1进行扩展,扩展后的功能为P,其中P由原有功能P1与新功能P2组成。新功能P由类A的子类B来完成,则子类B在完成新功能P2的同时,有可能会导致原有功能P1发生故障。
解决方案:当使用继承时,遵循里氏替换原则。类B继承类A时,除添加新的方法完成新增功能P2外,尽量不要重写父类A的方法,也尽量不要重载父类A的方法。

个人理解:
继承是面向对象的三大特性之一,但继承破坏了类的封装性,并且耦合性太大,如果只是通过继承父类的方法与变量来达到减少代码的书写,明显继承是得不偿失的。并且继承使得子类可以重写父类的方法,但这可能造成乱用使子类方法偏离父类方法的意图,使得代码内聚性降低,且不利于代码的扩展与复用。

里氏替换原则通俗的来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。(对扩展开放,对修改关闭)

它包含以下4层含义:
1.子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。
2.子类中可以增加自己特有的方法。
3.当子类的方法重载父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松。
4.当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。

依赖倒置原则

定义:
高层模块不应该依赖低层模块,二者都应该依赖其抽象;抽象不应该依赖细节;细节应该依赖抽象。即针对接口编程,不要针对实现编程

问题由来:类A直接依赖类B,假如要将类A改为依赖类C,则必须通过修改类A的代码来达成。这种场景下,类A一般是高层模块,负责复杂的业务逻辑;类B和类C是低层模块,负责基本的原子操作;假如修改类A,会给程序带来不必要的风险。
解决方案:将类A修改为依赖接口I,类B和类C各自实现接口I,类A通过接口I间接与类B或者类C发生联系,则会大大降低修改类A的几率。


个人理解:
在传统的软件开发中,常常会把项目进行分层,通过一层层不断的叠加来实现最终的效果,而高层次依赖与低层次的接口。有句话大概是这么说的,计算机世界没有什么不能模拟的,如果没有的话,那就再加一层。这种方式能够帮助我们理清各个功能模块。而且越是大型的系统,层次划分越明确,但是这也造成一个缺点,如果低层次进行了修改,甚至是删除某些Api,那么就会造成高层次的不兼容问题,那么假如我们在高层次与低层次之间定义好统一接口,只要低层次的修改遵守了定义好的接口,那么就会极大的减少高层不兼容问题的发生。

依赖倒置原则基于这样一个事实:相对于细节的多变性,抽象的东西要稳定的多。以抽象为基础搭建起来的架构比以细节为基础搭建起来的架构要稳定的多。在java中,抽象指的是接口或者抽象类,细节就是具体的实现类,使用接口或者抽象类的目的是制定好规范和契约,而不去涉及任何具体的操作,把展现细节的任务交给他们的实现类去完成
依赖倒置原则的核心思想是面向接口编程.


依赖倒置有三种方式来实现

  1. 通过构造函数传递依赖对象;
    比如在构造函数中的需要传递的参数是抽象类或接口的方式实现。
  2. 通过setter方法传递依赖对象;
    即在我们设置的setXXX方法中的参数为抽象类或接口,来实现传递依赖对象。
  3. 接口声明实现依赖对象,也叫接口注入;
    即在函数声明中参数为抽象类或接口,来实现传递依赖对象,从而达到直接使用依赖对象的目的。

在实际编程中,我们一般需要做到如下3点:
低层模块尽量都要有抽象类或接口,或者两者都有。
变量的声明类型尽量是抽象类或接口。
使用继承时遵循里氏替换原则。


接口隔离原则

定义:客户端不应该依赖它不需要的接口;一个类对另一个类的依赖应该建立在最小的接口上。
问题由来:类A通过接口I依赖类B,类C通过接口I依赖类D,如果接口I对于类A和类B来说不是最小接口,则类B和类D必须去实现他们不需要的方法。
解决方案:将臃肿的接口I拆分为独立的几个接口,类A和类C分别与他们需要的接口建立依赖关系。也就是采用接口隔离原则。

个人理解:
在Java开发中,有种说法是面向接口编程,因为所谓的面向接口就是定义了一个开发规范,是我们对软件系统某一方面的抽象,使我们更容易对软件系统进行修改,维护与更新。而面向接口编程就是通过面向对象语言提供的多态性与接口或抽象类相结合的方式提供支持的。通过接口我们能够知道实现此接口的类有那些功能。假如我们将所有的规范都放入到一个接口中,那么就会造成这个接口的臃肿,使我们不清楚实现此接口的类所具有的功能与特性,并且造成大量的空方法实现。使得我们原本希望通过接口规范进行约束与抽象实现类的功能的作用大大削弱,使得项目的维护难度大大提升,也不利于团队的开发。所以每个接口都应该划分清自己的职责,避免类实现的混乱与对不需要的接口的依赖。


接口隔离原则的含义是:建立单一接口,不要建立庞大臃肿的接口,尽量细化接口,接口中的方法尽量少。也就是说,我们要为各个类建立专用的接口,而不要试图去建立一个很庞大的接口供所有依赖它的类去调用。本文例子中,将一个庞大的接口变更为3个专用的接口所采用的就是接口隔离原则。在程序设计中,依赖几个专用的接口要比依赖一个综合的接口更灵活。接口是设计时对外部设定的“契约”,通过分散定义多个接口,可以预防外来变更的扩散,提高系统的灵活性和可维护性。


说到这里,很多人会觉的接口隔离原则跟之前的单一职责原则很相似,其实不然。其一,单一职责原则原注重的是职责;而接口隔离原则注重对接口依赖的隔离。其二,单一职责原则主要是约束类,其次才是接口和方法,它针对的是程序中的实现和细节;而接口隔离原则主要约束接口接口,主要针对抽象,针对程序整体框架的构建。


采用接口隔离原则对接口进行约束时,要注意以下几点:
接口尽量小,但是要有限度。对接口进行细化可以提高程序设计灵活性是不挣的事实,但是如果过小,则会造成接口数量过多,使设计复杂化。所以一定要适度。
为依赖接口的类定制服务,只暴露给调用的类它需要的方法,它不需要的方法则隐藏起来。只有专注地为一个模块提供定制服务,才能建立最小的依赖关系。
提高内聚,减少对外交互。使接口用最少的方法去完成最多的事情。
运用接口隔离原则,一定要适度,接口设计的过大或过小都不好。设计接口的时候,只有多花些时间去思考和筹划,才能准确地实践这一原则。


迪米特法则

定义:一个对象应该对其他对象保持最少的了解。
问题由来:类与类之间的关系越密切,耦合度越大,当一个类发生改变时,对另一个类的影响也越大。
解决方案:尽量降低类与类之间的耦合。


自从我们接触编程开始,就知道了软件编程的总的原则:低耦合,高内聚。无论是面向过程编程还是面向对象编程,只有使各个模块之间的耦合尽量的低,才能提高代码的复用率。低耦合的优点不言而喻,但是怎么样编程才能做到低耦合呢?那正是迪米特法则要去完成的。


迪米特法则又叫最少知道原则,最早是在1987年由美国Northeastern University的Ian Holland提出。通俗的来讲,就是一个类对自己依赖的类知道的越少越好。也就是说,对于被依赖的类来说,无论逻辑多么复杂,都尽量地的将逻辑封装在类的内部,对外除了提供的public方法,不对外泄漏任何信息。迪米特法则还有一个更简单的定义:只与直接的朋友通信。首先来解释一下什么是直接的朋友:每个对象都会与其他对象有耦合关系,只要两个对象之间有耦合关系,我们就说这两个对象之间是朋友关系。耦合的方式很多,依赖、关联、组合、聚合等。其中,我们称出现成员变量、方法参数、方法返回值中的类为直接的朋友,而出现在局部变量中的类则不是直接的朋友。也就是说,陌生的类最好不要作为局部变量的形式出现在类的内部。


迪米特法则的初衷是降低类之间的耦合,由于每个类都减少了不必要的依赖,因此的确可以降低耦合关系。但是凡事都有度,虽然可以避免与非直接的类通信,但是要通信,必然会通过一个“中介”来发生联系。过分的使用迪米特原则,会产生大量这样的中介和传递类,导致系统复杂度变大。所以在采用迪米特法则时要反复权衡,既做到结构清晰,又要高内聚低耦合。


开闭原则

定义:一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。即软件实体应尽量在不修改原有代码的情况下进行扩展。
问题由来:在软件的生命周期内,因为变化、升级和维护等原因需要对软件原有代码进行修改时,可能会给旧代码中引入错误,也可能会使我们不得不对整个功能进行重构,并且需要原有代码经过重新测试。
解决方案:当软件需要变化时,尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来实现变化。

个人理解:
当我们写项目时,因为需求的不确定性与业务的发展等原因,要经常会对原有的项目进行不断的修改与添加新的功能,软件工程的出现很大一部分原因就是为了解决这种不确定性。当我们项目的模块之间如果依赖程度较高,或者说耦合性比较大时,那么对与原有项目代码的进行修改,那就会出现噩梦般的情况,一个方法的修改可能会出现一连串的连锁反应。即一个方法的改变就会造成多个类的多个方法发生错误,这时候要排除问题也比较麻烦。所以好的项目就应该少修改原有项目的代码,尤其是一些与业务直接相关的模块,那么不对原有项目进行修改,怎么实现新的需求呢,其实我们可以借助于抽象性对项目进行扩充与替换,而不需要修改原有代码,假如我们在开发时就定义好了接口,那么在后续开发中,我们只要遵循原先定义好的类,就可以将原有的项目模块进行替换与添加,软件开发要写文档的原因很重要的是为了写好规范,让后来者能通过文档快速的了解
原有的项目,但跟文档比起来,先设计出有良好的接口更能让开发者快速的对原项目进行修改,而不是陷入到无休止的修改BUG上。很明显要实现开闭原则,其实就是要尽量实现其他的原则。


开闭原则是面向对象设计中最基础的设计原则,它指导我们如何建立稳定灵活的系统。开闭原则可能是设计模式六项原则中定义最模糊的一个了,它只告诉我们对扩展开放,对修改关闭,可是到底如何才能做到对扩展开放,对修改关闭,并没有明确的告诉我们。以前,如果有人告诉我“你进行设计的时候一定要遵守开闭原则”,我会觉的他什么都没说,但貌似又什么都说了。因为开闭原则真的太虚了。


在仔细思考以及仔细阅读很多设计模式的文章后,终于对开闭原则有了一点认识。其实,我们遵循设计模式前面5大原则,以及使用23种设计模式的目的就是遵循开闭原则。也就是说,只要我们对前面5项原则遵守的好了,设计出的软件自然是符合开闭原则的,这个开闭原则更像是前面五项原则遵守程度的“平均得分”,前面5项原则遵守的好,平均分自然就高,说明软件设计开闭原则遵守的好;如果前面5项原则遵守的不好,则说明开闭原则遵守的不好。


开闭原则无非就是想表达这样一层意思:用抽象构建框架,用实现扩展细节。因为抽象灵活性好,适应性广,只要抽象的合理,可以基本保持软件架构的稳定。而软件中易变的细节,我们用从抽象派生的实现类来进行扩展,当软件需要发生变化时,我们只需要根据需求重新派生一个实现类来扩展就可以了。当然前提是我们的抽象要合理,要对需求的变更有前瞻性和预见性才行。


说到这里,再回想一下前面说的5项原则,恰恰是告诉我们用抽象构建框架,用实现扩展细节的注意事项而已:单一职责原则告诉我们实现类要职责单一;里氏替换原则告诉我们不要破坏继承体系;依赖倒置原则告诉我们要面向接口编程;接口隔离原则告诉我们在设计接口的时候要精简单一;迪米特法则告诉我们要降低耦合。而开闭原则是总纲,他告诉我们要对扩展开放,对修改关闭。


最后说明一下如何去遵守这六个原则。对这六个原则的遵守并不是是和否的问题,而是多和少的问题,也就是说,我们一般不会说有没有遵守,而是说遵守程度的多少。任何事都是过犹不及,设计模式的六个设计原则也是一样,制定这六个原则的目的并不是要我们刻板的遵守他们,而需要根据实际情况灵活运用。对他们的遵守程度只要在一个合理的范围内,就算是良好的设计。


合成/聚合复用原则

定义:尽量使用对象组合,而不是继承来达到复用的目的。


合成复用原则就是在一个新的对象里通过关联关系(包括组合关系和聚合关系)来使用一些已有的对象,使之成为新对象的一部分;新对象通过委派调用已有对象的方法达到复用功能的目的。简言之:复用时要尽量使用组合/聚合关系(关联关系),少用继承。


在面向对象设计中,可以通过两种方法在不同的环境中复用已有的设计和实现,即通过组合/聚合关系或通过继承,但首先应该考虑使用组合/聚合,组合/聚合可以使系统更加灵活,降低类与类之间的耦合度,一个类的变化对其他类造成的影响相对较少;其次才考虑继承,在使用继承时,需要严格遵循里氏代换原则,有效使用继承会有助于对问题的理解,降低复杂度,而滥用继承反而会增加系统构建和维护的难度以及系统的复杂度,因此需要慎重使用继承复用。


通过继承来进行复用的主要问题在于继承复用会破坏系统的封装性,因为继承会将基类的实现细节暴露给子类,由于基类的内部细节通常对子类来说是可见的,所以这种复用又称“白箱”复用,如果基类发生改变,那么子类的实现也不得不发生改变;从基类继承而来的实现是静态的,不可能在运行时发生改变,没有足够的灵活性;而且继承只能在有限的环境中使用(如类没有声明为不能被继承)。


由于组合或聚合关系可以将已有的对象(也可称为成员对象)纳入到新对象中,使之成为新对象的一部分,因此新对象可以调用已有对象的功能,这样做可以使得成员对象的内部实现细节对于新对象不可见,所以这种复用又称为“黑箱”复用,相对继承关系而言,其耦合度相对较低,成员对象的变化对新对象的影响不大,可以在新对象中根据实际需要有选择性地调用成员对象的操作;合成复用可以在运行时动态进行,新对象可以动态地引用与成员对象类型相同的其他对象。


一般而言,如果两个类之间是“Has-A”的关系应使用组合或聚合,如果是“Is-A”关系可使用继承。”Is-A”是严格的分类学意义上的定义,意思是一个类是另一个类的”一种”;而”Has-A”则不同,它表示某一个角色具有某一项责任。


继承复用
优点:

  1. 新的实现较为容易,因为基类的大部分功能可以通过继承关系自动进入派生类
  2. 修改或扩展继承而来的实现较为容易
    缺点:
  3. 继承复用破坏包装,因为继承将基类的实现细节暴露给派生类,这种复用也称为白箱复用
  4. 如果基类的实现发生改变,那么派生类的实现也不得不发生改变
  5. 从基类继承而来的实现是静态的,不可能在运行时发生改变,不够灵活

合成/聚合复用
优点:

  1. 新对象存取成分对象的唯一方法是通过成分对象的接口;
  2. 这种复用是黑箱复用,因为成分对象的内部细节是新对象所看不见的;
  3. 这种复用支持包装;
  4. 这种复用所需的依赖较少;
  5. 每一个新的类可以将焦点集中在一个任务上;
  6. 这种复用可以在运行时动态进行,新对象可以使用合成/聚合关系将新的责任委派到合适的对象。
    缺点:
  7. 通过这种方式复用建造的系统会有较多的对象需要管理。

耦合内聚分类

耦合性分类(低――高): 无直接耦合;数据耦合;标记耦合;控制耦合;公共耦合;内容耦合;
1 无直接耦合:
2 数据耦合: 指两个模块之间有调用关系,传递的是简单的数据值,相当于高级语言的值传递;
3 标记耦合: 指两个模块之间传递的是数据结构,如高级语言中的数组名、记录名、文件名等这些名字即标记,其实传递的是这个数据结构的地址;
4 控制耦合: 指一个模块调用另一个模块时,传递的是控制变量(如开关、标志等),被调模块通过该控制变量的值有选择地执行块内某一功能;
5 公共耦合: 指通过一个公共数据环境相互作用的那些模块间的耦合。公共耦合的复杂程序随耦合模块的个数增加而增加。
6 内容耦合: 这是最高程度的耦合,也是最差的耦合。当一个模块直接使用另一个模块的内部数据,或通过非正常入口而转入另一个模块内部。


内聚性分类(低――高): 偶然内聚;逻辑内聚;时间内聚;通信内聚;顺序内聚;功能内聚;
1 偶然内聚: 指一个模块内的各处理元素之间没有任何联系。
2 逻辑内聚: 指模块内执行几个逻辑上相似的功能,通过参数确定该模块完成哪一个功能。
3 时间内聚: 把需要同时执行的动作组合在一起形成的模块为时间内聚模块。
4 通信内聚: 指模块内所有处理元素都在同一个数据结构上操作(有时称之为信息内聚),或者指各处理使用相同的输入数据或者产生相同的输出数据。
5 顺序内聚: 指一个模块中各个处理元素都密切相关于同一功能且必须顺序执行,前一功能元素输出就是下一功能元素的输入。
6 功能内聚: 这是最强的内聚,指模块内所有元素共同完成一个功能,缺一不可。与其他模块的耦合是最弱的。

参考资料

什么是高内聚、低耦合
设计模式专栏
设计模式6大原则之-单一职责原则
设计模式
《java与模式》笔记(六) 合成/聚合复用原则