001-设计模式【概述、耦合、UML、七大原则】详细分析

迷茫了一周,一段时间重复的 CRUD ,着实让我有点烦闷,最近打算将这些技术栈系列的文章先暂时搁置一下,开启一个新的篇章《设计模式》,毕竟前面写了不少 “武功招式” 的文章,也该提升一下内功了

一 设计模式概述

(一) 什么是设计模式

设计模式,即Design Patterns,是指在软件设计中,被反复使用的一种代码设计经验。使用设计模式的目的是为了可重用代码,提高代码的可扩展性和可维护性

1995年,GoF(Gang of Four,四人组/四人帮)合作出版了《设计模式:可复用面向对象软
件的基础》一书,收录了23种设计模式,从此树立了软件设计模式领域的里程碑,【GoF设计模式】

(二) 为什么学习设计模式

前面我们学习了 N 种不同的技术,但是归根结底,也只是 CRUD 与 调用之间的堆砌,或许这个创意亦或是业务很完善、很强大,其中也巧妙运用了各种高效的算法,但是说白了,这也只是为了实现或者说解决某个问题而做的

还有时候,两个人同时开发一款相同的产品,均满足了预期的需求,但是 A 的程序,不仅代码健壮性强,同时后期维护扩展更是便捷(这种感觉,我们会在后面具体的设计模式中愈发的感觉到)而 B 的代码却是一言难尽啊

有一句话总结的非常好:

  • 设计模式的本质是面向对象设计原则的实际运用,是对类的封装性、继承性和多态性以及类的关联关系和组合关系的充分理解

也就是说,毕竟像例如Java这样面向对象的语言中,如何实现一个可维护,可维护的代码,那必然就是要降低代码耦合度,适当复用代码,而要实现这一切,就需要充分的利用 OOP 编程的特性和思想

注:下面第二大点补充【耦合】的相关概念,若不需要跳转第三四大点【UML类图及类图间的关系】/【设计模式七大原则】

在之前我写 Spring依赖注入的时候【万字长文】 Spring框架层层递进轻松入门(0C和D),就是从传统开发,讲到了如何通过工厂模式,以及多例到单例的改进,来一步步实现解耦,有兴趣的朋友可以看一下哈

二 什么是耦合?(高/低)

作为一篇新手都能看懂的文章,开始就一堆 IOC AOP等专业名词扔出去,好像是不太礼貌,我得把需要铺垫的知识给大家尽量说一说,如果对这块比较明白的大佬,直接略过就OK了

耦合,就是模块间关联的程度,每个模块之间的联系越多,也就是其耦合性越强,那么独立性也就越差了,所以我们在软件设计中,应该尽量做到低耦合,高内聚

生活中的例子:家里有一条串灯,上面有很多灯泡,如果灯坏了,你需要将整个灯带都换掉,这就是高耦合的表现,因为灯和灯带之间是紧密相连,不可分割的,但是如果灯泡可以随意拆卸,并不影响整个灯带,那么这就叫做低耦合

代码中的例子:来看一个多态的调用,前提是 B 继承 A,引用了很多次

A a = new B();
a.method();

如果你想要把B变成C,就需要修改所有new B() 的地方为 new C() 这也就是高耦合

如果如果使用我们今天要说的 spring框架 就可以大大的降低耦合

A a = BeanFactory().getBean(B名称);
a.method();

这个时候,我们只需要将B名称改为C,同时将配置文件中的B改为C就可以了

常见的耦合有这些分类:

(一) 内容耦合

当一个模块直接修改或操作另一个模块的数据,或者直接转入另一个模块时,就发生了内容耦合。此时,被修改的模块完全依赖于修改它的模块。 这种耦合性是很高的,最好避免

public class A {
    public int numA = 1;
}

public class B {
    public static A a = new A();
    public static void method(){
        a.numA += 1;
    }
    public static void main(String[] args) {
       method();
       System.out.println(a.numA);
    }
}

(二) 公共耦合

两个以上的模块共同引用一个全局数据项就称为公共耦合。大量的公共耦合结构中,会让你很难确定是哪个模块给全局变量赋了一个特定的值

