设计模式

史上最全设计模式导学目录(完整版)_史上最全设计模式lovelion-CSDN博客

引言

打了会题,决定开个坑,就决定是你了,设计模式----模式是在特定环境下人们解决某类重复出现问题的一套成功或有效的解决方案。

金句:学习设计模式,关键是学习设计思想,不能简单地生搬硬套,也不能为了使用设计模式而过度设计,要合理平衡设计的复杂度和灵活性,并意识到设计模式也并不是万能的。

设计模式,即Design Patterns,是指在软件设计中,被反复使用的一种代码设计经验。使用设计模式的目的是为了可重用代码,提高代码的可扩展性和可维护性。

为什么要使用设计模式?

根本原因还是软件开发要实现可维护、可扩展,就必须尽量复用代码,并且降低代码的耦合度。

在软件开发生命周期的每一个阶段都存在着一些被认同的模式

软件模式是在软件开发中某些可重现问题的一些有效解决方法,软件模式的基础结构主要由四部分构成,包括问题描述【待解决的问题是什么】、前提条件【在何种环境或约束条件下使用】、解法【如何解决】和效果【有哪些优缺点】。

问题(Problem)描述了应该在何时使用模式,它包含了设计中存在的问题以及问题存在的原因;解决方案(Solution)描述了一个设计模式的组成成分,以及这些组成成分之间的相互关系,各自的职责和协作方式,通常解决方案通过UML类图和核心代码来进行描述;效果(Consequences)描述了模式的优缺点以及在使用模式时应权衡的问题。

软件模式与具体的应用领域无关,也就是说无论你从事的是移动应用开发、桌面应用开发、Web应用开发还是嵌入式软件的开发,都可以使用软件模式。

在软件模式中,设计模式是研究最为深入的分支,设计模式用于在特定的条件下为一些重复出现的软件设计问题提供合理的、有效的解决方案

设计模式主要是基于OOP编程提炼的,它基于以下几个原则:

开闭原则

软件应该对扩展开放,而对修改关闭。这里的意思是在增加新功能的时候,能不改代码就尽量不要改,如果只增加代码就完成了新功能,那是最好的。

里氏替换原则

这是一种面向对象的设计原则,即如果我们调用一个父类的方法可以成功,那么替换成子类调用也应该完全可以运行。

必须原则

  1. 面向接口编程,而不是面向实现。这个很重要,也是优雅的、可扩展的代码的第一步,
  2. 职责单一原则。每个类都应该只有一个单一的功能,并且该功能应该由这个类完全封装起来。
  3. 对修改关闭,对扩展开放。对修改关闭是说,我们辛辛苦苦加班写出来的代码,该实现的功能和该修复的 bug 都完成了,别人可不能说改就改;对扩展开放就比较好理解了,也就是说在我们写好的代码基础上,很容易实现扩展。

以上只是一些介绍,正片还未开始

分类

根据用途,设计模式可分为创建型(Creational),结构型(Structural)和行为型(Behavioral)三种。

创建型模式主要用于描述如何创建对象结构型模式主要用于描述如何实现类或对象的组合行为型模式主要用于描述类或对象怎样交互以及怎样分配职责

在GoF 23种设计模式中包含5种创建型设计模式、7种结构型设计模式和11种行为型设计模式。

此外,根据某个模式主要是用于处理类之间的关系还是对象之间的关系,设计模式还可以分为类模式和对象模式。我们经常将两种分类方式结合使用,如单例模式是对象创建型模式,模板方法模式是类行为型模式。

img

面向对象设计原则

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

面向对象设计原则为支持可维护性复用而诞生,这些原则蕴含在很多设计模式中,它们是从许多设计方案中总结出的指导性原则

常见的面向对象设计原则有

单一职责原则:一个类只负责一个功能领域中的相应职责

开闭原则:软件实体应对扩展开放,而对修改关闭

里氏代换原则:所有引用基类对象的地方能够透明地使用其子类的对象

依赖倒转原则:抽象不应该依赖于细节,细节应该依赖于抽象

接口隔离原则:使用多个专门的接口,而不使用单一的总接口

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

迪米特原则:一个软件实体应当尽可能少地与其他实体发生相互作用


单一职责原则

一个类只负责一个功能领域中的相应职责,或者可以定义为:就一个类而言,应该只有一个引起它变化的原因。

在软件系统中,一个类(大到模块,小到方法)承担的职责越多,它被复用的可能性就越小,而且一个类承担的职责过多,就相当于将这些职责耦合在一起,当其中一个职责变化时,可能会影响其他职责的运作,因此要将这些职责进行分离,将不同的职责封装在不同的类中,即将不同的变化原因封装在不同的类中,如果多个职责总是同时发生改变则可将它们封装在同一类中。 单一职责原则是实现高内聚、低耦合的指导方针,它是最简单但又最难运用的原则,需要设计人员发现类的不同职责并将其分离。

