设计模式

1. 设计模式原则

设计模式原则,其实就是程序员在编译时,应当遵守的原则,也就是各种设计模式的基础(即:设计模式为什么这样设计的依据)

标记 设计模式原则名称 简单定义
OCP 开闭原则 对扩展开放,对修改关闭
SRP 单一职责原则 一个类只负责一个功能领域中的相应职责
LSP 里氏代换原则 所有引用基类的地方必须能透明地使用其子类的对象
DIP 依赖倒转原则 依赖于抽象,不能依赖于具体实现
ISP 接口隔离原则 类之间的依赖关系应当建立在最小的接口上
CARP 合成/聚合复用原则 尽量使用合成/聚合,而不是通过继承达到复用的目的
LOD 迪米特法则 一个软件十一应当尽可能少的与其他实体发生相互作用

七大原则之间并不是相互孤立的,彼此间存在着一定关联,一个可以是另一个原则的加强或是基础。违反其中的某一个,可能同时违反了其余的原则。

开闭原则是面向对象的可复用设计的基石。其他设计原则是实现开闭原则的手段和工具。

一般地,可以把这七个原则分成了以下两个部分:

flowchart LR 面向设计原则-->设计目标; 面向设计原则-->设计方法; 设计目标-->开闭原则; 设计目标-->里氏替换原则; 设计目标-->迪米特原则; 设计方法-->单一职责原则; 设计方法-->接口分隔原则; 设计方法-->依赖倒置原则; 设计方法-->组合/聚合复用原则;

开闭原则(Open Close Principle,OCP)

  • 对扩展开放,对更改关闭
  • 类模块应该是可扩展的,但是不可修改。

开闭原则要求通过扩展来满足需求的变化,而不是更改。

里氏代换原则(Liskov Substitution Principle, LSP)

  • 子类必须能够替换它们的基类(IS-A)
  • 继承表达类型抽象

迪米特原则(Law of Demeter, LoD)

迪米特原则是1987年秋天由lan holland在美国东北大学一个叫做迪米特的项目设计提出的,它要求一个对象应该对其他对象有最少的了解,所以迪米特法则又叫做最少知识原则(Least Knowledge Principle, LKP)。

单一职责原则(Single Responsibility Principle, SRP)

  • 一个类应该仅有一个引起它变化的原因
  • 变化的方向隐含着类的责任

接口分离原则(Interface Segregation Principle,ISP)

  • 不应该强迫客户程序依赖它们不用的方法
  • 接口应该小而完备

依赖倒置原则(Dependence Inversion Principle, DIP)

  • 高层模块(稳定)不应该依赖于低层模块(变化),二者都应该依赖于抽象(稳定)
  • 抽象(稳定)不应该依赖于实现细节(变化),实现细节应该依赖于抽象(稳定)

组合/聚合复用原则(Composite/Aggregate Reuse Principle CARP)

  • 尽量使用组合/聚合,不要使用类继承
  • 在一个新的对象里面使用一些已有的对象,使之成为新对象的一部分,新对象通过向这些对象的委派达到复用已有功能的目的。就是说要尽量的使用合成和聚合,而不是继承关系达到复用的目的

各种原则要求的侧重点不同,总地来说:

  • 开闭原则是核心,对扩展开放对修改关闭是软件设计、后期扩展的基石

  • 单一职责原则要求我们设计接口,制定模块功能时保持模块或者接口功能单一,接口设计或功能设计尽量保持原子性,修改一处不能影响全局或其它模块

  • 里氏替换原则和依赖倒置原则,按照作者的理解,这俩原则总的是要求我们要面向接口、面向抽象编程,设计程序的时候尽可能使用基类或者接口进行对象的定义或引用,而不是具体的实现,否则实现一旦有变更,上层调用者就必须做出对应变更,这样一来,整个模块可能都需要重新调整,非常不利于后期拓展

  • 接口隔离原则具体应用到程序中,比如我们在传统 MVC 开发时,Service 层调用 DAO 层一般会使用接口进行调用,各层之间尽量面向接口通信,其实也是一种降低模块耦合的方法

  • 迪米特法则的初衷也是为了降低模块耦合,代码示例中我们引入了类似 “中间人” 的概念,上层模块不直接调用下层模块,而是引入第三方进行代办,这也是为了降低模块的耦合度

  • 组合/聚合复用原则,聚合是一种弱关联,而组合是一种强关联,表现在 UML 类图上的话聚合是使用空心四边形加箭头表示,而组合是使用实心四边形加箭头表示,合成复用原则总的就是要求我们尽量利用好已有对象,从而达到功能复用,具体是聚合还是组合,还是一般关联,就要看具体情况再定了

这 7 种设计原则是软件设计必须尽量遵循的原则。

1.1 单一职责原则

单一职责原则是指不要存在多于一个导致类变更的原因。通俗的说,即一个类只负责一项职责。

