物件導向設計原則 – 6 大原則(二)

物件導向設計原則 – 6 大原則(二)

Overview of Content

在物件導向軟體設計中,一系列的原則被視為引導著優雅且可維護程式碼的指南。這些建議的原則包括「單一職責原則 Single Responsibility Principle」、「里氏替換原則 Liskov Substitution」、「依賴倒置原則 Dependence Inversion」、「介面隔離原則 Interface Segregation」、「迪米特原則 Low of Demeter」以及「開閉原則 Open Close Principle」。這些原則形成了物件導向設計的基石,有助於建立具有彈性和可擴展性的程式結構。

物件導向設計原則:(object-oriented design principles)

在本文中,我們將深入探討這些原則,從「單一職責原則」開始,它提倡每個類別應該只有一個修改的理由,以確保程式碼的可讀性和維護性。接著,我們將深入「里氏替換原則」,討論如何通過繼承建立更有彈性的程式結構。隨後,我們會探討「依賴倒置原則」,強調高階模組不應該依賴於低階模組的具體實現方式。

設計原則最佳實踐:

此外,我們還將探討這些原則的最佳實踐,以及在實際應用中的一些建議。我們會深入了解如何在程式設計中達到更好的模組化、擴展性和可維護性。這將有助於讀者更好地理解如何將這些原則融入到他們的日常開發實踐中,創建出更加優雅且強大的軟體設計。

隨著我們逐一深入這些原則,本文將提供清晰的指引,使讀者能夠在自己的項目中更有效地應用這些設計原則,以達到更優雅且易於維護的程式碼。無論您是初學者還是有經驗的開發者,我們相信這些內容將為您在物件導向設計中的旅程提供有價值的見解

  • 我將 6 大原則分為兩篇:
    • 物件導向設計原則 – 6 大原則(一):包括「單一職責原則 Single Responsibility Principle」、「里氏替換原則 Liskov Substitution」、「依賴倒置原則 Dependence Inversion
    • 物件導向設計原則 - 6 大原則(二):包括「介面隔離原則 Interface Segregation」、「迪米特原則 Low of Demeter」以及「開閉原則 Open Close Principle

介面隔離原則 Interface Segregation

介面也可以稱為「接口」

● 介面隔離原則 全名 Interface Segregation Principle,其定義:

A. 客戶端不需要依賴於它不需要的街口,也就是 讓介面顆粒粒度最小化,不讓類別依賴他不需要的介面

B. 類與類之間的依賴關係應該建立在最小介面上

● 如果界面過大也會導致使用上的困難(可能強迫了一堆不需要的方法),分散定義多個介面可以防止未來變更的擴散,提高靈活性、可維護性

● 界面隔離的目的是解開系統耦合,讓其更容易的被重構、更改、重新部屬

界面隔離 vs. 單一職責

它們之間的不同主要在於 審視角度 的不同

A. 單一職責重點是業務上的劃分,其可能有許多方法在一個類、介面中

B. 介面隔離則是介面中的方法盡可能的少,有幾個模塊就要有幾個介面

界面最小化

● 這裡定義了一個 dyson 空氣清淨機界面,它的基本功能就是風扇、空氣清淨並使用,範例程式如下

A. 空氣清淨機界面功能總和


interface IFan {

    fun airVolume() : Int

    fun cleanAir() : Boolean

}

B. dyson 牌空氣清淨實現類


class Dyson : IFan {
    override fun airVolume(): Int {
        return 3
    }

    override fun cleanAir(): Boolean {
        return true
    }

}

C. 建立一個假的使用者(高層模組),他使用 IFan 界面(依賴低層模組)


class User(private val fan: IFan) {

    fun useFan() {
        println("Air volume(${fan.airVolume()}, clean air(${fan.cleanAir()}))")
    }

}

使用者依賴一整塊完整功能的介面,其 UML 如下

● 以上這看似一個好的設計(符合單一職責),但是存在介面上設計的些許紕漏,它的界面不夠細緻,這導致了使用者依賴了過多方法

假設使用者需要一個不用空氣清淨的單純風扇?那就必須修改實做 Dyson 類

A. 最小化界面,將最每個方法拆分


interface IAirFan {

    fun airVolume() : Int

}

interface ICleanAir {

    fun cleanAir() : Boolean

}

B. 依照業務功能,針對不同產品實做不同介面


class Dyson2 : IAirFan, ICleanAir {
    override fun airVolume(): Int {
        return 3
    }

    override fun cleanAir(): Boolean {
        return true
    }

}

class OnlyFan : IAirFan {

    override fun airVolume(): Int {
        return 5
    }

}

C. 修改使用者,讓使用者依賴最小介面


class User2(private val fan: IAirFan, private val clean : ICleanAir) {

