设计模式-面向对象设计原则

设计模式(Design Pattern)是前辈们在代码实践中所总结的经验,是解决某些特定问题的套路。在使用一些优秀的框架时,可能会接触到它里面所运用到的一些设计模式,又或许你在编码去设计一些模块时,为了提高代码可复用性、扩展性、可读性等,运用到的一些设计理念也会与某些设计模式思想相吻合。

系统的了解和学习设计模式是很有必要的,能帮助提升面对对象设计的能力,了解各种设计模式的特点和运用场景

在学习设计模式前,先了解下面对对象的设计原则

面对对象设计原则

对于一个好的面对对象软件系统的设计来说,可维护性可复用性是很重要的,如何同时提高一个系统的可维护性和可复用性是面对对象设计需要解决的核心问题之一。

在面对对象设计中,面对对象设计原则是为了去支持可维护性和可复用性的,这些原则会体现在很多的设计模式中,也就是说这些设计原则实际上就是从这些设计方案中总结提取出来的指导性原则。

最常见的7种面向对象设计原则

设计原则名称 定义
开闭原则(Open-Closed Principle, OCP) 软件实体应对扩展开放,而对修改关闭
单一职责原则(Single Responsibility Principle, SRP) 一个类只负责一个功能领域中的相应职责
里氏代换原则(Liskov Substitution Principle, LSP) 所有引用基类对象的地方能够透明地使用其子类的对象
依赖倒转原则(Dependence Inversion Principle, DIP) 抽象不应该依赖于细节,细节应该依赖于抽象
接口隔离原则(Interface Segregation Principle, ISP) 使用多个专门的接口,而不使用单一的总接口
合成复用原则(Composite Reuse Principle,CRP) 尽量使用对象组合,而不是继承来达到复用的目的
迪米特法则(Law of Demeter, LoD) 一个软件实体应当尽可能少地与其他实体发生相互作用

设计原则

开闭原则

开闭原则(开放-封闭原则)有两个特征,对扩展是开放的(Open for extension)对修改是封闭的(Open for modification)。也就是说一个软件实体(模块、类、函数等等)要实现变化,应该是通过扩展而不是修改已有的代码

任何的软件在其生命周期内需求都可能会发生变化,既然变化是必然的,我们就应该在设计时尽量适应这些变化,以提高项目的稳定性和灵活性。如果一个软件设计符合开闭原则,那么可以非常方便地对系统进行扩展,而且在扩展时无须修改现有代码,使得软件系统在拥有适应性和灵活性的同时具备较好的稳定性和延续性。随着软件规模越来越大,软件寿命越来越长,软件维护成本越来越高,设计满足开闭原则的软件系统也变得越来越重要

为了满足开闭原则,需要对系统进行抽象化设计,抽象化是开闭原则的关键。设计模块时,对最可能发生变化的地方,通过构造抽象来隔离这些变化。在Java、C#等编程语言中,可以为系统定义一个相对稳定的抽象层,而将不同的实现行为移至具体的实现层中完成。在很多面向对象编程语言中都提供了接口、抽象类等机制,可以通过它们定义系统的抽象层,再通过具体类来进行扩展。如果需要修改系统的行为,无须对抽象层进行任何改动,只需要增加新的具体类来实现新的业务功能即可,实现在不修改已有代码的基础上扩展系统的功能,达到开闭原则的要求

这里举一个简单的例子,某个系统中某个功能可以来显示各种类型的图表,比如饼图和柱状图。开始的设计方案如下:

ChartDisplay中的display方法如下

1
2
3
4
5
6
7
if (type.equals("pie")) {
PieChart chart = new PieChart();
chart.display();
}else if (type.equals("bar")) {
BarChart chart = new BarChart();
chart.display();
}

在这个例子中,假如我需要添加新的图表对象(折线图LineChart),那么我需要在ChartDisplay中的display方法中去添加新的判断逻辑,这是不符合开闭原则。ChartDisplay类是用来做图表的显示工作,但具体的图表是变化的,需要将这些变化隔离出来

