软件设计模式原则

软件设计模式原则

在我们面向对象的设计过程中,我们的code既要可读性高方便维护,同时还需要另外一个可复用性。这次我们来学习软件设计模式中涉及到的一些原则————>面向对象设计 SOLID

这就是吐槽: 但是我们往往接手的项目,都是苦不堪言,一把鼻涕一把泪,看前人留下的杰作,那是相当痛苦,千万匹MMP奔腾,填坑,还技术债。

入正题,在有些说七大原则,有些说六大原则,有些说五大原则,但是最重要的就是SOLID了。

国内的博文大部分都说七大和六大原则,Google下solid,看老外的文章和wiki都是五大原则。

我们先看SOLID,是罗伯特·C·马丁搞出来的首字母缩写记忆,这个Bob大叔。

Single Responsibility Principle:单一职责原则,解释:一个类只负责一个功能领域中的相应职责

Open Closed Principle:开闭原则,解释:软件实体应对扩展开放,而对修改关闭

Liskov Substitution Principle:里氏替换原则,解释:所有引用基类对象的地方能够透明地使用其子类的对象

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

Dependence Inversion Principle:依赖倒置原则,解释:抽象不应该依赖于细节,细节应该依赖于抽象

剩下的两大原则

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

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

单一职责原则

a class should have only a single responsibility (i.e. only changes to one part of the software’s specification should be able to affect the specification of the class).

顾名思义,就是一个类只负责一个职责。不能有多个导致类变更的原因。

让类的职责单一,只需要负责自己部分,复杂度就会降低,代码维护起来也更加容易。原则不仅仅适用于类,对于接口和方法也适用,即一个接口/方法,只负责一件事,这样的话,接口就会变得简单,方法中的代码也会更少,易读,便于维护。事实上,由于一些其他的因素影响,类的单一职责在项目中是很难保证的。通常,接口和方法的单一职责更容易实现。

好处:

代码的粒度降低了,类的复杂度降低了。

可读性提高了,每个类的职责都很明确,可读性自然更好。

可维护性提高了,可读性提高了,一旦出现 bug ,自然更容易找到他问题所在。

改动代码所消耗的资源降低了,更改的风险也降低了。

高内聚、低耦合

假设有一类C,含有logging、order、pay3个方法,实则就这个类就不是单一指责了,按要求后我们保留单一指责order方法。后期需求来了,我们要对order方法进行修改,产生了order1和order2,尽量在方法级别保留了单一指责,或者这个时候修改为类C1和C2,都有order方法。这两种方法,我们往往选择前者较多,但是有些2者都不选择,直接就在类C中order方法进行修改,支持多种需求,在这一步就违法单一指责。需求有可能在未来导致order方法扩散到n个。记住,在指责扩散到无法控制时,一定要重构。

开闭原则

software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.

软件实体如类、模块、方法等对于扩展开放的,但是对于修改封闭的。

我们试想一下,产品来需求了,对我们原有的代码要进行升级或者改变的时候,可能我们在修改旧的业务代码时,往往也会新增新的bug,这个时候,有可能因为功能复杂情况下要对整个功能进行重构。这个时候,就应该想到开闭原则,通过扩展实现改变而不是修改原有的东西。

有没有感觉,对扩展开放,对修改关闭。说起来很简单,感觉像啥也没说一样,具体起来该如何做。

Open–closed principle中一文有描述,通过继承抽象类和实现接口

img

其实,这也正是我们面向对象中的框架设计—–抽象,用抽象构建框架,用实现扩展细节。我们队框架进行关闭,通过实现细节进行开放。而我们的业务常常就是这些细节,通过细节的实现,我们就能完成扩展。但是前提是我们的框架要足够抽象化,具有针对需求的满足性,要有足够的预埋性,这其实也是需要一定的经验。

里氏替换原则

objects in a program should be replaceable with instances of their subtypes without altering the correctness of that program.

程序中的对象应该是可以在不改变程序正确性的前提下被它的子类所替换的。简单点,就是我用基类代替子类的话,程序还是一样的,是对的。

Liskov substitution principle有讲明里氏替换原则的来路。Barbara Liskov女士提出来的。

PDF中有相关的介绍

是这么定义的:

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

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

通俗点理解:

例如有两个类,一个类为BaseClass,另一个是SubClass类,并且SubClass类是BaseClass类的子类,那么一个方法如果可以接受一个BaseClass类型的基类对象base的话,如:method1(base),那么它必然可以接受一个BaseClass类型的子类对象sub,method1(sub)能够正常运行。反过来的代换不成立,如一个方法method2接受BaseClass类型的子类对象sub为参数:method2(sub),那么一般而言不可以有method2(base),除非是重载方法。

里氏代换原则是实现开闭原则的重要方式之一,由于使用基类对象的地方都可以使用子类对象,因此在程序中尽量使用基类类型来对对象进行定义,而在运行时再确定其子类类型,用子类对象来替换父类对象

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

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

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

(3) Java语言中,在编译阶段,Java编译器会检查一个程序是否符合里氏代换原则,这是一个与实现无关的、纯语法意义上的检查,但Java编译器的检查是有局限的。

里氏替换原则通俗的来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
class A{
public int func1(int a, int b){
return a-b;
}
}

public class Client{
public static void main(String[] args){
A a = new A();
System.out.println("100-50="+a.func1(100, 50));
System.out.println("100-80="+a.func1(100, 80));
}
}

运行结果:

100-50=50

100-80=20

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class B extends A{
public int func1(int a, int b){
return a+b;
}

public int func2(int a, int b){
return func1(a,b)+100;
}
}

public class Client{
public static void main(String[] args){
B b = new B();
System.out.println("100-50="+b.func1(100, 50));
System.out.println("100-80="+b.func1(100, 80));
System.out.println("100+20+100="+b.func2(100, 20));
}
}

类B完成后,运行结果:

100-50=150

100-80=180

100+20+100=220

通过这个举例可以看出子类改变了父类原有的功能,子类无意或者有意重写了父类的方法。导致本来的减法变成了加法。

接口隔离原则

many client-specific interfaces are better than one general-purpose interface.

多个特定客户端接口要好于一个宽泛用途的接口。

建立单一接口,不要建立庞大臃肿的接口,尽量细化接口,接口中的方法尽量少。也就是说,我们要为各个类建立专用的接口,而不要试图去建立一个很庞大的接口供所有依赖它的类去调用。将一个庞大的接口变更为多个专用的接口所采用的就是接口隔离原则。

例如:接口I有5个方法M1,M2,M3,M4,M5,假设现在有3个实现类C1实现5分方法,实现类C2只实现前面3个方法M1,M2,M3其他2个则是空实现,实现类C3只实现M4,M5其他3个则是空实现。这个时候就体现出接口庞大了。此时应将接口I拆分成接口I1有方法M1,M2,M3,接口I2有方法M4,M5。而实现类C1实现接口I1和I2,实现类C2实现接口I1,实现类C3实现接口I2。

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

采用接口隔离原则对接口进行约束时,要注意以下几点:

接口尽量小,但是要有限度。对接口进行细化可以提高程序设计灵活性是不挣的事实,但是如果过小,则会造成接口数量过多,使设计复杂化。所以一定要适度。

为依赖接口的类定制服务,只暴露给调用的类它需要的方法,它不需要的方法则隐藏起来。只有专注地为一个模块提供定制服务,才能建立最小的依赖关系。

提高内聚,减少对外交互。使接口用最少的方法去完成最多的事情。

运用接口隔离原则,一定要适度,接口设计的过大或过小都不好。设计接口的时候,只有多花些时间去思考和筹划,才能准确地实践这一原则。

依赖倒置原则

one should “depend upon abstractions, [not] concretions.

依赖于抽象而不是一个实例。其实想想依赖注入是该原则的一种实现方式。

The principle states:

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions).
  2. Abstractions should not depend on details. Details should depend on abstractions.

翻译过来是:高层模块不应该依赖底层模块,两者都应该依赖其抽象。抽象不应该依赖细节。细节应该依赖抽象。

在Java语言中的表现就是:模块间的依赖通过抽象发生,实现类之间不发生直接的依赖关系,其依赖关系是通过接口或抽象类产生的。接口或抽象类不依赖于实现类。实现类依赖于接口或抽象类。

简而言之,尽可能的使用接口或者抽象类,即所谓的面向接口编程

依赖倒转原则要求我们在程序代码中传递参数时或在关联关系中,尽量引用层次高的抽象层类,即使用接口和抽象类进行变量类型声明、参数类型声明、方法返回类型声明,以及数据类型的转换等,而不要用具体类来做这些事情。为了确保该原则的应用,一个具体类应当只实现接口或抽象类中声明过的方法,而不要给出多余的方法,否则将无法调用到在子类中增加的新方法。