    fun useFan() {
        println("Air volume(${fan.airVolume()}, clean air(${clean.cleanAir()}))")
    }

}

這樣最小化介面,並可以保持介面的穩定度,也方便之後重構(影響會較小);其 UML 概念圖如下

界面方法:合適大小

界面最小化的原則

小要有一定的限度,重點是不能違反單一職責!如果 界面隔離、單一職責 衝突,則以單一職責為主,因為 單一職責才是業務的重點!(首先我們必須先滿足業務需求)

界面的高內聚

介面是對外的承諾,承諾越少其風險越低,修改時的代價就相對的低

● 何謂高內聚?

高內聚就是提高界面、類或是模塊處理業務的能力,減少對外的耦合依賴

通常方法越多其依賴耦合越大

訂製化介面

為了單一個體需求客制介面服務

界面設計的限制

界面最小化的靈活同時也帶來了更多個界面、檔案,複雜化並增加開發的代價、維護成本變高;但它並沒有不好,而是我們必須通過經驗來判斷

界面隔離重點:最佳實踐

● 其實不只是界面、更甚是類也應該有這個觀念,而最小化的是沒有準則的,但我們可以根據一些條件來考量

A. 一個介面只服務一個子模塊、或是業務邏輯

B. 把業務邏輯設置到介面中,並時常回顧思考

C. 若介面已經複雜化

● 尚未高頻率使用:重構

● 高頻率使用、已到 release 環境:使用 Adapter 隔離介面,重新打造乾淨環境

D. 了解環境、拒絕盲從!深入了解業務邏輯,最好的界面就出自於你手~


迪米特原則 Low of Demeter

● 迪米特原則 Low of Demeter 又稱為最少知識原則,一個對象為了減低耦合性,應該對其他類的依賴越少越好

每個對象都一定會與其他對象產生耦合關係,這耦合關係可能是聚合、依賴、組合、關聯...等等

● 迪米特原則 還有另外一個英文描述 only talk to your immediate friends

最少知識:類的關係

A. 不出現關係三角關係(或是更多)

● 首先先看一個有三角關係的類圖(分為下載管理、下載行為、檔案資訊),其 UML 類關係如下,可以看出三角關係


class FileTask constructor(private val action: DownloadAction) {

    fun getDownloadAction() : DownloadAction {
        return action
    }

}

class DownloadAction constructor(private val url: String) {

    fun startDownload() {
        println("Download $url")
    }

}

class DownloadManager {

    fun start() {
        val action = DownloadAction("http//:www.test.123")

        val task = FileTask(action)

        task.getDownloadAction().startDownload()

    }

}

● 依照最少知識原則,讓一個類認識更少的類


class FileTask2 constructor(private val url: String) {

    fun start()  {
        DownloadAction2(url).startDownload()
    }

}

class DownloadAction2 constructor(private val url: String) {

    fun startDownload() {
        println("Download $url")
    }

}

class DownloadManager2 {

    fun start() {
        FileTask2("http//:www.test.123").start()

    }

}

B. 類與類之間的關係是建立在類之間,不是方法之間,這是啥意思?

也就是說一個類不要通過一個方法隱性引入另一個類 (Link),這會使關係過於複雜,看以下範例


class A {

    fun getB() : B = B()
}


class B {

    fun getC() : C = C()
}

class C {

    fun showInfo() {
        println("A->B->C")
    }

}

fun main() {
    // 極為不推薦這種做法
    A().getB().getC().showInfo();
}

暴露細節:使用者代價

當然這是說返回都是不同類型時就會過於複雜,如果返回的是自身(this)那就不會有這種問題

● 設計一個類(或介面)時我們為了分清方法責任,有時會把方法拆分得很細,然而對於使用者來說這樣好嗎?這個答案是不一定,但可以幾個準則

A. 業務邏輯上是否需要拆分這麽細

拆分的過細是否符合高內聚的設計概念

B. 使用者使用這些方法是否代價過大(承擔了過多個細節)?

是否需要主動不斷調用下一個方法才能達到業務邏輯

● 範例程式(這裡不關心依賴倒置問題,專注看迪米特原則):

A. 對外暴露較多方法:使用者經過層層調調用才能達到某個業務目的,代價較大


class HttpTask {
    var url: String? = null
}

class DownloadPool constructor(private val task: HttpTask) {
    fun start() : Boolean {
        println("Start task: ${task.url}")
        return task.url != null
    }
}

class DownloadController {

    private val task = HttpTask()

    private var pool: DownloadPool = DownloadPool(task)

    private var isSuccess: Boolean = false

    fun setUrl(url: String) {
        task.url = url
    }

    fun getUrl() : String? {
        return task.url
    }

    fun startDownload() {
        isSuccess = pool.start()
    }

    fun getDownloadResult() : Boolean {
        return isSuccess
    }

}