缺点一览:

如果一个对象承担了太多的职责,至少存在以下两个缺点:

  1. 一个职责的变化可能会削弱或者抑制这个类实现其他职责的能力;
  2. 当客户端需要该对象的某一个职责时,不得不将其他不需要的职责全都包含进来,从而造成冗余代码或代码的浪费。

优点一览:

降低类的复杂度。一个类只负责一项职责,其逻辑肯定要比负责多项职责简单得多。 提高类的可读性。复杂性降低,自然其可读性会提高。 提高系统的可维护性。可读性提高,那自然更容易维护了。 变更引起的风险降低。变更是必然的,如果单一职责原则遵守得好,当修改一个功能时,可以显著降低对其他功能的影响。

【例】众所周知,交通工具有汽车这种陆地上的,也有飞机这种天上的,也有轮船,如果控制交通工具的类把所有交通工具的运行都放到一个方法去执行那么会出现这种情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class single_test {
public static void main(String[] args) {
Vehicle car = new Vehicle("汽车");
car.run(car.getname());
Vehicle airPlane = new Vehicle("飞机");
airPlane.run(airPlane.getname());
Vehicle ship = new Vehicle("轮船");
ship.run(ship.getname());
}
}

class Vehicle{
private String name;

public Vehicle(String name) {
this.name = name;
}

public String getname() {
return name;
}

public void setname(String name) {
this.name = name;
}

public void run(String name) {
System.out.println(name+"交通工具是在陆地上运行");
}
}

运行结果

汽车交通工具是在陆地上运行 飞机交通工具是在陆地上运行 轮船交通工具是在陆地上运行

我们可以知道一个交通工具的总类去负责每种类型的交通工具的运行方式,它负责的太宽了,导致运行出现逻辑问题。

优化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
public class single_test {
public static void main(String[] args) {
LandVehicle car = new LandVehicle("汽车");
car.run(car.getName());

AirVehicle airPlane = new AirVehicle("飞机");
airPlane.run(airPlane.getName());

WaterVehicle ship = new WaterVehicle("轮船");
ship.run(ship.getName());
}
}

class LandVehicle{
private String name;

public LandVehicle(String name) {
this.name = name;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public void run(String name) {
System.out.println(name+"交通工具是在陆地上运行");
}
}

class AirVehicle{
private String name;

public AirVehicle(String name) {
this.name = name;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public void run(String name) {
System.out.println(name+"交通工具是在天上运行");
}
}

class WaterVehicle{
private String name;

public WaterVehicle(String name) {
this.name = name;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public void run(String name) {
System.out.println(name+"交通工具是在水上运行");
}
}

运行结果

汽车交通工具是在陆地上运行 飞机交通工具是在天上运行 轮船交通工具是在水上运行

一个很简单的问题居然需要创建三个类,内存消耗太大。我们有没有方法不需要这么浪费内存又做到单一职责呢?

二次优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class single_test {
public static void main(String[] args) {
Vehicle transition = new Vehicle();

transition.landRun("汽车");
transition.airRun("飞机");
transition.waterRun("轮船");
}
}

class Vehicle{

public void airRun(String name)
{
System.out.println(name+"交通工具是在天上运行");
}

public void waterRun(String name)
{
System.out.println(name+"交通工具是在水上运行");
}

public void landRun(String name)
{
System.out.println(name+"交通工具是在陆地上运行");
}
}

运行结果

汽车交通工具是在陆地上运行 飞机交通工具是在天上运行 轮船交通工具是在水上运行

第二次优化以后虽然在类级别违反了单一职责原则,但是在方法上遵循了该原则,而且这样使得程序变得更加简单、更加适合使用。

单一职责原则注意事项和细节