Dependency inversion.png

这张图,其实就很好的表达了依赖倒置原则,ObjectB应该依赖InterfaceA去申明ObjectA而非直接去依赖ObjectA,而ObjectA也应该依赖InterfaceA,这就是高层模块不应该依赖底层模块,两者都应该依赖其抽象细节应该依赖抽象

我们来看看如下的示例,刚开始,学生只是简单的完成学校里的白天课堂作业。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class SchoolWork {
public void work() {
System.out.println("学生每天在学校就完成课堂作业了");
}
}

public class Student {
public void work(SchoolWork schoolWork) {
schoolWork.work();
}
}

//客户端调用
Student student = new Student();
SchoolWork work = new SchoolWork();
student.work(work);

我们发现,学生每天都在课堂上就完成了作业,但是随着时间慢慢的过去,学生们,课堂作业变多了,老师开始布置家庭作业了。那么Student类中再写一个写家庭作业的方法嘛,万一老师又给学生布置其他作业又写一种方法吗,肯定不是的啦。

这个时候就需要把作业抽象起来了,用接口或者抽象类都可以。

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
public interface Work {
public void work();
}

public class SchoolWork implements Work {
public void work() {
System.out.println("学生每天在学校就完成课堂作业了");
}
}

public class HomeWork implements Work {
public void work() {
System.out.println("学生每天在家就完成家庭作业了");
}
}

public class Student {
public void work(Work work) {
work.work();
}
}

//客户端调用
Student student = new Student();
Work work = new SchoolWork();
student.work(work);
work = new HomeWork();
student.work(work);

这就是面向接口编程。Work work = new SchoolWork();和work = new HomeWork();就是变量的声明类型尽量是抽象类或者接口

在实际编程中,我们一般需要做到如下3点:

  • 低层模块尽量都要有抽象类或接口,或者两者都有。
  • 变量的声明类型尽量是抽象类或接口。
  • 使用继承时遵循里氏替换原则。

迪米特法则

From Wikipedia, the free encyclopedia

Each unit should have only limited knowledge about other units: only units “closely” related to the current unit.
Each unit should only talk to its friends; don’t talk to strangers.
Only talk to your immediate friends.

每个单位对其他单位的了解应该是有限,除了跟自己相对密切的单位。的不要和陌生人说话啊,只与直接的朋友通信。

看完之后,是不是云里雾里,不知道说什么鸟语花香。其实就是,一个对象应该对其他对象保持最少的了解。啊,你还是不知道它说了个啥。通俗的讲就是一个类对自己依赖的类知道的越少越好,也就是对于被依赖的类,向外公开的方法应该尽可能的少(尽可能的不用用public修饰方法)。对于上面的朋友的定义来讲,两个对象之间的耦合关系称之为朋友,通常有依赖、关联、聚合和组成等。而直接朋友则通常表现为关联、聚合和组成关系,即两个对象之间联系更为紧密,通常以成员变量、方法的参数和返回值的形式出现。同时了陌生的类最好不要作为局部变量的形式出现在类的内部。

朋友了也是有这样几种定义的

当前对象本身(this);

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

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

当前对象所创建的对象。

来看个示例,关计算机的业务。 主要是针对只暴露应该暴露的方法

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
//计算机类
public class Computer{

public void saveCurrentTask(){
//do something
}
public void closeService(){
//do something
}
public void closeScreen(){
//do something
}

public void closePower(){
//do something
}

public void close(){
saveCurrentTask();
closeService();
closeScreen();
closePower();
}
}

//人
public class Person{
private Computer c;

...

public void clickCloseButton(){
//现在你要开始关闭计算机了,正常来说你只需要调用close()方法即可,
//但是你发现Computer所有的方法都是公开的,该怎么关闭呢?于是你写下了以下关闭的流程:
c.saveCurrentTask();
c.closePower();
c.close();

//亦或是以下的操作
c.closePower();

//还可能是以下的操作
c.close();
c.closePower();
}

}

从被依赖者的角度,只应该暴露应该暴露的方法。那么这里的c对象应该哪些方法应该是被暴露的呢?很显然,对于Person来说,只需要关注计算机的关闭操作,而不关心计算机会如何处理这个关闭操作,因此只需要暴露close()方法即可。

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 Computer{

private void saveCurrentTask(){
//do something
}
private void closeService(){
//do something
}
private void closeScreen(){
//do something
}

private void closePower(){
//do something
}

public void close(){
saveCurrentTask();
closeService();
closeScreen();
closePower();
}
}