抽象化的方法:

  • 增加一个抽象类AbstractChart,作为其他具体图表类的父类
  • ChartDisplay的display方法只针对抽象父类AbstractChart,而具体的图表类交由客户端去选择

重构后的结构如下

如上,ChartDisplay只针对抽象类AbstractChart编程,通过setChart来获得具体的图表对象,dispalay方法中直接执行 chart.display(),当我们要新增新的图表,那么直接创建图表子类继承AbstractChart,并实现自己的display方法就好,并不需要修改已有的代码。

单一职责原则

单一职责原则(Single Responsibility Principle, SRP):一个类应该只有一个职责,对外只提供一种功能,应该有且仅有一个原因引起类的变化

能力越大,责任越大?我们不能创建一个“超级类”,能解决所有的事情,相反,一个类(大到模块,小到方法)所承担的责任越多,那么他被复用的可能性就越小。而且一个类承担的职责过多,这些职责耦合度会很高,当其中一个职责变化时,可能会影响其他职责的运作,因此要将这些职责进行分离,将不同的职责封装在不同的类中,将不同的变化原因封装在不同的类中,如果多个职责总是同时发生改变则可将它们封装在同一类中

单一职责原则,用于控制类的粒度大小,实现高内聚、低耦合,它是最简单但又最难运用的原则,如何发现类的不同职责并将其分离,需要具有较强的分析设计能力和相关实践经验。如果你能够想到多于一个动机去改变一个类,那么这个类就有多于一个的职责,就要考虑类的职责分离

记得在刚入门Java接触到 JDBC的时候,为了实现查询学生列表,一口气从数据库的连接到数据查询再到数据展示,简直“一气呵成”,但这种面向过程式的编程却没有很好的扩展性,当我想要再实现其他功能时,将会有大量重复的代码,而重复的地方需要修改,那就更麻烦了。后来稍微改进了,建立了只负责数据库连接资源的类DBUtil,再到后来使用持久层的框架。职责划分后,开发时便只需关注业务的处理

单一职责适用于接口、类,同时也适用于方法,一个方法尽可能做一件事情,比如一个方法修改用户密码,不要把这个方法放到“修改用户信息”方法中,这个方法的颗粒度很粗

上面的方法就职责不清晰,不单一,下面替换成具体的修改动作,通过命名我们就能知晓方法的大概处理逻辑

里式替换原则

里氏代换原则(Liskov Substitution Principle, LSP):所有引用基类(父类)的地方必须能透明地使用其子类的对象

里氏代换原则告诉我们,在软件中,只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生任何错误或异常,程序将不会产生任何错误和异常,反过来则不成立,如果一个软件实体使用的是一个子类对象的话,那么它不一定能够使用基类对象

里式替换才使得开发-封闭成为可能,子类的可替代性才使得使用父类类型的地方可以在无需修改的情况下就可以扩展。里氏代换原则是实现开闭原则的重要方式之一,由于使用基类对象的地方都可以使用子类对象,因此在程序中尽量使用基类类型来对对象进行定义,而在运行时再确定其子类类型,用子类对象来替换父类对象

  • 子类的所有方法必须在父类中声明,或子类必须实现父类中声明的所有方法。根据里氏代换原则,为了保证系统的扩展性,在程序中通常使用父类来进行定义,如果一个方法只存在子类中,在父类中不提供相应的声明,则无法在以父类定义的对象中使用该方法。
  • 我们在运用里氏代换原则时,尽量把父类设计为抽象类或者接口,让子类继承父类或实现父接口,并实现在父类中声明的方法,运行时,子类实例替换父类实例,我们可以很方便地扩展系统的功能,同时无须修改原有子类的代码,增加新的功能可以通过增加一个新的子类来实现。里氏代换原则是开闭原则的具体实现手段之一

依赖倒转原则

如果说开闭原则是面向对象设计的目标的话,那么依赖倒转原则就是面向对象设计的主要实现机制之一,它是系统抽象化的具体实现

