SOLID 五原則那一兩件事情

Jast Lai
Jastzeonic
Published in
14 min readOct 26, 2018
其實我還蠻想放 MGS 的 Snake….

前言,固體五原則?

那開頭我一樣引用一下英文維基百科的固體五原則條目來說一下好了:

In object-oriented computer programming, the term SOLID is a mnemonic acronym for five design principles intended to make software designs more understandable, flexible and maintainable.

那中文意思大約是:

在物件導向裏頭,SOLID 是一個方便於記憶的簡寫,意指目的使軟體設計更為便於理解、更便於修改、而且更好維護的五項設計原則。

印象第一次聽到這個詞彙,是在大學修軟體工程課程的時候。想想那時候還把 No Silver Bullet 讀過一遍,其實也是半矇半懂的,現在讀其實也是有點矇矇的,可能還是得隨著經驗成長才能慢慢理解論文裡頭說的吧。

那固體五原則,其實也是一樣的,當時看也不懂,實際上職場跑過專案後,再回來看,其實也還是懞懞懂懂,現在再回去看 Clean Architecture ,再回想自己經歷過的專案,自己挖得坑,自己跳得坑,才多少明白 SOLID 設計原則的目的。

軟體設計是很自由的,方法很多種,所以原則並不是規定你該做什麼,而是規定你什麼不該做。

那我這邊花一篇敘述一下這段時間理解的 SOLID 到底是什麼樣的意思。

“S” Single Responsibility principle 單一職責原則

a class should have only a single responsibility (i.e. changes to only one part of the software’s specification should be able to affect the specification of the class).

翻成中文的話:

一個 class 應該只有一個職責(換句話說,每次針對軟體特定範圍的變更只應影響到特定範圍的 class)

我認為有需要先解釋一下職責(responsibility)為何?

在現實中我們說工作職責(work responsibility),指得是工作中所負責的工作範圍和所需承擔的相對責任,舉例來說,棒球隊的內野部分,投手負責投球,捕手負責接球與本壘的防禦區段,一壘手負責守備一壘區段,二壘手則負責二壘,三壘手則負責三壘(這只是簡單舉例,投手有時得守一壘有時得守三壘還有自責分非自責分那些就不在這討論範圍了)。

在軟體工程中也是一樣的,我們可以想像一個 class 是一個如同工作者一般的單一個體,該 class 所負責的工作範圍應該是有限的,那它的職責就不應該包山包海,正如同正常情況下一個球隊不會希望整個內野只有一個人在守備一樣,最好有數個球員各司其職、獨立專精,這便是所謂的單一職責。

就跟大多數的軟體工程原則一樣,判斷該功能是不是屬於該 class 並沒有絕對的方法,能否判斷這點正是工程師討飯吃的眾多本事之一,要練出這本事多半得靠經驗累積了

  • 這個 class 萬把行,那肯定是這個 class 負責得職責過重了,可以把部分功能拆出去或者是往下細分
  • 一個 class 實作需要傳入十幾二十個 parameters,這樣該 class 依賴的東西太多了,是不是有一些參數根本不用給它,因為根本不是他的工作範圍可,以簡化收束
  • 我怎麼看這個 method 命名跟這個 class 壓根子沒有辦丁點關係啊,這件事情是不是根本就應該在 class 實作之前就做了,或者是應該交由其他 class 來做

等等族繁不及備載的情況…。

那上述只說到職責問題,並沒有說到更動的部分,關於這部分,其實還有另一具更常看到的說法:

A class should have only one reason to change.

中文就是:

一個 class 只因該因為一個理由而更動

私心覺得這句話很難理解,理由是什麼玩意?為什麼 class 只能因為一個理由而被改變?

那在理解上,可以把理由(reason)當作是因為涉及到該 class 所負責的職責(responsibility),換句話說,如果要更動該 class,是因為涉及到該職責的所以才需要進行變更,那便是更動該 class 的唯一理由。

“O” Open/closed principle 開放/封閉原則

