设计模式学习笔记:设计模式七大原则

维基百科对设计模式的分类

什么是设计模式

可以用一句话概括设计模式———设计模式是一种利用OOP的封闭、继承和多态三大特性,同时在遵循单一职责原则、开闭原则、里氏替换原则、迪米特法则、依赖反转原则、接口隔离原则及合成/聚合复用原则的前提下,被总结出来的经过反复实践并被多数人知晓且经过分类和设计的可重用的软件设计方式。

什么是 GOF(四人帮,全拼 Gang of Four)?

在 1994 年,由 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 四人合著出版了一本名为 Design Patterns - Elements of Reusable Object-Oriented Software(中文译名:设计模式 - 可复用的面向对象软件元素) 的书,该书首次提到了软件开发中设计模式的概念。

四位作者合称 GOF(四人帮,全拼 Gang of Four

为什么要用设计模式

  • 设计模式是高级软件工程师和架构师面试基本必问的项目(先通过面试进入这个门槛我们再谈其它)
  • 设计模式是经过大量实践检验的安全高效可复用的解决方案。不要重复发明轮子,而且大多数时候你发明的轮子还没有已有的好
  • 设计模式是被主流工程师/架构师所广泛接受和使用的,你使用它,方便与别人沟通,也方便别人code review(这个够实在吧)
  • 使用设计模式可以帮你快速解决80%的代码设计问题,从而让你更专注于业务本身
  • 设计模式本身是对几大特性的利用和对几大设计原则的践行,代码量积累到一定程度,你会发现你已经或多或少的在使用某些设计模式了
  • 架构师或者team leader教授初级工程师设计模式,可以很方便的以大家认可以方式提高初级工程师的代码设计水平,从而有利于提高团队工程实力

是不是一定要尽可能使用设计模式

每个设计模式都有其适合范围,并解决特定问题。所以项目实践中应该针对特定使用场景选用合适的设计模式,如果某些场景下现在的设计模式都不能很完全的解决问题,那也不必拘泥于设计模式本身。实际上,学习和使用设计模式本身并不是目的,目的是通过学习和使用它,强化面向对象设计思路并用合适的方法解决工程问题。

面向对象三大基本特性

封装

封装,也就是把客观事物封装成抽象的类,并且类可以把自己的属性和方法只让可信的类操作,对不可信的进行信息隐藏。

继承

继承是指这样一种能力,它可以使用现有的类的所有功能,并在无需重新编写原来类的情况下对这些功能进行扩展。

多态

多态指一个类实例的相同方法在不同情形有不同的表现形式。具体来说就是不同实现类对公共接口有不同的实现方式,但这些操作可以通过相同的方式(公共接口)予以调用。

设计模式七大原则

查阅的资料有说五大原则六大七大都有,这里我就拿最多的都讲一下

五大

五大简称SOLID

  1. S单一职责SRP
  2. O开放封闭原则OCP
  3. L里氏替换原则LSP
  4. I接口隔离法则
  5. D依赖反转原则DIP

六大

多了一个

6.迪米特法则(最少知道原则)

七大

多了一个

7.合成复用原则

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

定义

不要存在多于一个导致类变更的原因。通俗的说,即一个类只负责一项职责,应该仅有一个引起它变化的原因

      单一职责原则告诉我们:一个类不能太“累”!在软件系统中,一个类(大到模块,小到方法)承担的职责越多,它被复用的可能性就越小,而且一个类承担的职责过多,就相当于将这些职责耦合在一起,当其中一个职责变化时,可能会影响其他职责的运作,因此要将这些职责进行分离,将不同的职责封装在不同的类中,即将不同的变化原因封装在不同的类中,如果多个职责总是同时发生改变则可将它们封装在同一类中。

      单一职责原则是实现高内聚、低耦合的指导方针,它是最简单但又最难运用的原则,需要设计人员发现类的不同职责并将其分离,而发现类的多重职责需要设计人员具有较强的分析设计能力和相关实践经验。

问题由来

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

O开-闭原则(Open-Closed Principle, OCP)

定义

一个软件实体应当对扩展开放,对修改关闭。即软件实体应尽量在不修改原有代码的情况下进行扩展。

由来

软件开发过程中,可能会对功能进行修改,如果直接修改旧代码,可能会导致潜在的问题。

解决

当软件代码需要进行变动时,尽量以添加新的代码来完成,而不去修改原有的代码。也即通过扩展来完成所需要的功能的添加。

代码演示

新建一个人的类,有学习的功能。

Human.java

public class Human {
    public void learn () {
        System.out.println("学语文");
    }
}

Main.java

public class Main {
    public static void main(String[] args) {
        Human human = new Human();
        human.learn();
    }
}

运行结果

UML

那么如果我要学数学呢

就不得不修改老代码Human.java

Human.java

public class Human {
    public void learn() {
        System.out.println("学数学");
    }
}

这样直接修改以前的代码,风险是比较大的,稍不留神就可以改出问题。而且不符合开闭原则。

正确做法

LearnChinese.java

public class LearnChinese extends Human {
    @Override
    public void learn() {
        System.out.println("学语文");
    }
}

LearnMath.java

public class LearnMath extends Human {
    @Override
    public void learn() {
        System.out.println("学数学");
    }
}

Main.java

public class Main {
    public static void main(String[] args) {
        Human human = new LearnChinese();
        human.learn();
        human = new LearnMath();
        human.learn();
    }
}

运行结果

UML

当然,对于上面的例子,也可以这样

public class Human {
    public void learnChinese() {
        System.out.println("学语文");
    }
    public void learnMath() {
        System.out.println("学数学");
    }
}

UML

这样改虽然也不符合开闭原则,但是这样改也不是不行,毕竟方法是死的人是活的,具体问题要具体分析。

L里氏替换原则(Liskov Substitution principle,LSP)

看完上面的概念估计很多人都和我一样不是太理解,或者比较好奇,为什么叫里氏替换?其原因是:这项原则最早是在1988年,由麻省理工学院的一位姓里的女士(Barbara Liskov)提出来的。

定义

继承必须确保父类所拥有的性质在子类中仍然成立。

由来

通过子类来完成父类的任务,可能会产生问题。

解决

子类可以实现父类的抽象方法,但是不去Override父类的非抽象方法。这也算是某种意义上的开闭原则吧,尽量不要去影响旧有的代码,通过扩展(取新名字,而不是Override)来完成新功能。

里氏替换原则通俗的来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。它包含以下4层含义:

  1. 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。
  2. 子类中可以增加自己特有的方法。
  3. 当子类的方法重载父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松。
  4. 当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。

对于这四点,这里讲的比较好

https://www.cnblogs.com/hellojava/archive/2013/03/15/2960905.html

我只做简单介绍

代码

Animal.java

public class Animal {
    public String speak() {
        return "我是动物";
    }
}

Main.java

public class Main {
    public static void main(String[] args) {
        Animal animal = new Animal();
        System.out.println("动物:" + animal.speak());
    }
}

运行结果

UML

那么,现在扩展一下,增加一个Bird类

Bird.java

public class Bird extends Animal {
    @Override
    public String speak() {
        return "我是小鸟";
    }
}

Main.java

public class Main {
    public static void main(String[] args) {
        Animal animal = new Bird();
        System.out.println("动物:" + animal.speak());
        System.out.println("小鸟:" + animal.speak());
    }
}

UML

运行结果

这种情况下就出现问题。我在学习这个原则的时候也有一个疑问,子类重写父类的方法是很常见的操作,这是多态的特性,为什么这个原则却不让子类重写呢?

在这里找到了答案https://www.zhihu.com/question/27191817

下面简单做下总结

多态与里氏替换原则

这里要分类讨论

1、如果继承就是为了多态,而多态的前提就是子类重写父类方法,那么为了符合LSP,就要把这个父类的改为抽象类,并定义抽象方法,然后让子类重写这个方法。父类为抽象类的话,就不能实例化,也就不存在这个问题。

对于上面的例子,应该这样改

Animal.java

public abstract class Animal {
    public abstract String speak();
}

这样改完之后,Animal类就无法直接实例化,也就解决了上面的问题

2、如果继承就是为了父类的代码复用,那么父类的这个方法就不能被子类改写,如果子类要扩展相关的功能,只能自己增加方法

Animal.java

public class Animal {
    public String speak() {
        return "我是动物";
    }
}

Bird.java

public class Bird extends Animal {
    @Override
    public String speak() {
        // 直接返回父类结果
        return super.speak();
    }

    public String birdSpeak() {
        return "我是小鸟";
    }
}

Main.java

public class Main {
    public static void main(String[] args) {
        Animal animal = new Bird();
        System.out.println("动物:" + animal.speak());
        System.out.println("小鸟:" + ((Bird) animal).birdSpeak());
    }
}

UML

运行结果

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

定义

使用多个专门的接口,而不使用单一的总接口,即客户端不应该依赖那些它不需要的接口,一个类对另一个类的依赖应建立在最小接口上。

由来

一个接口里完成了很多工作,但是当个功能只需要调用接口里的一小部分功能的时候,如果调用这个接口,就会做一些不必要的工作,甚至可能产生问题。

解决

一个接口尽量完成比较单一的任务,这样两个类交互时,产生的影响才会在控制范围内。

接口隔离原则的含义是:建立单一接口,不要建立庞大臃肿的接口,尽量细化接口,接口中的方法尽量少。也就是说,我们要为各个类建立专用的接口,而不要试图去建立一个很庞大的接口供所有依赖它的类去调用。在程序设计中,依赖几个专用的接口要比依赖一个综合的接口更灵活。

单一职责原则与接口隔离原则

单一职责直说职责单一,只要是职责是单一的,有多个方法也是可以的

接口隔离注重的事对接口的隔离,就是接口要细化,不要多个功能混杂在一起

单一职责原则主要是约束类,其次才是接口和方法,它针对的是程序中的实现和细节;而接口隔离原则主要约束接口接口,主要针对抽象,针对程序整体框架的构建。

实际开发中并不是粒度越小越好,有时候粒度过小反而会增加开发难度,实际开发中要灵活使用。

代码

IAnimalAction.java

public interface IAnimalAction {
    void eat();
    void fly();
    void swim();
}

Fish.java

public class Fish implements IAnimalAction {
    @Override
    public void eat() {

    }
    @Override
    public void fly() {

    }
    @Override
    public void swim() {

    }
}

 Bird.java

public class Bird implements IAnimalAction {
    @Override
    public void eat() {
       
    }
    @Override
    public void fly() {

    }
    @Override
    public void swim() {

    }
}

UML

要实现IAnimalAction接口,就必须实现它的所有方法。但是这里明显Fish是不会飞的,Bird不会游泳(有的鱼也会飞,有的鸟也可以下水,不要在意这些细节。。。)

正确做法

IEatAnimalAction.java

public interface IEatAnimalAction {
    void eat();
}

IFlyAnimalAction.java

public interface IFlyAnimalAction {
    void fly();
}

ISwimAnimalAction.java

public interface ISwimAnimalAction {
    void swim();
}

Bird.java

public class Bird implements IEatAnimalAction, IFlyAnimalAction {
    @Override
    public void eat() {

    }
    @Override
    public void fly() {

    }
}

Fish.java

public class Fish implements IEatAnimalAction, ISwimAnimalAction {
    @Override
    public void eat() {

    }
    @Override
    public void swim() {

    }
}

UML

D依赖反转原则(Dependency Inversion Principle, DIP)

定义

高层模块不依赖于底层模块,两者都应该依赖于抽象,抽象不依赖于细节,细节依赖于抽象。换言之,要针对接口编程,而不是针对实现编程。

由来

各模块之间交叉调用,就会带来很强的耦合性,往往会牵一发而动全身,改动一个地方,很多地方都会受到影响,增加出错的风险。

解决

主要是通过面对接口编程,将实现细节与业务逻辑分开,大家谁要不要依赖谁,都依赖抽象的接口,业务逻辑只和抽象的接口打交到,而不必关注具体的实现过程。同样实现过程也不必关注业务,它只需要关注接口即抽象即可。

里氏替换与依赖反转原则

依赖反转原则引用书中的定义如下:

抽象不应该依赖于细节,细节应当依赖于抽象。换言之,要针对接口编程,而不是针对实现编程

这个原则和上一个原则乍一看起来我觉得好像是雷同了,但是仔细分析一下后发现其实并没有。

里氏替换原则更具体点理解,说的是父类和子类在各自定义的时候应该遵循的一种原则,重点在于父类和子类的定义。
而依赖反转原则说的则应该是如何更合理使用父类和子类,重点在于如何使用。
但是这两个原则说的都是父类和子类的问题,因此很显然也有必然的联系,只有遵循里氏替换原则合理的定义了父类和子类,才可能更合理的遵循依赖反转原则。
因此这也涉及到一个问题,子类虽然从语法上来说可以有自己的对外开放的方法,那么是否应该提供这样的方法呢?
很显然的,如果要完全遵循依赖反转原则,子类就不应该定义自己的对外开放的方法,否则针对接口编程的时候,那子类的那些对外开放的特有方法就成了摆设。

代码

Human.java

public class Human {
    public void learn() {
        System.out.println("学语文");
    }
}

Main.java

public class Main {
    public static void main(String[] args) {
        Human human = new Human();
        human.learn();
    }
}

UML

运行结果

如果我想学数学呢?

Human.java

public class Human {
    public void learnChinses() {
        System.out.println("学语文");
    }

    public void learnMath() {
        System.out.println("学数学");
    }
}

Main.java

public class Main {
    public static void main(String[] args) {
        Human human = new Human();
        human.learnChinses();
        human.learnMath();
    }
}

UML

上层Main.java,下层实现Human.java

这里会发现,上层的Main.java严重依赖下层实现Human.java。每次修改功能都必须修改Human.java

正确做法

引入接口

Human.java

public class Human {
    public void learn(ILearn iLearn) {
        iLearn.learn();
    }
}

ILearn.java

public interface ILearn {
    void learn();
}

LearnChinese.java

public class LearnChinese implements ILearn {
    @Override
    public void learn() {
        System.out.println("学语文");
    }
}

LearnMath.java

public class LearnMath implements ILearn {
    @Override
    public void learn() {
        System.out.println("学数学");
    }
}

Main.java

public class Main {
    public static void main(String[] args) {
        Human human = new Human();
        ILearn iLearn = new LearnChinese();
        human.learn(iLearn);
        iLearn = new LearnMath();
        human.learn(iLearn);
    }
}

UML

分析

  1. 面向接口编程
  2. 上层Main.java修改或添加新功能时不再依赖底层Human.java
  3. 抽象ILearn.java不依赖底层Human.java
  4. 下层Human.java依赖抽象ILearn.java
  5. 上层与下层都依赖抽象ILearn.java

小结

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

依赖反转原则的中心思想是面向接口编程,传递依赖关系有三种方式,以上的说的是接口传递,另外还有两种传递方式:构造方法传递和setter方法传递,相信用过Spring框架的,对依赖的传递方式一定不会陌生。

总之,依赖反转原则就是要我们面向接口编程,理解了面向接口编程,也就理解了依赖反转。

迪米特法则/最少知道原则(Law of  Demeter, LoD)

定义

只和你最直接的类(成员变量、方法参数、方法返回值中的类)沟通,尽可能少地与其他实体发生交互。

由来

想降低类之间的耦合关系,相互之间尽量减少依赖关系。

解决

与越少的类相互越好,尽量做到低耦合高内聚。尽量做到模块化。

      如果一个系统符合迪米特法则,那么当其中某一个模块发生修改时,就会尽量少地影响其他模块,扩展会相对容易,这是对软件实体之间通信的限制,迪米特法则要求限制软件实体之间通信的宽度和深度。迪米特法则可降低系统的耦合度,使类与类之间保持松散的耦合关系。因为耦合性越强,修改起来就越麻烦, 因为牵扯的关系太多,稍不留神就可能会改错。

      迪米特法则还有几种定义形式,包括:不要和陌生人”说话只与你的直接朋友通信等,在迪米特法则中,对于一个对象,其朋友包括以下几类:

  1. 当前对象本身(this);
  2. 以参数形式传入到当前对象方法中的对象;
  3. 当前对象的成员对象;
  4.  如果当前对象的成员对象是一个集合,那么集合中的元素也都是朋友;
  5. 当前对象所创建的对象。

      任何一个对象,如果满足上面的条件之一,就是当前对象的“朋友”,否则就是“陌生人”。在应用迪米特法则时,一个对象只能与直接朋友发生交互,不要与“陌生人”发生直接交互,这样做可以降低系统的耦合度,一个对象的改变不会给太多其他对象带来影响。

      迪米特法则要求我们在设计系统时,应该尽量减少对象之间的交互,如果两个对象之间不必彼此直接通信,那么这两个对象就不应当发生任何直接的相互作用,如果其中的一个对象需要调用另一个对象的某一个方法的话,可以通过第三者转发这个调用。简言之,就是通过引入一个合理的第三者来降低现有对象之间的耦合度

代码

例子:老师班长查询班级人数

MyClass.java

// 班级类
public class MyClass {
    int studentCount = 60; // 班级人数
    public int getStudentCount() {
        return studentCount;
    }
    public void setStudentCount(int studentCount) {
        this.studentCount = studentCount;
    }
}

 Monitor.java

// 班长类
public class Monitor {
    public void checkClassStuCount(MyClass myClass) {
        int studentCount = myClass.getStudentCount();
        System.out.println("班级的人数为:" + studentCount);
    }
}

 Teacher.java

public class Teacher {
    public void commandMonitorCheckStuCount(Monitor monitor) {
        MyClass myClass = new MyClass();
        monitor.checkClassStuCount(myClass);
    }
}

 Main.java

public class Main {
    public static void main(String[] args) {
        Teacher teacher = new Teacher();
        Monitor monitor = new Monitor();
        teacher.commandMonitorCheckStuCount(monitor);
    }
}

UML

存在问题

  1. 对于Teacher类而言Monitor直接的参数,是朋友,而MyClass不是朋友
  2. Teacher和Monitor是直接的关系,但不应该和MyClass有直接的关系
  3. 目前的这种写法就违背了LoD

应该这样改

Main.java与MyClass.java不变

Monitor.java

// 班长类
public class Monitor {
    public void checkClassStuCount() {
        MyClass myClass = new MyClass();
        int studentCount = myClass.getStudentCount();
        System.out.println("班级的人数为:" + studentCount);
    }
}

Teacher.java

public class Teacher {
    public void commandMonitorCheckStuCount(Monitor monitor) {
        monitor.checkClassStuCount();
    }
}

UML

主要改的就是把Teacher.java里面的Student给删去了。

合成/聚合复用原则(Composite/Aggregate Reuse Principle,CARP / CRP)

定义

尽量使用对象组合,而不是继承来达到复用的目的

由来

继承关系是在编译时就确定了,如果想在运行时改变父类与子类的关系就不行了;另外父类改变了一定会影响到子类。继承的关系限制了更灵活地复用代码。

解决

通过使用合成/聚合来替代继承关系,达到更灵活地修改代码。

复用一般指的是自己本身不具备的方法,但可以拿来使用,而实现方式通常是组合聚合继承
1、继承指的是,在父类中写的方法被子类继承后,子类不需要再写一遍这个方法,子类的对象就可以调用。
2、组合指的是,声明一个类的时候,把另一个类以属性的方式声明,然后在这个类的对象中便包含了那个类的对象,然后这个类的对象中就可以调用那个类中的方法,从而实现自己不用定义,当能实现某些功能。
3、而聚合通常是说把另一个类的对象以参数的方式传进来,然后这个类的对象的方法中也就可以调用参数对象的方法,这样也实现了自己不定义,但能实现某些功能。
以上三种方式都能实现代码和功能的复用,减少了重复代码,但是继承会破坏类的封装性,把父类的实现细节暴露给子类,同时如果父类声明为不可被继承,那么还不能被复用,这些都是非必要,不建议使用继承复用的原因。
相反的,组合和聚合就更加的灵活,具体的实现也不会暴露给其他组合的类,因此建议使用组合和聚合实现代码和功能的复用,也就是合成复用原则。

总结

说到这里,再回想一下前面说的5项原则,恰恰是告诉我们用抽象构建框架,用实现扩展细节的注意事项而已:

  • 单一职责原则:实现类要职责单一
  • 里氏替换原则:不要破坏继承体系
  • 依赖反转原则:要面向接口编程
  • 接口隔离原则:设计接口的时候要精简单一
  • 迪米特法则:降低耦合
  • 复用原则:灵活复用代码
  • 而开闭原则是总纲,他告诉我们要对扩展开放,对修改关闭

最后说明一下如何去遵守这六个原则。对这六个原则的遵守并不是是和否的问题,而是多和少的问题,也就是说,我们一般不会说有没有遵守,而是说遵守程度的多少。任何事都是过犹不及,设计模式的六个设计原则也是一样,制定这六个原则的目的并不是要我们刻板的遵守他们,而需要根据实际情况灵活运用。对他们的遵守程度只要在一个合理的范围内,就算是良好的设计。

参考:

http://liaowo.me/articles/2018/07/18/1531904510753.html

https://www.jianshu.com/p/a489dd5ad1fe

https://yq.aliyun.com/articles/255168

https://blog.csdn.net/u011225629/article/details/47699613

https://www.kancloud.cn/digest/feihedp/196360

http://www.jasongj.com/design_pattern/summary/

https://www.zhihu.com/question/27191817

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值