我們在前面的 內聚和耦合 有提到過,內聚並不是無腦把相關的程式碼都封在一起就好了,也有分成健康的和不健康的。但我們要怎麼知道這個類別是否足夠健康呢?單一職責原則就是很好的檢驗方式,這篇就讓我們來紀錄一下。

單一職責原則 (Single Responsibility Principle)

「單一職責」原則顧名思義,就是一個類別應該只負責一個職責

但是這樣太過籠統了,「職責」相當容易產生誤會,容易變成各說各話。畢竟咱們工程師最愛戰定義了嘛。

「你這類別不優,它有兩個職責!登入跟登出!」

『沒有啦,我這個類別就是負責帳戶管理的啊』

OSSO。乾脆你全部放一起,然後說是負責網站管理算了,呵」

『……你存心來找碴的是不是?』

為了避免像這樣產生職場糾紛,我們需要先定義一下什麼是「職責」。經過前輩們的努力(解釋)之後,單一職責的定義就成了:

就一個類別而言,應該只有一個引起它變化的原因

另外,我也看過「一個類別應該只對一個角色負責」的說法,這兩者的核心概念是一樣的。

這邊讓我們簡單舉個例子。如果在訂單管理的類別中有一個新增訂單的方法,在收到訂單之後,會依序處理訂單、並取出會員的聯絡資訊,再依靠聯絡資訊寄送通知信件給會員。但它的實作全靠自己來,如下:

新增訂單()
{
   // 收到訂單
   /*
     一些訂單的商業邏輯
   */

   // 寫入訂單
   /*
     一些和資料庫連線寫入資料的處理
   */

   // 取得聯絡資訊
   /*
     一些連到資料表或服務拿會員資料的處理
   */

   // 寄送通知
   /*
     一些寄送信件的處理,如寄送者和寄送方式等等
   */
}

這樣一路就是流水帳打完收工,這樣的一個函式參雜了一堆不相干的邏輯,可能動輒數百行,每一段都處理各種不同的工作,一看就很明顯違反單一職責原則。

當訂單處理的商業邏輯、查詢會員資料的邏輯或是通知會員的方式有變更的時候,這個函式都會受到影響,也就是說這個函式同時對多個不同對象負責。這樣的類別或函式就是不穩定的

遇到這種情況,我們可以將其拆分。讓上帝的歸上帝,讓凱薩的歸凱薩。

例如說會員的處理一律封裝回會員管理類別,我們再藉由會員管理類別去調用其方法取回資料;寄送信件也封裝到通知管理類別,不用去管用什麼方法通知的,我們只需要去要求其通知即可。

有些朋友可能會有疑問,這樣不就會和會員處理類別、通知管理類別之類的其他類別有了耦合關係嗎?有這樣的疑問是很合理的,這也就是為什麼我們會需要介面來讓類別之間不要直接彼此依賴,這部份我們在 介面 有詳細介紹。

新增訂單(訂單)
{
    處理訂單商業邏輯(訂單)

    訂單資料存取服務.儲存訂單() // 可能由資料存取層或連線管理等該職責的地方去實現

    通知服務.寄送訂單通知(訂單.訂購人編號)
}

處理訂單商業邏輯(訂單)
{
   // 專注在處理商業邏輯,不用管其他事
}

// 其他的職責拆分出去給負責該工作的類別
通知服務 { 寄送訂單通知(編號); }
訂單資料存取服務 { 儲存訂單(); }

我們把工作交給負責該職責的類別去做,自己只需要關注在自己正在處理的職責即可。聰明的朋友可能已經注意到了,這就是封裝的體現。封裝得夠舒服,我們就能舒服地處理自己的事情就好,這就是分工合作的偉大呀。

(2022-01-23) 補充:

前面提到的「職責」的部分,雖然我們前面提過了是「引起變化的原因」,但可能還是太過模糊,畢竟咱們工程師真的最愛戰定義了嘛。

今天在群組裡有前輩分享了講解單一職責原則的影片:Fred 聊聊 SOLID 設計原則,其中單一職責的部份,將前述的「引起變化」從業務需求變更的方式切入

影片中用實例來說明什麼時候該切分職責和其重點,例如 「業務耦合造成的問題就是職責不明確」、「不要讓類別去碰它不該做的事情等等」 ,我個人覺得非常不錯,推薦給想更了解單一職責原則或 SOLID 的朋友

雖然文章中的例子相對簡單。但有一個部份我個人覺得要特別注意:單一職責當然也可以用在函式上。甚至資料表或任何需要管理、分類抽象事物的東西上。

有些朋友可能跟我前陣子一樣,覺得函式就是用來消除重複的程式碼,直到我看了 可不可以不要寫糙 code 和一句「難道只有重複才需要做成 Function 嗎?」才明白:函式真正的工作其實是封裝邏輯。