(三) 外部耦合

一组模块都访问同一全局简单变量,而且不通过参数表传递该全局变量的信息,则称之为外部耦合 从定义和图中也可以看出,公共耦合和外部耦合的区别就在于前者是全局数据结构后者是全局简单变量

(四) 控制耦合

控制耦合 。一个模块通过接口向另一个模块传递一个控制信号,接受信号的模块根据信号值而进行适当的动作,这种耦合被称为控制耦合,也就是说,模块之间传递的不是数据,而是一些标志,开关量等等

(五) 标记耦合

标记耦合指两个模块之间传递的是数据机构,如高级语言的数组名、记录名、文件名等这些名字即为标记,其实传递的是这个数据结构的地址

(六) 数据耦合

模块之间通过参数来传递数据,那么被称为数据耦合。数据耦合是最低的一种耦合形 式,系统中一般都存在这种类型的耦合,因为为了完成一些有意义的功能,往往需要将某些模块的输出数据作为另 一些模块的输入数据

(七) 非直接耦合

两个模块之间没有直接关系,它们之间的联系完全是通过主模块的控制和调用来实现的

三 UML 类图及类图之间的关系

在一个相对完善的软件系统中,每个类都有其责任,类与类之间,类与接口之间同时也存在着各种关系,UML(统一建模语言)从不同的角度定义了多种图,在软件建模时非常常用,下面我们说一下在设计模式中涉及相对较多的类图,因为在后面单个设计模式的讲解中,我们会涉及到,也算是一个基础铺垫。

(一) 类

类是一组相关的属性和行为的集合,是一个抽象的概念,在UML中,一般用一个分为三层的矩形框来代表类

  • 第一层:类名称,是一个字符串,例如 Student

  • 第二层:类属性(字段、成员变量)格式如下:

    • [可见性]属性名:类型[=默认值]
    • 例如:-name:String
  • 第三层:类操作(方法、行为),格式如下:

    • [可见性]名称(参数列表)[:返回类型]
  • 例如:+ display():void

(二) 接口

接口,是一种特殊而又常用的类,不可被实例化,定义了一些抽象的操作(方法),但不包含属性其实能见到接口 UML 描述的有三种形式:

  • 第一种:使用一个带有名称的小圆圈来表示,上面的Dog是接口名,下面是接口定义的方法
  • 第二种:使用一个“框”来表示,和类很像,但是在最上面特别标注了 <<interface>>

(三) 关系

(1) 依赖关系

定义:如果一个元素 A 的变化影响到另一个元素 B,但是反之却不成立,那么这两个元素 B 和 A 就可以称为 B 依赖 A

  • 例如:开门的人 想要执行开门这个动作,就必须借助于钥匙,这里也就可以说,这个开门的人,依赖于钥匙,如果钥匙发生了什么变化就会影响到开门的人,但是开门的人变化却不会影响到钥匙开门
  • 例如:动物生活需要氧气、水分、食物,这就是一个很字面的依赖关系

依赖关系作为对象之间耦合度最低的一种临时性关联方式

在代码中,某个类的方法通过局部变量、方法的参数或者对静态方法的调用来访问另一个类(被依赖类)中的某些方法来完成一些职责。

(2) 关联关系

关联就是类(准确的说是实例化后的对象)之间的关系,也就是说,如果两个对象需要在一定时间内保持一定的关系,那么就可以称为关联关系。

  • 例如:学生(Student)在学校(School)学习知识(Knowledge)那么这三者之间就存一个某种联系,可以建立关联关系
  • 例如:大雁(WildGoose)年年南下迁徙,因为它知道气候(climate)规律

关联关系的双方是可以互相通讯的,也就是说,“一个类知道另一个类”

这种关联是可以双向的,也可以是单向的

  • 双向的关联可以用带两个箭头或者没有箭头的实线来表示

  • 单向的关联用带一个箭头的实线来表示,箭头从使用类指向被关联的类

  • 也可以在关联线的两端标注角色名,代表两种不同的角色

