设计模式 ~ 面向对象 6 大设计原则浅析与实践

987 阅读16分钟

前言

不管是在工作中,还是相关框架的源码的阅读过程中,或多或少我们都会有一些设计模式的应用和对设计模式的一些思考。

一直以来就想系统的研究下设计模式。接下的日子会发表一些自己对设计的模式的使用和思考。

设计模式是一套被反复使用、多数人知晓、经过分类编目的优秀代码设计经验的总结,所以学习设计模式不管是阅读优秀的框架还是编写健壮可扩展的代码都有裨益

下面就从设计模式分类 及 6 大基本原则开始探讨吧

设计模式的分类

提到设计模式一般机会想到 GoF (Gang of Four),由 Erich GammaRichard HelmRalph JohnsonJohn Vlissides 四个人组成,所以也叫"四人帮"

1994GoF 写的《设计模式:可复用面向对象软件的基础》书籍出版,第一次将设计模式提升到理论高度,并提出了 23 种基本的设计模式

23 中设计模式主要分为三类:创建型、结构型、行为型

创建型

创建型模式主要是用来创建对象的模式,抽象了创建对象的过程,也就是说对外界屏蔽对象是如何被创建和组合的,外界只知道这些对象的共同接口,而无需知道对象的具体实现细节。

创建型设计模式主要包括:

  • 单例模式
  • 工厂方法模式
  • 抽象工厂模式
  • 建造者模式
  • 原型模式

结构型

结构型 主要侧重于 类与类 或者 对象与对象 之间的结构

例如 适配器模式代理模式 都属于结构型

适配器模式 侧重的是类与类之间的结构,适配器模式 将一个接口转换成需求方期待的另一个接口,从而使原本不兼容的两个类能一起工作

代理模式 侧重的是对象之间的关系,代理模式 通过一个对象控制另一个对象的访问。

结构型设计模式主要包括:

  • 代理模式
  • 装饰模式
  • 适配器
  • 组合模式
  • 桥梁模式
  • 外观模式
  • 享元模式

行为型

行为型 设计模式顾名思义关注的是对象的行为。即关注的是对象的函数。

例如 迭代器 模式就是提供一个函数用来访问容器里的各个元素;模板方法 模式将共性的函数封装在父类里,将特性的函数交给子类来实现。

行为型设计模式主要包括:

  • 模板方法模式
  • 命令模式
  • 责任链模式
  • 策略模式
  • 迭代器模式
  • 中介者模式
  • 观察者模式
  • 备忘录模式
  • 访问者模式
  • 状态模式
  • 解释器模式

面向对象6大设计原则

面向对象设计原则也被称为 SOLID 原则 , 根据 维基百科SOLID 的介绍, 它最初由 Robert C.Martin2000 年的 论文 中提出

所以网上很多人说 GoF 23 种设计模式遵循了 SOLID 原则感觉有些不妥, 因为 GoF1994 年提出, SOLID2000 年提出

所以 GoF 提出的时候还没有 SOLID 所以就没有遵循 SOLID 一说,并且 GoF 有些模式是违背了 SOLID 原则的

面向对象设计原则主要包括

  • 单一职责原则 (Single Responsibility Principe 简称 SRP)
  • 开闭原则 (Open-Closed Principle 简称 OCP)
  • 里氏替换原则 (Liskov Substitution Principle 简称 LSP)
  • 接口隔离原则 (Interface Segregation Principle 简称 ISP)
  • 依赖倒置原则 (Dependency Inversion Principe 简称 DIP)

所以面向对象设计原则又称 SOLID 原则, 由上面设计原则首字母组成

除了 SOLID 原则, 还有一个 迪米特法则 (Law of Demeter 简称 LoD). 下面就来分析下这些原则

单一职责原则

单一职责原则 全称 Single Responsibility Principe 简称 SRP , 顾名思义就是一个类应该只有一个职责, 应当只有一个引起它变化的原因

如果一个类包含了多个职责功能, 那么不管哪个功能需要修改, 都会导致这个类需要修改

单一职责原则体现了类的 高内聚细粒度, 类的高内聚和细粒度有利于代码的重用

除此以外, 单一职责原则说明类承担的功能单一, 那也就说明对其他对象的依赖也就越少, 受其他对象的约束和影响就越少, 耦合度就越低, 这就是所谓的 低耦合

例如我们 Android 开发中常用的 MVP 开发模式就是单一职责原则的体现

MVP

  • View 层专注于视图的展示
  • Presenter 层专注业务逻辑
  • Model 层提供数据