Gather together the things that change for the same reasons. Separate those things that change for different reasons.

  1. 问题引入

    类T负责两个不同的职责:职责P1,职责P2。当由于职责P1需求发生改变而需要修改类T时,有可能会导致原本运行正常的职责P2功能发生故障。

  2. 单一职责原则是什么

    不要存在多于一个导致类变更的原因

    单一职责是高内聚低耦合的一个体现,通俗的讲就是一个类只能负责一个职责,修改一个类不能影响到别的功能,也就是说只有一个导致该类被修改的原因。
    遵循单一职责原则。分别建立两个类 T1、T2,使 T1 完成职责 P1 功能,T2 完成职责 P2 功能。这样,当修改类T1 时,不会使职责 P2 发生故障风险;同理,当修改 T2 时,也不会使职责 P1 发生故障风险。

    说到单一职责原则,很多人都会不屑一顾,因为它太简单了。稍有经验的程序员即使从来没有读过设计模式,从来没有听说过单一职责原则,在设计软件的时候也会自觉遵守这个重要原则,因为这是一个常识。在软件编程中,谁也不希望因为修改了一个功能导致其他的功能发生故障。而避免出现这一问题的方法便是遵循单一职责原则。虽然单一职责原则如此简单,并且被认为是常识,但是即使是经验丰富的程序员写出的程序也会有违背这一设计原则的代码存在。这是职责扩散导致的。所谓的职责扩散,指的就是因为某种原因,职责P1被分化为粒度更细的职责P1和P2。

    比如说,类T只负责一个职责P,这样设计是符合单一职责原则的。后来由于某种原因,或许是需求变更了,又或许是程序的设计者境界提高了,需要将职责P细分为粒度更细的职责P1和P2。这时候,如果要使程序遵循单一职责原则,就需要将类T也分解为两个类T1和T2,分别去负责P1和P2两个职责。但是在程序已经写好的情况下这样做,十分浪费时间和精力。所以,简单地修改类T,用它来负责两个职责是一个比较不错的选择,虽然这样做会有悖于单一职责原则。

    适当地违背单一职责原则有时候反而能提高开发效率,因此设计原则还是要按照实际需求来选择使用的。

  3. 优点

  • 可以降低类的复杂度,一个类只负责一项职责,其逻辑肯定要比负责多项职责简单的多
  • 提高类的可读性,提高系统的可维护性
  • 可维护性提高
  • 变更引起的风险降低,变更是必然的,如果单一职责原则遵守的好,当修改一个功能时,可以显著降低对其他功能的影响
  1. 总结

    单一职责原则提出了一个编写程序的标准,用“职责”或“变化原因”来衡量接口或类设计得是否有优良,但是“职责”和“变化原因”都是不可度量的,因项目而异,因环境而异。我们应该尽力去满足接口的单一职责,适当的满足类的单一职责,设计时向单一职责靠拢即可而不必必须满足,这也是折衷的一种体现。

1.2 接口隔离原则

接口分离原则指在设计时采用多个与特定客户类有关的接口比采用一个通用的接口要好。即,一个类要给多个客户使用,那么可以为每个客户创建一个接口,然后这个类实现所有的接口;而不要只创建一个接口,其中包含所有客户类需要的方法,然后这个类实现这个接口。

  1. 问题引入

    类 A 通过接口 I 依赖类 B,类 C 通过接口 I 依赖类 D,如果接口 I 对于类 A 和类 B 来说不是最小接口,则类 B 和类 D 必须去实现他们不需要的方法。

  2. 接口隔离原则是什么

    客户端不应该依赖它不需要的几口

    类间的依赖关系应该建立在最小的接口上

    也就是说一个类对另一个类的依赖应该建立在最小的接口上,通俗的讲就是需要什么就提供什么,不需要的就不要提供。

    接口中的方法应该尽量少,不要使接口过于臃肿,不要有很多不相关的逻辑方法。

  3. 优点

  • 高内聚,低耦合
  • 可读性高,易于维护
  1. 代码示例
protocol I {
    func method1()
    func method2()
    func method3()
    func method4()
    func method5()
}

class A {
    func depend1(i: I) {
        i.method1()
    }

    func depend2(i: I) {
        i.method2()
    }

    func depend3(i: I) {
        i.method3()
    }
}

class B : I {
    func method1() {
        print("类 B实现接口 I 的方法 1")
    }

    func method2() {
        print("类 B实现接口 I 的方法 2")
    }

    func method3() {
        print("类 B实现接口 I 的方法 3")
    }

    func method4() {}

    func method5() {}
}

class C {
    func depend1(i: I) {
        i.method1()
    }

    func depend2(i: I) {
        i.method4()
    }

    func depend3(i: I) {
        i.method5()
    }
}

class D : I {
    func method1() {
        print("类 D实现接口 I 的方法 1")
    }

    func method2() {

    }

    func method3() {

    }

    func method4() {
        print("类 D实现接口 I 的方法 4")
    }

    func method5() {
        print("类 D实现接口 I 的方法 5")
    }

}

let a = A()
a.depend1(i: B())
a.depend2(i: B())
a.depend3(i: B())

let c = C()
c.depend1(i: D())
c.depend2(i: D())
c.depend3(i: D())

可以看到,如果接口过于臃肿,只要接口中出现的方法,不管对依赖于它的类有没有用处,实现类中都必须去实现这些方法,这显然不是好的设计。如果将这个设计修改为符合接口隔离原则,就必须对接口I进行拆分。

protocol I1 {
    func method1()
}