依赖倒转原则(Dependency Inversion Principle, DIP):高层模块不应该依赖低层模块,两者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象

上面的定义有些别扭,引入《设计模式之禅》的话来说明依赖倒转

高层模块和低层模块容易理解,每一个逻辑的实现都是由原子逻辑组成的,不可分割的原子逻辑就是低层模块,原子逻辑的再组装就是高层模块。那什么是抽象?什么又是细节呢?在Java语言中,抽象就是指接口或抽象类,两者都是不能直接被实例化的;细节就是实现类,实现接口或继承抽象类而产生的类就是细节,其特点就是可以直接被实例化,也就是可以加上一个关键字new产生一个对象。

依赖倒置原则在Java语言中的表现就是:

  • 模块间的依赖通过抽象发生,实现类之间不发生直接的依赖关系,其依赖关系是通过接口或抽象类产生的;
  • 接口或抽象类不依赖于实现类;
  • 实现类依赖接口或抽象类

更精简的定义就是要面向接口编程(Object-Oriented Design),而不是针对实现编程

看到依赖倒转和它的定义,是否会想起Spring的依赖注入(Dependency Injection, DI)和控制反转(Inversion of Control,IOC),通常我们使用Spring的IoC容器时,会声明依赖的接口,在程序运行时确定具体的实现类并注入。这样便降低了类间的耦合性、提高了系统的稳定性

接口分离原则

接口隔离原则(Interface Segregation Principle, ISP):使用多个专门的接口,而不使用单一的总接口,
即客户端不应该依赖那些它不需要的接口

根据接口隔离原则,当一个接口太大时,我们需要将它分割成一些更细小的接口,使用该接口的客户端仅需知道与之相关的方法即可。每一个接口应该承担一种相对独立的角色,不干不该干的事,该干的事都要干。这里的“接
口”往往有两种不同的含义:一种是指一个类型所具有的方法特征的集合,仅仅是一种逻辑上的抽象;另外一种是指某种语言具体的“接口”定义,有严格的定义和结构,比如Java语言中的interface。对于这两种不同的含义,ISP的表达方式以及含义都有所不同:

(1) 当把“接口”理解成一个类型所提供的所有方法特征的集合的时候,这就是一种逻辑上的概念,接口的划分将直接带来类型的划分。可以把接口理解成角色,一个接口只能代表一个角色,每个角色都有它特定的一个接口,此时,这个原则可以叫做“角色隔离原则”。

(2) 如果把“接口”理解成狭义的特定语言的接口,那么ISP表达的意思是指接口仅仅提供客户端需要的行为,客户端不需要的行为则隐藏起来,应当为客户端提供尽可能小的单独的接口,而不要提供大的总接口。在面向对象编程语言中,实现一个接口就需要实现该接口中定义的所有方法,因此大的总接口使用起来不一定很方便,为了使接口的职责单一,需要将大接口中的方法根据其职责不同分别放在不同的小接口中,以确保每个接口使用起来都较为方便,并都承担某一单一角色。接口应该尽量细化,同时接口中的方法应该尽量少,每个接口中只包含一个客户(如子模块或业务逻辑类)所需的方法即可,这种机制也称为“定制服务”,即为不同的客户端提供宽窄不同的接口。

接口隔离原则单一职责都是为了提高类的内聚性、降低它们之间的耦合性,体现了封装的思想,但两者是不同的:

  • 单一职责原则注重的是职责,而接口隔离原则注重的是对接口依赖的隔离
  • 单一职责原则主要是约束类,它针对的是程序中的实现和细节;接口隔离原则主要约束接口,主要针对抽象和程序整体框架的构建

合成复用原则

合成复用原则又称为组合/聚合复用原则(Composition/Aggregate Reuse Principle, CARP)

合成复用原则(Composite Reuse Principle, CRP):尽量使用对象组合,而不是继承来达到复用的目的

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

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