假设 Model 层一开始是从远程服务器拉取数据, 如果改成从本地获取数据, ViewPresenter 无需做任何修改

其实 MVP 模式的要求还是挺宽松的, 如果开发者没有处理好职责的划分, 实际开发中也可能会违背单一职责, 导致功能发生变化需要修改好几个地方. 当然这并不是 MVP 的问题, 不管使用哪种程序架构, 都需要开发者对基本的设计原则了然于胸. 这个问题在文章的最后会通过案例的方式继续介绍

开闭原则

开闭原则全称 Open-Closed Principle 简称 OCP , 简而言之就是一个软件实体应当对扩展开放, 对修改关闭.

也就是说可以在不修改原有代码的情况下改变它的行为

是不是很酷, 但是这也是软件设计的时候最难做到的地方, 开闭原则是面向对象设计的终极目标

开闭原则是最基础的原则, 可以把其他原则如单一职责、里式替换原则、依赖倒置、接口隔离、迪米特法则看做是开闭原则的具体体现, 怎么理解这句话呢?

就拿我们上面 MVP 的例子来说, 我们说 MVP 也是单一职责的一种体现, 那 MVP 是怎么体现了开闭原则的呢?

我们上面提到如果 Model 层一开始是从远程拉取数据, 如果改成从本地数据库拉去, ViewPresenter 不需要修改.

你可能会问, 没看出哪里体现了开闭原则(对修改关闭, 对扩展开放).

举个例子, 假设你拉取的 文章详情 逻辑, 从远程服务器拉去我们定义在一个类中叫做 ArticleRemoteSource 实现了 IArticleSource 接口, 业务方法都定义在接口中. 然后通过 Dagger2 框架将 ArticleRemoteSource 对象注入到 Presenter 中.

此时将拉取数据的方式从服务器改成从本地或者其他地方拉取, 只需要通过扩展的方式来实现, 定义一个类叫做 ArticleDao 实现上面的 IArticleSource 接口, 然后修改的 PresenterArticleDao 注入即可, 此时 Presenter 的修改是极小的, 这就是体现了开闭原则的对修改关闭对扩展开放的原则

里氏替换原则

里氏替换原则全称 Liskov Substitution Principle 简称 LSP

里氏替换原则是和面向对象中的继承息息相关的

里氏替换原则简而言之就是: 所有引用基类的地方必须能够使用其子类对象, 而且替换成子类也不会产生任何错误或异常

里式替换原则让开发者更好的编写出面向接口编程的代码, 由于里式替换原则在面向对象中的重要性, Java 代码在编译的时候, 编译器就会去检查程序是否符合里氏替换原则, 例如下面的代码, 编译器会报错

// 声明一个父类
public class Parent {
    public void test(){
    }
}

// 继承父类
public class Liskov extends Parent {
    // 覆写父类的方法, 将访问控制符改成 private
    @Override
    private void test() {
        super.test();
    }
}

因为子类对象在外面无法访问 test 方法, 违反了 里式替换原则 , 所以编译器报错

接口隔离原则

接口隔离原则全称 Interface Segregation Principle 简称 ISP

接口隔离原则的一种定义是: 客户端不应该依赖它不需要的接口 另一种定义是:类间的依赖关系应该建立在最小的接口上

两种不同的定义大概的意思都是一样的: 减少没必要的依赖, 这样耦合性更低

举个 《Java 设计模式及实践》 上面的一个例子我觉得很好说明了接口隔离原则

假设有汽车修理工这样的类: Mechanic, 有个修理的方法:repairCar(ICar car)

class Mechanic {
    void repairCar(ICar car);
}

interface ICar {
    void repair();
    void sell();
}

class Car implements ICar {
    void repair() {
        //...
    }
    void sell() {
        //...
    }
}

我们发现 MechanicrepairCar 方法参数依赖 ICar 接口,但是 ICar 接口有两个方法 repairsell

只不过 repairCar 方法只会用到 ICarrepair 方法,而不需要 sell 方法,这是一个糟糕的设计,它并不符合 接口隔离原则,我们可以将其改造成符合 接口隔离原则 :

interface IRepairable {
    void repair();
}

interface ISellable {
    void sell();
}

class Mechanic {
    void repairCar(IRepairable repairable);
}

interface ICar extends IRepairable, ISellable {
}

class Car implements ICar {
    void repair() {
        //...
    }
    void sell() {
        //...
    }
}

上面的例子很好的说明了 接口隔离原则,把接口拆分的更细,让代码更好的重用

但是总是把接口拆分的更细,总是带来收益吗?

不一定!