protocol I2 {
    func method2()
    func method3()
}

protocol I3 {
    func method4()
    func method5()
}

class A {
    func depend1(i: I1) {
        i.method1()
    }

    func depend2(i: I2) {
        i.method2()
    }

    func depend3(i: I2) {
        i.method3()
    }
}

class B: I1, I2 {
    func method1() {
        print("类 B实现接口 I1 的方法 1")
    }

    func method2() {
        print("类 B实现接口 I2 的方法 2")
    }

    func method3() {
        print("类 B实现接口 I2 的方法 3")
    }
}

class C {
    func depend1(i: I1) {
        i.method1()
    }

    func depend2(i: I3) {
        i.method4()
    }

    func depend3(i: I3) {
        i.method5()
    }
}

class D : I1, I3 {
    func method1() {
        print("类 D实现接口 I1 的方法 1")
    }

    func method4() {
        print("类 D实现接口 I3 的方法 4")
    }

    func method5() {
        print("类 D实现接口 I3 的方法 5")
    }
}

接口隔离原则的含义是:建立单一接口,不要建立庞大臃肿的接口,尽量细化接口,接口中的方法尽量少。也就是说,我们要为各个类建立专用的接口,而不要试图去建立一个很庞大的接口供所有依赖它的类去调用。本文例子中,将一个庞大的接口变更为3个专用的接口所采用的就是接口隔离原则。在程序设计中,依赖几个专用的接口要比依赖一个综合的接口更灵活。接口是设计时对外部设定的“契约”,通过分散定义多个接口,可以预防外来变更的扩散,提高系统的灵活性和可维护性。

  1. 总结

很多人会觉的接口隔离原则跟之前的单一职责原则很相似,其实不然。

  • 单一职责原则原注重的是职责;而接口隔离原则注重对接口依赖的隔离
  • 为依赖接口的类定制服务,只暴露给调用的类它需要的方法,它不需要的方法则隐藏起来。只有专注地为一个模块提供定制服务,才能建立最小的依赖关系
  • 提高内聚,减少对外交互。使接口用最少的方法去完成最多的事情

运用接口隔离原则,一定要适度,接口设计的过大或过小都不好。设计接口的时候,只有多花些时间去思考和筹划,才能准确地实践这一原则。

1.3 依赖倒转(倒置)原则

依赖倒置原则指的是高层模块(稳定)不应该依赖于低层模块(变化),二者都应该依赖于抽象(稳定)。抽象(稳定)不应该依赖于实现细节(变化),实现细节应该依赖于抽象(稳定)。

  1. 问题引入

类A直接依赖类 B,假如要将类 A 改为依赖类 C,则必须通过修改类A的代码来达成。这种场景下,类 A 一般是高层模块,负责复杂的业务逻辑;类B和类 C 是低层模块,负责基本的原子操作;假如修改类A,会给程序带来不必要的风险。

  1. 依赖倒转原则是什么

高层模块不应该依赖低层模块,二者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象。

依赖倒置原则基于这样一个事实:相对于细节的多变性,抽象的东西要稳定的多。以抽象为基础搭建起来的架构比以细节为基础搭建起来的架构要稳定的多。在 Java中,抽象指的是接口或者抽象类,细节就是具体的实现类,使用接口或者抽象类的目的是制定好规范和契约,而不去涉及任何具体的操作,把展现细节的任务交给他们的实现类去完成。

依赖倒置原则的核心思想是面向接口编程,我们依旧用一个例子来说明面向接口编程比相对于面向实现编程好在什么地方。

  1. 优点
  • 减少类间的耦合性,提高系统的稳定性
  • 降低并行开发引起的风险
  • 提高代码的可读性和可维护性

1.4 里氏替换原则

氏替换原则(Liskov Substitution Principle, LSP)是面向对象设计原则的一种,也叫里氏代换原则。里氏替换原则是关于继承的一个原则,遵循里氏替换原则能够更好地发挥继承的作用,里氏替换原则最早是在1988年,由麻省理工学院的一位姓里的女士(Barbara Liskov)提出来的。

  1. 问题引入

有一功能 P1,由类 A 完成。现需要将功能 P1 进行扩展,扩展后的功能为 P,其中P由原有功能 P1 与新功能 P2 组成。新功能 P 由类 A 的子类 B 来完成,则子类 B 在完成新功能 P2 的同时,有可能会导致原有功能 P1 发生故障。

  1. 里氏替换原则是什么

里氏替换原则的官方定义

  • 如果对每一个类型为 T1的对象 o1,都有类型为 T2 的对象o2,使得以 T1定义的所有程序 P 在所有的对象 o1 都代换成 o2 时,程序 P 的行为没有发生变化,那么类型 T2 是类型 T1 的子类型
  • 所有引用基类的地方必须能透明地使用其子类的对象

解读:

里氏替换原则强调的是设计和实现要依赖于抽象而非具体;子类只能去扩展基类,而不是隐藏或者覆盖基类,它包含以下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();
        System.out.println("2+1=" + c.func(2, 1));
    }
}

运行结果:2+1=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();
        System.out.println("2-1=" + c.func2(2, 1));
    }
}

运行结果:2-1=1

  • 覆盖或实现父类的方法时输入参数可以被放大

