Like Share Discussion Bookmark Smile

J.J. Huang   2020-02-25   Tech.   瀏覽次數:

技術觀念 | SOLID(物件導向設計)

因為在講解SOLID的過程很抽象,所以透過實際物品例子來做解釋;但是又會想要知道如何用程式去解釋。

所以在我參考多個文章後,特別挑了兩篇分別代表用「物品例子」與「程式例子」,將兩篇合併成一篇的用意是在於讓你的思考更加清晰清楚,可以更快速融會貫通其觀念。

簡介

在物件導向程式中,遵循SOLID這五項基本原則,可以幫助程式設計師寫出好維護、易擴充的程式架構。

  • SRP:Single Responsibility Principle(單一權責原則)

  • OCP:Open-Closed Principle(開放關閉原則)

  • LSP:Liskov Substitution Principle(里氏替換原則 )

  • ISP:Interface Segregation Principle(介面隔離原則)

  • DIP:Dependency Inversion Principle(依賴翻轉原則)

以上這五個原則所組成的第一個字就代表著SOLID

S: Single responsibility principle(SRP) 單一職責

SRP從字面上意義來看,就是一個人只負責一件事情,當一個人同時負責太多事情就很容易出錯,程式也是一樣,如果一個類別裡面包含太多不同的功能,一旦改動,很容易造成邊際效應。

這邊常常會出現誤會就是一個類別只能寫一個方法或功能,其實並不是這個意思,因此單一權責原則代表的意思是負責所有該事項相關的功能,總結一下說法就是「一個類別應該只處理它應該處理的服務」。

物品例子

阿文18歲生日那天取得汽車駕照,爸爸買一台車可以:

  • 在天上飛
  • 在路上走
  • 在水下游

給他當生日禮物。

當阿文想開這台車,就必須要有:

  • 機師職照
  • 汽車駕照
  • 潛艇證照

才能上路。

如哪天車故障了,可能要請:

  • 修飛機的技師
  • 修汽車的技師
  • 修潛艇的技師

三種專業人員一起查看問題在哪邊才能排除故障。

如果一個類別負擔太多工作,就會像上面的超級汽車一樣,不論是「使用上」或是後續的「維護」工作都可能會帶來很大的困擾。

要注意單一職責不是指一個類別裡面只有一個方法,在這邊,我們這台車責任是要可以在路上行駛,不過這不代表這台車只會擁有在路上行駛這個方法,實際上,行駛是由前進、後退、左轉、右轉、剎車等等基本功能組合而成的。

但從另外一個角度來看,又要注意功能被切的太細碎造成過度設計(over design)的情況,一台車雖然可以拆成方向盤、大燈、引擎、汽缸等等零件,每一個零件也都有不同的功能,但對汽車駕駛人來說,只要知道車子怎麼開就夠了,不需要去理解車子內部詳細的構造。對維修技師來說,了解細部零件的功能反而才是必要的,因此要怎麼規劃一個類別的責任,就要視實際的需求而定。如何定義一個類別(物件)的責任是一個很抽象也很難釐清的事情。

程式例子

以下的類別做了太多事情了,萬一房子設計/寫書規格/運動方式改變了,此時就必須回過頭來調整一下Person類別。
所以我們應該要把類別細切開來,並且將該類別進行歸類。

1
2
3
4
5
6
7
class Person{
void getMyName(){}
void buildAHouse(){}
void playBaseball(){}
void playBasketball()
void writeABook(){}
}

凝聚性:

通常一個類別會產生許多變數,每個變數都有個別的方法去使用它,這樣會導致某些方法參考某幾個變數,另外一些變數是由某幾個特定的方法才會參考它們,那這樣就代表一個意義。
「這些變數跟它所參考到的方法是同一個族群放一起就是凝聚性」

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 未分類
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(){}
}

註:保持凝聚性會得到許多小型的類別