继承复用的主要问题在于继承复用会破坏系统的封装性:

  • 因为继承会将基类的实现细节暴露给子类,由于基类的内部细节通常对子类来说是可见的,所以这种复用又称“白箱”复用
  • 子类与父类的耦合度高,如果父类发生改变,那么子类的实现也不得不发生改变,这不利于类的扩展与维护
  • 从父类继承而来的实现是静态的,不可能在运行时发生改变,没有足够的灵活性
  • 而且继承只能在有限的环境中使用(如类没有声明为不能被继承)

组合或聚合关系可以将已有的对象到新对象中,使之成为新对象的一部分

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

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

迪米特原则

迪米特法则(Law of Demeter,LoD)也称为最少知识原则(Least Knowledge Principle,LKP)

迪米特法则(Law of Demeter, LoD):一个软件实体应当尽可能少地与其他实体发生相互作用

如果一个系统符合迪米特法则,那么当其中某一个模块发生修改时,就会尽量少地影响其他模块,扩展会相对容易,这是对软件实体之间通信的限制,迪米特法则要求限制软件实体之间通信的宽度和深度。迪米特法则可降低系统的耦合度,使类与类之间保持松散的耦合关系。

迪米特法则还有几种定义形式,包括:不要和“陌生人”说话、只与你的直接朋友通信等,在迪米特法则中,对于一个对象,其朋友包括以下几类:

  1. 当前对象本身(this);

  2. 以参数形式传入到当前对象方法中的对象;

  3. 当前对象的成员对象;

  4. 如果当前对象的成员对象是一个集合,那么集合中的元素也都是朋友;

  5. 当前对象所创建的对象。

任何一个对象,如果满足上面的条件之一,就是当前对象的“朋友”,否则就是“陌生人”。在应用迪米特法则时,一个对象只能与直接朋友发生交互,不要与“陌生人”发生直接交互,这样做可以降低系统的耦合度,一个对象的改变不会给太多其他对象带来影响。

迪米特法则要求我们在设计系统时,应该尽量减少对象之间的交互,如果两个对象之间不必彼此直接通信,那么这两个对象就不应当发生任何直接的相互作用,如果其中的一个对象需要调用另一个对象的某一个方法的话,可以通过第三者转发这个调用。简言之,就是通过引入一个合理的第三者来降低现有对象之间的耦合度

在将迪米特法则运用到系统设计中时,要注意下面的几点:

  • 在类的划分上,应当尽量创建松耦合的类,类之间的耦合度越低,就越有利于复用,一个处在松耦合中的类一旦被修改,不会对关联的类造成太大波及
  • 在类的结构设计上,每一个类都应当尽量降低其成员变量和成员函数的访问权限
  • 在类的设计上,只要有可能,一个类型应当设计成不变类
  • 在对其他类的引用上,一个对象对其他对象的引用应当降到最低

总结

这 7 种设计原则是软件设计模式必须尽量遵循的原则,各种原则要求的侧重点不同。

  • 开闭原则是总纲,它告诉我们要【对扩展开放,对修改关闭】
  • 里氏替换原则告诉我们【不要破坏继承体系】
  • 依赖倒置原则告诉我们要【面向接口编程】
  • 单一职责原则告诉我们实现要【职责单一】
  • 接口隔离原则告诉我们在设计接口的时候要【精简单一】
  • 迪米特法则告诉我们要【降低耦合度】
  • 合成复用原则告诉我们要【优先使用组合或者聚合关系复用,少用继承关系复用】

23种设计模式

总体来说,设计模式按照功能分为三类23种:

  • 创建型(5种) : 工厂模式、抽象工厂模式、单例模式、原型模式、建造者模式
  • 结构型(7种): 适配器模式、装饰模式、代理模式 、外观模式、桥接模式、组合模式、享元模式
  • 行为型(11种): 模板方法模式、策略模式 、观察者模式、中介者模式、状态模式、责任链模式、命令模式、迭代器模式、访问者模式、解释器模式、备忘录模式

参考:《大话设计模式》、《设计模式之禅》、网上相关设计模式文章