设计模式为面向对象软件设计的前进道路照亮了明灯,而设计模式遵循面向对象软件设计的七大原则,这些原则也是设计模式的基础。
1. 单一职责原则
单一职责原则(Single Responsibility),它的定义是:对类来说的,一个类应该只负责一项职责。即是说,一个类只负责一种职责,让其职责单一化,如果有其他的职责则应该设计另外的类来进行协助。比如:订单处理类只负责处理订单,而不该去处理支付的逻辑。单一职责原则也可以引申到方法、类、模块甚至服务和子系统中。
类的职责单一,并不是说一个类只有一个方法,而是说一个类只承担实现一中业务职责,如:OrderService只负责处理订单相关的逻辑,而不会涉及支付相关的逻辑。 |
下面来看看具体的例子。假设我们设计一个Animal类,来管理动物的飞行、陆行、游水等动作。
1.1. 基础示例
这个版本的设计如下图所示:
初版的代码如下:
// 一个类负责动物的飞行方法
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
:
// 小鸟
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 "狗";
}
}
// ……其他动物
调用方的代码如下:
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
类的职责过多,那么多我们就需要来进行拆分,使其职责明确和单一。
我们只需要将原来的 Animal
按照行为拆分为更具体的子类,使子类来负责某一种行为即可,比如 FlyAnimal
表示能飞行的动物,而 LandAnimal
表示在陆地行走的动物等等。
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
:
class Bird extends FlyAnimal {
@Override
public String getName() {
return "小鸟";
}
}
然后,适用方在调用时就非常明确了,比如 Bird
实例现在只能调用 fly()
方法,没有其他的方法了,是不是清晰又明了呢?
2. 开闭原则
开闭原则(Open Closed Principle)是软件设计的最基础、最重要的原则,它的定义是:系统的模块、函数或类,应该对修改关闭,对扩展开放。
注意理解的关闭和开放,它们是对不同的角色而言的。修改关闭,指的是对使用者关闭修改,即使用该类、函数或者模块的一方是不需要修改自身代码的;扩展开发,是对提供方开放扩展,即提供该类、函数或者模块的一方要保证对其提供的功能的扩展性(功能扩展,而不是修改源代码)。
2.1. 基础示例
来看一个例子:我们编写一个听动物鸣叫声的程序,代码如下:
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()
方法来收听,代码如下:
// 客户端
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
需要更改代码才能使用新的功能,这违背对修改关闭的原则。
调用者代码如下:
public static void main(String[] args) {
Client client = new Client();
client.sound(1);
client.sound(2);
client.sound(3);
}
2.2. 示例改进
我们看看如何改进,是的客户端在提供方新增了动物的时候,不需要修改自身代码。
其他代码不需要变动,只需要将 Client
代码修改:
// 客户端
class Client {
// 听动物的叫声,新增动物不影响客户端代码
public void sound(AnimalCall animal) {
animal.call();
}
}
然后,调用方代码更改如下:
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方法,如果企鹅继承了鸟类,但是企鹅不会飞,因此这个继承关系违反了里氏替换原则(将企鹅换成鸟类不合适)。
我们来看看这个示例,初版的设计如下:
这里设计了Bird
超类,WildGoose
和 Penguin
子类,构成继承关系,大雁可以飞,但是企业是不能飞的,所以它必须覆盖父类的 setSpeed()
方法。代码如下:
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);
}
}
这种设计,客户端在调用时就会出现错误:
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
在进行深层次抽象即可。
将 Bird
向上在抽象出一个 Animal
类,只有真正能飞的动物才继承 Bird
,其他动物直接继承 Animal
即可。
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. 基础示例
前边的开闭原则示例已经很好的说明了依赖倒转原则了。我们再看一个例子:现在我们设计一个消息接收程序,能够接收各种消息。初版的设计如下:
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
只需要依赖接口即可,实现了代码解耦。
这样依赖,新的消息只需要实现 Message
接口,而 Client
不需要做任何改变。
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.马丁给“接口隔离原则”的定义是:
客户端不应该被迫依赖于它不使用的方法(Clients should not be forced to depend on methods they do not use)
一个类对另一个类的依赖应该建立在最小的接口上(The dependency of one class to another one should depend on the smallest possible interface)。
以上两个定义的含义是:要为各个类建立它们需要的专用接口,而不要试图去建立一个很庞大的接口供所有依赖它的类去调用。
接口隔离和单一职责 接口隔离原则跟单一原则存在一定的共性,他们都是为了提高类的内聚性,降低类的耦合性。他们的区别在于:单一职责强调的是类的职责,范围更大;而接口隔离针对的是具体的接口应该怎么设计,范围要小。另外,从本义理解,单一职责针对的是具体的实现类,不过我认为,接口的设计首先也应该符合单一职责原则,然后再考虑怎么进行接口隔离。 |
前边单一职责原则一节的例子,如果将抽象类 Animal
改为接口,我们看看会怎么样。设计如下:
按照这样的设计,每一个动物都要实现 Animal
接口的这些方法,即使根本用不到。你可能会说,我直接用默认接口实现就行了呗:
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 "大雁";
}
}
但是,实现类同样具备了这些自己根本不关心的方法,这违背了接口隔离原则,设计的还需要细粒度拆分。改进后的设计如下:
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)
的方法,来修改订单,PayService
的 paidSuccessfully
方法先查询订单,然后更改订单状态,再调用 OrderService
更新订单,类图如下:
public void paidSuccessfully(String orderNo) {
updatePayRecordStatus();
Order order = orderService.getOrder(orderNo); (1)
order.setStatus("PAID");
orderService.updateOrder(order);
}
1 | 局部变量 Order ,它不是 PayService 的直接朋友,而是方法内部变量,这违反了迪米特法则。 |
由于 OrderService
中,Order
类是它的直接朋友,那么应该将更新订单状态的动作交给 OrderService
了来完成。改进的设计如下:
在 OrderService
增加修改状态的方法:
public void updateOrderStatus(String orderNo, String status) {
Order order = getOrder(orderNo);
order.setStatus(status);
System.out.println("update pay record status");
}
然后 PayService
不需要与 Order
产生依赖:
public void paidSuccessfully(String orderNo) {
updatePayRecordStatus();
// 改进:将更新订单状态的方法放到OrderService中
orderService.updateOrderStatus(orderNo, "PAID");
}
7. 合成复用原则
合成复用原则(Composite Reuse Principle,CRP)又叫组合/聚合复用原则(Composition/Aggregate Reuse Principle,CARP)。它要求在软件复用时,要尽量先使用组合或者聚合等关联关系来实现,其次才考虑使用继承关系来实现。如果要使用继承关系,则必须严格遵循里氏替换原则。合成复用原则同里氏替换原则相辅相成的。
通常类的复用分为继承复用和合成复用两种,合成即通常所说的依赖,又分为聚合和组合两种。
继承复用虽然有简单和易实现的优点,但它也存在以下缺点:
继承复用破坏了类的封装性。因为继承会将父类的实现细节暴露给子类,父类对子类是透明的,所以这种复用又称为“白箱”复用
子类与父类的耦合度高。父类的实现的任何改变都会导致子类的实现发生变化,这不利于类的扩展与维护
它限制了复用的灵活性。从父类继承而来的实现是静态的,在编译时已经定义,所以在运行时不可能发生变化
采用组合或聚合复用时,可以将已有对象纳入新对象中,使之成为新对象的一部分,新对象可以调用已有对象的功能,它有以下优点:
它维持了类的封装性。因为成分对象的内部细节是新对象看不见的,所以这种复用又称为“黑箱”复用
新旧类之间的耦合度低。这种复用所需的依赖较少,新对象存取成分对象的唯一方法是通过成分对象的接口
复用的灵活性高。这种复用可以在运行时动态进行,新对象可以动态地引用与成分对象类型相同的对象
一个合成优于继承的例子,就是汽车管理程序,采用继承,设计如下:
采用继承关系设计,会产生大量子类,并且增加颜色和动力源都会修改代码,而采用复合关系就可以很好的解决问题:
8. 总结
本篇介绍了面向对象软件设计需要遵守的7大原则,软件设计运用中应该尽量遵守。其中,开闭原则是基础,它告诉我们软件设计应该对扩展开放,对修改关闭;里氏替换原则告诉我们不要破坏继承体系;依赖倒置原则告诉我们要面向接口、抽象类编程;单一职责原则告诉我们实现类、方法、模块要职责单一;接口隔离原则告诉我们在设计接口的时候要拆分最小接口;迪米特法则告诉我们对依赖的类知道的越少越好,以降低耦合度;合成复用原则告诉我们要优先使用组合或者聚合关系复用对象,尽量少用继承关系复用。
Appendix A: 如何区分组合和聚合
聚合和组合都是依赖关系,只是将依赖具体化了。
聚合,表示两者可以单独存在,又可以相互依赖协同实现功能。例如,手机和手机壳是一种聚合关系,笔记本电脑和鼠标也是一种聚合关系。
组合,表示依赖双方不能单独存在,只能一起协同实现功能。例如,手机和手机屏幕是组合关系,汽车和发动机是聚合关系。
在Java中,类A聚合类B,那么通常的形式如下:
public class A {
private B b;
public void setB(B b) {
this.b = b;
}
}
或者
public class A {
private B b;
public A() {}
public A(B b) {
this.b = b;
}
}
而类A组合类B,那么通常的形式如下:
public class A {
private B b = new B();
}
或者
public class A {
private B b;
public A() {
b = new B();
}
}
Java中,聚合和组合的区分主要看在依赖类实例化时,被依赖类是否也会被实例化。如果是,则数组合关系,否则是聚合关系。
在UML类图中,表示依赖、聚合、组合等对象关系如下图所示:
泛化:即继承关系
实现:即类实现接口
关联:两者存在关联性
聚合:聚合关系,如A聚合B,说明A依赖了B,A、B可以单独存在
组合:组合关系,如A组合B,表示A、B组合关系,不可单独存在
依赖:即使用到,如A依赖B,即是说A使用了B,是一种粗粒度的对象关系