在代码中通常将一个类的对象作为另一个类的成员变量来实现关联关系

下图是一个教师和学生的双向关联关系

(3) 聚合关系

聚合关系也称为聚集关系,它是一种特殊的较强关联关系。表示类(准确的说是实例化后的对象)之间整体与部分的关系,是一种 has-a 的关系

  • 例如:汽车(Car)有轮胎(Wheel),Car has a Wheel,这就是一个聚合关系,但是轮胎(Wheel)独立于汽车也可以单独存在,轮胎还是轮胎

聚合关系可以用带空心菱形的实线箭头来表示,菱形指向整体

(4) 组合关系

组合是一种比聚合更强的关联关系,其也表示类整体和部分之间的关系。但是整体对象可以控制部分对象的生命周期,一旦整体对象消失,部分也就自然消失了,即部分不能独立存在

聚合关系可以用带实心菱形的实线箭头来表示,菱形指向整体

### (5) 泛化关系

泛化描述一般与特殊(类图中“一般”称为超类或父类,“特殊”称为子类)的关系,是父类和子类之间的关系,是一种继承关系,描述了一种 is a kind of 的关系,特别要说明的是,泛化关系式对象之间耦合度最大的一种关系

Java 中 extend 关键字就代表着这种关系,通常抽象类作为父类,具体类作为子类

  • 例如:交通工具为抽象父类,汽车,飞机等就位具体的子类

泛化关系用带空心三角箭头的实线来表示,箭头从子类指向父类

(6) 实现关系

实现关系就是接口和实现类之间的关系,实现类中实现了接口中定义的抽象操作

实现关系使用带空心三角箭头的虚线来表示,箭头从实现类指向接口

四 设计模式七大原则

(一) 开闭原则

定义:软件实体应当对扩展开放,对修改关闭

我们在开发任何产品的时候,别指望需求是一定不变的,当你不得不更改的你的代码的时候,一个高质量的程序就体现出其价值了,它只需要在原来的基础上增加一些扩展,而不至于去修改原先的代码,因为这样的做法常常会牵一发而动全身。

也就是说,开闭原则要求我们在开发一个软件(模块)的时候,要保证可以在不修改原有代码的模块的基础上,然后能扩展其功能

我们下面来具体谈谈

(1) 对修改关闭

对修改关闭,即不允许在原来的模块或者代码上进行修改。

A:抽象层次

例如定义一个接口,不同的定义处理思路,会有怎样的差别呢

定义一

boolean connectServer(String ip, int port, String user, String pwd)

定义二

boolean connectServer(FTP ftp)
public class FTP{
    private String ip;
    private int port;
    private String user;
    private String pwd;
    ...... 省略 get set
}

两种方式看似都是差不多的,也都能实现要求,但是如果我们想要在其基础上增加一个新的参数

  • 如果以定义一的做法,一旦接口被修改,所有调用 connectServer 方法的位置都会出现问题
  • 如果以定义二的做法,我们只需要修改 FTP 这个实体类,添加一个属性即可
    • 这种情况下没有用到这个新参数的调用处就不会出现问题,即使需要调用这个参数,我们也可以在 FTP 类的构造函数中,对其进行一个默认的赋值处理

B:具体层次

对原有的具体层次的代码进行修改,也是不太好的,虽然带来的变化可能不如抽象层次的大,或者碰巧也没问题,但是这种问题有时候是不可预料的,或许一些不经意的修改会带了和预期完全不一致的结果

(2) 对扩展开放

对扩展开放,也就是我们不需要在原代码上进行修改,因为我们定义的抽象层已经足够的合理,足够的包容,我们只需要根据需求重新派生一个实现类来扩展就可以了

(3) 开发时如何处理

无论模块是多么“封闭”,都会存在一些无法对之封闭的变化。既然不可能完全封闭,设计人员必须对他设计的模块应该对那种变化封闭做出选择,他必须先猜测出最有可能发现的变化种类,然后构造抽象来隔离那些变化 ——《大话设计模式》