  • 降低类的复杂度,一个类只负责一项职责
  • 提高类的可读性,可维护性
  • 降低变更引起的风险
  • 通常情况下,我们应当遵守单一职责原则,只有逻辑足够简单,才可以在代码(类)级别违反单一职责原则;只有类中方法数量足够少,才可以在方法级别违反单一职责原则。

另一个例子的设计:

Sunny软件公司开发人员针对某CRM(Customer Relationship Management,客户关系管理)系统中客户信息图形统计模块提出了如图1所示初始设计方案:

image-20240328013359453

图1 初始设计方案结构图

在图1中,CustomerDataChart类中的方法说明如下:getConnection()方法用于连接数据库,findCustomers()用于查询所有的客户信息,createChart()用于创建图表,displayChart()用于显示图表。

现使用单一职责原则对其进行重构。 CustomerDataChart类承担了太多的职责,既包含与数据库相关的方法,又包含与图表生成和显示相关的方法。如果在其他类中也需要连接数据库或者使用findCustomers()方法查询客户信息,则难以实现代码的重用。无论是修改数据库连接方式还是修改图表显示方式都需要修改该类,它不止一个引起它变化的原因,违背了单一职责原则。因此需要对该类进行拆分,使其满足单一职责原则,类CustomerDataChart可拆分为如下三个类:

1
2
3
4
5
(1) DBUtil:负责连接数据库,包含数据库连接方法getConnection();

(2) CustomerDAO:负责操作数据库中的Customer表,包含对Customer表的增删改查等方法,如findCustomers();

(3) CustomerDataChart:负责图表的生成和显示,包含方法createChart()和displayChart()。
image-20240328013543101

开闭原则

开封-封闭原则. 设计软件要容易维护且不容易出问题的最好办法, 就是多扩展, 少修改.

最重要的面向对象设计原则。

一个软件实体应当对扩展开放,对修改关闭。即软件实体应尽量在不修改原有代码的情况下进行扩展。

软件实体可以指一个软件模块、一个由多个类组成的局部结构或一个独立的类

任何软件都需要面临一个很重要的问题,即它们的需求会随时间的推移而发生变化。当软件系统需要面对新的需求时,我们应该尽量保证系统的设计框架是稳定的。如果一个软件设计符合开闭原则,那么可以非常方便地对系统进行扩展,而且在扩展时无须修改现有代码,使得软件系统在拥有适应性和灵活性的同时具备较好的稳定性和延续性。随着软件规模越来越大,软件寿命越来越长,软件维护成本越来越高,设计满足开闭原则的软件系统也变得越来越重要。 为了满足开闭原则,需要对系统进行抽象化设计,抽象化是开闭原则的关键

在很多面向对象编程语言如C++中都提供了接口、抽象类等机制,可以通过它们定义系统的抽象层,再通过具体类来进行扩展。如果需要修改系统的行为,无须对抽象层进行任何改动,只需要增加新的具体类来实现新的业务功能即可,实现在不修改已有代码的基础上扩展系统的功能,达到开闭原则的要求。

总结为:

实现开放封闭的核心思想就是面对抽象编程,而不是面对具体编程,因为抽象相对稳定。 让类依赖于固定的抽象,所以对修改是封闭的;而通过面向对象的继承和多态机制,可以实现对抽象体的继承,通过覆写其方法来改变固有行为,实现新的扩展方法,所以对于扩展就是开放的。

如何使用开闭原则

1.抽象约束

1
2
3
第一,通过接口或者抽象类约束扩展,对扩展进行边界限定,不允许出现在接口或抽象类中不存在的public方法;
第二,参数类型、引用对象尽量使用接口或者抽象类,而不是实现类;
第三,抽象层尽量保持稳定,一旦确定即不允许修改。

2.元数据(metadata)控制模块行为

1
2
元数据就是用来描述环境和数据的数据,通俗地说就是配置参数,参数可以从文件中获得,也可以从数据库中获得。
Spring容器就是一个典型的元数据控制模块行为的例子,其中达到极致的就是控制反转(Inversion of Control)

3.封装变化

1
2
3
对变化的封装包含两层含义:
第一,将相同的变化封装到一个接口或者抽象类中;
第二,将不同的变化封装到不同的接口或抽象类中,不应该有两个不同的变化出现在同一个接口或抽象类中。

案例一: 画形状

需求: 有圆形, 有椭圆形, 根据要求画出相应的形状

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class GraphicEditor {

public void draw(Shape shape) {
if (shape.m_type == 1) {
drawRectangle();
} else if(shape.m_type == 2) {
drawCircle();
}
}

public void drawRectangle() {
System.out.println("画长方形");
}

public void drawCircle() {
System.out.println("画圆形");
}

class Shape {
int m_type;
}

class Rectangle extends Shape {
Rectangle() {
super.m_type=1;
}
}

class Circle extends Shape {
Circle() {
super.m_type=2;
}
}

}

这个代码, 初看是符合要求了, 再想想, 要是我增加一种形状呢? 比如增加三角形.

  • 首先, 要增加一个三角形的类, 继承自Shape

  • 第二, 要增加一个画三角形的方法drawTrriage()

