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

2019-10-30  本文已影响0人  TurboSnail

设计模式(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#等编程语言中,可以为系统定义一个相对稳定的抽象层,而将不同的实现行为移至具体的实现层中完成。在很多面向对象编程语言中都提供了接口、抽象类等机制,可以通过它们定义系统的抽象层,再通过具体类来进行扩展。如果需要修改系统的行为,无须对抽象层进行任何改动,只需要增加新的具体类来实现新的业务功能即可,实现在不修改已有代码的基础上扩展系统的功能,达到开闭原则的要求

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

image

ChartDisplay中的display方法如下

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类是用来做图表的显示工作,但具体的图表是变化的,需要将这些变化隔离出来

抽象化的方法:

重构后的结构如下

image

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

单一职责原则

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

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

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

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

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

image

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

image

里式替换原则

里氏代换原则(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种:

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

上一篇 下一篇

猜你喜欢

热点阅读