预先猜测程序的变化,实际上是有很大难度,或许不完善,亦或者完全是错误的,所以为了规避这一点,我们可以选择在刚开始写代码的时候,假设不会有任何变化出现,但当变化发生的时候,我们就要立即采取行动,通过 “抽象约束,封装变化” 的方式,创建抽象来隔离发生的同类变化

举例:

例如写一个加法程序,很容易就可以写的出来,这个时候变化还没有发生

如果这个时候让你增加一个减法或者乘除等的功能,你就发现,你就需要在原来的类上面修改,这显然违背了 “开闭原则”,所以变化一旦发生,我们就立即采取行动,决定重构代码,首先创建一个抽象类的运算类,通过继承多态等隔离代码,以后还想添加什么类型的运算方式,只需要增加一个新的子类就可以了,也就是说,对程序的改动,是通过新代码进行的,而不是更改现有代码

小结:

  • 我们希望开发刚开始就知道可能发生的变化,因为等待发现变化的时间越长,要抽象代码的代价就越大

  • 不要刻意的去抽象,拒绝不成熟的抽象和抽象本身一样重要

(二) 里氏替换原则

(1) 详细说明

定义:继承必须确保超类所拥有的性质在子类中仍然成立

里氏替换原则,主要说明了关于继承的内容,明确了何时使用继承,亦或使用继承的一些规定,是对于开闭原则中抽象化的一种补充

这里我们主要谈一下,继承带来的问题:

  • 继承是侵入性的,子类继承了父类,就必须拥有父类的所有属性和方法,降低了代码灵活度
  • 耦合度变高,一旦父类的属性和方法被修改,就需要考虑子类的修改,或许会造成大量代码重构

里氏替换原则说简单一点就是:它认为,只有当子类可以替换父类,同时程序功能不受到影响,这个父类才算真正被复用

其核心主要有这么四点内容:

  • ① 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法
  • ② 子类中可以增加自己特有的方法
  • ③ 当子类的方法重载父类的方法时,子类方法的前置条件(即方法的输入参数)要比父类的方法更宽松
  • ④ 当子类的方法实现父类的方法时(重写/重载或实现抽象方法),方法的后置条件(即方法的的输出/返回值)要比父类的方法更严格或相等

对照简单的代码来看一下,就一目了然了

① 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法

前半句很好理解,如果不实现父类的抽象方法,会编译报错

后半句是这里的重点,父类中但凡实现好的方法,其实就是在设定整个继承体系中的一系列规范和默认的契约,例如 鸟类 Bird 中,getFlyingSpeed(double speed) 用来获取鸟的飞行速度,但几维鸟作为一种特殊的鸟类,其实是不能飞行的,所以需要重写继承的子类方法 getFlyingSpeed(double speed) 将速度置为 0 ,但是会对整个继承体系造成破坏

虽然我们平常经常会通过重写父类方法来完成一些功能,同样这样也很简单,但是一种潜在的继承复用体系就被打乱了,如果在不适当的地方调用重写后的方法,或多次运用多态,还可能会造成报错

我们看下面的例子:

父类 Father

public class Father {
    public void speaking(String content){
        System.out.println("父类: " + content);
    }
}

子类 Son

public class Son extends Father {
    @Override
    public void speaking(String content) {
        System.out.println("子类: " + content);
    }
}

子类 Daughter

public class Daughter extends Father{
}

测试类 Test

public class Test {
    public static void main(String[] args) {
        // 直接调用父类运行的结果
        Father father = new Father();
        father.speaking("speaking方法被调用");

        // Son子类替换父类运行的结果
        Son son = new Son();
        son.speaking("speaking方法被调用");

        // Daughter子类替换父类运行的结果
        Daughter daughter = new Daughter();
        daughter.speaking("speaking方法被调用");

    }
}

运行结果:

父类: speaking方法被调用
子类: speaking方法被调用
父类: speaking方法被调用

② 子类中可以增加自己特有的方法

这句话理解起来很简单,直接看代码

父类 Father

public class Father {
    public void speaking(String content){
        System.out.println("父类: " + content);
    }
}

子类 Son