  • 第三, 在draw方法中增加一种类型type=3的处理方案. 这就违背了开闭原则-对扩展开发, 对修改关闭. 增加一个类型, 修改了三处代码.

合适的设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class GraphicEditor1 {

public void draw(Shape shape) {
shape.draw();
}



interface Shape {
void draw();
}

class Rectangle implements Shape {

@Override
public void draw() {
System.out.println("画矩形");
}
}

class Circle implements Shape {

@Override
public void draw() {
System.out.println("画圆形");
}
}

}

各种类型的形状自己规范自己的行为, 而GraphicEditor.draw()只负责画出来. 当增加一种类型三角形. 只需要 第一: 增加一个三角形的类,实现Shape接口 第二, 调用draw方法,划出来就可以了.

整个过程都是在扩展, 而没有修改原来的类. 这个设计是符合开闭原则的.

案例二:

比如现在有一个银行业务, 存钱, 取钱和转账. 最初我们会怎么思考呢?

  1. 首先有一个银行业务类, 用来处理银行的业务
  2. 银行有哪些业务呢? 存钱,取钱,转账, 这都是银行要执行的操作
  3. 那外部说我要存钱, 我要取钱,我要转账, 通过一个类型来告诉我们 代码就生成了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package com.lxl.www.designPatterns.sixPrinciple.openclosePrinciple.bank;

/**
* 银行业务
*/
public class BankBusiness {

public void operate(int type) {
if (type == 1) {
save();
} else if(type == 2) {
take();
} else if(type == 3) {
transfer();
}
}

public void save(){
System.out.println("存钱");
}

public void take(){
System.out.println("取钱");
}

public void transfer() {
System.out.println("转账");
}
}

咋一看已经实现了需求. 但是现在有新的需求来了, 银行要增加功能---理财. 理财是银行业务的一种, 自然是新增一个方法. 然后在operate()方法里增加一种类型. 这就是一个糟糕的设计, 增加新功能, 但是却修改了原来的代码

我们设计成接口抽象的形式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public interface Business {
public void operate();
}
//"Save" 类实现了一个名为 "Business" 的接口
public class Save implements Business{
@Override
public void operate() {
System.out.println("存钱业务");
}
}

public class Take implements Business {
@Override
public void operate() {
System.out.println("取钱业务");
}
}

public class Transfer implements Business {
@Override
public void operate() {
System.out.println("转账业务");
}
}


/**
* 银行业务类
*/
public class BankBusinesses {
/*
* 处理银行业务
* @param business
*/
public void operate(Business business) {
System.out.println("处理银行业务");
business.operate();
}
}

通过接口抽象的形式方便扩展, 加入要新增理财功能. 只需新增一个理财类, 其他业务代码都不需要修改.

其实, 在日常工作中, 经常会遇到这种情况. 因为我们平时写业务逻辑会更多一些, 而业务就像流水账, 今天一个明天一个一点一点的增加. 所以,当业务增加到3个的时候, 我们就要思考, 如何写能够方便扩展。


里氏代换原则

俗话说,老鼠生的会打洞,就是符合里式替换原则。

如果老鼠生的不会打洞,会上天,就违背了该原则。

所有引用基类(父类)的地方必须能透明地使用其子类的对象。

里氏代换原则告诉我们,在软件中将一个基类对象替换成它的子类对象,程序将不会产生任何错误和异常,反过来则不成立,如果一个软件实体使用的是一个子类对象的话,那么它不一定能够使用基类对象。例如:我喜欢动物,那我一定喜欢狗,因为狗是动物的子类;但是我喜欢狗,不能据此断定我喜欢动物,因为我并不喜欢老鼠,虽然它也是动物。 例如有两个类,一个类为BaseClass,另一个是SubClass类,并且SubClass类是BaseClass类的子类,那么一个方法如果可以接受一个BaseClass类型的基类对象base的话,如:method1(base),那么它必然可以接受一个BaseClass类型的子类对象sub,method1(sub)能够正常运行。反过来的代换不成立,如一个方法method2接受BaseClass类型的子类对象sub为参数:method2(sub),那么一般而言不可以有method2(base),除非是重载方法。 里氏代换原则是实现开闭原则的重要方式之一,由于使用基类对象的地方都可以使用子类对象,因此在程序中尽量使用基类类型来对对象进行定义,而在运行时再确定其子类类型,用子类对象来替换父类对象

里氏替换原则的主要作用如下。

  1. 里氏替换原则是实现开闭原则的重要方式之一。
  2. 它克服了继承中重写父类造成的可复用性变差的缺点。
  3. 它是动作正确性的保证。即类的扩展不会给已有的系统引入新的错误,降低了代码出错的可能性。

在使用里氏代换原则时需要注意如下几个问题:

(1)子类的所有方法必须在父类中声明,或子类必须实现父类中声明的所有方法。根据里氏代换原则,为了保证系统的扩展性,在程序中通常使用父类来进行定义,如果一个方法只存在子类中,在父类中不提供相应的声明,则无法在以父类定义的对象中使用该方法。

