logo头像

👨‍💻冷锋のIT小屋

Java设计模式(2)-软件设计遵循的七大原则

设计模式为面向对象软件设计的前进道路照亮了明灯,而设计模式遵循面向对象软件设计的七大原则,这些原则也是设计模式的基础。

1. 单一职责原则

单一职责原则(Single Responsibility),它的定义是:对类来说的,一个类应该只负责一项职责。即是说,一个类只负责一种职责,让其职责单一化,如果有其他的职责则应该设计另外的类来进行协助。比如:订单处理类只负责处理订单,而不该去处理支付的逻辑。单一职责原则也可以引申到方法、类、模块甚至服务和子系统中。

类的职责单一,并不是说一个类只有一个方法,而是说一个类只承担实现一中业务职责,如:OrderService只负责处理订单相关的逻辑,而不会涉及支付相关的逻辑。

下面来看看具体的例子。假设我们设计一个Animal类,来管理动物的飞行、陆行、游水等动作。

1.1. 基础示例

这个版本的设计如下图所示:

srp bad design
Figure 1. 初版设计

初版的代码如下:

Animal类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 一个类负责动物的飞行方法
abstract class Animal {
public void fly() {
System.out.println(getName() + "能够飞行");
}

public void runOnLand() {
System.out.println(getName() + "能在陆地上跑");
}

public void swimInWater() {
System.out.println(getName() + "能在水中游");
}

protected abstract String getName();
}

这里设计了一个抽象类,里边有各种方法来打印某种动物(具备名称)的这些行为。

然后,各种动物都可以继承 Animal

Animal下的具体动物
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
// 小鸟
class Bird extends Animal {
@Override
public String getName() {
return "小鸟";
}
}

// 大雁
class WildGoose extends Animal {
@Override
public String getName() {
return "大雁";
}
}

// 狗
class Dog extends Animal {
@Override
public String getName() {
return "狗";
}
}

// ……其他动物

调用方的代码如下:

调用方
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class NotSingleResponsibility {
public static void main(String[] args) {
// 小鸟可以飞行
Bird bird = new Bird();
bird.fly();
// 大雁可以飞行
WildGoose wildGoose = new WildGoose();
wildGoose.fly();
// 调用者调用fly,则会出现逻辑错误
Dog dog = new Dog();
dog.runOnLand();
// 逻辑错误
// dog.fly(); (1)
// dog.swimInWater(); (1)
// ……
}
}
1 客户端如果调用 fly() 方法,则会出现逻辑错误。你说狗会游泳,好吧,可以,但是,你确定存在飞狗吗?😃

分析

设计的 Animal 类将各种动物的行为都纳入旗下管理,职责过多,导致客户端在使用的时候只能自己来判断是否可以调用该方法了,搞不好调错了,就会出现业务逻辑错误!并且,随着动物的种类和数量越来越多,Animal 会越来越庞大且难以管理,适用方也会头疼不已。

根源弄清楚了,接下来看看应该如何改进!

1.2. 示例改进

既然 Animal 类的职责过多,那么多我们就需要来进行拆分,使其职责明确和单一。

srp good design
Figure 2. 改进后的设计

我们只需要将原来的 Animal 按照行为拆分为更具体的子类,使子类来负责某一种行为即可,比如 FlyAnimal 表示能飞行的动物,而 LandAnimal 表示在陆地行走的动物等等。

改进后的Animal
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
abstract class Animal {
protected abstract String getName();
}

// 飞行动物
abstract class FlyAnimal extends Animal {
protected void fly() {
System.out.println(getName() + "能够飞行");
}
}

// 陆生动物
abstract class LandAnimal extends Animal {
protected void runOnLand() {
System.out.println(getName() + "能在陆地上跑");
}
}

// 水生动物
abstract class WaterAnimal extends Animal {
protected void swimInWater() {
System.out.println(getName() + "能在水中游");
}
}

// 两栖动物
abstract class AmphibiousAnimal extends Animal {
protected void runOnLandAndSwimInWater() {
System.out.println(getName() + "既能在陆地跑,也能在水中游");
}
}

然后,就是是的具体的动物继承它的行为父类即可,比如 Bird 来继承 FlyAnimal

