2019年2月26日19:41:21
设计模式六大原则
为什么会有六大原则
有言曰,“无规矩不成方圆”,有“规”才能画“圆”,那设计模式要遵循的六大原则要画一个什么的“圆”呢?
这里要从面向对象编程说起,从面向过程编程到面向对象编程是软件设计的一大步,封装、继承、多态是面向对象的三大特征,本来这些都是面向对象的好处,但是一旦有人滥用了,就有了坏味道。
比如,封装是隐藏对象的属性和实现细节的,我想到了还没提倡MVC的时候,一个servlet里的doGet、doPost方法就完成了所有事情,业务逻辑、数据持久化、页面渲染等,这样一来我们需要修改业务逻辑的时候是修改这个servlet,需要修改数据持久化的是修改这个servlet,甚至页面修改也是修改这个servlet。这样可维护性就很差了。
因为滥用或者不正确的时候导致代码的坏味道,导致系统的可维护性和复用性等变低,所以面向对象需要遵循一些原则make the code better。如:一个servlet干所有事情可以改为MVC,每一层的类做自己负责的事情,遵循单一职责原则。
为了提高系统的可维护性、复用性和高内聚低耦合等,所以有了六大原则。因为设计模式是面向对象实践出来的经验,所以这六大原则既是面向对象的六大原则,也是设计模式的六大原则。
六大原则
先来个图,总体感受一下,其实说简单也简单,死记硬背这六个名词不用十分钟,但是要使用得游刃有余,还是要下一点功夫的。本文也只是纸上谈兵,聊聊六大原则的定义、用法、好处等。
单一职责原则(Single Responsibility Principle,SRP)
定义:不要存在多于一个导致类变化的原因(There should never be more than one reason for a class to change.)。
就像我前面说到的那个例子,一个servlet干完了所有事情,这样导致servlet变化的原因就不止一个了,所以要将这些事情分给不同的类。
比如我现在要实现一个登录的功能,servlet代码是这样的:
public class LoginServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
// 1、获取前端传过来的数据
// 2、连接数据库,查询数据
// 3、比较数据,得出结果
// 4、封装结果,返回给前端
}
}
应用MVC后,代码修改如下:
public class LoginController {
private LoginService loginService;
public ModelAndView Login(HttpServletRequest req, HttpServletResponse resp) {
// 1、获取前端传过来的数据
loginService.login();
// 4、封装结果,返回给前端
return null;
}
}
public class LoginService {
private UserDao userDao;
public boolean login() {
userDao.findOne();
// 3、比较数据,得出结果
return false;
}
}
public class UserDao {
public User findOne(){
// 2、连接数据库,查询数据
return null;
}
}
这样职责分明,有变更需求只需要找到职责相关的那部分修改就好。比如要修改比较逻辑,就修改Service层代码;要修改连接数据库,就修改Dao层就可以了;要修改返回页面的内容,就修改Controller层就可以了。
应用场景:在项目开始阶段要明确类的职责,如果发现一个类有两个或以上的职责,那就拆成多个类吧。如果是项目后期,要评估好修改的代价之后在重构。别让一个类做的事情太多。
好处:实现高内聚、低耦合,增加代码的复用性。
开闭原则(Open Closed Principle, OCP)
定义:软件实体,如:类、模块与函数,对于扩展开放,对修改关闭(Software entities like classes, modules and functions should be open for extension but closed for modifications.)。
从简单工厂模式到工厂方法模式,完美诠释了开闭原则的应用场景。有兴趣可以查看本人所写的《简单工厂模式》和《工厂方法模式》。
用类对象实现操作符运算:
简单工厂模式实现:
public static IOperation createOperation(String op) {
IOperation operation = null;
if ("+".equals(op)) {
operation = new AddOperationImpl();
} else if ("-".equals(op)) {
operation = new SubOperationImpl();
} else if ("*".equals(op)) {
operation = new MulOperationImpl();
} else if ("/".equals(op)) {
operation = new DivOperationImpl();
}
return operation;
}
这是简单工厂模式中的工厂角色实现创建所有实例的内部逻辑的方法,调用方法时,根据传进来的操作符选择不同的实现类,但是如果我要添加一个乘方的话,就需要添加else if结构,没有对修改关闭,这样就不符合开闭原则了。
工厂方法模式实现:
// 加
// 创建具体工厂
IOperationFactory operationFactory = new AddOperationFactoryImpl();
// 创建具体产品
IOperation operation = operationFactory.createOperation();
// 调用具体产品的功能
int result = operation.getResult(a, b);
需要什么运算,就继承IOperationFactory实现对应的实现类,使用时只需要在需要的地方new这个实现类即可。不用修改工厂类,增加运算就增加抽象工厂类的实现类,符合开闭原则。
应用场景:在系统的任何地方
好处:使得系统在拥有适应性和灵活性的同时具备较好的稳定性和延续性
里氏替换原则(Liskov Substitution Principle,LSP)
定义:使用基类的指针或引用的函数,必须是在不知情的情况下,能够使用派生类的对象(Functions that use pointers or references to base classes must be able to use objects of derived classes whithout knowing it.)。
为什么叫里氏替换原则? 里氏代换原则由2008年图灵奖得主、美国第一位计算机科学女博士Barbara Liskov教授和卡内基·梅隆大学Jeannette Wing教授于1994年提出。
里氏替换原则告诉我们,在软件中将一个基类对象替换成它的子类对象,程序将不会产生任何错误和异常,反过来则不成立,如果一个软件实体使用的是一个子类对象的话,那么它不一定能够使用基类对象。例如,我喜欢动物,那我一定喜欢狗,因为狗是动物的子类;但是我喜欢狗,不能据此断定我喜欢动物,因为我并不喜欢老鼠,虽然它也是动物。
上面叙述转为代码如下:
// 动物
public interface Animal {
public String getName();
}
// 狗
public class Dog implements Animal{
private String name = "狗";
@Override
public String getName() {
return this.name;
}
}
// 老鼠
public class Mouse implements Animal{
private String name = "老鼠";
@Override
public String getName() {
return this.name;
}
}
// 测试类
public class ISPTest {
public static void main(String[] args) {
Animal dog = new Dog();
Animal mouse = new Mouse();
iLoveAnimal(dog);
iLoveAnimal(mouse);
// iLoveDog(dog);
// iLoveDog(mouse);
}
public static void iLoveAnimal(Animal animal) {
System.out.println(animal.getName());
}
public static void iLoveDog(Dog dog) {
System.out.println(dog.getName());
}
}
其中iLoveAnimal(Animal animal)可以传递的对象参数有Dog和Mouse,因为Dog和Mouse是Animal的子类,所以编译通过。iLoveDog(Dog dog)不能Mouse为参数,虽然他们同属Animal的子类,编译不能通过。在编译阶段,Java编译器会检查一个程序是否符合里氏替换原则,但是Java编译器的检查是有局限的,这只是一个与实现无关、纯语法意义上的检查,在设计上我们要注意遵循里氏替换原则。
理论上,里氏替换原则是实现开闭原则的重要方式之一,使用基类对象的地方都可以使用子类对象,所以在程序中尽量使用基类类型来对对象进行定义,而在运行时在确定其子类类型,当子类类型改变时,就能实现扩展,并没有对现有的代码结构进行修改。
实践中,我们能做的是:
尽量将基类设计为抽象类和接口
子类必须实现父类中声明的所有方法,且子类的所有方法必须在基类中声明
最少知识原则(Least Knowledge Principle,LKP)又名迪米特法则(Law of Demeter)
定义:只与你最直接的朋友交流(Only talk to you immediate friends.)。
又名迪米特法则的原因是:迪米特法则来自于1987年美国东北大学(Northeastern University)一个名为“Demeter”的研究项目。
根据迪米特法则有,对象O
的一个方法M
仅能访问以下类型的对象:
1、当前对象
O
自身(this)2、
M
方法的参数对象(如,toString(Integer i)中对象i
)3、当前对象
O
成员对象(当前对象O
直接依赖的对象)4、
M
方法中所创建的对象
重要的是,方法M
不应该调用这些方法返回对象的方法,就是链式调用返回的但返回的并不是自身对象的对象的方法。和你朋友说话,而不是和你朋友的朋友,对于你来说是陌生人的人说话。
下面是一个例子:
public class LawOfDelimterDemo {
/**
* 这个方法有两个违反最少知识原则或迪米特法则的地方。
*/
public void process(Order o) {
// 这个方法调用符合迪米特法则,因为o是process方法的参数,是类型2的参数
Message msg = o.getMessage();
// 这个方法调用违反了迪米特法则,因为使用了msg对象,这个对象是从参数对象中得到的对象。
// 我们应该让Order去normalize这个Message,例如:o.normalizeMessage(),而不是使用msg对象的方法
msg.normalize();
// 这也是违反迪米特法则的,使用了方法链代替上面说的msg临时变量。
o.getMessage().normalize();
// 构造函数调用
Instrument symbol = new Instrument();
// 这个方法调用是符合迪米特法则的,因为Instrument实例是本地创建的,就是类型4的对象,process方法中所创建的对象
symbol.populate();
}
}
好处:降低系统的耦合性,增加系统的可维护性和适应性。因为较少依赖于其他对象的内部结构,其他对象的修改就重新修改它们的调用者。
坏处:可能会增加对象的方法,引发其他bug。
接口隔离原则(Interface Segregation Principle, ISP)
定义:一个类与另外一个类之间的依赖性,应该依赖于尽可能小的接口(The dependency of one class to another one should depend on the smallest possible interface.)。
例子:首先有一个经理,负责管理工人。其次,有两种类型的工人,一种是在平均水平的工人,一种是高效率的工人,这些工人都需要午休时间来吃饭。最后还有一种机器人在工作,但是机器人不需要午休。
设计实现代码:
interface IWorker {
public void work();
public void eat();
}
// 一般工人
class Worker implements IWorker {
public void work() {
// 正常工作
}
pubic void eat() {
// 午休吃饭
}
}
// 高效率工人
class SuperWorker implements IWorker {
public void work() {
// 高效率工作
}
public void eat() {
// 午休吃饭
}
}
// 机器人
class Rebot implements IWorker {
public woid work() {
// 工作
}
public void eat() {
// (实现代码为空,什么也不做)
}
}
class Manager {
IWorker worker;
public void setWorker(IWorker w) {
worker = w;
}
public void manage() {
worker.work();
worker.eat();
}
}
经理去管理工人的时候,调用接口eat方法的时候,机器人什么也不做。我们应该让接口变小,把IWorker接口拆分。
// 工作接口
interface IWorkable {
public void work();
}
// 吃饭接口
interface IFeedable {
public void eat();
}
// 一般工人
class Worker implements IWorkable, IFeedable {
public void work() {
// 正常工作
}
pubic void eat() {
// 午休吃饭
}
}
// 高效率工人
class SuperWorker implements IWorkable, IFeedable {
public void work() {
// 高效率工作
}
public void eat() {
// 午休吃饭
}
}
// 机器人
class Rebot implements IWorkable {
public woid work() {
// 工作
}
}
class Manager {
IWorkable worker;
IFeedable feed;
public void setWorker(IWorkable w) {
worker = w;
}
public void setfeed(IFeedable f) {
feed = f;
}
public void manageWork() {
worker.work();
}
public void manageFeed() {
feed.eat();
}
}
将IWorker接口拆分成IWorkable接口和IFeedable接口,将Manager类与工人类交互尽量依赖与比较小的接口。
在使用接口隔离原则时,我们需要注意控制接口的粒度,接口不能太小,如果太小会导致系统中接口泛滥,不利于维护;接口也不能太大,太大的接口将违背接口隔离原则,灵活性较差,使用起来很不方便。一般而言,接口中仅包含为某一类用户定制的方法即可,不应该强迫客户依赖于那些它们不用的方法。
依赖倒置原则(Dependence Inversion Principle, DIP)
定义:高层模块不应该依赖于底层模块,它们都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象(High level modules should not depends upon low level modules. Both should depend upon abstractions. Abstractions should not depend upon details. Details should depend upon abstractions.)。
尽量面对接口编程,而不是面对实现编程。
例子:你现在是一个导演,你要拍一部电影,准备找刘德华做主角。在电影里,刘德华是一个警察,可以捉犯人。
// 刘德华
class LiuDeHua {
public LiuDeHua(){}
public void catchPrisoner(){}
}
// 剧本
class Play {
LiuDeHua liuDeHua = new LiuDeHua();
liuDeHua.catchPrisoner();
}
但是华仔因为档期来不了,于是找古天乐。
// 古天乐
class GuTianLe {
public GuTianLe(){}
public void catchPrisoner(){}
}
// 剧本
class Play {
GuTianLe guTianLe = new GuTianLe();
guTianLe.catchPrisoner();
}
古仔说要捐钱建学校,没空来。于是又说要找刘青云,编剧心好累……
如果编剧只是面对接口编程,就会变成这样:
// 警察
interface Police {
public void catchPrisoner();
}
// 剧本
class Play {
private Police police;
public Play(Police p) {
police = p;
}
police.catchPrisoner();
}
这样无论谁来,只需要实现Police接口就可以按剧本拍了。
在实现依赖倒置原则时,我们需要针对抽象层编程,而将具体类的对象通过依赖注入(DependencyInjection, DI)的方式注入到其他对象中,依赖注入是指当一个对象要与其他对象发生依赖关系时,通过抽象来注入所依赖的对象。
常用的注入方式有三种,分别是:构造注入,设值注入(Setter注入)和接口注入。构造注入是指通过构造函数来传入具体类的对象,设值注入是指通过Setter方法来传入具体类的对象,而接口注入是指通过在接口中声明的业务方法来传入具体类的对象。这些方法在定义时使用的是抽象类型,在运行时再传入具体类型的对象,由子类对象来覆盖父类对象。
在大多数情况下,开闭原则、里氏替换原则和依赖倒置原则这三个设计原则会同时出现,开闭原则是目标,里氏代换原则是基础,依赖倒置原则是手段,它们相辅相成,相互补充,目标一致,只是分析问题时所站角度不同而已。
参考
Law of Demeter in Java - Principle of least Knowledge
面向对象设计原则之依赖倒转原则
2019年3月22日14:31:03