物件導向設計原則:里氏替換原則,定義、解析

物件導向設計原則:里氏替換原則,定義、解析

里氏替換原則(Liskov Substitution Principle)

定義

Subtypes must be substitutable for their base types.
-
子類別必須能取代父類別

里式替換原則是從 開放封閉原則 延伸出來的原則,若對開放封閉原則還不了解,建議先去瞭解開放封閉原則如何透過引入抽象來擴充程式碼的行為,再來學習里氏替換原則!

目的

讓開發人員確實地按照「介面」的定義進行實作,確保程式碼名符其實,避免發生無法預料的事情。

程式碼在編譯階段可以檢查出型別錯誤,卻不能檢查出開發人員犯傻。

因此里式替換原則要求開發人員確實地按照「介面」的定義進行實作,否則程式的行為將變得「不可預測」。換句話說,程式碼雖然可以“繞過”型別檢查使編譯成功,但有可能產生不可預知且不容易察覺的 Bugs。

解析

在開始講解之前,必須先引用 Uncle Bob 在 2017 年《Clean Architecture》對里氏替換原則的補充:

物件導向革命的最初幾年,里氏替換原則被用來指導「繼承的使用」。然而,多年以來里氏替換原則已經涉及到介面與實作,演變成了更廣泛的軟體設計原則。

引用這段是為了讓讀者知道,里氏替換原則不但適用於 繼承,也適用於 介面實作

接下來將會講解為什麼里氏替換原則可以同時套用到 繼承介面實作,以及里氏替換原則對物件導向開發的影響。

物件跟「抽象」與「介面」息息相關

「抽象」是人類處理複雜事物的方式。

人的大腦可以接收的訊息有限,因此在現實生活中,人類往往會對複雜的事物進行簡化,或將類似的事物歸納成同一類。對事物進行「抽象」雖然會忽略某些細節,但也讓人類更易於溝通、學習與管理。
舉例來說,向餐廳大廚點一份炒高麗菜就是利用「簡化」進行抽象,我們不會告訴大廚怎麼切菜、火要多大以及料理的順序;學校常見的告示牌“教室內不能喝飲料”則是透過「歸納」進行抽象,不可能將綠茶、奶茶、果汁、啤酒 …等等全部寫到告示牌上。

開發人員也會透過物件「封裝」的功能對程式碼進行抽象,把複雜的流程或業務規則隱藏到物件的內部。當程式碼被抽象成為物件後,就可以透過「外部視角」和「內部視角」來觀察一個物件:從「外部視角」觀察物件時,只能看見程式碼被簡化成一系列的 抽象行為。從內部觀察物件時,則可以看見每個行為的實作內容。

抽象描述了一個物件的基本特徵。

在外部視角中,只能得到物件公開(Public)的資訊,包含:公開屬性、常數、方法簽名(Signature,指方法名稱與其參數)。我們會將這些物件公開的資訊統稱為「介面」,所以很多物件導向設計(OOAD)的書籍提到介面時,可能同時是在講 Interface類別抽象類別

開發人員常常透過「介面」描述一個業務邏輯的基本特徵,包含要實現的功能目標與涉及範圍。並忽略介面的實際結構與行為實作內容。

「繼承」是為了共用父類別的介面

為了促使程式碼遵循 開放封閉原則,開發人員可以透過物件導向的繼承技術,繼承父類別的「介面」來擴充業務規則的邏輯。

不論是繼承或是介面,目的都是利用多型的特性來擴充業務規則的邏輯。這也是為什麼里氏替換原則可以同時適用於繼承與介面實作。

「繼承」不是為了共用父類別的程式碼

若只是想要共用父類別的邏輯,應該使用組合,而不是使用繼承。雖然沒有人會限制開發人員隨意地使用繼承,但如果使用繼承的目的不是為了「多型」,不但沒有讓繼承功能派上用場,還會迫使子類別公開父類別的「介面」。

契約式設計(Design by Contract)

里氏替換原則延伸出契約式設計,契約式設計用了三個條件來規範開發人員應該如何遵循「介面」的實作:

  1. 前置條件(pre-conditions)
    實作「介面」的實體物件,必須包含並保留所有「介面」的公開資訊。確保依賴「介面」的程式可以調用「介面」提供的功能。只有前置條件達成時,程式碼才會執行後置條件的邏輯。

  2. 後置條件(post-conditions)
    實作「介面」的實體物件,在執行完「介面」提供的功能後,必須回傳「介面」指定的回傳型別(Return Type)。約束開發人員要按照介面的定義實作功能。

  3. 不變性(invariants)
    前置條件後置條件 任一項條件沒有達成,程式碼就會報錯。

這三個條件就是物件導向語言中的 Interface 的限制條件,因此 Interface 也經常被稱作契約(Contract)。

範例

接下來利用 系統通知信件 示範違反與符合里氏替換原則的案例。

某系統有通知信件的功能,可以因應多種情境寄送對應的通知信件內容:

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
class EmailSender
{
private $mail;
private $emails;

/**
* 加入信件
*
* @param string $address
* @param EmailMaker $emailMaker 用於建立信件內容
*/
public function addEmail($address, EmailMaker $emailMaker)
{
$email = [
'address' => $address,
'emailHTML' => $emailMaker->makeEmailHTML(),
];
array_push($this->emails, $email);
}

/**
* 寄送信件
*/
public function send()
{
foreach ($this->emails as $email) {
$this->mail->setAddress($email['address']);
$this->mail->setBody($email['emailHTML']);
$this->mail->Send();
}
}
}