当子类的方法重载父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松

通过代码来讲解一下

public class ParentClazz {
    public void say(CharSequence str) {
        System.out.println("parent execute say " + str);
    }
}

public class ChildClazz extends ParentClazz {
    public void say(String str) {
        System.out.println("child execute say " + str);
    }
}

/**
 * 测试
 */
public class Main {
    public static void main(String[] args) {
        ArrayList list = new ArrayList();
        ParentClazz parent = new ParentClazz();
        parent.say("hello");
        ChildClazz child = new ChildClazz();
        child.say("hello");

    }
}

执行结果
parent execute say hello
child execute say hello

以上代码中我们并没有重写父类的方法,只是重载了同名方法,具体的区别是:子类的参数 String 实现了父类的参数 CharSequence。此时执行了子类方法,在实际开发中,通常这不是我们希望的,父类一般是抽象类,子类才是具体的实现类,如果在方法调用时传递一个实现的子类可能就会产生非预期的结果,引起逻辑错误,根据里氏替换的子类的输入参数要宽于或者等于父类的输入参数,我们可以修改父类参数为String,子类采用更宽松的 CharSequence,如果你想让子类的方法运行,就必须覆写父类的方法。代码如下:

public class ParentClazz {
    public void say(String str) {
        System.out.println("parent execute say " + str);
    }
}

public class ChildClazz extends ParentClazz {
    public void say(CharSequence str) {
        System.out.println("child execute say " + str);
    }
}

public class Main {
    public static void main(String[] args) {
        ParentClazz parent = new ParentClazz();
        parent.say("hello");
        ChildClazz child = new ChildClazz();
        child.say("hello");
    }
}

执行结果
parent execute say hello
parent execute say hello
  • 覆写或实现父类的方法时输出结果可以被缩小

当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格

代码实现案例如下

public abstract class Father {
    public abstract Map hello();
}

public class Son extends Father {
    @Override
    public Map hello() {
        HashMap map = new HashMap();
        System.out.println("son execute");
        return map;
    }
}

public class Main {
    public static void main(String[] args) {
        Father father = new Son();
        father.hello();
    }
}

执行结果
son execute
  1. 优点
  • 保证了父类的复用性,同时也能够降低系统出错误的故障,防止误操作,同时也不会破坏继承的机制,这样继承才显得更有意义。
  • 增强程序的健壮性,版本升级是也可以保持非常好的兼容性.即使增加子类,原有的子类还可以继续运行.在实际项目中,每个子类对应不同的业务含义,使用父类作为参数,传递不同的子类完成不同的业务逻辑,完美!
  1. 总结

继承作为面向对象三大特性之一,在给程序设计带来巨大便利的同时,也带来了弊端。比如使用继承会给程序带来侵入性,程序的可移植性降低,增加了对象间的耦合性,如果一个类被其他的类所继承,则当这个类需要修改时,必须考虑到所有的子类,并且父类修改后,所有涉及到子类的功能都有可能会产生故障。

里氏替换原则的目的就是增强程序健壮性,版本升级时也可以保持非常好的兼容性。

有人会说我们在日常工作中,会发现在自己编程中常常会违反里氏替换原则,程序照样跑的好好的。所以大家都会产生这样的疑问,假如我非要不遵循里氏替换原则会有什么后果?后果就是:你写的代码出问题的几率将会大大增加。

1.5 开闭原则

开闭原则是指一个软件实体,如类、模块和函数应该对扩展开放,对修改关闭,也就是说一个软件实体应该通过扩展来实现变化,而不是通过修改已有的代码来实现变化。

  1. 问题引入

在软件的生命周期内,因为变化、升级和维护等原因需要对软件原有代码进行修改时,可能会给旧代码中引入错误,也可能会使我们不得不对整个功能进行重构,并且需要原有代码经过重新测试。

  1. 开闭原则是什么

一个软件实体如类、模块和函数,应当对扩展开放,对修改关闭

开闭原则(Open Closed Principle,OCP)由勃兰特・梅耶(Bertrand Meyer)提出,他在 1988 年的著作《面向对象软件构造》(Object Oriented Software Construction)中提出:软件实体应当对扩展开放,对修改关闭(Software entities should be open for extension,but closed for modification),这就是开闭原则的经典定义。

开闭原则的意思就是说,你设计的时候,时刻要考虑,尽量让这个类是足够好,写好了就不要去修改了,如果新需求来,我们增加一些类就完事了,原来的代码能不动则不动。

这个原则有两个特性:

  • 一个是说对于扩展是开放的
  • 一个是说对于更改是封闭的

面对需求,对程序的改动是通过增加新代码进行的,而不是更改现有的代码。这就是“开放-封闭原则”的精神所在。

比如,刚开始需求只是写加法程序,很快在client类中完成后,此时变化没有发生,需求让再添加一个减法功能,此时会发现增加功能需要修改原来这个类,这就违背了开放-封闭原则,于是你就应该考虑重构程序,增加一个抽象的运算类,通过一些面向对象的手段,如继承、动态等来隔离具体加法、减法与client耦合,需求依然可以满足,还能应对变化。此时需求要添加乘除法功能,就不需要再去更改client及加减法类,而是增加乘法和除法子类即可。