比如在 Android 开发中,我们通常会将项目按照功能模块进行多模块划分,那么就不可避免的会有依赖

比如模块 A 依赖模块 B 的某个功能,我们可以将这个功能接口抽取到一个公共的模块,然后通过注入的方式将接口的实现类注入到 A 模块调用的地方,如:

// A 模块

@Inject
userSource IUserSource // 实现类在 B 模块

但是 A 模块依赖的 B 模块的某一个功能方法而已,然后我们把整个接口暴露了出来,这似乎不符合接口隔离原则

如果我们按照接口隔离原则进行改造的话,需要新建一个接口,将 B 模块依赖的功能方法抽取到这个新接口中,就类似上面的例子那样

但是这样有违反了 单一职责原则 本来接口里的方法都是承担某个功能相关职责,如果将其拆分势必需要维护新的接口,这样就会产生两个接口,如果后期功能迭代的过程中,模块 A 需要依赖接口里的两个方法,不仅需要维护新接口,还得维护老接口,这样就违反了 单一职责原则只有一个引起它变化的原因

所以根据接口隔离原则拆分接口时,首先需要满足单一职责原则

如果接口粒度太小,会导致接口数量剧增,如果粒度太大,不利于代码重用,灵活性降低,因此需要根据具体的情况来遵循不同的设计原则,或者拿不准的时候,将 6 大设计原则挨个进行验证,看看违反哪个,就像刚刚举的例子,改造成符合接口隔离原则 ,就违反了单一职责原则,符合单一职责原则,则违反了接口隔离原则,这个时候就要权衡利弊,采用哪种会更好一点,当然是符合单一职责原则更好,更利于代码的维护

依赖倒置原则

依赖倒置原则全称 Interface Segregation Principle 简称 ISP

依赖倒置原则主要包括三层含义:

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

其实总的来说一句话概括就是:面向接口(或抽象类)编程

什么是高层模块,什么是低层模块呢?下面通过一个图解释:

依赖倒置

将上图表示的代码测试一下:

public class Client {
    public static void main(String[] args){
        IDriver zhangsan = new Driver();
        ICar bmw = new BMW();
        zhangsan.drive(bmw);
    }
}

Client 类也属于高层模块,所以声明的时候使用都是接口(抽象),如果 zhangsan 开其 Benz 车只需要 new Benz() 即可,依赖倒置原则减少类之间的耦合

依赖主要有三种方式:

  • 构造函数传递依赖对象
  • Setter方法传递依赖对象
  • 接口方法中传递依赖对象(上面的例子就是)

迪米特法则

迪米特法则全称 Law of Demeter 简称 LoD,也称最少知识原则(Least Knowledge Principe,LKP)

迪米特法则定义是:一个对象应该对其他对象有最少的了解

该法则包含两层意思:只和需要耦合的对象交流;对朋友最少的了解

不要和陌生人说话,只和朋友交流

不要和陌生人说话,只和朋友交流 是什么意思呢? wiki.c2.com/?LawOfDemet… 讲的非常清晰:

  • 一个类中的方法可以访问本类中的其他方法
  • 一个类中的方法可以访问本类中的字段,但不能访问字段的字段
  • 在方法中可以直接访问其参数的方法
  • 在方法中创建的局部变量,可以访问局部变量的方法
  • 在方法中不应该访问全局对象的方法(能否作为参数传递进来)

综上所述,朋友 指的是需要依赖的对象(属性、方法、方法参数、局部变量等),只和需要依赖的对象交流,不能朋友的朋友产生关系,减少耦合

朋友间也有距离

上面说到了只和需要耦合的对象交流,除此以外,还需要对耦合的对象保持最少的了解

这个时候就需要控制好访问控制权限了,只暴露该保留的功能

《设计模式之禅》举得例子我觉得就挺好的:安装程序的时候都会好几步,比如是否确定安装,统一安装协议等等,代码如下所示:

public class Wizard {

    private Random rand = new Random(System.currentTimeMillis());

    public int first() {
        System.out.println("执行第一个方法");
        return rand.nextInt(100);
    }

    public int second() {
        System.out.println("执行第二个方法");
        return rand.nextInt(100);
    }

    public int third() {
        System.out.println("执行第三个方法");
        return rand.nextInt(100);
    }
}

class InstallSoftware {
    public void installWizard(Wizard wizard) {
        int first = wizard.first();
        if (first > 50) {
            int second = wizard.second();
            if (second > 50) {
                int third = wizard.third();
                if (third > 50) {
                    wizard.first();
                }
            }
        }
    }
}