source: https://www.flickr.com/photos/infinity-d/5240210597/

software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.

中文就是:

軟體實體(類別、模組、功能等等)應該是對於擴展是開放的,但對於修改則是封閉的

一如往常的,總覺得這個中文解釋的很微妙,那這個原則也確實可以有很多種定義,現在普通的情況會是:

用擴展取代對現有程式碼的修改

也就是說在現有功能已經確立的情況下,突然被許了個願望要新增一個新的功能,那應該不應該直接修改現有的程式碼,而是採用繼承擴增新功能的方法去實現這個新功能。

這麼很簡單,就是加了新功能,但不怕影響到現有功能。

話是這樣說,但這其實挺難的,因為在現有功能已經確立的情況下,通常很難會有一個可以漂亮繼承擴增新功能的 class 或者 module 。

舉例來說,這有一隻雞的模組,某天突然被許了願望要寫一個火龍,他們兩個很相似,會很刺耳的叫,都用兩隻腳走路,跑起來的樣子簡直一樣;但火龍又跟雞不一樣,火龍會吐火球,不小心接到還很痛,但雞不會吐火球,火龍會飛但雞不會,雖然展翅的模樣很像,這樣要讓火龍直接繼承雞嗎?

總感覺怪怪的…

可是基於開放/封閉原則,我不能對雞的模組做修改,所以這樣變成我得違背自己的感覺去硬著頭皮繼承嗎?

那其實就是最初設計的時候就要面臨的問題,因為在當初設計這雞的時候,就沒考慮到擴展性的問題,所以在面對要擴展的時候,才會有這樣的疑問。

那為了避免未來發生這樣的問題,方法就是抽象化

當初在設計雞的時候,應該考慮到雞的行為,把雞抽象化為原雞種,更甚一層在抽象化為雉科,這樣在要增加火龍這個 class 時,就可以藉由繼承雉科來去寫出飛龍種,乃至於火龍可以直接繼承飛龍種再去詳細實作火龍這個 class 了。

話說回來,在考慮到不影響現有程式碼的狀況,還是得完成弄出一條火龍的願望,以及設想到以後可能會被許願要加一個蒼火龍或者是銀火龍之類的情況要怎麼辦?

面對這樣的情況,我個人的做法會是根據現有的雞的 class 抽取功能並抽象化出原雞種乃至於雉科,寫一個飛龍種繼承雉科,藉此面對將來可能被許願需要新增蒼火龍、銀火龍還是角龍之類的。

但是此次不對雞這個 class 做任何修改,他不會繼承原雞種,因為雞明顯並不是本次的修改範疇,那什麼時候修改呢,或許是某次需要對雞本身做變更的某個未來吧。

「這樣並不足夠!」“This is not enough!”──Connor.
「永遠都不會足夠的。」“It will never be enough.”──Jun

“L” Liskov substitution principle 里斯克夫替換原則

objects in a program should be replaceable with instances of their subtypes without altering the correctness of that program.

中文意思是:

物件應該可以在不影響現有程式正確性的情況下被他的子類別實作替換

簡單來說就是,父債子還(X)原本父類別的所做的事情,換上繼承該父類別的子類時能不影響原先的運作(O)。

怎麼說呢?不知道讀這篇文章的各位有沒有碰過一種情況,一個 class 繼承了另一個 class ,但是因為大量覆寫父類別 class 的 method,做的事情基本上除了 call 的 method name 一樣以外跟所繼承的那個 class 已經壓根沒有一丁點關係了,這樣就明顯違反了這個替換原則了。

咦?等等,可是這樣繼承才可以在 Control flow 上互相替換,這樣可以省寫很多 code 耶。

當然啊,但這種情況他們兩個應該是繼承相同的 interface ,不應該是父子關係才對啊。

那為什麼要這樣做呢?

因為繼承父類別的子類別是可以隨時替換掉父類別的,即便是子類別很叛逆,做的事情已經跟父類別完全無關的時候也是一樣。