1
2
3
4
5
6
class Bird extends FlyAnimal {
@Override
public String getName() {
return "小鸟";
}
}

然后,适用方在调用时就非常明确了,比如 Bird 实例现在只能调用 fly() 方法,没有其他的方法了,是不是清晰又明了呢?

2. 开闭原则

开闭原则(Open Closed Principle)是软件设计的最基础、最重要的原则,它的定义是:系统的模块、函数或类,应该对修改关闭,对扩展开放

注意理解的关闭和开放,它们是对不同的角色而言的。修改关闭,指的是对使用者关闭修改,即使用该类、函数或者模块的一方是不需要修改自身代码的;扩展开发,是对提供方开放扩展,即提供该类、函数或者模块的一方要保证对其提供的功能的扩展性(功能扩展,而不是修改源代码)。

2.1. 基础示例

来看一个例子:我们编写一个听动物鸣叫声的程序,代码如下:

动物的鸣叫
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
abstract class AnimalCall {
abstract void call();
}

// 小鸟
class Bird extends AnimalCall {
@Override
public void call() {
System.out.println("小鸟叫声叽叽");
}
}

// 小狗
class Dog extends AnimalCall {
@Override
public void call() {
System.out.println("小狗叫声汪汪");
}
}

// 小猫
class Cat extends AnimalCall {
@Override
public void call() {
System.out.println("小猫叫声喵喵");
}
}

AnimalCall 提供了一个动物鸣叫的方法,然后客户端通过 sound() 方法来收听,代码如下:

Client代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 客户端
class Client {
// 听动物的叫声
public void sound(int type) {
if (type == 1) {
new Bird().call();
} else if (type == 2) {
new Dog().call();
} else if (type == 3) {
new Cat().call();
}
// ……
}
}

客户端通过传递不同的类型来收听不同动物的声音。现在如果要增加更多的动物,提供方只需要继承 AnimalCall 即可扩展。但是,客户端 Client 需要更改代码才能使用新的功能,这违背对修改关闭的原则。

调用者代码如下:

1
2
3
4
5
6
public static void main(String[] args) {
Client client = new Client();
client.sound(1);
client.sound(2);
client.sound(3);
}

2.2. 示例改进

我们看看如何改进,是的客户端在提供方新增了动物的时候,不需要修改自身代码。

其他代码不需要变动,只需要将 Client 代码修改:

修改后的 Client 代码
1
2
3
4
5
6
7
// 客户端
class Client {
// 听动物的叫声,新增动物不影响客户端代码
public void sound(AnimalCall animal) {
animal.call();
}
}

然后,调用方代码更改如下:

1
2
3
4
5
6
public static void main(String[] args) {
Client client = new Client();
client.sound(new Bird());
client.sound(new Dog());
client.sound(new Cat());
}

现在,新增动物,客户端代码不需要变化,只需要调用者调用 client.sound() ,参数传递新的动物实例即可。

3. 里氏代换原则

里氏代换(替换)原则(Liskov Substitution Principle),于1987年由麻省理工的女士Liskov提出,该原则指明了在软件设计中应该如何使用对象继承关系,它的定义如下:任何引用父类的地方,都可以换成子类

尽管继承给面向对象设计带来了诸多便利,如可以方便地复用和修改复用实现,但是继承也有一定的缺点,它常被认为“破坏了封装性”,例如继承会带来侵入性,使得软件可移植性降低,而且继承增加了对象间的耦合性,父类的修改需要考虑到子类,子类覆盖父类的方法,可能破坏继承体系。

所以,里氏替换原则指出,子类尽量不要覆盖父类的方法,尽量使用对象的依赖、聚合、组合关系来代替继承关系。

3.1. 基础示例

一个典型的示例是,鸟儿都有fly方法,如果企鹅继承了鸟类,但是企鹅不会飞,因此这个继承关系违反了里氏替换原则(将企鹅换成鸟类不合适)。

我们来看看这个示例,初版的设计如下:

lsp bad design
Figure 3. 初版设计类图

