Categories
程式開發

架構設計原則之我見(二):SOLID原則


SOLID原則,據WikiPedia所說,是由Robert C. Martin總結的面向對象設計原則。這個名字其實是以下五個原則的首字母簡寫:

  • Single responsibility principle;
  • Open/closed principle;
  • Liskov substitution principle;
  • Interface segregation principle;
  • Dependency inversion principle。

“Single responsibility principle”

這句話翻譯成中文是“單一職責原則”。這是一句缺乏主語的話,推斷應該是指設計師所設計的系統吧。所以補充完整後,整句話的意思應該是:“設計師所設計的目標系統,其職責應該是單一的”。

如何判定“職責”是否“單一”?

判定“職責單一”的標準是什麼難以回答,只能通過作者的文章進一步分析,嘗試理解作者原意。

這個原則也並非SOLID原則作者原創,據作者原文所說:“This principle was described in the work of Tom DeMarco and Meil​​ir Page-Jones . They called it cohesion”,原來這個原則來源於Tom DeMarco 和Meilir Page-Jones兩位前輩的工作,原本叫做“Cohesion”,也就是“內聚”。作者對“內聚”給出的解釋是:“A class should have only one reason to change”。下文根據作者所給出的例子,來進一步理解作者的意圖。

文章開頭以一個保齡球遊戲的編程設計來探討這一原則。原本Game類有兩個責任:一、負責跟踪當前幀,相當於打球;二、負責計算分數。作者認為,如果把這兩個職責放在同一個類中,會引起耦合,因此要對Game作架構拆分,把這兩個責任分別拆分給兩個不同的類,並給出了拆分的理由:“Because each responsibility is an axis of change”,意思是“因為每個職責都是一個變化的維度”。猜想作者想表達的是,由於這兩個職責是互相正交的維度,分拆開後,可以避免它們互相影響的意思。

這裡其實有兩個問題:

首先,兩個職責放在同一個類中,並不代表會發生耦合。

耦合的意思是當一個職責內部發生變動時,會影響到另外一個職責的正常執行。假設把兩個職責的代碼糅合在一起,形成一個大的代碼塊,這當然是耦合的,此時修改任何一個職責都要小心,牽一發而動全身。

但是我們可以把這兩個職責放在兩個不同的方法中,比如拆分成Game.trackFrame(), Game.calcScore()兩個方法後,在修改其中一個職責時,只要輸入輸出的參數不發生變化,也並不會產生耦合。也就是說,要解決耦合這一問題,並非只有“拆分成兩個不同的類”這一個解決方案,在同一個類中拆分成兩個方法也可以解決,因為拆分成方法是拆分成類的前提。是否需要拆分成類,還需要有其他方面的考慮,解耦這一理由還不夠充分,此處就不詳細展開。

其次,很多人都忽略了為何兩個職責可以被拆分開。

我們需要回到現實生活來分析保齡球遊戲的核心生命週期。

在現實生活中打保齡球時,確實有算分這一環節。在每一次打球結束時, 機器會自動給出分數。當然,在早期沒有機器時,這個分數肯定是由打球人自己來算的。為什麼後來可以拆分出來交給機器來算呢?因為算分活動必須等待打球結束才能進行,打球與算分二者在執行時間上是屬於完全不會發生交叉的兩個連續動作,且打球的結果作為算分的輸入,所以兩個動作本來就是沒有耦合的,可以拆分開,成為保齡球遊戲生命週期中的兩個相續活動。

這兩個活動哪一個才是核心生命週期活動呢?可以看到,人們去保齡球館是為了親身體驗打球,而不是為了體驗得分。而且即使沒有算分規則,人們也 可以玩的很開心,但如果沒有打球的體驗,只有算分規則,那麼這個遊戲也就不成立了。所以,這個遊戲的核心生命週期是打球,而非算分。算分只是在打球結束後對結果的計算,屬於非核心生命週,因此分數計算規則代碼可以從打球代碼中拆分出來,以保齡球遊戲所產生的結果作為算分的輸入來推動執行,形成樹狀結構。

而在拆分後,Game的原本功能並沒發生任何變化,只不過將其中一個步驟的實現代碼分離出去了而已,然後通過方法調用,以直接獲取結果的方式整合回歸,還是同一個整體,沒有發生變化。這一做法,使得Game能夠更加專注於其本身的職責,分數計算自身也能更加專注,各自被修改時也可以互不影響。

所以,二者能夠拆分開,並非“Because each responsibility is an axis of change”,而是因為其中存在非核心生命週期活動。並且拆分也並不僅限於拆分成類,首先應該能拆分成方法,這是拆分為類的前提。

“單一”與“內聚”

再從這個例子來分析“單一”的含義,確實還是叫“內聚”比較好。

從內聚的角度來看,在打球和算分兩個方法拆分開後,trackFrame()與calcScore()各自都專注於自身的業務,不受對方的影響,因此二者都是內聚的,自身都是完整的,只要給出輸入參數就可以獨立返回輸出結果。而且Game這個類完整包含了保齡球自身的業務,其自身也是內聚的。

可是一旦改成“單一職責”,意思就發生了變化,著重點變成了“單一”。其後文在詳細解釋時,又把表述從“an axis of change”改成“one reason to change”, 意思進一步發生了變化:“an axis of change”指的是一個維度,而“one reason to change”指的是一個理由。二種表述區別很大,完全誤解了“內聚”的本意,難怪會有很大的爭議。

另外怎樣才能算“職責單一”呢?這是沒有確定標準的,需要相對於某個一個參考點才能確定是否單一。比如Game包含打球和算分兩個步驟,難道Game的職責就不“單一”了嗎?不是的。保齡球遊戲需要打球和算分兩個步驟,以組成一個“單一”的運動,放在一起正是為“單一”運動而服務的,這樣做並不能說不“單一”。只有把對比的對象改為打球和算分時,才可以說Game的職責不單一。但是打球和算分本身就是從Game中拆分出來的,怎麼可以拿整體相對其拆分出來的部分來比“單一”呢?這不合理。如果真的這麼去比,即使把打球和算分二者拆分開後,算分的職責就“單一”了嗎?也不是的,算分也可以拆分為很多不同的規則,在規則的層面看,算分的職責也並不“單一”,還需要再拆分!按照這個“單一職責”分拆下去,永遠沒有止境,陷入死循環。

所以“單一”是一個相對的詞語,必須要看針對什麼來說是“單一”的,不能單獨來看。也不能因為一個事情分為兩個步驟,就說這個事情不“單一”,因為這兩個步驟所組成的是同一個事情,是單一的。而把這兩個步驟拆分開後由兩個人來分別執行,對於這兩個人來說,各自的職責仍然是單一的,但是不能因此而否認二者所組成的原來那個事情不“單一”。正因為這兩個人各自“單一”職 責的完成,組成了原本的那個“單一”的事情。

回過頭來,如果讀者明白“內聚”,站在“內聚”的角度來看“單一職責”原則, 來理解作者的“A class should have only one reason to change”這個解釋,就可以秒懂作者只不過是想表達“內聚”而已。因此,讀者千萬不要真的從“單一職責”的角度去理解這個原則,會很容易產生誤解,作者不過是想通過這一原則來表述作者所理解的“內聚”含義罷了。

掌握”內聚“,才是根本!

延展閱讀

架構設計原則之我見(一):反思 KISS 原則