既然是封裝邏輯這種抽象的東西,必然也會有其職責,自然也得好好注意單一職責囉。

走向單一職責

我們可以從上面的差別重新思考,遵守與不遵守單一職責原則會有哪些顯著的差異。

當我們並未遵守單一職責原則時,同個類別裡面充斥著不同工作的處理邏輯。也就是不健康的內聚:完全不夠聚,就只是盤散沙。

  • 容易產生意外的重複。每個類別每個方法都自己去查詢會員資料,當查詢會員資料的方式或規則有變更的時候,影響範圍就會非常大,同樣的事情有一大堆地方要改,還得要先全部找出來,想到就頭痛。

  • 同時我們在修改時也無法界定邊界,無法確定這次修改影響到的範圍,我們並不知道這些放在一起的東西,或是同一段做的所有事之間是否會相互影響,這將導致每次修改的時候都在挑戰我們自己的心臟負荷量,讓維護變成試膽大會,類別變成危樓改建。

  • 承上,我們為了要確保修改沒有問題,我們必須大量閱讀不相關的程式碼,無形中造成開發負擔,降低開發效率。

如果你曾經有閱讀別人的程式碼,卻始終看不懂這東西到底在幹嘛,每分鐘髒話數筆直上升的經驗。答應我,我們不要讓別人經歷相同的悲劇,我們要斬斷仇恨的鎖鏈。我們,今天就開始走向單一職責。

當我們終於選擇單一職責,我們的類別才能真的擁有健康的高內聚。以上的這些問題,也都變成:

  • 每個類別都專注在自己職責上,需要這個功能的其他類別就能來使用。大大提高了程式碼的重複使用程度,同時也降低了程式碼的重複性。並且因為類別內都是朝同樣職責前進的成員,彼此關聯性相當高,因此也提高了內聚。這兩點讓我們能迴避掉「要改的地方太多了,就改天吧」的悲傷結局。

  • 同時,當我們要修改時,只需要找到負責的類別修改。因為已經把不屬於職責的工作交給其他類別了,達到了封裝和隔離,所以我們就能輕鬆看出修改的區域和邏輯,並較少地被不相干的東西影響、馬上掌握修改的目標和影響範圍,使得架構和類別更容易管理。也就是說,單一職責可以達到降低耦合的效果。

展現你的意圖

單一職責讓我想到前陣子看的《先問為什麼》中的芹菜測試:當你在超市結帳時,手上拿著巧克力、豆漿、餅乾跟芹菜,沒有人看得出來你到底要幹嘛。

寫程式也是如此,如果你的類別或方法裡什麼都要,彼此間又甚無關連,那就沒人看得懂這到底是幹嘛的

如果團隊的其他人不能瞭解這個類別的職責,那後續協助修改的時候就會沒辦法把相同工作的程式碼歸類在一起,甚至難以修改,做起事綁手綁腳,新增個方法都會陷入混亂。整個架構就會開始腐敗。這也就是為什麼我們需要保持程式碼的可讀性,並且盡力實踐單一職責。

如同我在先問為什麼文中所引用的「你的一言一行,都要能證明你的信念」。在這裡,你的類別、方法,甚至是程式碼中的每一區塊,都必須要能夠展現你的意圖

因此,單一職責不只能用來檢驗類別。從一整個服務,到單一個函式,都可以用它的意圖來問問自己。這一段是否只有一個職責?是否只有一個原因造成改變?職責是否清晰?

當然,從模組到函式每一層級的抽象概念是不一樣的,模組有模組關注的點,函式有函式關注的點,其規模有所差異,請不要用函式的職責大小去要求整個類別,我個人覺得這中間的差異還是挺吃經驗的,但不去嘗試思考,就沒得經驗可說嘛。這邊還是鼓勵大家多多利用單一職責去檢驗任何片段的程式碼。

當我們利用單一職責原則去檢驗,或是思考方向的時候,如果列得出兩項以上的變更原因,且這些原因彼此關聯很薄弱的時候,就是警訊

反過來說,即使有兩個原因引起變化,但這些原因之間的關聯很強,例如總是一起變化,那其實就不必分離,或是可以暫緩分離,避免過度設計所引起的不必要的複雜性。(白話文來說就是走火入魔)

如果能做到撰寫功能當下,或是重構的時候不斷自我檢驗,那寫出來的程式碼品質相信也能展現出一定的水準了吧!共勉之。

本文整理時主要參考了這兩篇,寫得相當不錯,想更瞭解的朋友可以參考一下:

看到這篇覺得很不錯,從另一個角度切入單一職責,回來補充給各位朋友:

本系列下一篇:菜雞與物件導向 (11): 開放封閉原則

參考資料

同系列文章