设计模式(Design Patterns):维护、扩展代码的组织形式。它是开发人员对于代码组织方式的总结,每一种设计模式都是针对不同的代码逻辑场景提出最优的代码组织方式。

每个设计模式都会遵循一个或多个设计原则,这些原则有:

  1. 单一职责原则(Single Responsibility Principle, SRP)
  2. 开闭原则(Open Closed Principle,OCP)
  3. 里氏代换原则(Liskov Substitution Principle,LSP)
  4. 接口隔离原则(Interface Segregation Principle,ISP)
  5. 依赖倒转原则(Dependency Inversion Principle,DIP)
  6. 迪米特法则(Law of Demeter,LOD)
  7. 合成/聚合复用原则(Composite/Aggregate Reuse Principle,CARP)
标记 设计模式原则名称 简单定义
SRP 单一职责原则 一个类只负责一个功能领域中的相应职责
OCP 开闭原则 对扩展开放,对修改关闭
LSP 里氏代换原则 所有引用基类的地方必须能透明地使用其子类的对象
ISP 接口隔离原则 类之间的依赖关系应该建立在最小的接口上
DIP 依赖倒转原则 依赖于抽象,不能依赖于具体实现
LOD 迪米特法则 一个软件实体应当尽可能少的与其他实体发生相互作用
CARP 合成/聚合复用原则 尽量使用合成/聚合,而不是通过继承达到复用的目的

单一职责原则_、_开闭原则_、_里氏代换原则_、_接口隔离原则依赖倒转原则 就是我们平常熟知的SOLID

单一职责原则(Single Responsibility Principle, SRP)

定义:一个接口或类只有一个原因引起变化,也就是一个接口或类只有一个职责,它就负责一件事。。

如果职责是针对整个而言,在Java中一个会包含很多属性行为,要达到一个原因影响一个变更是很难的。除非拆成极端的最细颗粒,但这样维护时间成本会增大。

优点

  1. 类的复杂性降低,实现什么职责都有清晰明确的定义;

  2. 可读性提高,复杂性降低,那当然可读性提高了;

  3. 可维护性提高,可读性提高,那当然更容易维护了;

  4. 变更引起的风险降低,变更是必不可少的,如果接口的单一职责做得好,一个

  5. 接口修改只对相应的实现类有影响,对其他的接口无影响,这对系统的扩展性、维护性都有非常大的帮助。

对于单一职责原则, 我的建议是接口一定要做到单一职责,类的设计尽量做到只有一个原因引起变化。

开闭原则(Open Closed Principle,OCP)

定义:一个软件实体如类、模块和函数应该对扩展开放,对修改关闭

在软件的生命周期内,因为变化、升级和维护等原因,需要对软件原有的代码进行修改时,可能会给旧代码引入错误。因此,当软件需要变化时,我们应该尽量通过扩展的方式来实现变化,而不是通过修改已有的代码来实现。

优点:

  1. 增加稳定性

  2. 可扩展性高

总结:开闭原则是个很抽象感念,也是很虚的概念,它定义简单,但又不简单,我们在一直拥抱变化,试想如何让其保持遵循开闭原则

使用开闭原则需要注意什么:

  1. 开闭只是一个原则,口号实现拥抱变化的方法很多,前提条件是:类必须做到高内聚,低耦合,这样拥抱变化时减少不可预料故障。

  2. 项目规章非常重要, 有个稳定的规章,也是所有成员必须遵守的约定,能给我们带来非常多的好处,如提高开发效率,降低缺陷率,减少维护成本等。

  3. 预知变化

里氏代换原则(Liskov Substitution Principle,LSP)

定义: 所有引用基类的地方必须能透明地使用其子类的对象

里氏替换原则通俗的来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。子类完全可以替代父类,反之不成立,主要为继承量身打造。

继承的好处:

  1. 代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性;

  2. 提高代码的重用性;

  3. 子类可以形似父类,但又异于父类,“龙生龙,凤生凤,老鼠生来会打洞”是说子拥有父的“种”,“世界上没有两片完全相同的叶子”是指明子与父的不同;

  4. 提高代码的可扩展性,实现父类的方法就可以“为所欲为”了,君不见很多开源框架的扩展接口都是通过继承父类来完成的;

  5. 提高产品或项目的开放性。