嘛,通常子類別很叛逆無所謂,這還好察覺,但是子類別有點叛逆,也就是呼叫上基本一樣但又有點微妙的不一樣的時候,在使用上,可能就默默的埋下一個難以盤查的 bug ,在某天突然爆發結果莫名增加大量的維護成本。

“I” Interface segregation principle 介面分離原則

many client-specific interfaces are better than one general-purpose interface.

中文意思是:

大量特定功能的介面比單一通用的介面來得更好

Segregation 這個詞彙在這裡我會翻分離,用隔離的話單從字面意思去看還蠻容易誤會說是 class 與 class 之間的溝通要透過 interface 去做之類的,不過這項原則其實是單指介面方面的問題。

怎麼說呢?某天某個情況下,有一個功能需要時做一個介面,這個介面很厲害,生旦淨末丑,神仙老虎狗,龍套上下手,什麼他都有,它包含了上百個 method ,那當你有功能需要用到該介面的時候,很好,繼承他就可以解決不少問題了。

然而要實作的東西就那百來個 method 裡頭的一兩個而已。

乍看之下這個 interface 使用起來很方便,當下情況試用起來也不太有啥問題,就這樣一個接著一個去用,時間久了,這個 interface 的依賴者也來到上百個了。

某天,熊熊團隊中的某人被許願,要去修改這個介面,而他也確實改好這個介面,他也測試他當下修改的部分,看起來也沒問題,然後就上了。

隔了幾天,問題爆發了…這邊沒有回傳值,那邊沒有跑,這邊噴了個 Exception。

從這裡馬後砲來看,這些麻煩是可以避免的,也就是說,一個神仙老虎狗的 interface 設計明顯地違反了介面分離原則。

因為這個 interface 太過萬能了,導致他的依賴者數量過多,任何的更動都有可能對依賴它的依賴者造成影響,在未察覺的時候很容易產生無法預期的問題。所以我們會傾向讓每一次的更動在影響最小的範圍內,這也是這項原則的核心思維 — 我寧願有 100 個 interface 裏頭各有 1 個 method,也不要把 100 個 method 塞進 1 個 interface 裏頭。

舉例來說,我這有一個飛龍種的 interface 他有這邊的功能:

「吼叫」

「回天」

「火車衝撞」

「飛行」

今天突然被許了願要寫一隻轟龍,轟龍會吼、會回天旋轉打擊、而且牠很會跑,倒三角的衝撞範圍會讓習慣火龍火車的獵人吃盡苦頭,但他基本上是不會飛的,所以他並不需要實作飛行功能,但是在實作轟龍的時候反而因為飛行這項功能綁手綁腳的,在更動的時候還要注意到「飛行」這個功能是否會影響到轟龍這個 class 。

此時又給許了個願望叫「電龍」,夫魯夫魯很會放電,但是牠並不會「火車衝撞」,那對夫魯夫魯而言這項功能就跟「轟龍」的「飛行」一樣尷尬了。

天曉得哪天又來條龍,不會「吼叫」但是很會「回天」,或者是不會「回天」但很會「火車」的,那以這樣的考慮為前提,所以在設計時就將這四項分出成為各自獨立的 interface ,可以省去未來不少的麻煩。

“D” Dependency inversion principle 依賴反轉原則

A. High-level modules should not depend on low-level modules. Both should depend on abstractions.

B. Abstractions should not depend on details. Details should depend on abstractions.

中文的意思就是:

高階模組不應依賴於低階模組,兩者皆應依賴於抽象實體

抽象實體不應依賴於實作,實作應當依賴於抽象實體

反轉,究竟是反轉什麼玩意去了?

首先我認為首先要解釋的是 — 何謂高階,何謂低階。

嘛,高與低是一個相對的形容詞,那究竟是以什麼去定義高和低呢?在電腦科學中定義階層高低的方法是看「模組項目抽象化的程度」。

也就是說,項目越抽象的模組越高階,項目越具體的模組則越低階