fun main() {
    // 使用者調用不易,需要用很多方法
    DownloadController().apply {

        if (getUrl() == null) {
            throw Exception("Already set url.")
        }

        setUrl("http://www.123")

        startDownload()

        getDownloadResult().also {
            println("is download success? $it")
        }
    }
}

B. 減少對外暴露的方法:善用私有方法(或保護方法),來實現高內聚類;使用者調用起來極其簡單,很快速的可以達到業務邏輯


class HttpTask2 {
    var url: String? = null
}

class DownloadPool2 constructor(private val task: HttpTask2) {
    fun start() : Boolean {
        println("Start task: ${task.url}")
        return task.url != null
    }
}

class DownloadController2 {

    private val task = HttpTask2()

    private var pool: DownloadPool2 = DownloadPool2(task)


    private fun setUrl(url: String) {
        task.url = url
    }

    private fun getUrl() : String? {
        return task.url
    }

    fun startDownload() : Boolean {

        if (getUrl() == null) {
            throw Exception("Already set url.")
        }

        setUrl("http://www.123")

        pool.start()

        return pool.start()
    }


}

fun main() {
    // 使用者簡單調用,即可達成業務目標
    DownloadController2().startDownload()
}

● 設計時可以在反覆衡量是否要減少類的方法,因為對外開出的方法越多,修改時涉及的面積就越大

方法要放在哪個類?

● 如果遇到一個方法可以放在 A 類,也可以放在 B 類(或其他類),那我們可以用一個簡單原則來歸納該方法是否可以放在某個類中

A. 該方法是否會增加類之間的關係?(是否邏輯變複雜)

B. 該方法是否會產生不良的負面影響(可能是 改變類的責任、或是 不符合界面隔離... 等等參考)

迪米特重點:最佳實踐

● 迪米特原則的重點在於:

● 解耦類與類之間的關係,將其改為弱耦合關係

● 缺點也很明顯:出現大量的中間類、攜帶過多的參數、複雜度變高;要反覆權衡才能達到最好的平衡

最佳實踐

● 如果一個類經過兩次調用(最多兩次)才能達到其效果,就要考慮重構了

轉跳越多層其複雜度越高

開閉原則 Open Close Principle

新增開放修改關閉,透過繼承升級或擴充程式,也就是說一個軟體實體類應該透過 擴展 來實現、擁抱商業邏輯的改變

● 開閉原則是維運的重點

變化模塊:範例

● 會變化的三種類型(方向)

A. 邏輯變化

不涉及任何模塊,但純改變算法、邏輯概念

修改完後記得去檢查所有依賴類,是否都符合新修改的邏輯(最好就是有單元測試保護)

B. 低層模組(子模組)變化

就上面例子中的拓展類 MovieLoader 就是低層模組,它實現了新的業務邏輯來符合需求,最終高層模組再修改

下面小節會舉個例子

● 高層模組的修改是必然,它的修改並不算在開閉原則之外(要符合業務邏輯就一定要修改到應用的高層邏輯)

C. 視圖變化

就是 UI 界面設計的變化,它可以能間接的影響(考驗)到你程式設計是否可以 Handle 住

● 開閉原則建議我們以擴充(變化模塊)的方式來達成業務需求,而不是透過修改已有的程式來達成業務需求,那我們就要思考一下 是哪個區塊的變化才算達成開閉原則 ?

以下舉個例子來說明

A. 抽象類定義:這個抽象類是對外的一個 契約,透過這個契約我們可以拓展不同的實做,來達到不同的業務邏輯


abstract class BaseLoader {
    protected var cache = LibraryCache()

    abstract fun download(name: String)

    abstract fun read(index: Int): String
}

B. 低層模組(實做抽象):實現基礎業務邏輯的地方


// 低層模組
class BookLoader : BaseLoader() {
    override fun download(name: String) {
        cache.setCache(name).also {
            println("download book")
        }
    }

    override fun read(index: Int): String {
        return cache.getCache(index).also {
            println("read book = $it")
        }
    }
}

C. 高層模組(應用):應用並組合一到多個低層模組,最終實現一個完整的業務邏輯


fun main() {
    var loader : BaseLoader = BookLoader()

    loader.apply {

        arrayOf("平行世界", "奇異點", "量子力學", "粒子加速器", "5G 世界").forEachIndexed { index, book ->
            download(book)

            read(index)
        }

    }
}

UML 如下圖

● 從上可以看出 BaseLoader 是一個契約,我們可以透過抽象的契約來拓展不同的業務邏輯,如下


// 新拓展的業務邏輯

class MovieLoader : BaseLoader() {
    override fun download(name: String) {
        cache.setCache(name).also {
            println("Use Live TV")

            println("download movie")
        }
    }

    override fun read(index: Int): String {
        return cache.getCache(index).also {
            println("Use Live TV")

            println("watch movie = $it")
        }
    }
}

