設計模式 ~ 面向對象 6 大設計原則剖析與實戰

設計模式系列文章目錄導讀:

設計模式 ~ 面向對象 6 大設計原則剖析與實戰
設計模式 ~ 模板方法模式分析與實戰
設計模式 ~ 觀察者模式分析與實戰
設計模式 ~ 單例模式分析與實戰
設計模式 ~ 深入理解建造者模式與實戰
設計模式 ~ 工廠模式剖析與實戰
設計模式 ~ 適配器模式分析與實戰
設計模式 ~ 裝飾模式探究
設計模式 ~ 深入理解代理模式
設計模式 ~ 小結

前言

不管是在工作中,還是相關框架的源碼的閱讀過程中,或多或少我們都會有一些設計模式的應用和對設計模式的一些思考。

一直以來就想系統的研究下設計模式。接下的日子會發表一些自己對設計的模式的使用和思考。

設計模式是一套被反覆使用、多數人知曉、經過分類編目的優秀代碼設計經驗的總結,所以學習設計模式不管是閱讀優秀的框架還是編寫健壯可擴展的代碼都有裨益

下面就從設計模式分類 及 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)

迪米特法則定義是:一個對象應該對其他對象有最少的瞭解

該法則包含兩層意思:只和需要耦合的對象交流;對朋友最少的瞭解

不要和陌生人說話,只和朋友交流

不要和陌生人說話,只和朋友交流 是什麼意思呢? http://wiki.c2.com/?LawOfDemeter 講的非常清晰:

  • 一個類中的方法可以訪問本類中的其他方法
  • 一個類中的方法可以訪問本類中的字段,但不能訪問字段的字段
  • 在方法中可以直接訪問其參數的方法
  • 在方法中創建的局部變量,可以訪問局部變量的方法
  • 在方法中不應該訪問全局對象的方法(能否作爲參數傳遞進來)

綜上所述,朋友 指的是需要依賴的對象(屬性、方法、方法參數、局部變量等),只和需要依賴的對象交流,不能朋友的朋友產生關係,減少耦合

朋友間也有距離

上面說到了只和需要耦合的對象交流,除此以外,還需要對耦合的對象保持最少的瞭解

這個時候就需要控制好訪問控制權限了,只暴露該保留的功能

《設計模式之禪》舉得例子我覺得就挺好的:安裝程序的時候都會好幾步,比如是否確定安裝,統一安裝協議等等,代碼如下所示:

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版)》

如果你覺得本文幫助到你,給我個關注和讚唄!

另外本文涉及到的代碼都在我的 AndroidAll GitHub 倉庫中。該倉庫除了 設計模式,還有 Android 程序員需要掌握的技術棧,如:程序架構、設計模式、性能優化、數據結構算法、Kotlin、Flutter、NDK,以及常用開源框架 Router、RxJava、Glide、LeakCanary、Dagger2、Retrofit、OkHttp、ButterKnife、Router 的原理分析 等,持續更新,歡迎 star。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章