講完這段感覺好像在說咒文,有點講玄學的感覺,直接舉例好了,舉例來說,我這有個 class 叫「豪華大轎車」,他有以下功能:

  • 向前加速
  • 煞車
  • 倒車
  • 轉動方向盤左轉
  • 轉動方向盤右轉

那我這另外有一個 class 叫「兩地交通方法」他有以下功能:

  • 從 A 地到達 B 地

那可以看出「豪華大轎車」的功能明顯地比「兩地交通方法」的功能具體許多,所以可以判斷出「豪華大轎車」是低階模組,「兩地交通方法」則是高階模組。

所以有一個解釋是:

  • 解決越特定問題的模組是低階模組
  • 解決越通用問題的模組是高階模組

了解這點後,再回來看,在傳統的設計中,我們通常會讓高階的模組依賴低階模組,承接「豪華大轎車」的那個例子我們可能會用「豪華大轎車」完成「兩地交通方法」從 A 地到達 B 地的功能,這樣明顯的,「兩地交通方法」依賴著「豪華大轎車」。

那今天因為豪華大轎車要走的陸路正在施工沒有辦法走,只好採用水路方案,於是我們建立了一個「無與倫比豪華大郵輪」,來去完成「兩地交通方法」A 地到達 B 地的功能。

無奈突然又發生了這幾天海象不好,「無與倫比豪華大郵輪」無法出航的問題,沒關係,我們可以採用空路,於是我們又建立了一個「飛很快豪華大客機」,來去完成「兩地交通方法」 A 地到達 B 地的功能。

然後這幾天風大,「飛很快豪華大客機」又停飛,沒關係,我們可以用「豪華大客車」走其他路線,於是乎..(以下省略 100 次)

因為「兩地交通方法」來回變更了一百次,所以就要改一百次嗎?其實沒有必要,因為這樣花的成本實在太高了。

這邊我們依據依賴反轉原則,新增一個抽象介面「交通工具」,讓「豪華大轎車」、「無與倫比豪華大郵輪」等模組繼承它,然後讓「兩地交通方法」依賴「交通工具」去完成 A 地到 B 地的方法,需要更換交通工具的時候只要簡單改一兩行或者是根據情況作判斷就可以了。

「兩地交通方法」並不會知道他使用的是「豪華大轎車」還是「無與倫比豪華大郵輪」,他只要知道「交通工具」有什麼方法可以達到他的目的就可以了,這樣成功將低「兩地交通方法」依賴對象數量,在需要變動對象時,就可縮小影響的範圍了

(說實話我覺得這個原則跟單純從介面分離原則這個標題的字面意思更像,不過兩個有不小的差異就是了)

那回到最初的問題,反轉是反轉什麼呢?這個原則反轉當然不是指反轉高低階的相依關係,因為這樣沒有意義,勇者幹掉大魔王最後自己還是會變成大魔王…呃不對,高低階兩者的耦合,複雜的時候需要變更還是要花大把大把的時間,所以他們要依賴的對象只是從:

高階模組 -> 低階模組

變成:

高階模組-> 介面 && 低階模組 -> 介面

那究竟是反轉了什麼?理解上反轉的東西跟 IoC 一樣,雖然 DIP 跟 IoC 是兩件事情,但是兩項原則反轉的東西是類似的,就是把靜態綁定(Statically binding)反轉成了動態綁定(Dynamic binding),關於靜態綁定與動態綁定,這個我在這篇講述 IoC的文章有提到,不明白意思可以參考看看。

結語

這篇也是寫了個漏漏長,這五項原則其實也是為了之後維護迅速,不會幹聲連連去訂一出來的設計原則,不過原則是很死板的,或許在開發很龐大東西時,順著原則可以讓未來省去不少時間,但有時只是要弄一個簡單的東西,為了符合原則花了大把大把的時間,結果發現這只是一個免洗筷,用完就丟,實在也是沒有必要的,老樣的,適不適合還是得看當下的情況是怎麼去應對。

--

--

Jast Lai
Jastzeonic

A senior who happened to be an Android engineer.