绝对的修改关闭是不可能的,无论模块是多么的封闭,都会存在一些无法对之封闭的变化,既然不可能完全封闭,设计人员必须对于他设计的模块应该对哪种变化封闭做出选择。他必须先猜测出最有可能发生的变化种类,然后构造抽象来隔离那些变化。在我们最初编写代码时,假设变化不会发生,当变化发生时,我们就创建抽象来隔离以后发生同类的变化。

我们希望的是在开发工作展开不久就知道可能发生的变化,查明可能发生的变化所等待的时候越长,要创建正确的抽象就越困难。开闭原则是面向对象设计的核心所在,遵循这个原则可以带来面向对象技术所声称的巨大好处,也就是可维护、可扩展、可复用、灵活性好。开发人员应该仅对程序中呈现出现频繁变化的那些部分做出抽象,然而对于应用程序中的每个部分都刻意地进行抽象同样不是一个好主意,拒绝不成熟的抽象和抽象本身一样重要。开闭原则,可以保证以前代码的正确性,因为没有修改以前代码,所以可以保证开发人员专注于将设计放在新扩展的代码上。

简单的用一句经典的话来说:过去的事已成历史,是不可修改的,因为时光不可倒流,但现在或明天计划做什么,是可以自己决定(即扩展)的。

  1. 优点

如果一个软件系统符合开闭原则的,那么从软件工程的角度来看,它至少具有这样的好处:

  • 对软件测试友好
    • 软件遵守开闭原则的话,软件测试时只需要对扩展的代码进行测试就可以了,因为原有的测试代码仍然能够正常运行。
  • 可复用性好
    • 我们可以在软件完成以后,仍然可以对软件进行扩展,加入新的功能,非常灵活。因此,这个软件系统就可以通过不断地增加新的组件,来满足不断变化的需求。
  • 可维护性好
    • 由于对于已有的软件系统的组件,特别是它的抽象底层不去修改,因此,我们不用担心软件系统中原有组件的稳定性,这就使变化中的软件系统有一定的稳定性和延续性。
  1. 示例

以课程为例说明什么是开闭原则,首先定义课程接口以及英语课程接口实现:

/**
* 定义课程接口
*/
public interface ICourse {
    String getName();  // 获取课程名称
    Double getPrice(); // 获取课程价格
    Integer getType(); // 获取课程类型
}

/**
 * 英语课程接口实现
 */
public class EnglishCourse implements ICourse {

    private String name;
    private Double price;
    private Integer type;

    public EnglishCourse(String name, Double price, Integer type) {
        this.name = name;
        this.price = price;
        this.type = type;
    }

    @Override
    public String getName() {
        return null;
    }

    @Override
    public Double getPrice() {
        return null;
    }

    @Override
    public Integer getType() {
        return null;
    }
}

// 测试
public class Main {
    public static void main(String[] args) {
        ICourse course = new EnglishCourse("小学英语", 199D, "Mr.Zhang");
        System.out.println(
                "课程名字:"+course.getName() + " " +
                "课程价格:"+course.getPrice() + " " +
                "课程作者:"+course.getAuthor()
        );
    }
}

项目上线,课程正常销售,但是我们产品需要做些活动来促进销售,比如:打折。那么问题来了:打折这一动作就是一个变化,而我们要做的就是拥抱变化,现在开始考虑如何解决这个问题,可以考虑下面三种方案:

  • 修改接口

    在之前的课程接口中添加一个方法 getSalePrice() 专门用来获取打折后的价格;

    如果这样修改就会产生两个问题,所以此方案否定

    • ICourse 接口不应该被经常修改,否则接口作为契约的作用就失去了
    • 并不是所有的课程都需要打折,加入还有语文课,数学课等都实现了这一接口,但是只有英语课打折,与实际业务不符
public interface ICourse {

    // 获取课程名称
    String getName();

    // 获取课程价格
    Double getPrice();

    // 获取课程类型
    String getAuthor();

    // 新增:打折接口
    Double getSalePrice();
}
  • 修改实体类

在接口实现里直接修改 getPrice()方法,此方法会导致获取原价出问题;或添加获取打折的接口 getSalePrice(),这样就会导致获取价格的方法存在两个,所以这个方案也否定。

  • 通过扩展实现变化

直接添加一个子类 SaleEnglishCourse ,重写 getPrice()方法,这个方案对源代码没有影响,符合开闭原则,所以是可执行的方案,代码如下,代码如下:

public class SaleEnglishCourse extends EnglishCourse {

    public SaleEnglishCourse(String name, Double price, String author) {
        super(name, price, author);
    }

    @Override
    public Double getPrice() {
        return super.getPrice() * 0.85;
    }
}

综上所述,如果采用第三种,即开闭原则,以后再来个语文课程,数学课程等等的价格变动都可以采用此方案,维护性极高而且也很灵活

1.6 迪米特法则

迪米特原则(Law of Demeter, LoD)是面向对象设计原则的一种,也叫最少知道原则。迪米特原则是1987年秋天由lan holland在美国东北大学一个叫做迪米特的项目设计提出的,它要求一个对象应该对其他对象有最少的了解,所以迪米特法则又叫做最少知识原则。

  1. 问题引入