public class Client {
    public static void main(String[]args) {
        InstallSoftware install = new InstallSoftware();
        install.installWizard(new Wizard());
    }
}

上面的代码也是符合只和耦合对象(Wizard)交流的,但是 Wizard 暴露了三个方法, 可以将安装的方法(installWizard)逻辑放到 Wizard 中, 然后将 first/second/third 方法改成 private,不对外暴露:

public class Wizard {
    private Random rand = new Random(System.currentTimeMillis());

    private int first() {
        System.out.println("执行第一个方法");
        return rand.nextInt(100);
    }

    private int second() {
        System.out.println("执行第二个方法");
        return rand.nextInt(100);
    }

    private int third() {
        System.out.println("执行第三个方法");
        return rand.nextInt(100);
    }

    // 新增方法
    public void installWizard() {
        int first = first();
        if (first > 50) {
            int second = second();
            if (second > 50) {
                int third = third();
                if (third > 50) {
                    first();
                }
            }
        }
    }
}

class InstallSoftware {
    public void installWizard(Wizard wizard) {
        wizard.installWizard();
    }
}

经过上面的改造,Wizard 从暴露三个方法到只暴露一个方法,内聚性更强,更利于扩展,将来对 first/second/third 的修改,对外是透明的

实践

下面以项目中的真是需求作为案例,来分析下对面向对象原则的应用和思考

需求描述:目前加载数据的逻辑是从网络加载,如果失败,则展示失败页面。现在改成先从本地加载数据,然后展示,接着尝试从远程加载数据,然后展示。

项目是采用 MVP 架构,加上缓存逻辑之前与之后的主要代码逻辑,以订单列表为例:

订单列表 View 层

// 订单获取成功(没有本地缓存逻辑)
@Override public void getOrderListSuccess(final OrderListResponse response){
    // 渲染数据...
}


// 订单获取成功(加上本地缓存逻辑)
@Override public void getOrderListSuccess(final OrderListResponse response, boolean isCache, boolean isFirstPage){
    // 如果是缓存
	if(isCache){
		//...
	}
	// 如果是第一页
	if(isFirstPage){
	    //...
	}
}

订单列表 Presenter 层

mOrderLocalRepository.getOrderList().flatMap({
	//... 渲染数据
}).flatMap({
    // 从远程加载
	return mOrderRemoteRepository.getOrderList()
}).subscribe({
	//... 渲染数据
},{
	//... 加载失败
})

订单列表 Model 层

新增从本地获取订单类 OrderLocalRepository, 这个和远程获取订单类时一样的,只是数据的来源不同而已

从上面的代码来看, Model 层没有什么好说的,只是添加了新的类,View 层因为添加了缓存的逻辑,修改 getOrderListSuccess 方法签名

首先 View 层违反了 单一职责原则,如果将来需要把缓存逻辑去掉的话,需要修改 View 层,Presenter

View 最好是做纯展示,逻辑统一放到 Presenter 中,诸如 isCache/isFirstPage 的判断可以放到 Presenter 进行处理,

这样的话就不要修改 getOrderListSuccess 方法了,然后通过 Presenter 来调用 View, 如:

// 如果是缓存
if(isCache){
	mView.xxx()
}

// 如果是第一页
if(isFirstPage){
	mView.yyy()
}

这样如果只涉及到逻辑的变更只需要修改 Presenter 层即可,View 层不需要做任何改动,如果只涉及到展示的变更,只需要修改 View 层即可

例如不需要了缓存逻辑,只需要修改 Presenter 即可,不用修改 View 层的 getOrderListSuccess 方法及其对应的接口

上面的案例除了违反了 单一职责原则,其实也违反了 迪米特法则,虽然关于缓存的状态参数 isCache/isFirstPage 是 Presenter 通过方法参数传递给 View 的,但是对于 View 来说不需要知道也没有关系的,这样的话内聚性更强,更利于扩展

小结

在设计功能的时候,经常会遇到有些逻辑或者方法放这个类也行,放那个类也行,可以通过反问自己:这样的设计后面修改的话,修改的地方是不是很多,是不是不利于维护等,考虑的时候最好能够将面向对象的设计原则检验一遍,是不是违反了某个原则,先人总结出来的设计原则,一般都经过了时间的检验了。值得我们在项目中反复的思考和琢磨,这样才能形成属于自己的经验

Reference

  • 《设计模式之禅》
  • 《Java设计模式及实践》
  • 《Java设计模式深入研究》
  • 《设计模式(Java版)》

下面是我的公众号,干货文章不错过,有需要的可以关注下,有任何问题可以联系我:

公众号:  chiclaim