本文集网络上文章及自己coding和理解的结果而来,是设计模式学习的开篇。
本文介绍设计模式的一些概念,分类,和设计原则。
概念:
设计模式(Design Patterns)是一套被反复使用、多数人知晓的、经过分类的、代码设计经验的总结。使用设计模式是为了可重用代码、让代码更容易被他人理解、保证代码可靠性。毫无疑问,设计模式于己于他人于系统都是多赢的,设计模式使代码编制真正工程化,设计模式是软件工程的基石,如同大厦的一块块砖石一样。项目中合理的运用设计模式可以完美的解决很多问题,每种模式在现在中都有相应的原理来与之对应,每一个模式描述了一个在我们周围不断重复发生的问题,以及该问题的核心解决方案,这也是它能被广泛应用的原因。
分类:
总共23种设计模式,分为三类:
创建型模式,共五种:工厂方法模式、抽象工厂模式、单例模式、建造者模式、原型模式。
结构型模式,共七种:适配器模式、装饰器模式、代理模式、外观模式、桥接模式、组合模式、享元模式。
行为型模式,共十一种:策略模式、模板方法模式、观察者模式、迭代子模式、责任链模式、命令模式、备忘录模式、状态模式、访问者模式、中介者模式、解释器模式。
设计原则:
- 开闭原则(Open Close Principle)
开闭原则就是说对扩展开放,对修改关闭。在程序需要进行拓展的时候,不能去修改原有的代码,实现一个热插拔的效果。所以一句话概括就是:为了使程序的扩展性好,易于维护和升级。想要达到这样的效果,我们需要使用接口和抽象类,后面的具体设计中我们会提到这点。
下面我们以简单工厂和工厂方法为例,体会下开闭原则:
- 简单工厂
/**
* 功能简述: 运算符抽象类,输入两个整数,返回结果
*
* @author
*/
public abstract class Operation {
protected int numA;
protected int numB;
public abstract int getResult();
}
/**
* 功能简述: 加法实现类
*
* @author
*/
public class OperationAdd extends Operation {
@Override
public int getResult() {
return numA + numB;
}
}
/**
* 功能简述: 减法实现类
*
* @author
*/
public class OperationSub extends Operation {
@Override
public int getResult() {
return numA - numB;
}
}
/**
* 功能简述: 简单工厂类
*
* @author
*/
public class OperationFactory {
public static Operation createOperate(String operate) {
Operation oper = null;
switch (operate) {
case "+":
oper = new OperationAdd();
break;
case "-":
oper = new OperationSub();
break;
}
return oper;
}
}
/**
* 功能简述: 客户端调用
*
* @author
*/
public class Client {
public static void main(String[] args) {
//输出3
Operation oper = OperationFactory.createOperate("+");
if (null != oper) {
oper.numA = 1;
oper.numB = 2;
System.out.println(oper.getResult());
}
//-----------------------------------------------------------------------
//输出-1
oper = OperationFactory.createOperate("-");
if (null != oper) {
oper.numA = 1;
oper.numB = 2;
System.out.println(oper.getResult());
}
}
}
上述的代码是简单工厂的一个例子,比较简单。下面我们需要新增一个乘法的实现类,我们需要怎么做?首先,需要写一个OperationMul继承Operation,实现父类中的getResult方法,返回乘积;其次,需要在工厂类OperationFactory中新增一段case语句,当case"*"号时返回OperationMul实现;最后,在client方法中测试实现。
/**
* 功能简述: 乘法实现类
*
* @author
*/
public class OperationMul extends Operation {
@Override
public int getResult() {
return numA * numB;
}
}
/**
* 功能简述: 简单工厂类,改造,新增了对*的处理
*
* @author
*/
public class OperationFactory {
public static Operation createOperate(String operate) {
Operation oper = null;
switch (operate) {
case "+":
oper = new OperationAdd();
break;
case "-":
oper = new OperationSub();
break;
case "*":
oper = new OperationMul();
break;
}
return oper;
}
}
/**
* 功能简述: 客户端调用
*
* @author
*/
public class Client {
public static void main(String[] args) {
//输出3
Operation oper = OperationFactory.createOperate("+");
if (null != oper) {
oper.numA = 1;
oper.numB = 2;
System.out.println(oper.getResult());
}
//-----------------------------------------------------------------------
//输出-1
oper = OperationFactory.createOperate("-");
if (null != oper) {
oper.numA = 1;
oper.numB = 2;
System.out.println(oper.getResult());
}
//-----------------------------------------------------------------------
//输出2
oper = OperationFactory.createOperate("*");
if (null != oper) {
oper.numA = 1;
oper.numB = 2;
System.out.println(oper.getResult());
}
}
}
我们分析下上面的代码,为了新增对"*"的处理,我们新增了一个类,同时修改了一个类,在这个例子中我们对新增开放,同时也对修改开放了。因为修改了简单工厂类,虽然加的代码不多,但为了验证代码的正确性,我们不得不回归测试"+","-"的使用场景。
- 工厂方法
工厂方法是工厂模式的变体,遵循了开闭原则,下面看代码。我们稍微简化下简单工厂的实现方法,一开始我们只有一个"+"实现类,之后我们再新增"-"实现类。
/**
* 功能简述: 运算符抽象类,输入两个整数,返回结果
*
* @author
*/
public abstract class Operation {
protected int numA;
protected int numB;
public abstract int getResult();
}
/**
* 功能简述: 加法实现类
*
* @author
*/
public class OperationAdd extends Operation {
@Override
public int getResult() {
return numA + numB;
}
}
/**
* 功能简述: 抽象工厂类,此类只定义接口,不定义实现,此为和简单工厂的区别之一
*
* @author
*/
public interface OperationFactory {
Operation createOperate();
}
/**
* 功能简述: 加法工厂类
*
* @author
*/
public class OperationAddFactory implements OperationFactory {
@Override
public Operation createOperate() {
return new OperationAdd();
}
}
/**
* 功能简述: 客户端调用
*
* @author
*/
public class Client {
public static void main(String[] args) {
OperationAddFactory addFactory = new OperationAddFactory();
Operation oper = addFactory.createOperate();
oper.numA = 1;
oper.numB = 2;
//输出3
System.out.println(oper.getResult());
}
}
下面同样,我们来新增一个减法实现类。
/**
* 功能简述: 减法实现类
*
* @author
*/
public class OperationSub extends Operation {
@Override
public int getResult() {
return numA - numB;
}
}
/**
* 功能简述:减法工厂类
*
* @author
*/
public class OperationSubFactory implements OperationFactory {
@Override
public Operation createOperate() {
return new OperationSub();
}
}
/**
* 功能简述: 测试实现
*
* @author
*/
public class Client {
/**
* 功能描述: <br>
*
* @param args
*/
public static void main(String[] args) {
OperationAddFactory addFactory = new OperationAddFactory();
Operation oper = addFactory.createOperate();
oper.numA = 1;
oper.numB = 2;
//输出3
System.out.println(oper.getResult());
OperationSubFactory subFactory = new OperationSubFactory();
oper = subFactory.createOperate();
oper.numA = 1;
oper.numB = 2;
//输出-1
System.out.println(oper.getResult());
}
}
同样,我们分析下工厂方法模式,我们新增了一个减法实现类,及减法工厂类,但并没有修改加法相关的任何代码,真正做到了开闭原则。至于工厂类的区别,那正是简单工厂和工厂方法的区别之一,在工厂方法这种实现中,不仅抽象了操作,还抽象了工厂。
读到这里,大家应该对开闭原则有个初步的印象了。
- 里氏代换原则(Liskov Substitution Principle)
里氏代换原则(Liskov Substitution Principle LSP)面向对象设计的基本原则之一。
里氏代换原则中说,任何基类可以出现的地方,子类一定可以出现。
LSP是继承复用的基石,只有当衍生类可以替换掉基类,软件单位的功能不受到影响时,基类才能真正被复用,而衍生类也能够在基类的基础上增加新的行为。里氏代换原则是对“开-闭”原则的补充。实现“开-闭”原则的关键步骤就是抽象化。而基类与子类的继承关系就是抽象化的具体实现,所以里氏代换原则是对实现抽象化的具体步骤的规范。
里氏替换原则包含以下4层含义:
- 子类可以实现父类的抽象方法,但是不能覆盖父类的非抽象方法。
- 子类中可以增加自己特有的方法。
- 当子类覆盖或实现父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松。
- 当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。
下面逐个阐述下:
- 子类可以实现父类的抽象方法,但是不能覆盖父类的非抽象方法。
在我们做系统设计时,经常会设计接口或抽象类,然后由子类来实现抽象方法,这里使用的其实就是里氏替换原则。子类可以实现父类的抽象方法很好理解,事实上,子类也必须完全实现父类的抽象方法,哪怕写一个空方法,否则会编译报错。
里氏替换原则的关键点在于不能覆盖父类的非抽象方法。父类中凡是已经实现好的方法,实际上是在设定一系列的规范和契约,虽然它不强制要求所有的子类必须遵从这些规范,但是如果子类对这些非抽象方法任意修改,就会对整个继承体系造成破坏。而里氏替换原则就是表达了这一层含义。
看一个例子:
public class C {
public int func(int a, int b){
return a+b;
}
}
public class C1 extends C{
@Override
public int func(int a, int b) {
return a-b;
}
}
public class Client{
public static void main(String[] args) {
C c = new C1();
//运行结果:2+1=1
System.out.println("2+1=" + c.func(2, 1));
}
}
上面的运行结果明显是错误的。类C1继承C,后来需要增加新功能,类C1并没有新写一个方法,而是直接重写了父类C的func方法,违背里氏替换原则,引用父类的地方并不能透明的使用子类的对象,导致运行结果出错。
- 子类中可以增加自己特有的方法。
在继承父类属性和方法的同时,每个子类也都可以有自己的个性,在父类的基础上扩展自己的功能。前面其实已经提到,当功能扩展时,子类尽量不要重写父类的方法,而是另写一个方法,所以对上面的代码加以更改,使其符合里氏替换原则,代码如下:
public class C {
public int func(int a, int b) {
return a + b;
}
}
public class C1 extends C {
public int func2(int a, int b) {
return a - b;
}
}
public class Client {
public static void main(String[] args) {
C1 c = new C1();
//运行结果:2-1=1
System.out.println("2-1=" + c.func2(2, 1));
}
}
- 当子类覆盖或实现父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松。
面向对象的编程,其中继承有个大原则,任何子类的对象都可以当成父类的对象使用。
如有父类为人类,可以使用一般的枪,有警察类,可以使用任何的枪。
class Person {
void shoot(SimpleGun simpleGun);
}
class Police extends Person {
void shoot(Gun gun);
}
其中SimpleGun extends Gun。
这样的话任何警察类的对象都可以被当做人类来使用。
也就是说警察类既然会使用任何的枪,当然可以使用一般的枪。
Person person = new Police();
person.shoot(simpleGun);
而如果反过来,普通人可以使用任何抢,警察只能使用一般枪。
class Person {
void shoot(Gun gun);
}
class Police extends Person {
void shoot(SimpleGun simpleGun);
}
这样的话就不合理了,既然警察是人类的一个子类,所以警察也是人类,既然是人类就应该能使用人类的方法,也就是使用任何的枪,可以根据上面的定义,反而警察类的能力还变小了。
所以,子类的能力必须大于等于父类,即父类可以使用的方法,子类都可以使用。
- 当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。
返回值也是同样的道理。
假设一个父类方法返回一个List,子类返回一个ArrayList,这当然可以。
如果父类方法返回一个ArrayList,子类返回一个List,就说不通了。
这里子类返回值的能力是比父类小的。
还有抛出异常的情况。
任何子类方法可以声明抛出父类方法声明异常的子类。
而不能声明抛出父类没有声明的异常。
这一切都是为了,任何子类的对象都可以当做父类使用。
- 依赖倒转原则(Dependence Inversion Principle)
这个是开闭原则的基础,具体内容:针对接口编程,依赖于抽象而不依赖于具体。
高层模块不应该依赖低层模块,二者都应该依赖其抽象;抽象不应该依赖细节;细节应该依赖抽象。
类A直接依赖类B,假如要将类A改为依赖类C,则必须通过修改类A的代码来达成。这种场景下,类A一般是高层模块,负责复杂的业务逻辑;类B和类C是低层模块,负责基本的原子操作;假如修改类A,会给程序带来不必要的风险。
将类A修改为依赖接口I,类B和类C各自实现接口I,类A通过接口I间接与类B或者类C发生联系,则会大大降低修改类A的几率。
依赖倒置原则基于这样一个事实:相对于细节的多变性,抽象的东西要稳定的多。以抽象为基础搭建起来的架构比以细节为基础搭建起来的架构要稳定的多。在Java中,抽象指的是接口或者抽象类,细节就是具体的实现类,使用接口或者抽象类的目的是制定好规范和契约,而不去涉及任何具体的操作,把展现细节的任务交给他们的实现类去完成。
依赖倒置原则的核心思想是面向接口编程,我们依旧用一个例子来说明面向接口编程比相对于面向实现编程好在什么地方。场景是这样的,母亲给孩子讲故事,只要给她一本书,她就可以照着书给孩子讲故事了。代码如下:
class Book{
public String getContent(){
return "很久很久以前有一个阿拉伯的故事……";
}
}
class Mother{
public void narrate(Book book){
System.out.println("妈妈开始讲故事");
System.out.println(book.getContent());
}
}
public class Client{
public static void main(String[] args){
Mother mother = new Mother();
mother.narrate(new Book());
}
}
运行结果:
妈妈开始讲故事
很久很久以前有一个阿拉伯的故事……
运行良好,假如有一天,需求变成这样:不是给书而是给一份报纸,让这位母亲讲一下报纸上的故事,报纸的代码如下:
class Newspaper{
public String getContent(){
return "林书豪38+7领导尼克斯击败湖人……";
}
}
这位母亲却办不到,因为她居然不会读报纸上的故事,这太荒唐了,只是将书换成报纸,居然必须要修改Mother才能读。假如以后需求换成杂志呢?换成网页呢?还要不断地修改Mother,这显然不是好的设计。原因就是Mother与Book之间的耦合性太高了,必须降低他们之间的耦合度才行。
我们引入一个抽象的接口IReader。读物,只要是带字的都属于读物:
interface IReader{
public String getContent();
}
Mother类与接口IReader发生依赖关系,而Book和Newspaper都属于读物的范畴,他们各自都去实现IReader接口,这样就符合依赖倒置原则了,代码修改为:
class Newspaper implements IReader {
public String getContent(){
return "林书豪17+9助尼克斯击败老鹰……";
}
}
class Book implements IReader{
public String getContent(){
return "很久很久以前有一个阿拉伯的故事……";
}
}
class Mother{
public void narrate(IReader reader){
System.out.println("妈妈开始讲故事");
System.out.println(reader.getContent());
}
}
public class Client{
public static void main(String[] args){
Mother mother = new Mother();
mother.narrate(new Book());
mother.narrate(new Newspaper());
}
}
运行结果:
妈妈开始讲故事
很久很久以前有一个阿拉伯的故事……
妈妈开始讲故事
林书豪17+9助尼克斯击败老鹰……
这样修改后,无论以后怎样扩展Client类,都不需要再修改Mother类了。这只是一个简单的例子,实际情况中,代表高层模块的Mother类将负责完成主要的业务逻辑,一旦需要对它进行修改,引入错误的风险极大。所以遵循依赖倒置原则可以降低类之间的耦合性,提高系统的稳定性,降低修改程序造成的风险。
采用依赖倒置原则给多人并行开发带来了极大的便利,比如上例中,原本Mother类与Book类直接耦合时,Mother类必须等Book类编码完成后才可以进行编码,因为Mother类依赖于Book类。修改后的程序则可以同时开工,互不影响,因为Mother与Book类一点关系也没有。参与协作开发的人越多、项目越庞大,采用依赖导致原则的意义就越重大。现在很流行的TDD开发模式就是依赖倒置原则最成功的应用。
传递依赖关系有三种方式,以上的例子中使用的方法是接口传递,另外还有两种传递方式:构造方法传递和setter方法传递,相信用过spring框架的,对依赖的传递方式一定不会陌生。在实际编程中,我们一般需要做到如下3点:
低层模块尽量都要有抽象类或接口,或者两者都有。
变量的声明类型尽量是抽象类或接口。
使用继承时遵循里氏替换原则。
依赖倒置原则的核心就是要我们面向接口编程,理解了面向接口编程,也就理解了依赖倒置。
- 接口隔离原则(Interface Segregation Principle)
这个原则的意思是:使用多个隔离的接口,比使用单个接口要好。
类A通过接口I依赖类B,类C通过接口I依赖类D,如果接口I对于类A和类B来说不是最小接口,则类B和类D必须去实现他们不需要的方法。
将臃肿的接口I拆分为独立的几个接口,类A和类C分别与他们需要的接口建立依赖关系。也就是采用接口隔离原则。
这个图的意思是:类A依赖接口I中的方法1、方法2、方法3,类B是对类A依赖的实现。类C依赖接口I中的方法1、方法4、方法5,类D是对类C依赖的实现。对于类B和类D来说,虽然他们都存在着用不到的方法(也就是图中红色字体标记的方法),但由于实现了接口I,所以也必须要实现这些用不到的方法。
可以看到,如果接口过于臃肿,只要接口中出现的方法,不管对依赖于它的类有没有用处,实现类中都必须去实现这些方法,这显然不是好的设计。如果将这个设计修改为符合接口隔离原则,就必须对接口I进行拆分。在这里我们将原有的接口I拆分为三个接口,拆分后的设计下图所示:
接口隔离原则的含义是:建立单一接口,不要建立庞大臃肿的接口,尽量细化接口,接口中的方法尽量少。也就是说,我们要为各个类建立专用的接口,而不要试图去建立一个很庞大的接口供所有依赖它的类去调用。本文例子中,将一个庞大的接口变更为3个专用的接口所采用的就是接口隔离原则。在程序设计中,依赖几个专用的接口要比依赖一个综合的接口更灵活。接口是设计时对外部设定的“契约”,通过分散定义多个接口,可以预防外来变更的扩散,提高系统的灵活性和可维护性。
说到这里,很多人会觉的接口隔离原则跟之前的单一职责原则(后面会细说)很相似,其实不然。其一,单一职责原则原注重的是职责;而接口隔离原则注重对接口依赖的隔离。其二,单一职责原则主要是约束类,其次才是接口和方法,它针对的是程序中的实现和细节;而接口隔离原则主要约束接口接口,主要针对抽象,针对程序整体框架的构建。
采用接口隔离原则对接口进行约束时,要注意以下几点:
- 接口尽量小,但是要有限度。对接口进行细化可以提高程序设计灵活性是不挣的事实,但是如果过小,则会造成接口数量过多,使设计复杂化。所以一定要适度。
- 为依赖接口的类定制服务,只暴露给调用的类它需要的方法,它不需要的方法则隐藏起来。只有专注地为一个模块提供定制服务,才能建立最小的依赖关系。
- 提高内聚,减少对外交互。使接口用最少的方法去完成最多的事情。
运用接口隔离原则,一定要适度,接口设计的过大或过小都不好。设计接口的时候,只有多花些时间去思考和筹划,才能准确地实践这一原则。
- 迪米特法则(最少知道原则)(Demeter Principle)
为什么叫最少知道原则,就是说:一个实体应当尽量少的与其他实体之间发生相互作用,使得系统功能模块相对独立。
狭义的迪米特法则定义:也叫最少知识原则(LKP,Least Knowledge Principle)。如果两个类不必彼此直接通信,那么这两个类就不应当发生直接的相互作用。如果其中的一个类需要调用另一个类的某一个方法的话,可以通过第三者转发这个调用。
广义的迪米特法则定义:一个模块设计得好坏的一个重要的标志就是该模块在多大的程度上将自己的内部数据与实现有关的细节隐藏起来。信息的隐藏非常重要的原因在于,它可以使各个子系统之间脱耦,从而允许它们独立地被开发、优化、使用阅读以及修改。
比较形象的说明这个法则的示例:
如果你想让你的狗狗跑的话,你会对狗狗说还是对四条狗腿说?
如果你去店里买东西,你会把钱交给店员,还是会把钱包交给店员让他自己拿?
《设计模式其实很简单》这本书中给了一个例子,如下:
//某人类
public class Somebody{
//参数Friend类的方法
public void operation1(Friend friend){
Stranger stranger=friend.provide();
stranger.operation3();
}
}
//朋友类Friend
public class Friend{
//私有数据成员,某个陌生人
private Stranger stranger=new Stranger();
public void operation2(){
}
public Stranger provide(){
return stranger;
}
}
上面的代码违反了迪米特法则,因为如果要给Somebody定义一个它的直接关联类(即一个Friend的话),那么这个类应该是Friend类,因为Somebody的一个方法需要Friend类作为参数,那么这样以来,Stranger类就不是Somebody的直接关联类,但是上面的Somebody的operation1方法却直接调用了Stranger类的operation3方法,所以这违反了迪米特法则。
所以应该在Friend类中加一个方法,它执行了对Stranger类的operation3方法的调用。然后在Somebody的operation1方法中有friend调用加入的那个方法,从而实现和上面代码相同的功能。
代码如下:
//朋友类
public class Friend{
private Stranger stranger=new Stranger();
pubic void operation2(){
}
public void provide(){
return stranger;
}
pubic void forward(){
stranger.operation3();
}
}
//某人类
public class Somebody{
public void operation1(Friend friend){
friend.forward();
}
}
- 合成复用原则(Composite Reuse Principle)
原则是尽量使用合成/聚合的方式,而不是使用继承。
- 对象的继承关系在编译时就定义好了,所以无法在运行时改变从父类继承的子类的实现
- 子类的实现和它的父类有非常紧密的依赖关系,以至于父类实现中的任何变化必然会导致子类发生变化
- 当你复用子类的时候,如果继承下来的实现不适合解决新的问题,则父类必须重写或者被其它更适合的类所替换
这种依赖关系限制了灵活性,并最终限制了复用性