public class Son extends Father {
    public void playGuitar () {
        System.out.println("这是Son类playGuitar方法");
    }
}

测试类 Test

public class Test {
    public static void main(String[] args) {
        // 直接调用父类运行的结果
        Father father = new Father();
        father.speaking("speaking方法被调用");

        // Son子类替换父类运行的结果
        Son son = new Son();
        son.speaking("speaking方法被调用");
        son.playGuitar();
    }
}

运行结果:

父类: speaking方法被调用
父类: speaking方法被调用
这是Son类playGuitar方法

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

这里要注意,我们说的是重载,可不是重写,下面我们按照里氏替换原则要求的,将父类方法参数范围设小一点 (ArrayList) ,将子类同名方法参数范围写大一些 (List) ,测试后的结果,就是只会执行父类的方法,不执行父类重载后的方法(注:参数名虽然相同,但是类型不同,还是重载,不是重写)

父类 Father

public class Father {
    public void speaking(ArrayList arrayList) {
        System.out.println("父类: " + arrayList.get(0));
    }
}

子类 Son

public class Son extends Father {
    public void speaking(List list) {
        System.out.println("子类: " + list.get(0));
    }
}

测试类 Test

public class Test {
    public static void main(String[] args) {
        ArrayList arrayList = new ArrayList();
        arrayList.add("speaking方法被调用");

        // 直接调用父类运行的结果
        Father father = new Father();
        father.speaking(arrayList);

        // Son子类替换父类运行的结果
        Son son = new Son();
        son.speaking(arrayList);
    }
}

运行结果:

父类: speaking方法被调用
父类: speaking方法被调用

如果我们将范围颠倒一下,将父类方法参数范围设大一些,子类方法参数设小一些,就会发现我明明想做的是重载方法,而不是重写,但是父类的方法却被执行了,逻辑完全出错了,所以这也是这一条的反例,并不满足里氏替换原则

父类 Father

public class Father {
    public void speaking(List list) {
        System.out.println("父类: " + list.get(0));
    }
}

子类 Son

public class Son extends Father {
    public void speaking(ArrayList arrayList) {
        System.out.println("子类: " + arrayList.get(0));
    }
}

测试类 Test

public class Test {
    public static void main(String[] args) {
        ArrayList arrayList = new ArrayList();
        arrayList.add("speaking方法被调用");

        // 直接调用父类运行的结果
        Father father = new Father();
        father.speaking(arrayList);

        // Son子类替换父类运行的结果
        Son son = new Son();
        son.speaking(arrayList);
    }
}

运行结果:

父类: speaking方法被调用
子类: speaking方法被调用

④ 当子类的方法实现父类的方法时(重写/重载或实现抽象方法),方法的后置条件(即方法的的输出/返回值)要比父类的方法更严格或相等

父类中定义一个抽象方法,返回值类型是 List,子类中重写这个方法,返回值类型可以为 List,也可以更精确或更严格,例如 ArrayList

父类 Father

public abstract class Father {
    public abstract List speaking();
}

子类 Son

public class Son extends Father {
    @Override
    public ArrayList speaking() {
        ArrayList arrayList = new ArrayList();
        arrayList.add("speaking方法被调用");
        return arrayList;
    }
}

测试类 Test

public class Test {
    public static void main(String[] args) {
        Father father = new Son();
        System.out.println(father.speaking().get(0));
    }
}

运行结果:

speaking方法被调用

但是,如果反过来,将父类抽象方法返回值定义为范围较小的 ArrayList,将子类重写方法中,反而将返回值类型方法,设置为 List,那么程序在编写的时候就会报错

(2) 修正违背里氏替换原则的代码

现在网上几种比较经典的反例,“几维鸟不是鸟”,“鲸鱼不是鱼” 等等

我打个比方,如果按照惯性和字面意思,如果我们将几维鸟也继承鸟类

但是几维鸟是不能飞行的,所别的鸟通过 setSpeed 方法都能附一个有效的值,但是几维鸟就不得不重写这个 setSpeed 方法,让其设置 flySpeed 为 0,这样已经违反了里氏替换原则