继承的缺陷:

  1. 继承是侵入性的。只要继承,就必须拥有父类的所有属性和方法;
  2. 降低代码的灵活性。子类必须拥有父类的属性和方法,让子类自由的世界中多了些约束;
  3. 增强了耦合性。当父类的常量、变量和方法被修改时,需要考虑子类的修改,而且在缺乏规范的环境下,这种修改可能带来非常糟糕的结果——大段的代码需要重构。

里氏代换原则包含的四层含义:

  1. 子类必须完全实现父类的方法

  2. 子类必须完全实现父类的方法。子类可以有自己的方法和属性,相应子类可以胜任父类,但是父类不可以胜任子类

  3. 覆盖或者实现父类的方法时输入参数被放大:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public class Father {
    public Collection doSomething(HashMap map){
    System.out.println("父类被执行...");
    return map.values();
    }
    }

    public class Son extends Father {
    //放大输入参数类型
    public Collection doSomething(Map map){
    System.out.println("子类被执行...");
    return map.values();
    }
    }
  4. 覆写或实现父类的方法时返回结果可以被缩小

    当父类返回一个类型 T,子类的相同方法(覆写或重载)返回值为 S,里氏替换原则要求 S 必须小于等于 T,也就是说要么 S 和 T 一个类型,要么 S 是 T 的子集,为什么,在默念这一句话:有父类的地方,子类应该完全胜任。

依赖倒转原则(Dependency Inversion Principle,DIP)

定义:

  1. 高层模块不应该依赖低层模块,两者都应该依赖其抽象;
  2. 抽象不应该依赖细节;
  3. 细节应该依赖抽象。

在实现依赖倒转原则时,我们需要针对抽象层编程,而将具体类的对象通过依赖注入(DependencyInjection, DI)的方式注入到其他对象中,依赖注入是指当一个对象要与其他对象发生依赖关系时,通过抽象来注入所依赖的对象。常用的注入方式有三种,分别是:构造注入,设值注入(Setter 注入)和接口注入。Spring 的 IOC 是此实现的典范。

从 Java 角度看待依赖倒转原则的本质就是:面向接口(抽象)编程。

  • 每个具体的类都应该有其接口或者基类,或者两者都具备。
  • 类中的引用对象应该是接口或者基类。
  • 任何具体类都不应该派生出子类。
  • 尽量不要覆写基类中的方法。
  • 结合里氏代换原则使用。

遵循依赖倒转原则的一个例子,场景是司机开车:

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
public interface Driver {

void drive();

void setCar(Car car);
}

public interface Car {

void run();
}

public class DefaultDriver implements Driver {

private Car car;

@Override
public void drive() {
car.run();
}

@Override
public void setCar(Car car) {
this.car = car;
}
}

public class Bmw implements Car {

@Override
public void run() {
System.out.println("Bmw runs...");
}
}

public class Benz implements Car {

@Override
public void run() {
System.out.println("Benz runs...");
}
}

public class App {

public static void main(String[] args) throws Exception {
Driver driver = new DefaultDriver();
Car car = new Benz();
driver.setCar(car);
driver.drive();
car = new Bmw();
driver.setCar(car);
driver.drive();
}
}

这样实现了一个司机可以开各种类型的车,如果还有其他类型的车,只需要新加一个Car的实现即可。

接口隔离原则(Interface Segregation Principle,ISP)

定义: 客户端不应该依赖它不需要的接口,类间的依赖关系应该建立在最小的接口上。

简单来说就是建立单一的接口,不要建立臃肿庞大的接口。也就是接口尽量细化,同时接口中的方法尽量少。

单一职责原则注重的是类和接口的职责单一,这里职责是从业务逻辑上划分的,但是在接口隔离原则要求当一个接口太大时,我们需要将它分割成一些更细小的接口,使用该接口的客户端仅需知道与之相关的方法即可。也就是说,我们在设计接口的时候有可能满足单一职责原则但是不满足接口隔离原则。

接口隔离原则的规范:

  • 使用接口隔离原则前首先需要满足单一职责原则。
  • 接口需要高内聚,也就是提高接口、类、模块的处理能力,少对外发布public的方法。
  • 定制服务,只提供访问者需要的方法。
  • 接口设计是有限度的,接口的设计粒度越小,系统越灵活,但是值得注意不能过小,否则变成”字节码编程”。