這些類別幾乎不需要花時間去理解,就可以立刻知道它所對應的功能,而且如果有必要調整某一個方法,產生邊際效應的風險趨近於零,從測試的角度來看,只需要驗證邏輯正確性,整體來說就是簡單到不行。

再來就是如果需要擴增方法,沒有程式碼會因為新增方法而遭到破壞,這樣的設計就符合單一職責原則也同時支援到開放封閉原則。

隔離修改:

每次改變需求的時候,我們的實作類別就會進行變動,此時相依的類別就會被影響到,進而產生變動後程式錯誤的風險。

1
2
3
4
5
6
7
8
9
10
11
12
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),就可以解決掉這個問題了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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類別進行修復或新增即可。

O: Open/close principle(OCP) 開放/封閉原則

開放封閉原則意思就是程式要容易變動而且不被修改,當初我看到這句話的時候,想到這句話不就是互相矛盾?但是後來查了很多資料,發現它的意思其實應該是:「程式盡量不被修改且容易擴充」。

物品例子

物件導向程式設計最重要的開放(擴充)封閉(修改)原則。一套軟體應該要保留彈性,可以擴充新功能,但如果裡面程式碼的耦合度(Coupling)過高,新增功能時可能會影響舊功能,甚至會造成程式Bug,因此增新功能時就必須很小心仔細以原本正常程式碼被改壞了,另外高耦合的程式碼在有Bug需要維護修改也是同樣的麻煩。有鑑於此,舊程式碼應該是封閉修改的,或是某個舊功能需要調整,也不應該取影響到其他功能。

以阿文的車來說,我們在設計時就要針對車上不同的功能做模組化,例如說想將大燈改成又白又亮刺瞎別人的眼睛,汽車技師只要更換燈泡,不需也不能動到引擎的部分(引擎部分封閉修改);

或者今天要阿文要上山賞雪,可以直接在輪胎上綁上雪鏈(開放擴充),就可以在雪地上行走,不用整個輪胎換掉。開放/封閉原則就如同字面上的意思,開放新增功能,封閉修改其他不相關的功能。

程式例子

抽象化

其實只要把注入改成抽象類別或介面,就可以達到擴充的功能了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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的子類別脫鉤了,如此一來無論未來要增加多少種運動,都可以擴充且不需要更動到程式碼了。

L: Liskov substitution principle(LSP) Liskov替換

物品例子

在一個系統中,子類別應該可以替換掉父類別而不會影響程式架構。
阿文要開車去外婆家,阿文家車庫裡面有很多台車,我們先看其中三台車,如下圖:

阿文坐上的樂高車後,發現這是樂高積木組成的模型車,沒有引擎,根本不能上路!
這時候子類別樂高車並沒有辦法執行父類別car的路上跑功能,這種情況就不符合Liskov替換原則,子類別應該可以執行父類別想做的事情。

程式例子

這個原則主要是應用在替換這個字,里氏最早提出來的這個原則,意思是說只要你把傳入的類別抽象化起來,那麼就可以透過更換的方式把目標無痛轉換,這段話到底在說什麼?

訂定合約

對於某個類別來說,它接收到的合約是可以執行某個規格,所以只要你是根據該規格所生產出來的物件,該類別都可以執行它。

舉個例子,以剛剛OCP原則的例子來改寫解釋:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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的實作,一段是比較沒有彈性的實作,另外一段是抽象過後比較有彈性的實作。

•實作一:比較沒彈性的實作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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操作方法都必須要再進行調整,而原本已經正確的程式,就因此被變動到,進而產生可能錯誤的風險,所以讓我們來試看看另外一種比較有彈性的作法。

•實作二:比較彈性的實作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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類別都可以執行它。

I: Interface Segregation Principle(ISP) 介面隔離

物品例子

把不同功能的功能從介面中分離出來。