这里设计了Bird超类,WildGoosePenguin 子类,构成继承关系,大雁可以飞,但是企业是不能飞的,所以它必须覆盖父类的 setSpeed() 方法。代码如下:

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
abstract class Bird {
int speed;

public void setSpeed(int speed) {
this.speed = speed;
}

public void fly(int distance) {
int time = distance / speed;
System.out.println(getName() + "飞行了" + time + "分钟");
}

abstract String getName();
}

// 大雁
class WildGoose extends Bird {
@Override
String getName() {
return "大雁";
}
}

// 企鹅
class Penguin extends Bird {
@Override
String getName() {
return "企鹅";
}

@Override
public void setSpeed(int speed) {
// 企鹅不会飞
super.setSpeed(0);
}
}

这种设计,客户端在调用时就会出现错误:

1
2
3
4
5
6
7
8
public static void main(String[] args) {
Bird bird = new WildGoose();
bird.setSpeed(10);
bird.fly(1000);
// 运行出错
bird = new Penguin();
bird.fly(1000);
}

因为 Penguin 类覆盖了父类的方法,速度变为0。

3.2. 示例改进

改进的方式也很简单,既然企鹅不会飞,那么就不应该继承鸟类。我们只需要将 Bird 在进行深层次抽象即可。

lsp good design
Figure 4. 改进后的设计

Bird 向上在抽象出一个 Animal 类,只有真正能飞的动物才继承 Bird,其他动物直接继承 Animal 即可。

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
abstract class Animal {
int speed;

public void setSpeed(int speed) {
this.speed = speed;
}

abstract String getName();
}

abstract class Bird extends Animal {
public void fly(int distance) {
int time = distance / speed;
System.out.println(getName() + "飞行了" + time + "分钟");
}
}

// 大雁
class WildGoose extends Bird {
@Override
String getName() {
return "大雁";
}
}

// 企鹅
class Penguin extends Animal {
@Override
String getName() {
return "企鹅";
}

@Override
public void setSpeed(int speed) {
super.setSpeed(0);
}
}

4. 依赖倒置原则

依赖倒置(倒转)原则(Dependency Inversion Principle)指出:高层模块不应该依赖低层模块,两者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象。简单而言,就是要求软件应该面向接口编程,而不是面向具体实现编程(这里的接口其实是一个概念,可以是具体的Java接口,也可以是抽象类)。

使用接口或者抽象类的目的是制定好规范和契约,而不去涉及任何具体的操作,把展现细节的任务交给它们的实现类去完成。

依赖倒置原则是实现开闭原则的重要途径之一,它降低了客户与实现模块之间的耦合。

4.1. 基础示例

前边的开闭原则示例已经很好的说明了依赖倒转原则了。我们再看一个例子:现在我们设计一个消息接收程序,能够接收各种消息。初版的设计如下:

dip bad design
Figure 5. 消息接收程序初版设计
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Client {
public void receiveMessage(WechatMessage wm) {
System.out.println("收到消息:" + wm.getMsg());
}

public void receiveMessage(EmailMessage em) {
System.out.println("收到消息:" + em.getMsg());
}

public void receiveMessage(SmsMessage sm) {
System.out.println("收到消息:" + sm.getMsg());
}
}

// 微信消息
class WechatMessage {
public String getMsg() {
return "这是微信消息";
}
}

// ……其他消息类

这种设计虽然能满足需求,但是`Client` 类直接依赖消息发送类的具体实现,违反了开闭原则和依赖倒置原则,依赖了具体实现,并且每次增加消息类时,客户端也需要更改代码。

4.2. 示例改进

比较好的设计是,将消息抽象为接口,Client 只需要依赖接口即可,实现了代码解耦。

dip good design
Figure 6. 改进的设计

这样依赖,新的消息只需要实现 Message 接口,而 Client 不需要做任何改变。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Client {
public void receiveMessage(Message message) {
System.out.println("收到消息:" + message.getMsg());
}
}

// 抽象一个消息接口
interface Message {
String getMsg();
}

// 微信消息
class WechatMessage implements Message {
@Override
public String getMsg() {
return "这是微信消息";
}
}
// ……其他消息类型

5. 接口隔离原则

既然依赖倒置原则提倡针对接口编程,那么如何正确设计接口呢?就需要用到接口隔离原则了。

接口隔离原则(Interface Segregation Principle)指出:一个类对另一个类的依赖应该建立在最小的接口上