迪米特法则(Law of Demeter,LOD)

定义: 一个软件实体应当尽可能少地与其他实体发生相互作用。每一个软件单位对其他的单位都只有最少的知识,而且局限于那些与本单位密切相关的软件单位。

迪米特法则的初衷在于降低类之间的耦合。由于每个类尽量减少对其他类的依赖,因此,很容易使得系统的功能模块功能独立,相互之间不存在(或很少有)依赖关系。迪米特法则不希望类之间建立直接的联系。如果真的有需要建立联系,也希望能通过它的友元类(中间类或者跳转类)来转达。

包含 3 层含义:

  1. 只有朋友交流

    最少朋友思维,不必要和朋友的朋友都认识,只要认识你这个朋友,你的朋友会找到他的朋友帮你办好事。

    只对自己必然要联系对象进行关联,不必要的对象减少耦合,不应和过多对象建立关系,如果过多就该考虑如何分出管理了,“尽量做到满身筋骨,而不是肥嘟嘟!”

  2. 朋友间应该保持适当距离

    即使关联类之间,也应该保持相应“距离”, 不能无所不知,不需要完全暴露所有细节,这就是前面说的高内聚,只提供公共方法,具体实现对外不需要暴露,

    注意: 迪米特法则要求类“羞涩”一点,尽量不要对外公布太多的 public 方法和非静态的 public 变量,尽量内敛,多使用 private 访问权限。

  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
public class Girl {

}

public class GroupLeader {

private final List<Girl> girls;

public GroupLeader(List<Girl> girls) {
this.girls = girls;
}

public void countGirls() {
System.out.println("The sum of girls is " + girls.size());
}
}

public class Teacher {

public void command(GroupLeader leader){
leader.countGirls();
}
}

public class App {

public static void main(String[] args) throws Exception {
Teacher teacher = new Teacher();
GroupLeader groupLeader = new GroupLeader(Arrays.asList(new Girl(), new Girl()));
teacher.command(groupLeader);
}
}

这个例子中,体育课代表就是中间类,体育课代表对于体育老师来说就是”直接的朋友”,如果去掉体育课代表这个中间类,体育老师必须亲自清点女生的人数(实际上就数人数这个功能,体育老师是不必要获取所有女生的对象列表),这样做会违反迪米特法则。

合成/聚合复用原则(Composite/Aggregate Reuse Principle,CARP)

定义:尽量使用合成/聚合,而不是通过继承达到复用的目的。

合成/聚合复用原则就是在一个新的对象里面使用一些已有的对象,使之成为新对象的一部分;新的对象通过向内部持有的这些对象的委派达到复用已有功能的目的,而不是通过继承来获得已有的功能。

聚合(Aggregate)的概念

聚合表示一种弱的”拥有”关系,一般表现为松散的整体和部分的关系,其实,所谓整体和部分也可以是完全不相关的。例如 A 对象持有 B 对象,B 对象并不是 A 对象的一部分,也就是 B 对象的生命周期是 B 对象自身管理,和 A 对象不相关。

合成(Composite)的概念

合成表示一种强的”拥有”关系,一般表现为严格的整体和部分的关系,部分和整体的生命周期是一样的。

聚合和合成的关系

这里用山羊举例说明聚合和合成的关系:

为什么要用合成/聚合来替代继承达到复用的目的

继承复用破坏包装,因为继承将基类的实现细节暴露给派生类,基类的内部细节通常对子类来说是可见的,这种复用也称为”白箱复用”。这里有一个明显的问题是:派生类继承自基类,如果基类的实现发生改变,将会影响到所有派生类的实现;如果从基类继承而来的实现是静态的,不可能在运行时发生改变,不够灵活。

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

总结

设计模型和设计原则是从开发实践中总结出来的最有组织代码形式,但在我们自己实际的业务场景中设计原则之间可能会有矛盾处,我们应该灵活使用不要教条主义生搬硬套。

参考

https://www.infoq.cn/article/5hoopguzfyfzd91ldg5g

https://www.throwable.club/2019/05/05/design-pattern-basic-law/

https://thinkkeep.github.io/design-patterns/zh/uml/design-principle.html