程式P.02 - SOLID
🚀 三句話總結
- 了解SOLID是為了解決什麼問題而提出的
- 了解SOLID是什麼
- 理解敏捷和瀑布流開發,哪個比較好?
☘️ 為什麼我要了解
在我幻想的軟體生涯應是,根據既定的需求去實作產品,但進入社會後才發現原來這是不可能的。
需求總是一直改變不止是老闆的需求,有時候甚至是大環境的改變而造成的,所以學會如何開發出當需求改變後,我們程式能夠迅速的改變並不付出太高的成本,而SOLID就是其中一個解決這問題的答案。
🎨 改變了什麼
了解到SOLID和敏捷開發要解決的問題是什麼,而不是一昧的知道什麼是敏捷而不知道他為什麼會誕生,並且由於SOLID原則效益非常高,以至於後續瀑布流開發也可採用SOLID方案進行開發,畢竟就連微軟這種大公司也因為科技發展被迫改為敏捷開發。
✍️ 總結和心得
程式誕生之初的開發模式
在很久很久以前當時的電腦還是小眾市場,所以軟體開發也是小眾,並沒有什麼開發原則,當時開發大致上是「瀑布流開發」。
但隨著科技發展,電腦慢慢走入大眾市場,而軟體的開發也面臨了挑戰,說好的需求卻被突發因素被迫修改,程式碼也因為耦合導致修改幅度之大,於是有個工程師受不了,問了一個問題
我們知道需求變更無法避免,組件耦合無法避免,難道因為無法避免,我們就要讓他們惡化嗎
所以以此問題,「敏捷式開發」這個概念被人們創造出來
SOLID
敏捷式開發就是為了實現「降低需求改變所付出的成本」。而敏捷式開發可以透過多種方案實現該效果,其中一個最重要的方案就是 SOLID。
該方案是著重在工程師的Coding原則,SOLID分別是五個縮寫而成:
- SRP(單一職責):Single responsibility principle
- OCP(開閉原則):Open/Closed Principle
- LIP(里氏替換原則):Liskov Substitution principle
- ISP(介面隔離原則):Interface segregation principles
- DIP(依賴反轉原則):Dependency inversion principle
在Coding中工程師必須遵守以上原則(後續會有文章詳細介紹各原則),而遵守後即可以實現
「需求改變,程式碼不修改,透過新增方式實現需求」
而在SOLID之中不是SRP最重要,反而是OCP原則才是核心
為了實現OCP,必須使用以下SRP、DIP、LSP、ISP …etc 實作出OCP的原則。
OCP - 開閉原則
“Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.”
軟體應該允許擴展但不允許修改(類,模塊,函數等)。
這句話是出自Uncle bob對於OCP的描述,這句話引發很多爭議,如果需求改變了改成讓我需要修改原始碼那怎麼辦?你是在找魔法師還是工程師?
所以這句話被延伸解釋為:
系統要能妥善預測複雜度的發生點,並在適合的地方建立「擴展點」;以便當需求改變/新增行為時,能夠透過擴展點實作,而原有主流程或依賴該擴展點的程式不需修改。
這就是開閉原則的由來,開放擴展點的擴充,封閉並保護依賴拓展點的程式不會被修改
Ex:
假設你要設計一個槍枝AK47
原本AK47只能裝載普通子彈
1 | class 普通子彈 { |
但是由於平日訓練不能使用實彈,所以突然要AK47能夠裝載「橡膠子彈」,如果沒有OCP的話就會發生以下情況:
1 | class 普通子彈 { |
那如果後續又要新增:曳光彈、破片彈、穿甲彈 ..etc,那麼是不是每次新增都要修改reload、shooting 等等函數呢?是不是等同於每次都重新撰寫了AK47了?
明顯違反SOLID的目標(降低需求改變的成本),因為每次修改都會破壞穩定性,所以我們可以利用interface實現
1 | class 普通子彈 extends 子彈 { |
今天想換什麼子彈就換什麼子彈,透過事先建立擴展點(interface),實現需求改變透過開放擴充實現,並且封閉原有的程式不會因此被變動(reload函數),並由此可知,到時候發射出問題,絕對就是新加入子彈的問題而不是「因為要加入新子彈而修改原始碼意外導致的BUG」。
Ex:
一台重機,在賽車場上必須使用光頭胎
一台重機,在平面道路上必須使用正常輪胎
今天你不會因為換了輪胎就把整台重機都拆開吧,這就是一種開閉原則,我們封閉並保護了引擎、油門…etc,開放了輪胎的更換。
SRP - 單一職責
我把這規則劃分為兩個面向:
- 大定義:「一個模組只對唯一的角色(利益相關者)負責」。
- 小定義:「一個函數只做一件事」。
小定義很清楚,簡單說就是採下煞車,就應該煞車,而不是煞車+雨刷。
1 | function read(path){ |
大定義就詳細說明下:
因為SOLID是基於「需求改變」而誕生的,那需求為什麼改變是因為「人」,所以SRP應該要對那個需求角色做為負責,舉個例子:
Ex:
計算機的功能,1+1=2
如果要實現該功能看起來就只用對對一個人做為負責,但其實是錯誤的
- 顯示數字是對使用者負責,像是為什麼要用白色,為什麼不能用粗體 …etc,所以應該獨立出一個層級,專門負責顯示,也是對使用者顯示做負責
- 1+1為什麼是等於2,這是核心邏輯,是商務邏輯,所以是對產品的核心負責,所以應該獨立出一個層級
所以由以上範例大致上會長這樣:
1 | function calculator(a,b,math){ |
所以「一個模組只對唯一的角色負責」,其實更多的是進行分類,想想如果calculator函數裡面整合了顯示+邏輯,那萬一我今天想改成二進制的加法,那是不是就要修改到calculator函數,那是不是就代表可能也會修改到顯示程式,那不就違反了我們SOLID最初追求的目標嗎(降低需求改變的成本)?
所以單一職責做得好,就會分類的好,因此我們就能實作開閉原則的封閉效果
Ex:
今天你是DBA,PM有個需求要為會員辨別男女,於是提議你使用身分證字號去做辨別,只要是2開頭全部都是女生,只要1開頭全部都是男生,那請問這是否違反了SRP?
違反了。萬一日後開放變性的話,男變女,但他身分證字號不會改,於是你判斷他是女生,但實際上他已經做了變性手術他就是女生,所以BUG產生了。
綜上所述,我們知道SRP是「一個模組止隊唯一的角色負責」,身分證字號他是編號功能,卻被迫擔任兩個角色
- 編號
- 性別
所以日後變性合法化後不就代表程式邏輯要改?所以為了解決這問題我們應該做好分類,
- 身分證字號 = 編號
- 性別選項 = 性別
如何界定是否滿足SRP,就是這個XX是做什麼的?如果回答是做了A「和」B… 則違反SRP
LIP - 里氏替換原則
有一種耦合關係是「繼承」,所以該規則一開始是描述繼承,但是UncleBob 又把這觀念昇華到一個概念,所以分為以下:
所有對某個介面的實現,都可以視為對該介面的 subType ,而所有 subType 都必須是可以替換這些介面的功能,所有 subType 都必須遵守對介面的承諾。
符合遵守對介面的承諾
首先如果 介面 是 正方形,那麼子類型不論怎麼發展,都必須是符合正方形的定義,不能將正方形改為四邊形,哪怕四邊形也是正方形一種,但卻不是所有四邊形都是正方形。

替換性
當滿足遵守對介面的承諾時,那麼我們所有子類都可以直接互相替換,像是下圖,驗證有兩種方式驗證,而兩種驗證方式都可以取代 父類,並且使 依賴父類的應用程式,不會被修改原始碼。

Ex:
假設有一個類叫做「鳥」,而繼承他的人有「老鷹」、「企鵝」
但是企鵝不會飛,所以如果我們設計一個「鳥類」的生活空間,不會有飛機介入撞死鳥,但你會發現這措施只會對老鷹起作用,而不會對企鵝起作用,因為他不會飛,所以為了處理企鵝你還要額外的配套方案。
由上述規則可以知道繼承有很清楚的目標性,提高了介面的穩定性,而如果違反LIP原則,到時候我們將會為特例付出很多變更成本,這也是違反SOLID的原則(降低需求改變的成本),LIP因此列為五大原則之一。
其實什麼場合可以使用繼承又是另一門學問,所以下面就提出滿足以下條件才可以使用繼承的規則:
- 是否能夠滿足LSP?
- 是否需要多態(多型)?
- 是否一個composition不能解的場景?
如果只是單純code reuse 的話,請先考慮 composition 實現方式,因為就以 code reuse 方式繼承會延伸很多維護性的問題,像是改父類某個功能,但你不知到可能這功能子類有人亂使用,於是就造成維護性問題,而若透過存取宣告詞避免造成這問題,那麼該父類難道不能算是一個 composition 嗎?
Ex:
composition over inheritance,炮友理論。
假設今天你的擇偶標準是
- 愛我的人
- 打炮能力很強的人
基於上述兩點,你開始從炮友裡找愛你的人。
這其中的耦合非常嚴重,不論是邏輯上還是物理上都是耦合,你可以說男友 is 炮友,但是萬一有一天你發現他打炮能力不好了,於是我們開始DEBUG
- 他太操,可能按摩一下就會好
- 我做了某件事情,他不愛我,所以打炮能力降低了
用程式的說法,你不知道打炮為什麼會出問題,會不會是因為你改了其他東西導致他出問題,這之間的複雜度就很高,並且如果你想換掉,你還要從茫茫大海中找尋具備這兩個條件的人,非常苛刻。
所以建議使用組合的方式,交一個男友,再交一個炮友,問題就變得很簡單,如果打炮能力降低,那勢必就是炮友那邊出事,絕對不是因為不愛我才降低打炮能力,並且要更換也非常簡單就能滿足。
ISP - 介面隔離原則
不應強迫客戶端依賴他不使用的方法
假設有三個程式依賴了大模組OPS,但這三個只需要使用到大模組的其中一個功能,
這會發生什麼問題?
編譯時間過長
如果今天我只改了op1的名稱為Op1,那麼你以為只有User1會重新編譯嗎?錯,是全部依賴的物件都要重新編譯,所以當初UuncleBob遇到只是重新命名其中一個變數,編譯卻要花數小時。容易模糊職責
如果一個模組違反了ISP,那麼有可能這個模組違反了SRP功能,容易出現萬用

容易加深複雜度
因為萬用模組的情況,很容易把應該封裝的細節卻都public出來,因為可能對其他人來說他需要依賴這些細節,像是A要Write,於是我們封裝了OpenFile函數,但是B卻因為業務需求必須使用OpenFile函數,於是就導致對A來說應該封裝的卻開放,違反封裝功能。
所以我們應該使用介面進行隔離,如下
這樣解決了第1、3的問題,而日後如果要針對第2點修正,我們可以新增模組來替換這個介面,如下:
DIP - 依賴反轉原則
原始碼的依賴關係只涉及抽象不涉及具體
在SRP中分層的概念,我們可以將一個需求分離出很多層級,而DIP注重的是高層次模塊(如應用層、UI)依賴於低層次模塊(如數據訪問層、商業邏輯..etc)的抽象而非具體實現。
這邊舉幾個例子,證明DIP的好處:
Ex:
假設今天你的房間找了王阿姨幫你打掃,但是萬一有一天王阿姨請假了,而我的房間髒亂了我要找林阿姨過來幫忙,但是林阿姨沒有像王阿姨一樣使用吸塵器,而是掃把,於是打掃出問題了。
所以這時候應該耦合的仲介公司,我們只要求仲介公司派來的人要帶吸塵器處理,於是到時候我就可以很快的教導打掃阿姨哪邊要怎麼清潔
Ex:
假設有個模組叫做ProductServer負責某個商業邏輯,他依賴的Repo,但我們使用DIP,讓他依賴interface
那我們來實際看這商業邏輯是在做什麼功能:
1 | class ProductService{ |
如果repo依賴的是介面的話,某天我想做單元測試,測試商業邏輯是否正確,於是我就可以替換ProductRepo換成測試資料,所以使用DIP可以讓我們想換誰就換誰。
而DIP的功能還不只是這樣,上述中的Cache是使用HttpCache這模組,但是如果某天我想換成 Redis、Local Storage、MySQL,甚制我不想用快取,那上述程式碼不論是哪種方案都必須修改原始碼,所以我們必須針對Cache進行DIP重搆
1 | class ProductService{ |
根據上述DIP方案,我就可以想換什麼就換什麼,甚至我今天不想用任何快取,我都可以使用該方案實現,完全達到「需求改變,原始碼不須更改」。
敏捷 vs 瀑布
所以我們知道SOLID是因為敏捷式開發而誕生,但隨著人們發現SOLID非常好用,所以哪怕是瀑布流開發也仍會有人使用SOLID進行開發。
那麼是不是所有的開發都必須採用敏捷呢?答案是不一定。
「敏捷」二字並不是代表使用該方案,專案開發時間就會縮短,他的敏捷只是對「需求改變」可以很敏捷(彈性)的反映,剛剛只是提到實現敏捷的一種方案,但實際上還有其他方案需要實作,而這些方案都實作在「需求不容易改變」的產品,那麼勢必會有很多浪費時間的情況。
- 瀑布流對於「需求不容易改變」的產品是最有效率的方案。
- 敏捷流對於「需求很容易改變」的產品是最有效率的方案。
根據專案選擇最適合的方案。
並且如果選擇敏捷流開發,工程師絕對絕對要遵守SOLID原則,敏捷的基礎都是在SOLID,其他方案只是更「優化」敏捷,如果工程師沒有SOLID原則,那麼當需求改變時只能硬幹,並且老闆也認為敏捷就是能快速處理「需求改變」,最終壞味道就瘋狂擴散,變成傳說中的「隕石開發」。