在這個系統中,所有情境的通知信件都是透過 EmailSender 來寄送信件。從上面程式碼中可以發現,開發人員希望透過 多型 來建立不同情境的信件樣板,因此在 addEmail 方法中引入一個專門用來建立信件樣板的介面 EmailMaker

1
2
3
4
5
6
7
8
interface EmailMaker
{
/**
* 建立信件 HTML 內容
* @return string
*/
public function makeEmailHTML(): string;
}

到目前為止,EmailSender 已經建立起 開放封閉原則 的 Plugin 架構,開發人員只需要新增實作 EmailMaker 介面的類別,就能替系統建立全新的通知信件種類(開放擴充)。完全不需要更改 EmailSender 的程式碼(關閉修改)。

里氏替換原則就像一個審查機制,監督開發人員在實作 開放封閉原則 Plugin 架構的介面(EmailMaker)時,讓程式碼的行為符合介面的定義。目的是確保開放封閉原則的核心業務邏輯(EmailSender)可以安全地使用 Plugin 來擴充邏輯

違反里氏替換原則

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
/**
* 上課遲到通知信件 HTML 產生器
*/
class LateForClassEmailHTML implements EmailMaker
{
public function __construct($studentId, $classInfo)
{
$this->studentId = $studentId;
$this->classInfo = $classInfo;
}

/**
* 建立信件 HTML 內容
* @return string
*/
public function makeEmailHTML(): string
{
// 建立 上課遲到通知信件 HTML 樣板
$this->template = new Template('emails');
$template = $this->template->load('emails/template/lateForClass', $this->classInfo);

// 扣除學生課程總成績
$studentCourse = StudentCourse::where(['studentId' => $this->studentId, 'classId' => $this->classInfo['classId']);
$studentCourse->totalScore = $studentCourse->totalScore - 1;
$studentCourse->save();

return $template;
}
}

在這個案例中,需求為「若學生上課遲到就寄送遲到通知信件,並扣除學生的課程總成績 1 分」。

開發人員新增 LateForClassEmailHTML 類別並實作 EmailMaker 介面替系統新增「學生上課遲到」通知信件內容。

但是上面的範例違反了里氏替換原則,因為 EmailMaker 介面明確定義 makeEmailHTML 的目的是「建立信件 HTML 內容」,但開發人員卻將「扣除學生的課程總成績」邏輯寫在 makeEmailHTML 函式中。雖然程式碼仍然會通過型別檢查(Type Hint),但卻會增加維護系統的困難度。

這些「不符合介面定義的程式碼」被放在不合理的地方,就會成為系統的技術債,開發人員會需要更多時間找碴程式碼,例如,從 Controller 層根本看不出「扣除學生的課程總成績」的邏輯在哪裡被執行:

1
2
3
4
5
6
7
8
// Controller 層
public function StudentLateForClass {
/** ...省略 */
$emailMaker = new LateForClassEmailHTML($student->id, $classInfo);
$emailSender = new EmailSender();
$emailSender->addEmail($student->email, $emailMaker);
$emailSender->send();
}

符合里氏替換原則

開發人員在實作「介面」的時候,應該完全按照介面的「定義」來撰寫功能,而且要不多也不少:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 上課遲到通知信件 HTML 產生器
*/
class LateForClassEmailHTML implements EmailMaker
{
public function __construct($studentId, $classInfo)
{
$this->classInfo = $classInfo;
}

/**
* 建立信件 HTML 內容
* @return string
*/
public function makeEmailHTML(): string
{
// 建立 上課遲到通知信件 HTML 樣板
$this->template = new Template('emails');
$template = $this->template->load('emails/template/lateForClass', $this->classInfo);

return $template;
}
}

「介面」不只是定義了一個類別的職責,也畫出類別的邊界。如果程式碼不符合「介面」所定義的範圍,就要將不符合定義的程式碼從介面中搬移到適合的地方

1
2
3
4
5
6
7
8
9
10
11
12
13
// Controller 層
public function StudentLateForClass {
/** ...省略 */
$emailMaker = new LateForClassEmailHTML($student->id, $classInfo);
$emailSender = new EmailSender();
$emailSender->addEmail($student->email, $emailMaker);
$emailSender->send();

// 扣除學生的課程總成績
$studentCourse = StudentCourse::where(['studentId' => $this->studentId, 'classId' => $this->classInfo['classId']);
$studentCourse->totalScore = $studentCourse->totalScore - 1;
$studentCourse->save();
}

結論

開放封閉原則必須透過 統一的抽象介面 來擴充核心業務規則的邏輯,因此《設計模式》作者們提出 “Program to an interface, not an implementation.”,將需求的問題域定義成抽象介面,系統才能安全地的擴展程式碼。搭配里氏替換原則對開發人員的限制,確保程式碼的行為符合「介面」的定義與期望結果,讓開放封閉原則可以信任實作「介面的程式碼」,最終讓系統可以用「增量式開發」的方式進行迭代釋出。

系列文章:

  1. 淺談物件導向 SOLID 原則對工程師的好處與如何影響能力
  2. 再談 SOLID 原則,Why SOLID?
  3. 物件導向設計原則:單一職責原則,定義、解析與實踐
  4. 物件導向設計原則:開放封閉原則,定義、解析與實踐
  5. 物件導向設計原則:裡氏替換原則,定義、解析

留言

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×