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
」
- 物件導向設計原則 - 6 大原則(一):包括「單一職責原則
單一職責 Single Responsibility Principle
● 單一職責:就一個類而言,應該僅有一個引起它變化的原因
● 例如兩個完全不一樣功能的函數就不應該放在同一類別之中,但是如何單一化責任是透過經驗判斷,並沒有一個標準
由於單一職責是一個較為模糊不清楚的定義,是需要每個人不同的經驗去判斷,所以較容易起爭議
BO & Biz 概念區分
● 以往我們接觸的程式都有物件、使用的參雜情況(如下)
interface IBookShop {
val name: String
val book: String
val prize: Int
fun buyBook(bookName: String)
fun orderBook(bookName: String)
fun getBook(phone: String) : String
}
職責不清,如下圖表示
● 這時就可以 使用 BO (Business Object
) 邏輯物件、Biz(Business Logic
) 邏輯行為,這個依據點作為單一直則的一種區分方式!
A. BO (Business Object
) 邏輯物件
把原先的 IBookShop 介面中的「純業務資訊」給抽出,像是書局名稱、書名、價格… 等等封裝 IReaderInfo 介面中,範例程式如下:
interface IReaderInfo {
val name: String
val book: String
val prize: Int
}
B. Biz (Business Logic
) 邏輯行為
再把原先的 IBookShop 介面中的「行為動作」給抽出來,像是買書、訂書、取書… 等等行為封裝在 IBookShopAction 介面中,範例程式如下:
interface IBookShopAction {
fun buyBook(info: IReaderInfo)
fun orderBook(info: IReaderInfo)
fun getBook(info: IReaderInfo) : String
}
經過 BO/Biz 整理過後的 UML 如下
細粒度的考量
● 關於類方法、接口的細粒度,謂何說是 考量 呢?
因為若是拆分過細,反而是 人工增加了系統的複雜度 (生搬硬套只會導致由原則引起的類的劇增),以下舉一個例子範應這個狀況
A. 原本接口的設計,其粒度較大,能夠接收、發送任意數據
interface ICommunication {
fun receiveData(parser: Any)
fun sendData(parser: Any)
}
B. 依據單一職責 parser 的行為應該區分開來,不該由方法中自己實現,這個行為也將接口細粒度變小
interface IParserData {
fun parser(data: Any)
}
interface IDataTransfer {
fun receiveData(parser: IParserData)
fun sendData(parser: IParserData)
}
● 以上這個修改是好的嘛??
大體上是好的,但它同時增加了使用者使用的複雜度;下圖中 1. 組合使用起來就必須實現兩個類,並將其拼湊在一起、2. 直接實現
單一職責重點:最佳實踐
● 單一職責的特色
● 降低類的複雜度
● 提高程式碼可讀性
● 可維護性高
● 變更的風險降低… 因為職責的清晰劃分,讓更變只會作用在特定類或特定區域(但這不代表這個類不會修改),所以我們在設計時 最好注意到單一職責,可以降低修改類的影響!
● 最佳實踐方案
● 接口一定作到單一職責;而類的方法設計我們就是盡量~ 達到單一職責
● 對於方法的設計以照一個原則:這個方法是否能讓人一看見它就知道他是要拿來幹麻的?如果不能一眼看出其功能,代表它不符合單一職責!
interface IUserAccountModify {
// 不符合單一職責
fun modifyUserInformation(userName: String, userPhone: String, id: Long)
// 符合單一職責
fun changePasswd(userName: String, newPassword: String)
}
里氏原則 Liskov Substitution
● 里氏原則的全名是 Liskov Substitution
:引用基類的地方必須能透明的使用子類的對象,也就是實作的子類別可以隨意替換,而 抽象(抽象類、界面) 就是里氏原則的代表
簡單來說就是:父類可以出現的地方,替換為某個子類也不會影響其任何功能,並且要「符合父類定義的規範」、「完全在父類的掌控之下」
// 定義抽象基類
abstract class Shape {
abstract fun area(): Double
}
// 定義子類 - 正方形
class Square(val sideLength: Double) : Shape() {
override fun area(): Double {
return sideLength * sideLength
}
}
// 定義子類 - 圓形
class Circle(val radius: Double) : Shape() {
override fun area(): Double {
return Math.PI * radius * radius
}
}
// 函數接受 Shape 對象並計算面積
fun calculateArea(shape: Shape): Double {
return shape.area()
}
fun main() {
// 使用正方形
val square = Square(5.0)
val squareArea = calculateArea(square)
println("正方形的面積:$squareArea")
// 使用圓形
val circle = Circle(3.0)
val circleArea = calculateArea(circle)
println("圓形的面積:$circleArea")
}
● 里式原則 & 開閉原則
兩者之間常常是形影不離的,透過里式原則的 抽象來拓展,可以達到開閉原則的定義條件(對拓展開放、對修改封閉)
● 里氏原則是「語法」(程式面)、「語意」(設計面)相同的推論,若兩者不符合,則該類不適用於里氏原則
通常錯誤都是兩方配合,也就是 誤用語法、誤解語意 造成的
● 里氏原則不僅限制於類的繼承,也可以用在界面上的繼承(
implements
)里氏原則是建立類在界面的「合約」之上!! 為了補足(或是說實現) 規範抽象
繼承規則 4 層含意
● 里式替換原則為良好的繼承定義了一個規範,其(繼承)規範包含了 4 層含意
A. 子類必須完全實現父類的方法!(當然這裡我們不談論中間層的抽象類)
● AbstractPoker
類:父類宣告抽象方法
abstract class AbstractPoker {
abstract fun material() : String
abstract fun playTimes() : Int
}
● PaperPoker
、PlasticPoker
類:子類必須(一定)要完成父類的方法
class PaperPoker : AbstractPoker() {
override fun material(): String {
return "Paper"
}
override fun playTimes(): Int {
return 50
}
}
class PlasticPoker : AbstractPoker() {
override fun material(): String {
return "Plastic"
}
override fun playTimes(): Int {
return 500
}
}
● 作為使用抽象類的開發者,可以宣告父類型作為接收函數,並且可以隨時替換實做,不必擔實做的細節(使用者僅須要了解「抽象界面上給予的承諾」即可)
fun main() {
// play 函數不會知道真正的實做
fun play(poker: AbstractPoker) {
println("${poker.material()},play times: ${poker.playTimes()}")
}
play(PaperPoker())
play(PlasticPoker())
}
● 子類實現時要注意「合約」
如果 子類已經不能完整實現父類的方法,亦或曲解的父類方法的原意(或是不符合業務邏輯),這時就必須斷開父子繼承關係!改為採用依賴、聚合、組合... 等等關係取代
否則其基底就容易歪掉,之後就不容易修正
B. 子類可以有自己的特性!這裡的特性就是指子類可以有自己的方法、屬性
class PlasticPoker : AbstractPoker() {
override fun material(): String {
return "Plastic"
}
override fun playTimes(): Int {
return 500
}
// 子類自身特性
fun waterproof() : Boolean {
return true
}
}
● 但這種寫法也要 注意 Downcast 的不安全性
Downcast 就是將父類 強行 轉換成子類,這個行為可能會倒置
ClassCastException
錯誤,因為 父類沒有子類特性的實做如果發生 ClassCastException 問題,那就違反了里式原則
● 這個描述與前面所說的相同,都在強調父類(或基類)對象可以被其子類替換而不應該影響程式的正確性
「透明替換」表示在使用子類對象時,不應該需要知道它是子類,而應該像使用父類一樣。這樣才能確保符合里氏替换原則
C. Function 參數 特色:Overload
時 子類的參數可以被放大! 以子類的角度來看
在方法名相同的情況下要重載方法有幾個方式 1. 不同參數數量(這大家都知道就不說了)、2. 擴大子類的參數範圍、3. 縮小子類的參數範圍
● 子類擴大(模糊化)的參數範圍:
如果子類參數擴大,那就 符合里式原則;父類出現的地方,子類替換後 仍在父類控制範圍(這是個重點)
open class Father {
open fun getCollection(map: HashMap<String, String>) : Collection<String> {
println("Father work")
return map.values
}
}
class Child : Father() {
// Overload
// 子類放大接收的參數範圍
fun getCollection(map: Map<String, String>) : Collection<String> {
println("Child work")
return map.values
}
}
● 測試子類擴大參數後是否可被正常訪問? 這裡測試兩種狀況,其實就是父類、子類呼叫同個函數,並觀察是哪個實做類被呼叫到
fun main() {
fun test1() {
// 實做類 Father
val father = Father()
father.getCollection(HashMap<String, String>().apply {
put("A", "Apple")
}).let {
println(it)
}
}
fun test2() {
// 實做類 Child
val child = Child()
child.getCollection(HashMap<String, String>().apply {
put("A", "Apple")
}).let {
println(it)
}
}
test1()
test2()
}
● 從這裡可以看出來,子類完全不會被呼叫到,證明了子類的擴大參數範圍 (
HashMap
->Map
) 無法被指定到(永遠不會)!!保證了父類的規範 不會被覆蓋
● 子類縮小(更具體)的參數範圍:
縮小參數 不符合里式原則;將原先父類的替換為子類,子類 Overload
函數後就可能被訪問到 (不在父類控制範圍)
open class Father2 {
open fun getCollection(map: Map<String, String>) : Collection<String> {
println("Father work")
return map.values
}
}
class Child2 : Father2() {
// Overload
fun getCollection(map: HashMap<String, String>) : Collection<String> {
println("Child work")
return map.values
}
}
曲解了父類的規範,不符合里氏替換原則
● 測試子類縮小參數後是否可被正常訪問:這裡測試兩種狀況,其實就是父類、子類呼叫同個函數,並觀察是哪個實做類被呼叫到
fun main() {
fun test1() {
val father = Father2()
father.getCollection(HashMap<String, String>().apply {
put("A", "Apple")
}).let {
println(it)
}
}
fun test2() {
val child = Child2()
child.getCollection(HashMap<String, String>().apply {
put("A", "Apple")
}).let {
println(it)
}
}
test1()
test2()
}
從結果可以看出來由於子類縮小參數範圍(
Map
->HashMap
)導致子類 Overload 的方法不能被覆蓋到,所以最終子類會被訪問!
D. Function 返回 特色:子類返回的類型可以縮小! Override 時子類返回的類型可以縮小,也就是可以返回父類定義的子類(但不能擴大)
仍在父類的控制範圍之內就 沒有違反父類的規範,反而是加強父類的規範!
● 返回類型的繼承關係如下
// 父類
open class ReturnFather
// 子類
class ReturnChild: ReturnFather()
● 繼承關係:Override 時 子類可以改動返回類型,子類縮小返回的範圍,這裡所說的縮小,是指「特殊化」(以 UML 的角度來說,父類稱為泛化,子類稱為特殊化)
這符合里氏原則
open class Father3 {
open fun getSample() : ReturnFather {
return ReturnFather()
}
}
class Child3 : Father3() {
// 縮小返回的範圍是可以的,仍在父類的掌控範圍之內
override fun getSample(): ReturnChild {
return ReturnChild()
}
}
里式原則重點:最佳實踐
● 特色:既然里氏原則的重點是 抽象,那我們就要來了解一下抽象「繼承」的優、缺點(這裡特別強調繼承是有原因的,以程式語言來說,繼承是一種達成里式原則的手法)
● 抽象「繼承」優點:
● 方法、成員的覆用
● 由於是抽象繼承,可拓展性變大
● 細節可由子類決定
● 抽象「繼承」缺點:
● 抽象是 侵入性、靜態,強迫擁有父類的所有屬性、方法
● 可能造成子類有不需要的方法、成員,抽象過大時或設計不良時,反而降低了靈活度
● 修改類的代價變大,在修改父類時同時要考慮到是否會影響其他子類
● 最佳實踐方案
● 盡量避免子類的 個人特性,一但子類有特性後就可能曲解父類原本設計的意圖,最終會 導致無法相互替換、不易維護的問題
當子類有 個人特性(或是說與父類不同意圖時),就應該拆分繼承關係!
依賴倒置原則 Dependence Inversion
● 依賴倒置原則全名是 Dependence Inversion Principle
:高層次模組不依賴於低層次模組的實現細節,反轉模組的依賴關係
A. 高層模組不一賴於低層模組,兩者應該依賴抽象(abstract
or interface
):實現類與實現類之間不該產生直接關係
● 高層模組:調用者
● 低層模組:實現端
B. 抽象不依賴細節:接口、抽象不依賴實現類
C. 細節應該依賴抽象:實現類應該依賴於接口、抽象
● 類與類之間的關係應該盡可能的依賴各自的抽象,如果互相依賴於細節,兩者之間就會直接產生耦合,導致細節修改後,就需要改動到原來的程式
● DIP 精簡的定義就是 物件導向 的重點
● 依賴倒置是 6 大原則中最不易實現的,但它是 實現開閉原則的重要技術
證明依賴倒置
● 要證明依賴倒置原則最常見的方式有兩種:1. 順推驗證、2. 反證法
A. 反證法(RAA):提出 偽命題(錯誤命題),然後推導出一個荒漠與已知條件互斥的結論(也就是反向證明)
依賴倒置的 偽命題:不使用依賴倒置原則也可以減少類之間的耦合關係!來達到低耦合、高穩定、可維護、可拓展性
● 首先寫一個相互依賴細節實做的低層模組,低層模組織間相互依賴(UML 如下)
class Computer constructor(val cpu: CPU) {
fun showInfo() {
println("CPU: ${cpu.getCore()}")
}
}
class CPU {
fun getCore() : Int {
return 2
}
}
● 再創建一個高層模組來使用低層模組
fun main() {
Computer(CPU()).showInfo()
}
結果如下
● 這時需要替換低層模組 CPU,增大其核心數,來看看是否好增加這個類?可以發現 被依賴者(CPU)的更動居然要依賴者 (Computer) 來承擔!
class PowerCPU {
fun getCore() : Int {
return 8
}
}
fun main() {
Computer(PowerCPU()).showInfo()
}
B. 順推驗證(inductive reasoning):根據提出的議題討論,推出和定義相同的結論
依賴倒置的 討論:使用依賴倒置原則減少類之間的耦合關係!來達到低耦合、高穩定、可維護、可拓展性
● 透過讓低層模組依賴細節來驗證是否可以有高拓展性
// 低層模組換成依賴接口
class Computer2 constructor(val cpu: ICPUInfo) {
fun showInfo() {
println("CPU: ${cpu.getCore()}")
}
}
interface ICPUInfo {
fun getCore() : Int
}
class CPU2 : ICPUInfo {
override fun getCore() : Int {
return 2
}
}
class PowerCPU : ICPUInfo {
override fun getCore() : Int {
return 8
}
}
● 這樣的關係下,被依賴者(CPU)的更動 不須要 依賴者 (Computer) 承擔! 證明依賴倒置,隱藏了實做細節,提供了最大化的隔離、穩定
● 何為穩定性?
高穩定的設計在周遭環境變化時仍可以作到很少的異動就達成業務需求!
TDD & 依賴倒置
● TDD 開發模式的技術,就是依賴倒置的最高級應用;先寫好單元測試再進撰寫實現類對於高質量代碼很有幫助!
A. 有關 Java、Kotlin 單元測試技術,請參考 Mockito & Mockk 框架
B. 接口注入的方式可以參考 依賴注入
依賴倒置重點:最佳實踐
● 依賴倒置最佳實踐
● 每個類都盡量有接口、抽象類:有了抽象才可以方便置換
● 變量的宣告類型,盡可能的使用抽象
● 如果一個基類已經是一個抽象類,並且它實現某個方法,那這個方法繼承的子類就盡量不要去複寫
避免子類曲解了父類方法的原意,造成了不穩定性
● 類應該由抽象產生,而不是具體派生!
// 具體類
class Phone {
// 方法只有具體類才有,它定義了細節並暴露了細節
fun prize() : Int {
return 1000
}
}
● 結合里式替換原則使用
● 接口:負責定義公開的屬性、方法,並聲明與其他物件的依賴關係(抽象依賴抽象)
● 抽象類:實現共有的業務邏輯,在必要時適當的對父類進行細化
更多的物件導向設計
物件導向的設計基礎如下,如果是初學者或是不熟悉的各位,建議可以從這些基礎開始認識,打好基底才能走個更穩(在學習的時候也需要不斷回頭看)!
創建模式 Creation Patterns
● 創建模式 PK
● 創建模式 - Creation Patterns
:
創建模式用於「物件的創建」,它關注於如何更靈活、更有效地創建物件。這些模式可以隱藏創建物件的細節,並提供創建物件的機制,例如單例模式、工廠模式… 等等,詳細解說請點擊以下連結
● Singleton 單例模式 | 解說實現 | Android Framework Context Service
● Abstract Factory 設計模式 | 實現解說 | Android MediaPlayer
● Factory 工廠方法模式 | 解說實現 | Java 集合設計
● Builder 建構者模式 | 實現與解說 | Android Framwrok Dialog 視窗
● Clone 原型模式 | 解說實現 | Android Framework Intent
行為模式 Behavioral Patterns
● 行為模式 PK
● 行為模式 - Behavioral Patterns
:
行為模式關注物件之間的「通信」和「職責分配」。它們描述了一系列物件如何協作,以完成特定任務。這些模式專注於改進物件之間的通信,從而提高系統的靈活性。例如,策略模式、觀察者模式… 等等,詳細解說請點擊以下連結
● Stragety 策略模式 | 解說實現 | Android Framework 動畫
● Interpreter 解譯器模式 | 解說實現 | Android Framework PackageManagerService
● Chain 責任鏈模式 | 解說實現 | Android Framework View 事件傳遞
● Specification 規格模式 | 解說實現 | Query 語句實做
● Command 命令、Servant 雇工模式 | 實現與解說 | 物件導向設計
● Memo 備忘錄模式 | 實現與解說 | Android Framwrok Activity 保存
● Visitor 設計模式 | 實現與解說 | 物件導向設計
● Template 設計模式 | 實現與解說 | 物件導向設計
結構模式 Structural Patterns
● 結構模式 PK
● 結構模式 - Structural Patterns
:
結構模式專注於「物件之間的組成」,以形成更大的結構。這些模式可以幫助你確保當系統進行擴展或修改時,不會破壞其整體結構。例如,外觀模式、代理模式… 等等,詳細解說請點擊以下連結
● Decorate 裝飾模式 | 解說實現 | 物件導向設計
● Iterator 迭代設計 | 解說實現 | 物件導向設計