  1. 我们在运用里氏代换原则时,尽量把父类设计为抽象类或者接口,让子类继承父类或实现父接口,并实现在父类中声明的方法,运行时,子类实例替换父类实例,我们可以很方便地扩展系统的功能,同时无须修改原有子类的代码,增加新的功能可以通过增加一个新的子类来实现。里氏代换原则是开闭原则的具体实现手段之一。

例一:

在Sunny软件公司开发的CRM系统中,客户(Customer)可以分为VIP客户(VIPCustomer)和普通客户(CommonCustomer)两类,系统需要提供一个发送Email的功能,原始设计方案如图所示:

image-20240328082828332

在对系统进行进一步分析后发现,无论是普通客户还是VIP客户,发送邮件的过程都是相同的,也就是说两个send()方法中的代码重复,而且在本系统中还将增加新类型的客户。为了让系统具有更好的扩展性,同时减少代码重复,使用里氏代换原则对其进行重构。

在本实例中,可以考虑增加一个新的抽象客户类Customer,而将CommonCustomer和VIPCustomer类作为其子类,邮件发送类EmailSender类针对抽象客户类Customer编程,根据里氏代换原则,能够接受基类对象的地方必然能够接受子类对象,因此将EmailSender中的send()方法的参数类型改为Customer,如果需要增加新类型的客户,只需将其作为Customer类的子类即可。

例二:

里氏替换原则在“几维鸟不是鸟”实例中的应用。

分析:鸟一般都会飞行,如燕子的飞行速度大概是每小时 120 千米。但是新西兰的几维鸟由于翅膀退化无法飞行。假如要设计一个实例,计算这两种鸟飞行 300 千米要花费的时间。显然,拿燕子来测试这段代码,结果正确,能计算出所需要的时间;但拿几维鸟来测试,结果会发生“除零异常”或是“无穷大”,明显不符合预期。

image-20240328083531470
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<?php
/**
* 里氏替换原则
*/

abstract class Bird
{
protected $flySpeed;

public function setSpeed(float $speed)
{
$this->flySpeed = $speed;
}

public function getFlyTime(float $distance)
{
return $distance / $this->flySpeed;
}
}

class Swallow extends Bird
{

}

class BrownKiwi extends Bird
{
public function setSpeed(float $speed)
{
parent::setSpeed(0); // TODO: Change the autogenerated stub
}
}

$swallow = new Swallow();
$brownKiwi = new BrownKiwi();
$swallow->setSpeed(120);
$brownKiwi->setSpeed(0);

echo '如果飞行300公里' . PHP_EOL;

try {
echo '燕子将飞行:' . $swallow->getFlyTime(300) . '小时' . PHP_EOL;
echo '几维鸟将飞行:' . $brownKiwi->getFlyTime(300) . '小时' . PHP_EOL;
} catch (Exception $exception) {
echo '错误了' . PHP_EOL;
}

程序的运行结果如下:

1
2
3
4
如果飞行300公里
燕子将飞行:2.5小时
PHP Warning: Division by zero in /design_pattern/LSP.php on line 17
几维鸟将飞行:INF小时

程序运行错误的原因是:几维鸟类重写了鸟类的 setSpeed(double speed) 方法,这违背了里氏替换原则。正确的做法是:取消几维鸟原来的继承关系,定义鸟和几维鸟的更一般的父类,如动物类,它们都有奔跑的能力。几维鸟的飞行速度虽然为 0,但奔跑速度不为 0,可以计算出其奔跑 300 千米所要花费的时间。

image-20240328083609802

通过里氏代换原则给我们带来了什么样的启示? 类的继承原则:如果一个继承类的对象可能会在基类出现的地方出现运行错误,则该子类不应该从该基类继承,或者说,应该重新设计它们之间的关系。 动作正确性保证:符合里氏代换原则的类扩展不会给已有的系统引入新的错误。


依赖倒转原则

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

抽象不应该依赖于细节,细节应当依赖于抽象。换言之,要针对接口编程,而不是针对实现编程。

依赖倒转原则要求我们在程序代码中传递参数时或在关联关系中,尽量引用层次高的抽象层类,即使用接口和抽象类进行变量类型声明、参数类型声明、方法返回类型声明,以及数据类型的转换等,而不要用具体类来做这些事情。

为了确保该原则的应用,一个具体类应当只实现接口或抽象类中声明过的方法,而不要给出多余的方法,否则将无法调用到在子类中增加的新方法。

在引入抽象层后,系统将具有很好的灵活性,在程序中尽量使用抽象层进行编程,而将具体类写在配置文件中,这样一来,如果系统行为发生变化,只需要对抽象层进行扩展,并修改配置文件,而无须修改原有系统的源代码,在不修改的情况下来扩展系统的功能,满足开闭原则的要求。

在实现依赖倒转原则时,我们需要针对抽象层编程,而将具体类的对象通过依赖注入的方式注入到其他对象中,依赖注入是指当一个对象要与其他对象发生依赖关系时,通过抽象来注入所依赖的对象。

常用的注入方式有三种,分别是:构造注入,设值注入(Setter注入)和接口注入

构造注入是指通过构造函数来传入具体类的对象.

设值注入是指通过Setter方法来传入具体类的对象

而接口注入是指通过在接口中声明的业务方法来传入具体类的对象。

这些方法在定义时使用的是抽象类型,在运行时再传入具体类型的对象,由子类对象来覆盖父类对象。 Sunny软件公司开发人员在开发某CRM系统时发现:该系统经常需要将存储在TXT或Excel文件中的客户信息转存到数据库中,因此需要进行数据格式转换。在客户数据操作类中将调用数据格式转换类的方法实现格式转换和数据库插入操作,初始设计方案结构如图所示:

image-20240328084152132

Sunny软件公司开发人员发现该设计方案存在一个非常严重的问题,由于每次转换数据时数据来源不一定相同,因此需要更换数据转换类,如有时候需要将TXTDataConvertor改为ExcelDataConvertor,此时,需要修改CustomerDAO的源代码,而且在引入并使用新的数据转换类时也不得不修改CustomerDAO的源代码,系统扩展性较差,违反了开闭原则,现需要对该方案进行重构。 在本实例中,由于CustomerDAO针对具体数据转换类编程,因此在增加新的数据转换类或者更换数据转换类时都不得不修改CustomerDAO的源代码。我们可以通过引入抽象数据转换类解决该问题,在引入抽象数据转换类DataConvertor之后,CustomerDAO针对抽象类DataConvertor编程,而将具体数据转换类名存储在配置文件中,符合依赖倒转原则。根据里氏代换原则,程序运行时,具体数据转换类对象将替换DataConvertor类型的对象,程序不会出现任何问题。更换具体数据转换类时无须修改源代码,只需要修改配置文件;如果需要增加新的具体数据转换类,只要将新增数据转换类作为DataConvertor的子类并修改配置文件即可,原有代码无须做任何修改,满足开闭原则。重构后的结构

image-20240328084332053

开闭原则是目标,里氏代换原则是基础,依赖倒转原则是手段

使用依赖倒转原则的编程方式+里氏转换的约束=开闭原则

看一个例子:一个爱学习的「我没有三颗心脏」同学现在正在学习「设计模式」和「Java」的课程,伪代码如下:

1
2
3
4
5
6
7
8
9
10
public class Wmyskxz {

public void studyJavaCourse() {
System.out.println("「我没有三颗心脏」同学正在学习「Java」课程");
}

public void studyDesignPatternCourse() {
System.out.println("「我没有三颗心脏」同学正在学习「设计模式」课程");
}
}

我们来模拟上层调用一下:

1
2
3
4
5
public static void main(String[] args) {
Wmyskxz wmyskxz = new Wmyskxz();
wmyskxz.studyJavaCourse();
wmyskxz.studyDesignPatternCourse();
}

原因一:有效控制影响范围

由于「我没有三颗心脏」同学热爱学习,随着学习兴趣的 “暴增”,可能会继续学习 AI(人工智能)的课程。这个时候,因为「业务的扩展」,要从底层实现到高层调用依次地修改代码。

我们需要在 Wmyskxz 类中新增 studyAICourse() 方法,也需要在高层调用中增加调用,这样一来,系统发布后,其实是非常不稳定的。显然在这个简单的例子中,我们还可以自信地认为,我们能 Hold 住这一次的修改带来的影响,因为都是新增的代码,我们回归的时候也可以很好地 cover 住,但实际的情况和实际的软件环境要复杂得多。

最理想的情况就是,我们已经编写好的代码可以 “万年不变”,这就意味着已经覆盖的单元测试可以不用修改,已经存在的行为可以保证保持不变,这就意味着「稳定」。任何代码上的修改带来的影响都是有未知风险的,不论看上去多么简单。

原因二:增强代码可读性和可维护性

另外一点,你有没有发现其实加上新增的 AI 课程的学习,他们三节课本质上行为都是一样的,如果我们任由这样行为近乎一样的代码在我们的类里面肆意扩展的话,很快我们的类就会变得臃肿不堪,等到我们意识到不得不重构这个类以缓解这样的情况的时候,或许成本已经变得高得可怕了。

原因三:降低耦合

《资本论》中有这样一段描述:

在商品经济的萌芽时期,出现了物物交换。假设你要买一个 iPhone,卖 iPhone 的老板让你拿一头猪跟他换,可是你并没有养猪,你只会编程。所以你找到一位养猪户,说给他做一个养猪的 APP 来换他一头猪,他说换猪可以,但是得用一条金项链来换…

所以这里就出现了一连串的对象依赖,从而造成了严重的耦合灾难。解决这个问题的最好的办法就是,买卖双发都依赖于抽象——也就是货币——来进行交换,这样一来耦合度就大为降低了。

接下来我们可以参考一下伪代码,先定一个课程的抽象 ICourse 接口:

1
2
3
public interface ICourse {
void study();
}

然后编写分别为 JavaCourseDesignPatternCourse 编写一个类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class JavaCourse implements ICourse {

@Override
public void study() {
System.out.println("「我没有三颗心脏」同学正在学习「Java」课程");
}
}

public class DesignPatternCourse implements ICourse {

@Override
public void study() {
System.out.println("「我没有三颗心脏」同学正在学习「设计模式」课程");
}
}

然后把 Wmyskxz 类改造成如下的样子:

1
2
3
4
5
6
public class Wmyskxz {

public void study(ICourse course) {
course.study();
}
}

再来是我们的调用:

1
2
3
4
5
public static void main(String[] args) {
Wmyskxz wmyskxz = new Wmyskxz();
wmyskxz.study(new JavaCourse());
wmyskxz.study(new DesignPatternCourse());
}

无论「我没有三颗心脏」的兴趣怎么暴涨,对于新的课程,都只需要新建一个类,通过参数传递的方式告诉它,而不需要修改底层的代码。实际上这有点像大家熟悉的依赖注入的方式了。


接口隔离原则

使用多个专门的接口,而不使用单一的总接口,即客户端不应该依赖那些它不需要的接口。

根据接口隔离原则,当一个接口太大时,我们需要将它分割成一些更细小的接口,使用该接口的客户端仅需知道与之相关的方法即可。每一个接口应该承担一种相对独立的角色,不干不该干的事,该干的事都要干。

实例

Sunny软件公司开发人员针对某CRM系统的客户数据显示模块设计了如图1所示接口,其中方法dataRead()用于从文件中读取数据,方法transformToXML()用于将数据转换成XML格式,方法createChart()用于创建图表,方法displayChart()用于显示图表,方法createReport()用于创建文字报表,方法displayReport()用于显示文字报表。 image-20240328091654457

在实际使用过程中发现该接口很不灵活,例如如果一个具体的数据显示类无须进行数据转换(源文件本身就是XML格式),但由于实现了该接口,将不得不实现其中声明的transformToXML()方法(至少需要提供一个空实现);如果需要创建和显示图表,除了需实现与图表相关的方法外,还需要实现创建和显示文字报表的方法,否则程序编译时将报错。

现使用接口隔离原则对其进行重构。

由于在接口CustomerDataDisplay中定义了太多方法,即该接口承担了太多职责,一方面导致该接口的实现类很庞大,在不同的实现类中都不得不实现接口中定义的所有方法,灵活性较差,如果出现大量的空方法,将导致系统中产生大量的无用代码,影响代码质量;另一方面由于客户端针对大接口编程,将在一定程序上破坏程序的封装性,客户端看到了不应该看到的方法,没有为客户端定制接口。因此需要将该接口按照接口隔离原则和单一职责原则进行重构,将其中的一些方法封装在不同的小接口中,确保每一个接口使用起来都较为方便,并都承担某一单一角色,每个接口中只包含一个客户端(如模块或类)所需的方法即可。

image-20240328091921091

简单来说,接口隔离原则指的是一个接口中应该只包含客户端需要的方法,而不应该包含不需要的方法。这样可以避免客户端依赖于无用的接口方法,从而降低耦合性。

例如,如果一个类实现了一个拥有很多方法的接口,而这个类只需要使用其中的一个方法,那么这个类就必须实现所有的方法,即使它们对该类来说没有用处。这样,就会导致代码的冗余和复杂性。

在使用接口隔离原则时,我们需要注意控制接口的粒度,接口不能太小,如果太小会导致系统中接口泛滥,不利于维护;接口也不能太大,太大的接口将违背接口隔离原则,灵活性较差,使用起来很不方便。


合成复用原则

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

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

通过继承来进行复用的主要问题在于继承复用会破坏系统的封装性,因为继承会将基类的实现细节暴露给子类,由于基类的内部细节通常对子类来说是可见的,所以这种复用又称“白箱”复用,如果基类发生改变,那么子类的实现也不得不发生改变;从基类继承而来的实现是静态的,不可能在运行时发生改变,没有足够的灵活性;而且继承只能在有限的环境中使用(如类没有声明为不能被继承)。 由于组合或聚合关系可以将已有的对象(也可称为成员对象)纳入到新对象中,使之成为新对象的一部分,因此新对象可以调用已有对象的功能,这样做可以使得成员对象的内部实现细节对于新对象不可见,所以这种复用又称为“黑箱”复用,相对继承关系而言,其耦合度相对较低,成员对象的变化对新对象的影响不大,可以在新对象中根据实际需要有选择性地调用成员对象的操作;合成复用可以在运行时动态进行,新对象可以动态地引用与成员对象类型相同的其他对象。 Sunny软件公司开发人员在初期的CRM系统设计中,考虑到客户数量不多,系统采用MySQL作为数据库,与数据库操作有关的类如CustomerDAO类等都需要连接数据库,连接数据库的方法getConnection()封装在DBUtil类中,由于需要重用DBUtil类的getConnection()方法,设计人员将CustomerDAO作为DBUtil类的子类,初始设计方案结构如图所示:

image-20240328093125395

随着客户数量的增加,系统决定升级为Oracle数据库,因此需要增加一个新的OracleDBUtil类来连接Oracle数据库,由于在初始设计方案中CustomerDAO和DBUtil之间是继承关系,因此在更换数据库连接方式时需要修改CustomerDAO类的源代码,将CustomerDAO作为OracleDBUtil的子类,这将违反开闭原则。【当然也可以修改DBUtil类的源代码,同样会违反开闭原则。】

我们在实现复用时应该多用关联,少用继承。

image-20240328093208843

CustomerDAO和DBUtil之间的关系由继承关系变为关联关系,采用依赖注入的方式将DBUtil对象注入到CustomerDAO中,可以使用构造注入,也可以使用Setter注入。如果需要对DBUtil的功能进行扩展,可以通过其子类来实现,如通过子类OracleDBUtil来连接Oracle数据库。由于CustomerDAO针对DBUtil编程,根据里氏代换原则,DBUtil子类的对象可以覆盖DBUtil对象,只需在CustomerDAO中注入子类对象即可使用子类所扩展的方法。例如在CustomerDAO中注入OracleDBUtil对象,即可实现Oracle数据库连接,原有代码无须进行修改,而且还可以很灵活地增加新的数据库连接方式。


迪米特法则

一个软件实体应当尽可能少地与其他实体发生相互作用。

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

不要和“陌生人”说话只与你的直接朋友通信

对于一个对象,其朋友包括以下几类:

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

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