2002年罗伯特·C.马丁给“接口隔离原则”的定义是:

  1. 客户端不应该被迫依赖于它不使用的方法(Clients should not be forced to depend on methods they do not use)

  2. 一个类对另一个类的依赖应该建立在最小的接口上(The dependency of one class to another one should depend on the smallest possible interface)。

以上两个定义的含义是:要为各个类建立它们需要的专用接口,而不要试图去建立一个很庞大的接口供所有依赖它的类去调用。

接口隔离和单一职责

接口隔离原则跟单一原则存在一定的共性,他们都是为了提高类的内聚性,降低类的耦合性。他们的区别在于:单一职责强调的是类的职责,范围更大;而接口隔离针对的是具体的接口应该怎么设计,范围要小。另外,从本义理解,单一职责针对的是具体的实现类,不过我认为,接口的设计首先也应该符合单一职责原则,然后再考虑怎么进行接口隔离。

前边单一职责原则一节的例子,如果将抽象类 Animal 改为接口,我们看看会怎么样。设计如下:

isp bad
Figure 7. 初版的设计

按照这样的设计,每一个动物都要实现 Animal 接口的这些方法,即使根本用不到。你可能会说,我直接用默认接口实现就行了呗:

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
interface Animal {
// 获取动物名称
String getName();

// 飞行
default void fly() {
System.out.println(getName() + "能够飞行");
}

// 陆行
default void runOnLand() {
System.out.println(getName() + "能在陆地上跑");
}

// 游水
default void swimInWater() {
System.out.println(getName() + "能在水中游");
}
}

// 大雁,假设大雁仅能飞行,那么runOnLand、swimInWater方法对于它而言没有用处
class WildGoose implements Animal {
@Override
public String getName() {
return "大雁";
}
}

但是,实现类同样具备了这些自己根本不关心的方法,这违背了接口隔离原则,设计的还需要细粒度拆分。改进后的设计如下:

isp good
Figure 8. 改进后的设计

6. 迪米特法则

迪米特法则(Law of Demeter,LoD),又叫作最少知识原则(Least Knowledge Principle,LKP),描述了如何设计对象之间的依赖关系,它的定义如下:一个对象对自己所依赖的对象知道的越少越好。更进一步的意思,即:只与你的直接朋友交谈,不跟“陌生人”说话(Talk only to your immediate friends and not to strangers)。

什么是直接朋友?对象与对象有耦合关系,我们就说他们是朋友关系。直接的朋友,指的是类的成员变量方法参数方法返回值中声明的类;出现在方法局部变量的类,不是直接朋友关系。因此,迪米特法则说明,设计类关系时将依赖的类尽量不声明为局部变量。

迪米特法则降低了类之间的耦合度,提高了模块的相对独立性。但是,过度使用迪米特法则,会产生大量的中介类,增加系统的复杂度,类、模块之间的通信效率变低,使用时需要反复权衡。

举个例子,支付service依赖于订单service,在支付完成后,除了要修改支付记录的状态,还需要修改订单的状态,那么此时应该如何来设计他们的关系?

假设现在的设计如下:

OrderService 提供了一个 update(Order order) 的方法,来修改订单,PayServicepaidSuccessfully 方法先查询订单,然后更改订单状态,再调用 OrderService 更新订单,类图如下:

lkp bad
1
2
3
4
5
6
public void paidSuccessfully(String orderNo) {
updatePayRecordStatus();
Order order = orderService.getOrder(orderNo); (1)
order.setStatus("PAID");
orderService.updateOrder(order);
}
1 局部变量 Order,它不是 PayService 的直接朋友,而是方法内部变量,这违反了迪米特法则。

由于 OrderService 中,Order 类是它的直接朋友,那么应该将更新订单状态的动作交给 OrderService 了来完成。改进的设计如下:

lkp good

OrderService 增加修改状态的方法:

1
2
3
4
5
public void updateOrderStatus(String orderNo, String status) {
Order order = getOrder(orderNo);
order.setStatus(status);
System.out.println("update pay record status");
}

然后 PayService 不需要与 Order 产生依赖:

1
2
3
4
5
public void paidSuccessfully(String orderNo) {
updatePayRecordStatus();
// 改进:将更新订单状态的方法放到OrderService中
orderService.updateOrderStatus(orderNo, "PAID");
}

7. 合成复用原则

合成复用原则(Composite Reuse Principle,CRP)又叫组合/聚合复用原则(Composition/Aggregate Reuse Principle,CARP)。它要求在软件复用时,要尽量先使用组合或者聚合等关联关系来实现,其次才考虑使用继承关系来实现。如果要使用继承关系,则必须严格遵循里氏替换原则。合成复用原则同里氏替换原则相辅相成的。

通常类的复用分为继承复用合成复用两种,合成即通常所说的依赖,又分为聚合和组合两种。

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

  • 继承复用破坏了类的封装性。因为继承会将父类的实现细节暴露给子类,父类对子类是透明的,所以这种复用又称为“白箱”复用

  • 子类与父类的耦合度高。父类的实现的任何改变都会导致子类的实现发生变化,这不利于类的扩展与维护

  • 它限制了复用的灵活性。从父类继承而来的实现是静态的,在编译时已经定义,所以在运行时不可能发生变化

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

  • 它维持了类的封装性。因为成分对象的内部细节是新对象看不见的,所以这种复用又称为“黑箱”复用

  • 新旧类之间的耦合度低。这种复用所需的依赖较少,新对象存取成分对象的唯一方法是通过成分对象的接口

  • 复用的灵活性高。这种复用可以在运行时动态进行,新对象可以动态地引用与成分对象类型相同的对象

一个合成优于继承的例子,就是汽车管理程序,采用继承,设计如下:

crp extend
Figure 9. 采用继承关系设计(图片来源网络)

采用继承关系设计,会产生大量子类,并且增加颜色和动力源都会修改代码,而采用复合关系就可以很好的解决问题:

crp composite
Figure 10. 采用复合关系设计(图片来源网络)

8. 总结

本篇介绍了面向对象软件设计需要遵守的7大原则,软件设计运用中应该尽量遵守。其中,开闭原则是基础,它告诉我们软件设计应该对扩展开放,对修改关闭;里氏替换原则告诉我们不要破坏继承体系;依赖倒置原则告诉我们要面向接口、抽象类编程;单一职责原则告诉我们实现类、方法、模块要职责单一;接口隔离原则告诉我们在设计接口的时候要拆分最小接口;迪米特法则告诉我们对依赖的类知道的越少越好,以降低耦合度;合成复用原则告诉我们要优先使用组合或者聚合关系复用对象,尽量少用继承关系复用。

Appendix A: 如何区分组合和聚合

聚合和组合都是依赖关系,只是将依赖具体化了。

聚合,表示两者可以单独存在,又可以相互依赖协同实现功能。例如,手机和手机壳是一种聚合关系,笔记本电脑和鼠标也是一种聚合关系。

组合,表示依赖双方不能单独存在,只能一起协同实现功能。例如,手机和手机屏幕是组合关系,汽车和发动机是聚合关系。

在Java中,类A聚合类B,那么通常的形式如下:

1
2
3
4
5
6
7
public class A {
private B b;

public void setB(B b) {
this.b = b;
}
}

或者

1
2
3
4
5
6
7
8
9
public class A {
private B b;

public A() {}

public A(B b) {
this.b = b;
}
}

而类A组合类B,那么通常的形式如下:

1
2
3
public class A {
private B b = new B();
}

或者

1
2
3
4
5
6
public class A {
private B b;
public A() {
b = new B();
}
}

Java中,聚合和组合的区分主要看在依赖类实例化时,被依赖类是否也会被实例化。如果是,则数组合关系,否则是聚合关系。

在UML类图中,表示依赖、聚合、组合等对象关系如下图所示:

uml class relationship
  • 泛化:即继承关系

  • 实现:即类实现接口

  • 关联:两者存在关联性

  • 聚合:聚合关系,如A聚合B,说明A依赖了B,A、B可以单独存在

  • 组合:组合关系,如A组合B,表示A、B组合关系,不可单独存在

  • 依赖:即使用到,如A依赖B,即是说A使用了B,是一种粗粒度的对象关系

支付宝打赏 微信打赏

赞赏是不耍流氓的鼓励