类与类之间的关系越密切,耦合度越大,当一个类发生改变时,对另一个类的影响也越大。

  1. 迪米特法则是什么
  • 一个对象应该对其他对象保持最少的了解
  • 这个原理的名称来源于希腊神话中的农业女神,孤独的得墨忒耳

迪米特原则目的是尽量降低类与类之间的耦合。

自从我们接触编程开始,就知道了软件编程的总的原则:低耦合,高内聚。无论是面向过程编程还是面向对象编程,只有使各个模块之间的耦合尽量的低,才能提高代码的复用率。低耦合的优点不言而喻,但是怎么样编程才能做到低耦合呢?那正是迪米特原则要去完成的。

迪米特原则通俗的来讲,就是一个类对自己依赖的类知道的越少越好。也就是说,对于被依赖的类来说,无论逻辑多么复杂,都尽量地的将逻辑封装在类的内部,对外除了提供的public方法,不对外泄漏任何信息。

迪米特原则还有一个更简单的定义:只与直接的朋友通信。

首先来解释一下什么是直接的朋友:每个对象都会与其他对象有耦合关系,只要两个对象之间有耦合关系,我们就说这两个对象之间是朋友关系。耦合的方式很多,依赖、关联、组合、聚合等。其中,我们称出现成员变量、方法参数、方法返回值中的类为直接的朋友,而出现在局部变量中的类则不是直接的朋友。也就是说,陌生的类最好不要作为局部变量的形式出现在类的内部。

  1. 优点
  • 使得软件更好的可维护性与适应性
  • 对象较少依赖其它对象的内部结构,可以改变对象容器(container)而不用改变它的调用者(caller)
  1. 示例

通过老师要求班长告知班级人数为例,讲解迪米特原则。先来看一下违反迪米特法则的设计,代码如下

public class Student {
    private Integer id;
    private String name;

    public Student(Integer id, String name) {
        this.id = id;
        this.name = name;
    }
}

public class Teacher {
    public void call(Monitor monitor) {
        List<Student> sts = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            sts.add(new Student(i + 1, "name" + i));
        }
        monitor.getSize(sts);
    }
}

public class Monitor {
    public void getSize(List list) {
        System.out.println("班级人数:" + list.size());
    }
}

上面这个设计的主要问题出在 Teacher 中,迪米特原则要求只与直接的朋友发生通信,而 Student 类并不是 Teacher 类的直接朋友(以局部变量出现的耦合不属于直接朋友),从逻辑上讲 Teacher 只与 Monitor 耦合就行了,与 Student 并没有任何联系,这样设计显然是增加了不必要的耦合。按照迪米特原则,应该避免类中出现这样非直接朋友关系的耦合。修改后的代码如下:

public class Student {
    private Integer id;
    private String name;

    public Student(Integer id, String name) {
        this.id = id;
        this.name = name;
    }
}

public class Teacher {
    public void call(Monitor monitor) {
        monitor.getSize();
    }
}

public class Monitor {
    public void getSize() {
        List<Student> sts = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            sts.add(new Student(i + 1, "name" + i));
        }
        System.out.println("班级人数" + sts.size());
    }
}

将Student 从 Teacher 抽掉,也就达到了 Student 和 Teacher 的解耦,从而符合了迪米特原则。

  1. 总结

迪米特原则的初衷是降低类之间的耦合,由于每个类都减少了不必要的依赖,因此的确可以降低耦合关系。但是凡事都有度,虽然可以避免与非直接的类通信,但是要通信,必然会通过一个“中介”来发生联系,例如本例中,老师(Teacher)就是通过班长(Monitor)这个“中介”来与 学生(Student)发生联系的。过分的使用迪米特原则,会产生大量这样的中介和传递类,导致系统复杂度变大。所以在采用迪米特法则时要反复权衡,既做到结构清晰,又要高内聚低耦合。

1.7 组合/聚合复用原则原则

组合/聚合复用原则(Composite/Aggregate Reuse Principle)是面向对象设计原则的一种,也叫合成复用原则。组合/聚合复用原则是指尽量使用组合/聚合,不要使用类继承。在一个新的对象里面使用一些已有的对象,使之成为新对象的一部分,新对象通过向这些对象的委派达到复用已有功能的目的。就是说要尽量的使用合成和聚合,而不是继承关系达到复用的目的。

  1. 合成复用原则是什么
  • 尽量采用组合(contains-a)、聚合(has-a)的方式而不是继承(is-a)的关系来达到软件的复用目的

组合/聚合复用原则是通过将已有的对象纳入新对象中,作为新对象的成员对象来实现的,新对象可以调用已有对象的功能,从而达到复用。 原则是尽量首先使用合成 / 聚合的方式,而不是使用继承

合成和聚合都是关联的特殊种类。合成是值的聚合(Aggregation by Value),而复合是引用的聚合(Aggregation by Reference)。

都知道,类之间有三种基本关系,分别是:关联(聚合和组合)、泛化(与继承同一概念)、依赖。

这里我们提一下关联关系,客观来讲,大千世界中的两个实体之间总是有着千丝万缕的关系,归纳到软件系统中就是两个类之间必然存在关联关系。如果一个类单向依赖另一个类,那么它们之间就是单向关联。如果彼此依赖,则为相互依赖,即双向关联。