//人
public class Person{
private Computer c;
...

public void clickCloseButton(){
c.close();
}

}

再来看一个示例,只依赖应该依赖的对象

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 class A {
public D do(B b) {
C c = b.getC().;
c.doSomething();
D d = c.getResult()
return d;
}
}

@Data
public class B {
private C c;
}

@Data
public class C {

public void doSomething() {
//.....
}

public D getResult() {
//.....
return new D();
}
}

@Data
public class D {
//.......
}

public class Client {
public void main(String[] args) {
A a = new A();
B b = new B();
System.out.println(a.do(b));
}
}

从上述例子中,可以看出,在A类的do方法中,类C其实相对于类A来说就是陌生人,高耦合在A类中。假设日后,我们对C类的doSomething和getResult方法改动时,就会影响到类A。

至于如何修改,想看看看官们有什么建议或者实现,欢迎留言。

总的来说,在将迪米特法则运用到系统设计中时,要注意下面的几点:在类的划分上,应当尽量创建松耦合的类,类之间的耦合度越低,就越有利于复用,一个处在松耦合中的类一旦被修改,不会对关联的类造成太大波及;在类的结构设计上,每一个类都应当尽量降低其成员变量和成员函数的访问权限;在类的设计上,只要有可能,一个类型应当设计成不变类;在对其他类的引用上,一个对象对其他对象的引用应当降到最低。高内聚,低耦合。

合成复用原则

也称为合成/聚合复用原则,要尽量先使用组合或者聚合等关联关系来实现,其次才考虑使用继承关系来实现。

其原则就是在一个新的对象里面使用一些已有的对象,使之成为新对象的一部分;新的对象通过向这些对象的委派达到复用已有功能的目的。

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

聚合(Aggregation)表示一种弱的‘拥有’关系,体现的是A对象可以包含B对象但B对象不是A对象的一部分。如上图中的大雁属于雁群的,一个雁群是有很多大雁。聚合关系用空心的菱形+实线来表示。

合成(Composition)则是一种强的’拥有’关系,体现了严格的部分和整体关系,部分和整体的生命周期一样。例如上图大雁人是有头、翅膀等合成的,大雁和头、翅膀的生命周期是一样的,大家一起玩完。合成关系用实心的菱形+实线来表示。

通常类的复用分为继承复用和合成复用

两种,继承复用虽然有简单和易实现的优点,但它也存在以下缺点。

  1. 继承复用破坏了类的封装性。因为继承会将父类的实现细节暴露给子类,父类对子类是透明的,所以这种复用又称为“白箱”复用。
  2. 子类与父类的耦合度高。父类的实现的任何改变都会导致子类的实现发生变化,这不利于类的扩展与维护。
  3. 它限制了复用的灵活性。从父类继承而来的实现是静态的,在编译时已经定义,所以在运行时不可能发生变化。

采用组合或聚合复用时,可以将已有对象纳入新对象中,使之成为新对象的一部分,新对象可以调用已有对象的功能,它有以下优点。

  1. 它维持了类的封装性。因为成分对象的内部细节是新对象看不见的,所以这种复用又称为“黑箱”复用。
  2. 新旧类之间的耦合度低。这种复用所需的依赖较少,新对象存取成分对象的唯一方法是通过成分对象的接口。
  3. 复用的灵活性高。这种复用可以在运行时动态进行,新对象可以动态地引用与成分对象类型相同的对象。

总结

单一职责原则告诉我们实现类要职责单一;里氏替换原则告诉我们不要破坏继承体系;依赖倒置原则告诉我们要面向接口编程;接口隔离原则告诉我们在设计接口的时候要精简单一;迪米特法则告诉我们要降低耦合。而开闭原则是总纲,他告诉我们要对扩展开放,对修改关闭。

参考

SOLID Design Principles Explained

The SOLID Principles in Real Life

S.O.L.I.D: The First 5 Principles of Object Oriented Design

SOLID

设计模式六大原则——SOLID

软件设计模式六大原则

浅谈 SOLID 原则的具体使用

白话设计——浅谈迪米特法则

合成复用原则——面向对象设计原则

Damon wechat
同步在个人微信公众号
坚持原创技术分享,您的支持将鼓励我继续创作!