阿文表示,上次要去阿嬤家算是特殊的需求,我家的車最主要就是拿來佔車庫避免空間浪費, 然後輪流擺在庭院炫富用,樂高車也可以算是一台車,我們來看看阿文家的車庫(圖左)。

這邊有一個小問題,發現了嗎?對阿文來說,車子不一定要有「路上跑」這個功能,阿文的樂高車是沒辦法在路上的跑的,這明顯違反了上一條LSP,因此我們必須修改車子的定義 ,大部分的車有「路上跑」功能,因此我們可以將這個功能分割到其他介面,分割後如下圖,如此一來我們就用介面隔離「路上跑」這個功能(圖右)。

程式例子

跟第一個SRP原則雷同,只是換成介面宣告太多方法,讓實作的類別非得實作一些沒用到的方法。

拿前面既有的例子來增加功能:

1
2
3
4
5
6
interface Exercise{
void play();
void drink();
void rest();
void useEquipment();
}

我們新增了喝、休息、操作器材的功能,但是其實有些運動其實不需要使用器材這個項目,因此我們需要把這個介面進行縮減。

1
2
3
4
5
6
7
8
interface Exercise{
void play();
void drink();
void rest();
}
interface Equipment{
void use();
}

如此一來我們就可以把使用設備這個界面獨立出來,有需要的運動可以自己去實作它,配合Person來進行操作。

D: Dependency Inversion Principle(DIP) 依賴反轉

物品例子

定義:高階模組不應依賴低階模組,兩個都應該依賴在抽象概念上;抽象概念不依賴細節,而是細節依賴在抽象概念。

上面這段文字看起來很抽象,讓我來翻譯翻譯,意思就是「話不能說的太死,盡量講一些概念性的東西」。

阿文要在庭院要弄一個賞車派對,邀請函上面寫著「阿文誠摯邀請你來欣賞Ferrari Fx2020超級跑車」,因此這個派對就會被綁死在Fx2020超級跑車上面。如果當天阿文的爸爸開著這輛車去打網球,阿文在開趴那天就很糗很糗了。

為了避免這種事情發生,邀請函上面最好是寫著「阿文誠摯邀請你來參加派對並且欣賞超級跑車」,這樣一來就算當天阿文爸把Ferrari Fx2020開走了,他只要另外拿出一台超級跑車就可以,雖然朋友們會有受騙的感覺,不過至少不會讓阿文當場丟盡面子。

程式例子

最初我們在宣告一個類別可能會是長這樣。

1
2
3
4
5
6
class Person{
private Baseball baseball = new Baseball();
void goExercise(){
baseball.play();
}
}

看起來一切都是正常的,可是這樣會出現一個嚴重的問題,就是當Baseball類別進行修改,而且不小心改壞掉會連帶Person類別一起壞掉,因此我們可以說:「Person依賴著Baseball
所以我們要透過抽象的方式,讓PersonBaseball那邊解開依賴,怎麼做呢?

首先定義一個虛擬類別:

1
2
3
4
5
6
7
8
9
10
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類別自己定義好。

操作行為

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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所定義的規格。

這樣就符合依賴反轉原則的定義。

高階模組不依賴低階模組,兩者都必須要依賴抽象。
抽象不能依賴細節,細節必須依賴抽象。

結論

關於SOLID的觀念,非常抽象,所以透過程式和例子表示後,我自己重複讀了好幾次,在寫文章的過程也在讀了好幾次,總算是對這個觀念也一定程度的了解;接下來就是在未來設計的時候,可以多多套入這些思考。

其實這五個原則都是在討論一件事情,就是程式變動後,要怎麼讓原本的程式不受影響,而這些原則會感覺解決方法其實非常雷同,透過這些原則所產生的解決方法可能會互相參雜彼此的概念,但是目的其實很清楚,就是要寫出一個乾淨、有彈性且好測試的程式。


註:以上參考了
SOLID 惡補觀念
物件導向程式設計基本原則 - SOLID