// 高層模組的修改
fun main() {
    var loader : BaseLoader = BookLoader()

    loader = MovieLoader()

    loader.apply {

        arrayOf("大亨小傳", "雞不可失", "葉問 4", "白頭山", "阿甘正傳").forEachIndexed { index, movie ->
            download(movie)

            read(index)
        }

    }

}

開閉原則:優點

A. 對於單元測試的友善

B. 提高覆用性

C. 可維護性

D. 物件導向開發

開閉原則的核心

● 開閉原則是一種概念(或是說口號),並有沒有像其它 5 點一樣就具體的定義,但我們可以透過以下幾個方向來了解到開閉原則的核心概念

A. 抽象約束:對擴展開放的首要前提,就是 抽象對於類的約束!

● 抽象約束包括三個子概念:1. 通過界面 或 抽象約束類、2. 參數類型、返回類型盡量使用界面 或 抽象、3. 抽象層盡量不要隨意修改

抽象、界面(契約)是對物件的通用描述,它並不代表具體的實現邏輯;我們 可以透過抽向來達到對於實體類的約束!


// 透過抽象來定義 通用描述(功能)
interface IBookShop {
    fun getBook(name: String) : Boolean

    fun getPrize() : Int

}

// 透過抽向來限制實體類
class MyBookShop : IBookShop {
    override fun getBook(name: String): Boolean { TODO() }

    override fun getPrize(): Int { TODO() }

    // 如果透過抽象類訪問對象,這個方法就無法被訪問
    fun showBookInformation(name: String) {
        // do something
    }

}

外部取用時,也就只能取用到抽象定義的方法!無法使用到實體類的方法


fun main() {
    val shop : IBookShop = MyBookShop()

    println("Prize: ${shop.getPrize()}, Name: ${shop.getBook("123 木頭人")}")

    // 由於被限制範圍,所以無法訪問實做類的方法
    shop.showBookInformation("123 木頭人")

}

B. 使用元數據 (meta data) 對控制模塊

元數據 (meta data) 感覺很難?但其實它也是一個編程,他是對已有的程式、環境進行拓展編譯,就像是 Java 的反射機制

簡單來說就是透過 時實(Real time)數據 來操控程式,而不是事先先編譯好的程式

這種操縱方式到達高級的境界就是 控制反轉(Inversion of Control)

C. 規範界面

對於團隊開發來講,你保證了一個功能界面,對於其他成員來說,使用起來就只需要知道界面即可,這樣合作起來就會相對高效

D. 界面封裝變化

就如同迪米特法則一樣,使用者只須知道這個界面提供哪些功能,而不並擔憂這個界面的實現狀況

詳細來說封裝變化有兩個要點要注意

● 將相同變化封裝到同一界面

● 將不同變化封裝到不同界面

我們所知的 23 個程式設計模式都是從 不同的角度 對變化進行一定程度的封裝!

開閉原則重點:最佳實踐

● 首先我們要知道,開閉原則是其他 5 大原則的核心

● 最佳實踐

6 大原則概念

6 大原則可以協助我們快速適應產品的變化(前提是類做到了高內聚、低耦合否則很難達到好的設計),但請不要侷限於這 6 大原則

規範界面

盡量讓自己的界面穩定避免不斷修改(不然別人使用起來也會很難用)

預知變化

架構師設計一套系統需要符合當前業務邏輯,同時也要預測未來的拓展可能性(當然這不是 100% 能辦到的),才可以擁抱改變


更多的物件導向設計

物件導向的設計基礎如下,如果是初學者或是不熟悉的各位,建議可以從這些基礎開始認識,打好基底才能走個更穩(在學習的時候也需要不斷回頭看)!

設計建模 2 大概念- UML 分類、使用

物件導向設計原則 – 6 大原則(一)

物件導向設計原則 – 6 大原則(二)

創建模式 Creation Patterns

創建模式 PK

創建模式 - Creation Patterns

結構模式 Structural Patterns

結構模式 PK

結構模式 - Structural Patterns

結構模式專注於「物件之間的組成」,以形成更大的結構。這些模式可以幫助你確保當系統進行擴展或修改時,不會破壞其整體結構。例如,外觀模式、代理模式… 等等,詳細解說請點擊以下連結

Bridge 橋接模式 | 解說實現 | 物件導向設計

Decorate 裝飾模式 | 解說實現 | 物件導向設計

Proxy 代理模式 | 解說實現 | 分析動態代理

Iterator 迭代設計 | 解說實現 | 物件導向設計

Facade 外觀、門面模式 | 解說實現 | 物件導向設計

Adapter 設計模式 | 解說實現 | 物件導向設計

Leave a Comment

Comments

No comments yet. Why don’t you start the discussion?

發表迴響