技術觀念 | SOLID(物件導向設計)
因為在講解SOLID
的過程很抽象,所以透過實際物品例子來做解釋;但是又會想要知道如何用程式去解釋。
所以在我參考多個文章後,特別挑了兩篇分別代表用「物品例子」與「程式例子」,將兩篇合併成一篇的用意是在於讓你的思考更加清晰清楚,可以更快速融會貫通其觀念。
簡介
在物件導向程式中,遵循SOLID
這五項基本原則,可以幫助程式設計師寫出好維護、易擴充的程式架構。
SRP:Single Responsibility Principle(單一權責原則)
OCP:Open-Closed Principle(開放關閉原則)
LSP:Liskov Substitution Principle(里氏替換原則 )
ISP:Interface Segregation Principle(介面隔離原則)
DIP:Dependency Inversion Principle(依賴翻轉原則)
以上這五個原則所組成的第一個字就代表著SOLID
。
S: Single responsibility principle(SRP) 單一職責
SRP
從字面上意義來看,就是一個人只負責一件事情,當一個人同時負責太多事情就很容易出錯,程式也是一樣,如果一個類別裡面包含太多不同的功能,一旦改動,很容易造成邊際效應。
這邊常常會出現誤會就是一個類別只能寫一個方法或功能,其實並不是這個意思,因此單一權責原則代表的意思是負責所有該事項相關的功能,總結一下說法就是「一個類別應該只處理它應該處理的服務」。
物品例子
阿文18歲生日那天取得汽車駕照,爸爸買一台車可以:
- 在天上飛
- 在路上走
- 在水下游
給他當生日禮物。
當阿文想開這台車,就必須要有:
- 機師職照
- 汽車駕照
- 潛艇證照
才能上路。
如哪天車故障了,可能要請:
- 修飛機的技師
- 修汽車的技師
- 修潛艇的技師
三種專業人員一起查看問題在哪邊才能排除故障。
如果一個類別負擔太多工作,就會像上面的超級汽車一樣,不論是「使用上」或是後續的「維護」工作都可能會帶來很大的困擾。
要注意單一職責不是指一個類別裡面只有一個方法,在這邊,我們這台車責任是要可以在路上行駛,不過這不代表這台車只會擁有在路上行駛這個方法,實際上,行駛是由前進、後退、左轉、右轉、剎車等等基本功能組合而成的。
但從另外一個角度來看,又要注意功能被切的太細碎造成過度設計(over design)的情況,一台車雖然可以拆成方向盤、大燈、引擎、汽缸等等零件,每一個零件也都有不同的功能,但對汽車駕駛人來說,只要知道車子怎麼開就夠了,不需要去理解車子內部詳細的構造。對維修技師來說,了解細部零件的功能反而才是必要的,因此要怎麼規劃一個類別的責任,就要視實際的需求而定。如何定義一個類別(物件)的責任是一個很抽象也很難釐清的事情。
程式例子
以下的類別做了太多事情了,萬一房子設計/寫書規格/運動方式改變了,此時就必須回過頭來調整一下Person
類別。
所以我們應該要把類別細切開來,並且將該類別進行歸類。
1 | class Person{ |
凝聚性:
通常一個類別會產生許多變數,每個變數都有個別的方法去使用它,這樣會導致某些方法參考某幾個變數,另外一些變數是由某幾個特定的方法才會參考它們,那這樣就代表一個意義。
「這些變數跟它所參考到的方法是同一個族群放一起就是凝聚性」
1 | // 未分類 |
註:保持凝聚性會得到許多小型的類別
這些類別幾乎不需要花時間去理解,就可以立刻知道它所對應的功能,而且如果有必要調整某一個方法,產生邊際效應的風險趨近於零,從測試的角度來看,只需要驗證邏輯正確性,整體來說就是簡單到不行。
再來就是如果需要擴增方法,沒有程式碼會因為新增方法而遭到破壞,這樣的設計就符合單一職責原則也同時支援到開放封閉原則。
隔離修改:
每次改變需求的時候,我們的實作類別就會進行變動,此時相依的類別就會被影響到,進而產生變動後程式錯誤的風險。
1 | class Exercise{ |
Person
類別相依於Exercise
類別,因此只要Exercise
類別一變動或者playBaseball
這個方法改壞掉了,則Person
類別就面臨出錯的風險。
那麼我們怎麼防堵這類的風險呢?其實也不難,我們只需要從將Exercise
類別的實例從外部注入(Injection
),就可以解決掉這個問題了。
1 | class Exercise{ |
如此一來,我們的Exercise
類別即便變動,比如說多了一個playPingPong
的方法,或者把playBaseball
方法改壞掉了,我們都不用再去爬Person
類別調整,只需要針對Exercise
類別進行修復或新增即可。
O: Open/close principle(OCP) 開放/封閉原則
開放封閉原則意思就是程式要容易變動而且不被修改,當初我看到這句話的時候,想到這句話不就是互相矛盾?但是後來查了很多資料,發現它的意思其實應該是:「程式盡量不被修改且容易擴充」。
物品例子
物件導向程式設計最重要的開放(擴充)封閉(修改)原則。一套軟體應該要保留彈性,可以擴充新功能,但如果裡面程式碼的耦合度(Coupling
)過高,新增功能時可能會影響舊功能,甚至會造成程式Bug
,因此增新功能時就必須很小心仔細以原本正常程式碼被改壞了,另外高耦合的程式碼在有Bug需要維護修改也是同樣的麻煩。有鑑於此,舊程式碼應該是封閉修改的,或是某個舊功能需要調整,也不應該取影響到其他功能。
以阿文的車來說,我們在設計時就要針對車上不同的功能做模組化,例如說想將大燈改成又白又亮刺瞎別人的眼睛,汽車技師只要更換燈泡,不需也不能動到引擎的部分(引擎部分封閉修改);
或者今天要阿文要上山賞雪,可以直接在輪胎上綁上雪鏈(開放擴充),就可以在雪地上行走,不用整個輪胎換掉。開放/封閉原則就如同字面上的意思,開放新增功能,封閉修改其他不相關的功能。
程式例子
抽象化
其實只要把注入改成抽象類別或介面,就可以達到擴充的功能了。
1 | abstract class Exercise{ |
稍微修改一下,這樣就讓Person
類別跟Exercise
的子類別脫鉤了,如此一來無論未來要增加多少種運動,都可以擴充且不需要更動到程式碼了。
L: Liskov substitution principle(LSP) Liskov替換
物品例子
在一個系統中,子類別應該可以替換掉父類別而不會影響程式架構。
阿文要開車去外婆家,阿文家車庫裡面有很多台車,我們先看其中三台車,如下圖:
阿文坐上的樂高車後,發現這是樂高積木組成的模型車,沒有引擎,根本不能上路!
這時候子類別樂高車並沒有辦法執行父類別car的路上跑功能,這種情況就不符合Liskov
替換原則,子類別應該可以執行父類別想做的事情。
程式例子
這個原則主要是應用在替換這個字,里氏最早提出來的這個原則,意思是說只要你把傳入的類別抽象化起來,那麼就可以透過更換的方式把目標無痛轉換,這段話到底在說什麼?
訂定合約
對於某個類別來說,它接收到的合約是可以執行某個規格,所以只要你是根據該規格所生產出來的物件,該類別都可以執行它。
舉個例子,以剛剛OCP
原則的例子來改寫解釋:
1 | interface Exercise{ |
上面完成個別類別的實作,接著我們會來寫兩段Person
的實作,一段是比較沒有彈性的實作,另外一段是抽象過後比較有彈性的實作。
•實作一:比較沒彈性的實作
1 | class Person{ |
可以看到如果我們要讓Tom
去打棒球,只需要產生Baseball
類別的實體,接著呼叫playBaseball
方法就可以了,問題來了,今天如果需要請Tom
再去打籃球,此時,原本的Person
類別以及main
操作方法都必須要再進行調整,而原本已經正確的程式,就因此被變動到,進而產生可能錯誤的風險,所以讓我們來試看看另外一種比較有彈性的作法。
•實作二:比較彈性的實作
1 | class Person{ |
如此可以看到Tom
可以同時操作baseball
以及basketball
物件,並且執行它,也就是說即便我新增再多個Exercise
的子類別,也可以無痛的替換掉執行的物件。
對於Tom
這個物件而言,無論如何都只會遵守Exercise
所訂定出來的合約操作。
只要是
Exercise
的子類別,Person
類別都可以執行它。
I: Interface Segregation Principle(ISP) 介面隔離
物品例子
把不同功能的功能從介面中分離出來。
阿文表示,上次要去阿嬤家算是特殊的需求,我家的車最主要就是拿來佔車庫避免空間浪費, 然後輪流擺在庭院炫富用,樂高車也可以算是一台車,我們來看看阿文家的車庫(圖左)。
這邊有一個小問題,發現了嗎?對阿文來說,車子不一定要有「路上跑」這個功能,阿文的樂高車是沒辦法在路上的跑的,這明顯違反了上一條LSP,因此我們必須修改車子的定義 ,大部分的車有「路上跑」功能,因此我們可以將這個功能分割到其他介面,分割後如下圖,如此一來我們就用介面隔離「路上跑」這個功能(圖右)。
程式例子
跟第一個SRP
原則雷同,只是換成介面宣告太多方法,讓實作的類別非得實作一些沒用到的方法。
拿前面既有的例子來增加功能:
1 | interface Exercise{ |
我們新增了喝、休息、操作器材的功能,但是其實有些運動其實不需要使用器材這個項目,因此我們需要把這個介面進行縮減。
1 | interface Exercise{ |
如此一來我們就可以把使用設備這個界面獨立出來,有需要的運動可以自己去實作它,配合Person
來進行操作。
D: Dependency Inversion Principle(DIP) 依賴反轉
物品例子
定義:高階模組不應依賴低階模組,兩個都應該依賴在抽象概念上;抽象概念不依賴細節,而是細節依賴在抽象概念。
上面這段文字看起來很抽象,讓我來翻譯翻譯,意思就是「話不能說的太死,盡量講一些概念性的東西」。
阿文要在庭院要弄一個賞車派對,邀請函上面寫著「阿文誠摯邀請你來欣賞Ferrari Fx2020
超級跑車」,因此這個派對就會被綁死在Fx2020
超級跑車上面。如果當天阿文的爸爸開著這輛車去打網球,阿文在開趴那天就很糗很糗了。
為了避免這種事情發生,邀請函上面最好是寫著「阿文誠摯邀請你來參加派對並且欣賞超級跑車」,這樣一來就算當天阿文爸把Ferrari Fx2020
開走了,他只要另外拿出一台超級跑車就可以,雖然朋友們會有受騙的感覺,不過至少不會讓阿文當場丟盡面子。
程式例子
最初我們在宣告一個類別可能會是長這樣。
1 | class Person{ |
看起來一切都是正常的,可是這樣會出現一個嚴重的問題,就是當Baseball
類別進行修改,而且不小心改壞掉會連帶Person
類別一起壞掉,因此我們可以說:「Person
依賴著Baseball
」
所以我們要透過抽象的方式,讓Person
從Baseball
那邊解開依賴,怎麼做呢?
首先定義一個虛擬類別:
1 | abstract class Exercise{ |
這樣一來就把Person
類別從Baseball
類別的魔爪逃走了,因為Person
遵循的是Exercise
的規範,而Exercise
只定義出你必須遵守的規則,其餘的由子類別自己自由發揮,也就是說Person
只要確保我呼叫play
可以正確玩到我要的運動,其餘的該運動規則會是由 Baseball
類別自己定義好。
操作行為
1 | class Person{ |
這樣的好處就顯而易見了,某一天我需要把Baseball
換成Basketball
,我就可以開一個Basketball
類別並且繼承Exercise
類別,如此一來,Person
類別也不需要調整可以直接吃掉新開類別所產生的物件,因為它只認Exercise
所定義的規格。
這樣就符合依賴反轉原則的定義。
高階模組不依賴低階模組,兩者都必須要依賴抽象。
抽象不能依賴細節,細節必須依賴抽象。
結論
關於SOLID
的觀念,非常抽象,所以透過程式和例子表示後,我自己重複讀了好幾次,在寫文章的過程也在讀了好幾次,總算是對這個觀念也一定程度的了解;接下來就是在未來設計的時候,可以多多套入這些思考。
其實這五個原則都是在討論一件事情,就是程式變動後,要怎麼讓原本的程式不受影響,而這些原則會感覺解決方法其實非常雷同,透過這些原則所產生的解決方法可能會互相參雜彼此的概念,但是目的其實很清楚,就是要寫出一個乾淨、有彈性且好測試的程式。