关联关系包括两种特例:聚合和组合。聚合,用来表示整体与部分的关系或者 “拥有” 关系。其中,代表部分的对象可能会被代表多个整体的对象所拥有,但是并不一定会随着整体对象的销毁而销毁,部分的生命周期可能会超越整体。好比班级和学生,班级销毁或解散后学生还是存在的,学生可以继续存在某个培训机构或步入社会,生命周期不同于班级甚至大于班级。

合成,用来表示一种强得多的 “拥有” 关系。其中,部分和整体的生命周期是一致的,一个合成的新的对象完全拥有对其组成部分的支配权,包括创建和泯灭。好比人的各个器官组成人一样,一旦某个器官衰竭,人也不复存在,这是一种 “强” 关联。

classDiagram Heart --* Student Heart --* Teacher Teacher -- Student Student --o Class Student --o School

如上图所示,Heart 和 Student、Teacher 都是一种 “强” 关联,人不能摆脱心脏而存在,即组合关系,而 Student 和 Class、School 是一种 “弱” 关联,脱离了学校、班级,学生还能属于社会或其他团体,即聚合关系。

  1. 优点
  • 可以降低类与类之间的耦合程度
  • 提高了系统的灵活性
  1. 示例
public class Person {
    public void talk(String name) {
        System.out.println(name + " say hello");
    }
    public void walk(String name) {
        System.out.println(name + " move");
    }
}

public class Manager extends Person { 
}

public class Employee extends Person {
}

根据组合/聚合复用原则大家需要首选组合,然后才能是继承,使用继承时需要严格的遵守里氏替换原则,务必满足“Is-A”的关系是才可以使用继承,而组合却是一种“Has-A”的关系。

由上面的代码能够看出,Manager和Employee继承了Person,但实际上每一个不同的职位具有不同的角色,如果大家填加了角色这个类,那麼继续使用继承的话只能使每个人只能具有一种角色,这显然是不科学的。

2. 设计模式

2.1 创建型模式

这类模式提供创建对象的机制, 能够提升已有代码的灵活性和可复用性。