  (3) 当前对象的成员对象;

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

  (5) 当前对象所创建的对象。

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

迪米特法则要求我们在设计系统时,应该尽量减少对象之间的交互,如果两个对象之间不必彼此直接通信,那么这两个对象就不应当发生任何直接的相互作用,如果其中的一个对象需要调用另一个对象的某一个方法的话,可以通过第三者转发这个调用。简言之,就是通过引入一个合理的第三者来降低现有对象之间的耦合度。 在将迪米特法则运用到系统设计中时,要注意下面的几点:在类的划分上,应当尽量创建松耦合的类,类之间的耦合度越低,就越有利于复用,一个处在松耦合中的类一旦被修改,不会对关联的类造成太大波及;在类的结构设计上,每一个类都应当尽量降低其成员变量和成员函数的访问权限;在类的设计上,只要有可能,一个类型应当设计成不变类;在对其他类的引用上,一个对象对其他对象的引用应当降到最低。

Sunny软件公司所开发CRM系统包含很多业务操作窗口,在这些窗口中,某些界面控件之间存在复杂的交互关系,一个控件事件的触发将导致多个其他界面控件产生响应,例如,当一个按钮(Button)被单击时,对应的列表框(List)、组合框(ComboBox)、文本框(TextBox)、文本标签(Label)等都将发生改变,在初始设计方案中,界面控件之间的交互关系可简化为如图所示结构:

image-20240328094042663

在图中,由于界面控件之间的交互关系复杂,导致在该窗口中增加新的界面控件时需要修改与之交互的其他控件的源代码,系统扩展性较差,也不便于增加和删除新控件。

现使用迪米特对其进行重构。

在本实例中,可以通过引入一个专门用于控制界面控件交互的中间类(Mediator)来降低界面控件之间的耦合度。引入中间类之后,界面控件之间不再发生直接引用,而是将请求先转发给中间类,再由中间类来完成对其他控件的调用。当需要增加或删除新的控件时,只需修改中间类即可,无须修改新增控件或已有控件的源代码 image-20240328094126341