面对子类如果不能完整的实现父类的方法,或者父类的方法已经在子类中发生了“异变”,就例如这里几维鸟特殊的 setSpeed 方法,则一般选择断开父类和子类的继承关系,重新设计关系

例如:

取消鸟和几维鸟的继承关系,定义鸟和几维鸟更一般的父类,动物类

(三) 依赖倒置

定义:

  • ① 高层模块不应该依赖低层模块,两者都应该依赖其抽象

  • ② 抽象不应该依赖细节,细节应该依赖抽象

先解释第 ① 点,其实这一点在我们以往的分层开发中,就已经用过了,例如我们的业务层 Service(高层模块)就没有依赖数据访问层 Dao/Mapper(低层模块),我们都通过 Mapper 的接口进行访问,这种情况下,如果数据访问层的细节发生了变化,那么也不会影响到业务层,但是如果直接依赖于实现,那么就会影响巨大

第 ② 点,还是在讨论要进行抽象的问题,抽象是高层,具体细节是底层,这和前一点也是契合的,正式说明了一条非常关键的原则 “面向接口编程,而非针对现实编程”

举个例子

例如一个 Client 客户想访问学校的 readBook 方法,可以这么写

public class Client {
    public void read(ASchool aSchool){
        System.out.println(aSchool.readBook());
    }
}

但是,这个地方其实就出现了一个比较大的问题,我们就是直接依赖了具体,而不是抽象,当我们想要查看另一个B学校的 readBook 方法,就需要将代码修改为

public class Client {
    public void read(BSchool bSchool){
        System.out.println(bSchool.readBook());
    }
}

但是开闭原则规定,对修改关闭,所以明显违背了开闭原则,如果我们将代码抽象出来,以接口访问就可以解决

定义学校接口 ISchool (I 是大写的 i 只是命名习惯问题,无特殊意义)

public interface ISchool {
    String readBook();
}

学校 A 和 B 分别实现这个接口,然后实现接口方法

public class ASchool implements ISchool {
    @Override
    public String readBook() {
        return "阅读《Java 编程思想》";
    }
}

public class BSchool implements ISchool {
    @Override
    public String readBook() {
        return "阅读《代码整洁之道》";
    }
}

Client 客户类,调用时,只需要传入接口参数即可

public class Client {
    public void read(ISchool school){
        System.out.println(school.readBook());
    }
}

看一下测试类

public class Test {
    public static void main(String[] args) {
        Client client = new Client();
        client.read(new ASchool());
        client.read(new BSchool());
    }
}

运行结果

阅读《Java 编程思想》
阅读《代码整洁之道》

(四) 单一职责原则

定义:单一职责原则规定一个类应该有且仅有一个引起它变化的原因,否则类应该被拆分

一个类,并不应该承担太多的责任,否则当为了引入类中的 A 职责的时候,就不得不把 B 职责 也引入,所以我们必须满足其高内聚以及细粒度

优点:

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

就比如大学老师,负责很多很多工作,但是不管是辅导员,授课老师,行政老师,虽然都可以统称为老师,但是将大量的内容和职责放到一个类中,显然是不合理的,不如细分开来

例如:

补充:大家可能看过 “羊呼吸空气,鱼呼吸水” 的例子,这里我不做演示,做一个说明,有时候,在类简单的情况下,也可以在代码或者方法级别上违背单一职责原则,因为即使一定的修改有一定开销,但是几乎可以忽略不计了,不过一般情况,我们还是要遵循单一职责原则

(五) 接口隔离原则

定义:

  • 客户端不应该被迫依赖于它不使用的方法

  • 或者——客户端不应该被迫依赖于它不使用的方法

其实这一原则的核心就是 “拆” ,如果在一个接口内存放过多的方法等内容,就会十分臃肿,竟可能的细化接口,也就是为每个类创建专用接口,毕竟依赖多个专用接口,比依赖一个综合接口更加灵活方便,同时,接口作为对外的一个 “入口”,拆散,隔离接口能够缩小外来因素导致的问题扩散范围

