在看過幾本架構面的書, 發現大量提到 SOLID 的觀念, 經過幾次反覆翻閱來整理一下自己所理解的觀念。
-
SRP: Single Responsibility Principle(單一權責原則)
-
OCP: Open-Closed Principle(開放關閉原則 )
-
LSP: Liskov Substitution Principle(里氏替換原則 )
-
ISP: Interface Segregation Principle(介面隔離原則)
-
DIP: Dependency Inversion Principle(依賴翻轉原則)
以上這五個原則所組成的第一個字就代表著 SOLID。
說明
一 . SRP: Single Responsibility Principle(單一權責原則)
SRP 從字面上意義來看, 就是一個人只負責一件事情, 當一個人同時負責太多事情就很容易出錯, 程式也是一樣, 如果一個類別裡面包含太多不同的功能, 一旦改動, 很容易造成邊際效應。
這邊常常會出現誤會就是一個類別只能寫一個方法或功能, 其實並不是這個意思, 因此單一權責原則代表的意思是負責所有該事項相關的功能, 總結一下說法就是: 一個類別應該只處理它應該處理的服務。
舉個例子來說。
class Person{
void getMyName(){}
void buildAHouse(){}
void playBaseball(){}
void playBasketball()
void writeABook(){}
}
以上的類別做了太多事情了, 萬一房子設計/寫書規格/運動方式改變了, 此時就必須回過頭來調整一下 Person 類別。
所以我們應該要把類別細切開來, 並且將該類別進行歸類。
凝聚性
通常一個類別會產生許多變數, 每個變數都有個別的方法去使用它, 這樣會導致某些方法參考某幾個變數, 另外一些變數是由某幾個特定的方法才會參考它們, 那這樣就代表一個意義:
這些變數跟它所參考到的方法是同一個族群放一起就是凝聚性。
舉個例子來說:
class Person{
private String houseName;
private String bookName;
private String myName;
void getMyName(){}
void buildAHouse(){}
void playBaseball(){}
void playBasketball()
void writeABook(){}
}
每個方法都有自己所屬的成員變數, 經過分類整理以後就會變成下面的樣子。
class Person{
private String myName;
void getMyName(){}
}
class House{
private String houseName;
void getHouseName(){}
}
class Book{
private String bookName;
void getBookName(){}
}
class Build{
public buildHouse(){}
}
class Write{
public writeBook(){}
}
class Exercise{
public playBaseball(){}
public playBasketball(){}
}
Clean Code(p. 157) : 保持凝聚性會得到許多小型的類別
這些類別幾乎不需要花時間去理解, 就可以立刻知道它所對應的功能, 而且如果有必要調整某一個方法, 產生邊際效應的風險趨近於零, 從測試的角度來看, 只需要驗證邏輯正確性, 整體來說就是簡單到不行。
再來就是如果需要擴增方法, 沒有程式碼會因為新增方法而遭到破壞, 這樣的設計就符合單一職責原則也同時支援到開放封閉原則。
隔離修改
每次改變需求的時候, 我們的實作類別就會進行變動, 此時相依的類別就會被影響到, 進而產生變動後程式錯誤的風險。
舉個例子來說:
class Exercise{
public playBaseball(){}
public playBasketball(){}
}
class Person{
private String myName;
private Exercise exercise = new Exercise();
void getMyName(){}
void goExercise(){
exercise.playBaseball();
}
}
Person 類別相依於 Exercise 類別, 因此只要 Exercise 類別一變動或者 playBaseball 這個方法改壞掉了, 則 Person 類別就面臨出錯的風險。
那麼我們怎麼防堵這類的風險呢?其實也不難, 我們只需要從將 Exercise 類別的實例從外部注入(Injection), 就可以解決掉這個問題了。
class Exercise{
public play(){}
}
class Person{
private String myName;
private Exercise exercise;
public Person(Exercise exercise){
this.exercise = exercise;
}
void getMyName(){}
void goExercise(){
exercise.playBaseball();
}
}
如此一來, 我們的 Exercise 類別即便變動, 比如說多了一個 playPingPong 的方法, 或者把 playBaseball 方法改壞掉了, 我們都不用再去爬 Person 類別調整, 只需要針對 Exercise 類別進行修復或新增即可。
二. OCP: Open-Closed Principle(開放封閉原則 )
開放封閉原則意思就是程式要容易變動而且不被修改, 當初我看到這句話的時候, 想到這句話不就是互相矛盾?但是後來查了很多資料, 發現它的意思其實應該是:
程式盡量不被修改且容易擴充。
危險行為
變動是程式裡面最常見而且最危險的一種行為。
所以如果我們要避免危險的行為就是不要去做它!那為什麼還是得去改變程式碼?主要來自幾個原因:
-
修 Bug
-
新增功能
-
重構
是不是很眼熟?這幾個動作都是常見的危險行為, 所以要避免危險就是不要去做它, 馬上寫好辭職信放到直屬長官桌上(X)。
抽象化
其實只要把注入改成抽象類別或介面, 就可以達到擴充的功能了。
舉個例子:
abstract class Exercise{
void play();
}
class Baseball extends Exercise{
@Override
void play(){
System.out.println("play baseball");
}
}
class Basketball extends Exercise{
@Override
void play(){
System.out.println("play basketball");
}
}
class Person{
private String myName;
private Exercise exercise;
public Person(Exercise exercise){
this.exercise = exercise;
}
String getMyName(){
return myName;
}
void goExercise(){
exercise.play();
}
}
public void main(){
Exercise baseball = new Baseball();
Exercise basketball = new Basketball();
Person john = new Person(baseball);
Person tom = new Person(basketball);
}
沒想到稍微修改一下, 這樣就讓 Person 類別跟 Exercise 的子類別脫鉤了, 如此一來無論未來要增加多少種運動, 都可以擴充且不需要更動到程式碼了。
三. LSP: Liskov Substitution Principle(里氏替換原則 )
這個原則主要是應用在替換這個字, 里氏最早提出來的這個原則, 意思是說只要你把傳入的類別抽象化起來, 那麼就可以透過更換的方式把目標無痛轉換, 這段話到底在說什麼?
訂定合約
對於某個類別來說, 它接收到的合約是可以執行某個規格, 所以只要你是根據該規格所生產出來的物件, 該類別都可以執行它。
舉個例子, 以剛剛 OCP 原則的例子來改寫解釋:
interface Exercise{
void play();
}
class Baseball implements Exercise{
@Override
void play(){
System.out.println("play baseball");
}
}
class Basketball implements Exercise{
@Override
void play(){
System.out.println("play basketball");
}
}
上面完成個別類別的實作, 接著我們會來寫兩段 Person 的實作, 一段是比較沒有彈性的實作, 另外一段是抽象過後比較有彈性的實作。
- 實作一. 比較沒彈性的實作
class Person{
private String myName;
private Baseball baseball = new Baseball();
void play(){
exercise.play();
}
void getMyName(){}
void playBaseball(){
baseball.play();
}
}
public void main(){
Person tom = new Person();
tom.playBaseball();
}
可以看到如果我們要讓 Tom 去打棒球, 只需要產生 Baseball 類別的實體, 接著呼叫 playBaseball 方法就可以了, 問題來了, 今天如果需要請 Tom 再去打籃球, 此時, 原本的 Person 類別以及 main 操作方法都必須要再進行調整, 而原本已經正確的程式, 就因此被變動到, 進而產生可能錯誤的風險, 所以讓我們來試看看另外一種比較有彈性的作法。
- 實作二. 比較彈性的實作
class Person{
private String myName;
private Exercise exercise;
public void setExercise(Exercise exercise){
this.exercise = exercise;
}
void play(){
exercise.play();
}
void getMyName(){}
void goExercise(){
exercise.play();
}
}
public void main(){
Exercise baseball = new Baseball();
Exercise basketball = new Basketball();
Person tom = new Person();
tom.setExercise(baseball);
tom.play();
tom.setExercise(basketball);
tom.play();
}
如此可以看到 Tom 可以同時操作 baseball 以及 basketball 物件, 並且執行它, 也就是說即便我新增再多個 Exercise 的子類別, 也可以無痛的替換掉執行的物件。
對於 Tom 這個物件而言, 無論如何都只會遵守 Exercise 所訂定出來的合約操作。
只要是 Exercise 的子類別, Person 類別都可以執行它。
四. ISP: Interface Segregation Principle(介面隔離原則)
跟第一個 SRP 原則雷同, 只是換成介面宣告太多方法, 讓實作的類別非得實作一些沒用到的方法。
拿前面既有的例子來增加功能:
interface Exercise{
void play();
void drink();
void rest();
void useEquipment();
}
我們新增了喝、休息、操作器材的功能, 但是其實有些運動其實不需要使用器材這個項目, 因此我們需要把這個介面進行縮減。
interface Exercise{
void play();
void drink();
void rest();
}
interface Equipment{
void use();
}
如此一來我們就可以把使用設備這個界面獨立出來, 有需要的運動可以自己去實作它, 配合 Person 來進行操作。
五. DIP: Dependency Inversion Principle(依賴反轉原則)
最初我們在宣告一個類別可能會是長這樣。
class Person{
private Baseball baseball = new Baseball();
void goExercise(){
baseball.play();
}
}
看起來一切都是正常的, 可是這樣會出現一個嚴重的問題, 就是當 Baseball 類別進行修改, 而且不小心改壞掉會連帶 Person 類別一起壞掉, 因此我們可以說:
Person 依賴著 Baseball
所以我們要透過抽象的方式, 讓 Person 從 Baseball 那邊解開依賴, 怎麼做呢?
首先定義一個虛擬類別:
abstract class Exercise{
void play();
}
讓 Baseball 去繼承這個類別:
public class Baseball extends Exercise{
@Override
void play() {
System.out.println("play baseball");
}
}
這樣一來就把 Person 類別從 Baseball 類別的魔爪逃走了, 因為 Person 遵循的是 Exercise 的規範, 而 Exercise 只定義出你必須遵守的規則, 其餘的由子類別自己自由發揮, 也就是說 Person 只要確保我呼叫 play 可以正確玩到我要的運動, 其餘的該運動規則會是由 Baseball 類別自己定義好。
操作行為
class Person{
private String myName;
private Exercise exercise;
public void setExercise(Exercise exercise){
this.exercise = exercise;
}
void play(){
exercise.play();
}
void getMyName(){}
void goExercise(){
exercise.play();
}
}
public void main(){
Exercise baseball = new Baseball();
Exercise basketball = new Basketball();
Person tom = new Person();
tom.setExercise(baseball);
tom.play();
tom.setExercise(basketball);
tom.play();
}
這樣的好處就顯而易見了, 某一天我需要把 Baseball 換成 Basketball , 我就可以開一個 Basketball 類別並且繼承 Exercise 類別, 如此一來, Person 類別也不需要調整可以直接吃掉新開類別所產生的物件, 因為它只認 Exercise所定義的規格。
這樣就符合依賴反轉原則的定義
高階模組不依賴低階模組,兩者都必須要依賴抽象。 抽象不能依賴細節,細節必須依賴抽象。
結論
其實這五個原則都是在討論一件事情, 就是程式變動後, 要怎麼讓原本的程式不受影響, 而這些原則會感覺解決方法其實非常雷同, 透過這些原則所產生的解決方法可能會互相參雜彼此的概念, 但是目的其實很清楚, 就是要寫出一個乾淨、有彈性且好測試的程式。
參考書籍
-
Clean Code : Clean Architecture 整潔的軟體設計與架構篇
-
Clean Code : 敏捷軟體開發技巧守則
-
大話重構
-
Kent Beck 的實作模式