2.1.1 工厂方法

  1. 意图

    工厂方法模式是一种创建型设计模式, 其在父类中提供一个创建对象的方法, 允许子类决定实例化对象的类型。

  2. 问题

    假设你正在开发一款物流管理应用。 最初版本只能处理卡车运输, 因此大部分代码都在位于名为 卡车的类中。

    一段时间后, 这款应用变得极受欢迎。 你每天都能收到十几次来自海运公司的请求, 希望应用能够支持海上物流功能。

    这可是个好消息。 但是代码问题该如何处理呢? 目前, 大部分代码都与 卡车类相关。 在程序中添加 轮船类需要修改全部代码。 更糟糕的是, 如果你以后需要在程序中支持另外一种运输方式, 很可能需要再次对这些代码进行大幅修改。

    最后, 你将不得不编写繁复的代码, 根据不同的运输对象类, 在应用中进行不同的处理。

  3. 解决方案

    工厂方法模式建议使用特殊的工厂方法代替对于对象构造函数的直接调用 (即使用 new运算符)。 不用担心, 对象仍将通过 new运算符创建, 只是该运算符改在工厂方法中调用罢了。 工厂方法返回的对象通常被称作 “产品”。

    乍看之下, 这种更改可能毫无意义: 我们只是改变了程序中调用构造函数的位置而已。 但是, 仔细想一下, 现在你可以在子类中重写工厂方法, 从而改变其创建产品的类型。

    但有一点需要注意:仅当这些产品具有共同的基类或者接口时, 子类才能返回不同类型的产品, 同时基类中的工厂方法还应将其返回类型声明为这一共有接口。

    举例来说, 卡车Truck和 轮船Ship类都必须实现 运输Transport接口, 该接口声明了一个名为 deliver交付的方法。 每个类都将以不同的方式实现该方法: 卡车走陆路交付货物, 轮船走海路交付货物。 陆路运输Road­Logistics类中的工厂方法返回卡车对象, 而 海路运输Sea­Logistics类则返回轮船对象。

    调用工厂方法的代码 (通常被称为客户端代码) 无需了解不同子类返回实际对象之间的差别。 客户端将所有产品视为抽象的 运输 。 客户端知道所有运输对象都提供 交付方法, 但是并不关心其具体实现方式。

  4. 模式结构

  5. 适应场景

    • 当你在编写代码的过程中,如果无法预知对象确切类别及其依赖关系时,可使用工厂方法。

      工厂方法将创建产品的代码与实际使用产品的代码分离, 从而能在不影响其他代码的情况下扩展产品创建部分代码。

      例如, 如果需要向应用中添加一种新产品, 你只需要开发新的创建者子类, 然后重写其工厂方法即可。

    • 如果你希望用户能扩展你软件库或框架的内部组件, 可使用工厂方法。

      继承可能是扩展软件库或框架默认行为的最简单方法。 但是当你使用子类替代标准组件时, 框架如何辨识出该子类?

      解决方案是将各框架中构造组件的代码集中到单个工厂方法中, 并在继承该组件之外允许任何人对该方法进行重写。

      让我们看看具体是如何实现的。 假设你使用开源 UI 框架编写自己的应用。 你希望在应用中使用圆形按钮, 但是原框架仅支持矩形按钮。 你可以使用 圆形按钮Round­Button子类来继承标准的 按钮Button类。 但是, 你需要告诉 UI框架UIFramework类使用新的子类按钮代替默认按钮。

      为了实现这个功能, 你可以根据基础框架类开发子类 圆形按钮 UIUIWith­Round­Buttons , 并且重写其 create­Button创建按钮方法。 基类中的该方法返回 按钮对象, 而你开发的子类返回 圆形按钮对象。 现在, 你就可以使用 圆形按钮 UI类代替 UI框架类。 就是这么简单!

    • 如果你希望复用现有对象来节省系统资源, 而不是每次都重新创建对象, 可使用工厂方法。

      在处理大型资源密集型对象 (比如数据库连接、 文件系统和网络资源) 时, 你会经常碰到这种资源需求。

      让我们思考复用现有对象的方法:

      1. 首先, 你需要创建存储空间来存放所有已经创建的对象。
      2. 当他人请求一个对象时, 程序将在对象池中搜索可用对象。
      3. … 然后将其返回给客户端代码。
      4. 如果没有可用对象, 程序则创建一个新对象 (并将其添加到对象池中)。

      这些代码可不少! 而且它们必须位于同一处, 这样才能确保重复代码不会污染程序。

      可能最显而易见, 也是最方便的方式, 就是将这些代码放置在我们试图重用的对象类的构造函数中。 但是从定义上来讲, 构造函数始终返回的是新对象, 其无法返回现有实例。

      因此, 你需要有一个既能够创建新对象, 又可以重用现有对象的普通方法。 这听上去和工厂方法非常相像。

  6. 实现方式

    1. 让所有产品都遵循同一接口。 该接口必须声明对所有产品都有意义的方法。

    2. 在创建类中添加一个空的工厂方法。 该方法的返回类型必须遵循通用的产品接口。

    3. 在创建者代码中找到对于产品构造函数的所有引用。 将它们依次替换为对于工厂方法的调用, 同时将创建产品的代码移入工厂方法。

      你可能需要在工厂方法中添加临时参数来控制返回的产品类型。

      工厂方法的代码看上去可能非常糟糕。 其中可能会有复杂的 switch分支运算符, 用于选择各种需要实例化的产品类。 但是不要担心, 我们很快就会修复这个问题。

    4. 现在, 为工厂方法中的每种产品编写一个创建者子类, 然后在子类中重写工厂方法, 并将基本方法中的相关创建代码移动到工厂方法中。

    5. 如果应用中的产品类型太多, 那么为每个产品创建子类并无太大必要, 这时你也可以在子类中复用基类中的控制参数。

      例如, 设想你有以下一些层次结构的类。 基类 邮件及其子类 航空邮件陆路邮件运输及其子类 飞机, 卡车火车航空邮件仅使用 飞机对象, 而 陆路邮件则会同时使用 卡车火车对象。 你可以编写一个新的子类 (例如 火车邮件 ) 来处理这两种情况, 但是还有其他可选的方案。 客户端代码可以给 陆路邮件类传递一个参数, 用于控制其希望获得的产品。

    6. 如果代码经过上述移动后, 基础工厂方法中已经没有任何代码, 你可以将其转变为抽象类。 如果基础工厂方法中还有其他语句, 你可以将其设置为该方法的默认行为。

  7. 优缺点

    • 你可以避免创建者和具体产品之间的紧密耦合。
    • 单一职责原则。 你可以将产品创建代码放在程序的单一位置, 从而使得代码更容易维护。
    • 开闭原则。 无需更改现有客户端代码, 你就可以在程序中引入新的产品类型。
    • × 应用工厂方法模式需要引入许多新的子类, 代码可能会因此变得更复杂。 最好的情况是将该模式引入创建者类的现有层次结构中。
  8. 与其他模式的关系

    • 在许多设计工作的初期都会使用工厂方法模式 (较为简单, 而且可以更方便地通过子类进行定制), 随后演化为使用抽象工厂模式、 原型模式或生成器模式(更灵活但更加复杂)。
    • 抽象工厂模式通常基于一组工厂方法, 但你也可以使用原型模式来生成这些类的方法。
    • 你可以同时使用工厂方法和迭代器模式来让子类集合返回不同类型的迭代器, 并使得迭代器与集合相匹配。
    • 原型并不基于继承, 因此没有继承的缺点。 另一方面, 原型需要对被复制对象进行复杂的初始化。 工厂方法基于继承, 但是它不需要初始化步骤。
    • 工厂方法是模板方法模式的一种特殊形式。 同时, 工厂方法可以作为一个大型模板方法中的一个步骤。

2.1.2 抽象工厂

2.1.3 生成器

2.1.4 原型

2.1.5 单例

2.2 结构型模式

2.3 行为模式

3. 参考资料

[1].重构大师

[2].图说设计模式

[3].图说设计模式(github)

[4].终于有人将23种设计模式与七大设计原则整理明白了(一)!!!

[5].极客教程