还是通过一个例子来展开:

现在有一个 “好学生的接口和实现类”,还有一个老师的抽象类和其子类,老师能做的,就是去找到好的学生

好学生 IGoodStudent 接口

public interface IGoodStudent {
    //学习成绩优秀
    void goodGrades();
    //品德优秀
    void goodMoralCharacter();
    //良好形象
    void goodLooks();
}

好学生 IGoodStudent 接口的实现类 GoodStudentImpl

public class GoodStudentImpl implements IGoodStudent {

    private String name;

    public GoodStudentImpl(String  name) {
        this.name = name;
    }

    @Override
    public void goodGrades() {
        System.out.println("【" +this.name + "】的学习成绩优秀");
    }

    @Override
    public void goodMoralCharacter() {
        System.out.println("【" +this.name + "】的品德优良");
    }

    @Override
    public void goodLooks() {
        System.out.println("【" +this.name + "】的形象良好");
    }
}

老师抽象类 AbstractTeacher

public abstract class AbstractTeacher {
    protected IGoodStudent goodStudent;

    public AbstractTeacher(IGoodStudent goodStudent) {
        this.goodStudent = goodStudent;
    }

    public abstract void findGoodStudent();
}

老师类 Teacher

public class Teacher extends AbstractTeacher {
    public Teacher(IGoodStudent goodStudent) {
        super(goodStudent);
    }

    @Override
    public void findGoodStudent() {
        super.goodStudent.goodGrades();
        super.goodStudent.goodMoralCharacter();
        super.goodStudent.goodLooks();
    }
}

测试类 Test

public class Test {
    public static void main(String[] args) {
        IGoodStudent goodStudent = new GoodStudentImpl("阿文");
        AbstractTeacher teacher = new Teacher(goodStudent);
        teacher.findGoodStudent();
    }
}

运行结果:

【阿文】的学习成绩优秀
【阿文】的品德优良
【阿文】的形象良好

一下子看来是没什么问题的,不过由于每个人的主观意识形态不同,或许每个人对于 “好学生” 的定义并不同,就例如就我个人而言,我认识为 “师者,传道授业解惑也” ,学生能学习其为人处世的道理与主动学习更是难能可贵,至于外貌更属于无稽之谈。针对不同人的不同不同定义,这个 IGoodStudent 接口就显得有一些庞大且不合时宜了,所以我们根据接口隔离原则,将 “好学生” 的定义进行一定的拆分隔离

学习的学生接口

public interface IGoodGradesStudent {
    //学习成绩优秀
    void goodGrades();
}

品德优秀的学生接口

public interface IGoodMoralCharacterStudent {
    //品德优秀
    void goodMoralCharacter();
}

好学生实现多个接口

public class GoodStudent implements IGoodGradesStudent,IGoodMoralCharacterStudent {

    private String name;

    public GoodStudent(String name) {
        this.name = name;
    }

    @Override
    public void goodGrades() {
        System.out.println("【" +this.name + "】的学习成绩优秀");
    }

    @Override
    public void goodMoralCharacter() {
        System.out.println("【" +this.name + "】的品德优良");
    }
}

(六) 迪米特法则

定义:如果两个类不必要彼此直接通讯,那么这两个类就不应当发生直接的相互作用,如果其中一个类需要调用另一个类的某一个方法的话,可以通过第三者转发这个调用

这句话的意思就是说,一个类对自己依赖的类知道越少越好,也就是每一个类都应该降低成员的访问权限,就像封装的概念中提到的,通过 private 隐藏自己的字段或者行为细节

迪米特法则中的“朋友”是指:当前对象本身、当前对象的成员对象、当前对象所创建的对象、当前对象的方法参数等这些对象当前对象存在关联、聚合或组合关系,可以直接访问这些对象的方法

注意:请不要过分的使用迪米特法则,因为其会产生过多的中间类,会导致系统复杂性增大,结构不够清晰

下面还是用一个例子来说一下

