一、概述
设计模式笔者之前也学习过一遍,但是惭愧工作中只用到几种常用的模式,比如单例模式,工厂模式,装饰者模式等。自己回想起来,发现大部分都差不多忘记了,所以,笔者想把设计模式重新学习一遍,也顺便用文字记录学习的过程,与大家分享。这篇是设计模式的开篇,里面会讲几个常用的设计原则,也会用代码去体现这些设计原则。
二、设计原则
2.1 单一职责
定义:单一职责的英文全称是Single Responsibility Principle,简称SPR。
英文解释是:There should never be more than one reason for a class to change.
翻译过来就是,一个类只能有且仅仅有一个原因导致类的变更。
我们用一个例子说明下:
需求场景:设计一个手机,手机包含功能为打电话,挂电话,播放音乐功能。
public interface Imobile {
//打电话
public void call(String number);
//播放音乐
public void playMusic(Object o);
//挂断电话
public void hangup();
}
复制代码上面设计了一个Imobile的接口,声明了打电话,挂断,播放音乐的方法,我们初步看,觉得这么设计没什么问题,但是如果我们考虑单一职责的话,这个设计就有问题了,其实单一职责最难划分的就是职责,我们针对这个场景可以给这个电话分为两个职责,打电话和挂电话是属于协议管理的,播放音乐其实属于附属功能管理,所以这里的职责就划分了两个:1.协议管理;2.附属功能管理。那么单一职责的定义就是:一个类只能有且仅仅有一个原因导致类的变更。而上面这个接口中划分了两个职责,而且,协议的变动,附属功能的变动,都会导致接口和类改变,所以,这个接口就是不符合单一职责的。那么如何让其满足单一职责原则呢?我们需要拆分接口,因为协议管理和附属功能管理两个彼此并不互相影响,所以我们可以直接拆分为两个接口,如下:
//协议管理接口
public interface IMobileManager {
//打电话
public void call(String number);
//挂断电话
public void hangup();
}
//附属功能接口
public interface Ifunction {
public void playMusic(Object o);
}
复制代码这个时候很多人可能不理解,你这么做的好处是什么呢?我感觉不到这么做的好处啊。这里做一个假设,假设这个时候新增了一部高级手机,它可以保持会话,这个时候协议管理接口需要修改了,需要新增一个保持会话的功能,这个时候实现类也要跟着改变,如果采用第一种设计,那么所有的电话都要修改。如果有一个玩具手机,它并不会通话,这个时候也要修改这个实现类,这个设计就糟糕了。如果采用了单一职责,玩具手机并不会实现协议管理的接口,只会实现附属功能接口,所以协议管理的修改并不会导致玩具手机也要修改。
2.1.1 单一职责的好处
类的复杂度降低了,各个职责都有清晰明确的定义
提高了可读性,知道什么接口是干什么的
提高了可维护性,某个接口的修改不会导致无关类受影响。
2.1.2 单一职责的补充
其实单一职责并不只要求接口,方法也是,我们写一个方法要能清晰的定义这个方法的职责,比如修改用户信息最好就要写多个方法来实现,不要就只写一个方法。类似于这样:
public interface IUserSerivice {
void updateUserInfo(User user);
}
复制代码这种设计不清晰,我们应该针对每一个修改都有一个方法,类似于这样:
public interface IUserSerivice {
void updateUserName(String name,String id);
void updateUserTelPhone(String phone,String id);
void updateUserHomeAddr(String adrr,String id);
}
复制代码这样写虽然很啰嗦,但是职责很清晰,后续代码也好维护,直接就能知道更新了什么信息。
2.2 里氏替换
定义:里氏替换原则的英文全称:Liskov Substitution Principle ,简称LSP。
英文解释:Functions that user pointer or references to base classes must be able to use objects of derived classes without knowing it.
翻译:所有引用基类的地方都必须能透明的使用其子类对象。
其实理解这句话很简单,无非就是父类执行的方法,替换成子类也可以正确执行并且达到一样的效果。我们先写一个没有按照里氏替换原则的代码。
public class Father {
public void doSomeThing(Map map){
System.out.println("父类执行啦!");
}
}
public class Son extends Father{
public void doSomeThing(HashMap map) {
System.out.println("子类执行了!");
}
}
public class Client1 {
public static void main(String[] args) {
HashMap map=new HashMap();
Father father=new Father();
father.doSomeThing(map);
}
}
public class Client2 {
public static void main(String[] args) {
HashMap map=new HashMap();
Son son=new Son();
son.doSomeThing(map);
}
}
复制代码我们执行客户端main方法,发现结果输出为:“父类执行啦!”,我们采用子类替换父类执行doSomeThing() 方法,发现输出结果是:“子类执行了!”,这和父类执行的结果不一致,不符合里氏替换原则,这里为什么没有执行父类的方法呢?这里因为是子类重载了父类的方法,客户端调用的参数是HashMap,所以匹配到了子类的方法。那么我们如何修改就能满足里氏替换原则呢?其实很简单,两种方式。
第一,直接继承,不要重写父类的非抽象方法。
第二,我们重载方法的参数范围必须大于等于父类的范围。
第一个好理解,那第二个怎么理解呢?我们还是用上面那个例子改动下,代码如下:
public class Father {
public void doSomeThing(HashMap map){
System.out.println("父类执行啦!");
}
}
public class Son extends Father{
public void doSomeThing(Map map) {
System.out.println("子类执行了!");
}
}
这里其实就只把子类的参数类型改成了Map,父类的参数类型改成了HashMap,
这样客户端声明的参数类型是HashMap,所以调用 son.doSomeThing(map)只会执行父类的方法。
这里其实可以总结一句:里氏替换原则就是要求,不要重写父类的非抽象方法,尽量不要重载父类的方法,如果要重载,需要注意方法的前置条件(形参),如果要保持子类的个性化,可以采用新增方法的方式。
2.2.1 里氏替换原则的作用
其实最主要的作用就是降级继承的复杂度,增强代码的可维护性
2.3 依赖倒置
定义:依赖倒置英文全称为:Dependence Inversion Princiole,简称DIP。
英文解释:High level modules should not depend upon low level modules. Both should depend upon abstractions. Abstractions should not depend upon details. Details should depend upon abstractions。
官方翻译:高层模块不应该依赖低层模块,两者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象。
依赖倒置,我们用通俗的解释就是,平常我们生活中的依赖都是依赖具体细节,比如我要用手机就是具体的某个手机,用电脑就是用具体的某台电脑,这个依赖倒置就是和我们生活是反的,故称为倒置,所以依赖倒置就是依赖抽象(接口或者抽象类)。我们同样用一个例子来说明下:
我们实现一个司机开车的例子,我们可以抽象出2个接口,一个是司机接口,一个是汽车接口。
public interface ICar {
//开汽车方法
public void run();
}
public interface IDriver {
//开车
public void driver(ICar car);
}
//汽车实现类,宝马车
public class BmwCar implements ICar {
@Override
public void run() {
System.out.println("宝马车开动啦");
}
}
//司机实现类,C1驾照司机
public class COneDriver implements IDriver {
@Override
public void driver(ICar car) {
System.out.println("我是C1驾照司机");
car.run();
}
}
// 客户端场景类
public class Client {
public static void main(String[] args) {
ICar bmw=new BmwCar();
IDriver cOneDriver=new COneDriver();
cOneDriver.driver(bmw);
}
}
这里实现了C1驾照司机开宝马车的场景,这就是依赖倒置原则的写法,那如果我不采用依赖倒置会发生什么情况呢?不依赖倒置也就是说要依赖细节,以上场景就会出现C1驾照车司机只能开宝马车的情况,这显然是有问题的。
2.3.1 依赖倒置的规则
根据上面的例子以及我们的分析,我们可以总结出依赖倒置的几个规则:
每个类尽量都有接口或者抽象类,或者抽象类和接口两者都具备。
变量的表面类型尽量是接口或者是抽象类。
任何类都不应该从具体的实现类中派生
尽量不要重写基类的方法
2.4 迪米特法则
定义:迪米特法则(Law of Demeter,LoD)也称为最少知识原则(Least Knowledge Principle,LKP)
迪米特法则通俗的解释就是,一个类要对其所耦合的类了解的尽量少,不管耦合的类内部多么复杂,都只管其暴露的public方法。迪米特法则另外一种说法是,只和朋友类交流。朋友类的定义:出现在成员变量、方法的输入输出参数中的类称为成员朋友类,而出现在方法体内部的类不属于朋友类。我们先看一个违法迪米特法则的例子。
场景:我们吃饭要经过客户点菜,服务员下单,厨师做菜这三个流程,我们来用代码设计这个场景。
//厨师接口
public interface ICooker {
//根据订单做菜
public void cooke(List orders);
}
//服务员接口
public interface IWaiter {
//下单
public void doOrder(List dishNames);
}
// 订单实体类
public class Order {
private List dishNames;
public Order(List dishNames) {
this.dishNames = dishNames;
}
public List getDishNames() {
return dishNames;
}
public void setDishNames(List dishNames) {
this.dishNames = dishNames;
}
}
// 服务员实现类
public class ChineseWaiter implements IWaiter {
private ICooker cooker;
public ChineseWaiter(ICooker cooker) {
this.cooker = cooker;
}
@Override
public void doOrder(List dishNames) {
List cookOrders=new ArrayList<>();
cookOrders.add(new Order(dishNames));
cooker.cooke(cookOrders);
}
}
//厨师实现类
public class ChineseCooker implements ICooker {
@Override
public void cooke(List orders) {
for (int i = 0; i < orders.size(); i++) {
Order order=orders.get(i);
List dishNames=order.getDishNames();
for (int j = 0; j < dishNames.size(); j++) {
System.out.println("我是中餐厨师,我做:"+dishNames.get(j));
}
}
}
}
//场景类
public class Client {
public static void main(String[] args) {
IWaiter waiter=new ChineseWaiter(new ChineseCooker());
List dishNames=new ArrayList<>();
dishNames.add("红烧鱼块");
dishNames.add("宫保鸡丁");
waiter.doOrder(dishNames);
}
}
复制代码我们自己思考下,其实上述代码中,违法迪米特法则地方就是服务员的实现类,我们发现,服务员实现类ChineseWaiter在实现类中,和非朋友类产生了依赖,这个依赖就是Order类,我们再回顾下朋友类的定义:出现在成员变量、方法的输入输出参数中的类称为成员朋友类,Order类并不满足这个定义,所以它违反了迪米特法则。那么我们如何修改满足迪米特法则呢?我们只要修改服务员实现类和场景类即可,修改后的代码如下:
public interface IWaiter {
//下单
public void doOrder(List orders);
}
public class ChineseWaiter implements IWaiter {
private ICooker cooker;
public ChineseWaiter(ICooker cooker) {
this.cooker = cooker;
}
@Override
public void doOrder(List orders) {
cooker.cooke(orders);
}
}
public class Client {
public static void main(String[] args) {
IWaiter waiter=new ChineseWaiter(new ChineseCooker());
List dishNames=new ArrayList<>();
dishNames.add("红烧鱼块");
dishNames.add("宫保鸡丁");
List orders =new ArrayList<>();
orders.add(new Order(dishNames));
waiter.doOrder(orders);
}
}
复制代码这里把订单的封装丢给了场景类中,服务员只依赖他的朋友类厨师类就可以了。那么这个迪米特法则有什么作用呢?其实迪米特法则最主要的作用就是降低耦合,从而使得类的复用率得以提高。但是采用迪米特法则后就会导致产生了过多的中间类和跳转类,导致系统的复杂性提高,所以我们在使用该法则的时候要权衡利弊,还是那句话,没有最完美的设计,只有最合适的设计。
2.5 接口隔离
英文解释:Clients should not be forced to depend upon interfaces that they don’t use.The dependency of one class to another one should depend on the smallest possible interface.
官方翻译:客户端不应该依赖它不需要的接口。类间的依赖关系应该建立在最小的接口上。
接口隔离原则,其实可以理解为接口设计的粒度要尽量小,接口中的方法要尽量少。这里其实和单一职责很相识,但是有区别,单一职责是职责的划分要求,每个接口只要表述对应的职责就可以了。但是接口隔离一般是对应于某个模块调用,可能只用到某个接口的部分方法,可以更细分。举例说明:
还是以单一职责的例子,设计手机。之前的代码是分为了一个功能接口,一个协议管理接口。代码见单一职责部分。我们看看如果是用接口隔离还可以怎么设计。我们其实还可以对功能接口可以划分更细的粒度,比如最新的iPhone手机有faceId功能,三星手机有虹膜功能。那这个时候,我还是用一个功能接口,就会导致接口非常冗余,一个接口有faceid,虹膜,但是实际上有些手机并没有这些功能,那么我们就可以对功能接口进行拆分。拆分成这样:
public interface ISamFunction {
//虹膜功能
public void iris();
}
public interface IAppleFnction {
//faceId 功能
public void faceId();
}
然后如果有手机既有虹膜又有faceId功能,直接实现两个接口就可以了。这样就满足了接口隔离原则。
2.6 开闭原则
英文解释:Software entities like classes,modules and functions should be open for extension but closed for modifications
官方翻译:一个软件实体如类、模块和函数应该对扩展开放,对修改关闭
开闭原则,其实是一个总的原则,前面五种原则其实都是开闭原则的具体实现,它并没有一个具体的设计思路,只是要求我们对设计的类,方法等对扩展开放,对修改关闭。掌握了前面五种设计原则,其实也就掌握了开闭原则了,这里就不举例说明了。
三、总结
1.单一职责
接口,类,方法的划分要职责单一,不要写出一个万能的接口,类和方法,要按照职责,写出明确职责的接口,类和方法,这样可读性好,可维护性高。
2.里氏替换原则
父类出现的地方,子类就能出现。它要求,尽量不要重写父类的非抽象方法,尽量不要重载父类的方法。
3.依赖倒置原则
类之间的依赖要依赖抽象(接口或者抽象类),不要依赖具体的实现类,这样方便后续的扩展。
4.迪米特原则
不要关注类的内部实现,只关注其public方法,只和自己的朋友类交流。这个原则要求,类的耦合只能是朋友类,朋友类指的是,出现在成员变量、方法的输入输出参数中的类。
5.接口隔离
要保持接口尽可能的细粒度,不要依赖不相关的接口和方法。这样才能提高接口的复用率。
6.开闭原则
是一个总的原则,是对前面五种原则一个总结的抽象。要求我们设计对扩展开放,对修改关闭。这个并不是要求我们不能去修改,我们要根据实际情况,竟可能的进行少的修改,尽可能的保证修改影响的范围尽可能的小。
最后,上面六个设计原则,都是一种原则,并不是要求我们生搬硬套这几种原则去写代码,这几种思想我们要理解消化,根据项目实际情况去设计,去写代码,没有最好的设计,没有万能的设计,没有一成不变的设计,只有最合适的设计。这里,分享无印良品的著名设计师原研哉的一个设计理念:
这样就好
四、参考
《设计模式之蝉》
转自:
作者:木木匠
链接:https://juejin.im/post/5bd00a586fb9a05ce46a08f4
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。