假设在学校的一个环境中,校长作为最高的职务所有人,肯定不会直接参与到对于老师和学生的管理中,而是通过一层一层的管理体系来进行统筹规划,这里的校长,和老师学生之间就可以理解为陌生关系,而校长和中层的教务主任却是朋友关系,毕竟教务主任数量少,也可以直接进行沟通

教务主任类 AcademicDirector

public class AcademicDirector {

    private Principal principal;
    private Teacher teacher;
    private Student student;

    public void setPrincipal(Principal principal) {
        this.principal = principal;
    }

    public void setTeacher(Teacher teacher) {
        this.teacher = teacher;
    }

    public void setStudent(Student student) {
        this.student = student;
    }

    public void meetTeacher() {
        System.out.println(teacher.getName() + "通过教务主任向" + principal.getName() + "汇报工作");
    }

    public void meetStudents() {
        System.out.println(student.getName() + "通过教务主任与" + principal.getName() + "见面");
    }

}

校长类 Principal

public class Principal {
    private String name;

    Principal(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}

老师类 Teacher

public class Teacher {
    private String name;

    Teacher(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}

学生类 Student

public class Student {
    private String name;

    Student(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}

测试类 Test

public class Test {
    public static void main(String[] args) {
        AcademicDirector a = new AcademicDirector();
        a.setPrincipal(new Principal("【张校长】"));

        a.setTeacher(new Teacher("【王老师】"));
        a.setStudent(new Student("【阿文】"));

        a.meetTeacher();
        a.meetStudents();
    }
}

补充:迪米特法则在《程序员修炼之道》一书中也有提及到 —— 26 解耦与得墨忒耳法则

函数的得墨忒耳法则试图使任何给定程序中的模块之间的耦合减至最少,它设法阻止你为了获得对第三个对象的方法的访问而进入某个对象。

通过使用函数的得墨忒耳法则来解耦 编写“羞怯”的代码,我们可以实现我们的目标:

Minimize Coupling Between Modules

使模块之间的耦合减至最少

(七) 合成复用原则

定义:在软件复用时,要尽量先使用组合或者聚合等关联关系来实现,其次才考虑使用继承关系来实现

这一点和里氏替换原则的目的是一致的,都是处理关于继承的内容,本质都是实现了开闭原则的具体规范

为什么用组合/聚合,不用继承

  • 继承破坏了类的封装性,因为父类对于子类是透明的,而组合/聚合则不会
  • 继承父子类之间之间的耦合度比组合/聚合新旧类高
  • 从父类继承来的实现是静态的,运行时不会发生变化,而组合/聚合的复用灵活性高,复用可在运行时动态进行

如果代码违背了里氏替换原则,弥补的方式,一个就是我们前面说的,加入一个更普通的抽象超类,一个就是取消继承,修改为组合/聚合关系

我们简单回忆一下

  • 继承我们一般都叫做 Is-a 的关系,即一个类是另一个类的一种,比如,狗是一种动物

  • 组合/聚合都叫做 Has-a,即一个角色拥有一项责任或者说特性

例如我们来讨论一下常见的特殊自行车(即变速自行车),首先按照类型可以分为 山地自行车和公路自行车,按照速度搭配又可以分为 21速自行车 ,24速自行车,27速自行车(简单分)

XX速山地自行/公路车,虽然说我们口头上可能会这么叫,但是其实这就是将速度这种 Has- a 的关系和 Is-a 的关系搞混了,而且如果通过继承,会带来很多的子类,一旦想要增加修改变速自行车种类以及速度类型,就需要修改源代码,违背了开闭原则,所以修改为组合关系

五 结尾

这篇文章写到这里就结束了,又是一篇 接近1W 字的内容,学习到一定阶段,确实会有一些瓶颈,经过对于类似设计模式等 “内功” 的学习,也突然发现开发真不是 CRUD 的不断重复,一段有质量的代码,更能让人有成就感,后面对于常见的设计模式我会一直更新下去,一边学习,一边总结,感谢大家的支持。


   转载规则


《001-设计模式【概述、耦合、UML、七大原则】详细分析》 BWH_Steven 采用 知识共享署名 4.0 国际许可协议 进行许可。
  目录