Skip to main content

Command Palette

Search for a command to run...

Lesson 18: SOLID 實戰篇 (3):完結LSP、ISP 與 魔王DIP

Updated
4 min read

關於LSP

接下來進到L了,這裡的L指的就是里氏替換原則 (LSP , Liskov Substitution Principle),這是許多後端工程師覺得最「抽象」的一個原則,但它其實是判斷「繼承 (Inheritance) 是否被濫用」最重要的標準。

什麼是里式替換 (LSP)?

「子類別 (Subclass) 必須能夠替換掉它們的父類別 (Base Class),且程式的行為不會發生錯誤。」

簡單來說,如果程式碼依賴於一個父類別(或介面),那麼隨便塞一個該父類別的「子類別」進去,程式都應該要能正常運作,而不需要去修改呼叫端的程式碼。

鴨子測試 (The Duck Test)

如果它看起來像鴨子,叫聲像鴨子,但需要裝電池才能動,那它就是違反了 LSP。因為當你把這隻「機械鴨」混進真正的鴨群裡,原本預期鴨子會游泳的程式邏輯就會崩潰。

P.S.這裡的鴨子測試是延伸出來的應用。

常見訊號

在 Code Review 中,如果看到以下幾種情況,通常就是違反了 LSP:

  1. 子類別拋出「未實作」例外:父類別有某個方法,但子類別根本不需要,只好在方法裡 throw new NotImplementedException()

  2. 子類別方法是空的:為了滿足介面定義,子類別把方法留空不做事。

  3. 呼叫端充滿了 instanceof 或類型檢查:呼叫端必須檢查「如果是 A 子類別,就做 X;如果是 B 子類別,就做 Y」。

實戰場景:支付閘道(Payment Gateway)的陷阱

假設我們正在開發一個電商系統的支付模組。我們有一個抽象的支付處理類別 PaymentHandler

違反LSP的設計

abstract class PaymentHandler
{
    abstract public function processPayment(float $amount);
    abstract public function refundPayment(float $amount);
}

class PayPalHandler extends PaymentHandler
{
    public function processPayment(float $amount) { /* 呼叫 PayPal API 付款 */ }
    public function refundPayment(float $amount) { /* 呼叫 PayPal API 退款 */ }
}

// 問題來了:公司決定引入「紅利點數支付」
// 紅利點數可以折抵現金,但是「不能退款」(假設這是業務邏輯,點數扣了就不退)
class RewardsHandler extends PaymentHandler
{
    public function processPayment(float $amount) { /* 扣除使用者點數 */ }

    public function refundPayment(float $amount)
    {
        // 違反 LSP!子類別無法履行父類別的承諾
        throw new Exception("Rewards cannot be refunded.");
    }
}

修正方案:隔離與拆分

介面隔離 (Interface Segregation),我們將「支付」與「退款」的行為分開。

interface Payable
{
    public function processPayment(float $amount);
}

interface Refundable
{
    public function refundPayment(float $amount);
}

// PayPal 既可以付錢也可以退錢
class PayPalHandler implements Payable, Refundable
{
    public function processPayment(float $amount) { /* ... */ }
    public function refundPayment(float $amount) { /* ... */ }
}

// 紅利點數只實作 Payable
class RewardsHandler implements Payable
{
    public function processPayment(float $amount) { /* ... */ }
}

現在,編譯器或靜態分析工具會幫我們把關。如果一個支付方式不支援退款,根本無法將它傳入退款的邏輯中。

class OrderService
{
    // 這裡 Type Hint 指定要 Refundable,所以絕對不會傳入 RewardsHandler
    public function refundOrder(Refundable $handler, float $amount)
    {
        $handler->refundPayment($amount);
    }
}

這樣一來,RewardsHandler 可以安心地當作 Payable 使用,而不會在需要 Refundable 的地方炸開。

契約設計 (Design by Contract)

LSP 在學術上還有兩個關於「契約」的重要規定,對於 API 設計很有幫助:

  1. 前置條件 (Preconditions) 不能更強:

    • 父類別說:「給我大於 0 的數字我就能跑」。

    • 子類別不能說:「給我大於 100 的數字我才跑」。(你要求更多,呼叫端會無所適從)

  2. 後置條件 (Postconditions) 不能更弱:

    • 父類別說:「我保證回傳一個有效的 JSON」。

    • 子類別不能說:「我可能會回傳 null 或空字串」。(你給的承諾變少了,呼叫端會處理錯誤)

補充說明 - 不是所有 override 都違反 LSP

里氏替換原則並不是禁止子類別覆寫 (override) 父類別的方法,它關心的是:

覆寫後,是否仍然遵守原本對呼叫端的「行為承諾」。

只要子類別的 override 沒有改變對外可觀察的行為契約,這樣的覆寫不但不違反 LSP,反而是常見且合理的設計。

合法、不違反 LSP 的 override 範例

class CachedUserRepository extends UserRepository
{
    public function findById(int $id): User
    {
        // 先查 Cache
        if ($user = $this->cache->get($id)) {
            return $user;
        }

        // Cache miss,才走父類別邏輯
        $user = parent::findById($id);
        $this->cache->set($id, $user);

        return $user;
    }
}
/**
方法簽章沒變
回傳型別與例外行為沒變
呼叫端完全不需要知道「有沒有用快取」
*/
class SecureFileStorage extends FileStorage
{
    public function save(string $path, string $content): void
    {
        // 子類別增加了加密行為
        $encrypted = $this->encrypt($content);

        parent::save($path, $encrypted);
    }
}
/**
父類別承諾的是「能成功儲存檔案」
子類別只是多做了一步處理
呼叫端不需要額外條件或調整使用方式
*/

總結

  • 繼承是「Is-A」的關係:不僅僅是名字像,行為也要像。如果子類別要把父類別的功能「關掉」或「報錯」,那就不該繼承。

  • LSP 是 OCP 的基礎:如果你違反了 LSP,呼叫端就必須檢查子類別類型,這就違反了 OCP (Open/Closed Principle)。

  • 多用組合/介面,少用繼承:當你發現繼承層級很難符合 LSP 時,通常代表你應該改用介面組合 (Composition) 的方式來設計。

關於ISP

什麼是介面隔離原則

Clients should not be forced to depend upon interfaces that they do not use.

與其設計一個無所不能的「胖介面」,不如把它拆分成多個特定的「瘦介面 (Thin Interface)」。

為什麼「胖介面」有壞味道

在後端開發中,我們經常會因為「貪圖方便」,把所有相關的方法都塞進同一個 Interface 裡。

全能的 UserInterface

interface UserInterface {
    public function login();
    public function register();
    public function sendEmail(); // 為了寄送歡迎信
    public function generateReport(); // 為了給後台管理員看數據
}

問題:

  • 不必要的依賴:負責「登入」的模組,為什麼要知道怎麼「產生報表」?

  • 實作的痛苦:如果你只想做一個簡單的「訪客註冊」功能,你被迫要去實作 generateReport()(可能只好留空或丟例外,這又違反了 LSP)。

  • 頻繁的修改:如果後台報表邏輯變了,導致 generateReport 簽章改變,連「登入模組」都要跟著重新編譯或測試,因為它們綁在同一個介面上。

實戰場景:工人與機器人

我們來看看ISP經典的教科書範例

違反 ISP 的設計

interface Worker {
    public function work();
    public function eat();
}

class HumanWorker implements Worker {
    public function work() { /* 工作 */ }
    public function eat() { /* 吃便當 */ }
}

// 💥 問題來了:引進機器人
class RobotWorker implements Worker {
    public function work() { /* 工作 */ }

    public function eat() {
        // 機器人不用吃飯,但介面強迫我實作
        // 這裡通常會丟例外,或留空,這違反了 LSP
        throw new Exception("Robot does not eat!"); 
    }
}

遵守 ISP 的修正

interface Workable {
    public function work();
}

interface Feedable {
    public function eat();
}

// 人類:既能工作也能吃
class HumanWorker implements Workable, Feedable {
    public function work() { /*...*/ }
    public function eat() { /*...*/ }
}

// 機器人:只能工作
class RobotWorker implements Workable {
    public function work() { /*...*/ }
}

補充說明 - 易混淆概念

還記得上面我們用於解決LSP的手法嗎?絕大多數因「介面過大」導致違反 LSP,解決方案正是 ISP。

LSP 是我們想要的「結果」,而 ISP 常常是達成這個結果的「方式」。

雖然 ISP 是解決 LSP 常見問題的解藥,但 LSP 的範圍比 ISP 更廣。有些 違反LSP的情況 ,不是靠拆分介面就能解決的。

範例:遵守 ISP 但違反 LSP

假設我們有一個銀行帳戶類別

// 介面很乾淨,只有一個方法,完全符合 ISP
interface Account {
    /**
     * @return float 剩餘餘額 (保證大於等於 0)
     */
    public function withdraw(float $amount): float;
}

class NormalAccount implements Account {
    public function withdraw(float $amount): float {
        // 正常的提款邏輯
        return $this->balance - $amount;
    }
}

// 這個子類別也實作了同樣的介面,結構上沒問題
class VipAccount extends NormalAccount {
    public function withdraw(float $amount): float {
        // 💥 違反 LSP!
        // 父類別/介面承諾餘額 >= 0,但 VIP 可以透支
        // 這改變了行為的「後置條件 (Postcondition)」
        return $this->balance - $amount; // 可能回傳負數
    }
}

分析

  1. ISP 沒問題:介面沒有多餘的方法,withdraw 是大家都需要的。

  2. LSP 違反了:VipAccount 的行為破壞了 Account 的隱性契約(餘額不能為負)。呼叫端如果預期餘額永遠是正的,程式就會出錯。

結語

違反 ISP 高機率導致 違反 LSP

  • 因為 ISP 造成了「胖介面」,強迫子類別去實作它做不到(或不該做)的方法。

  • 結果:子類別被迫「造假」或「罷工」:

    1. 丟出例外 (throw NotImplementedException)

    2. 空實作 (方法裡什麼都不寫) 。

但違反 LSP 不一定違反 ISP

  • 介面可能設計得非常完美、非常精簡(完全符合 ISP),但子類別的「實作邏輯」或是「繼承關係」本身就是錯的。

關於DIP

這就是SOLID的大魔王了,我都有點懷疑SOLID的順序是不是依照難易度排下來了。

DIP , Dependency inversion principle,依賴反轉原則。

什麼是依賴反轉原則?

DIP 有兩個繞口令的定義:

  1. 高層模組不應該依賴低層模組,兩者都應該依賴於抽象。

  2. 抽象不應該依賴於細節,細節應該依賴於抽象。

嗯…..是中文,但不懂…,還記得在L15有偷偷提到DIP嗎?那時候有提到:

DI 的核心原則是「依賴反轉 (DIP)」,「不要自己造工具,讓別人把工具傳進去。」

老闆與員工的關係

我們來看看一個生活化的比喻

沒有DIP:

老闆 想要做漢堡,老闆教員工A做漢堡,員工A離職,漢堡店陷入火海。

有DIP:

老闆 想要做漢堡,老闆製作了SOP,員工A針對SOP學會如何做漢堡,員工A離職,老闆找了員工B,員工B繼續快樂做漢堡。

為什麼叫 - 反轉

這裡我們就用L15的 OrderService 範例來進行說明,到底Inversion在哪裡

  1. 正常控制流OrderService 呼叫 GmailSender。 (上 -> 下)

  2. 傳統依賴關係OrderService use GmailSender。 (上 -> 下)

  3. DIP 依賴關係:

    1. OrderService 依賴 MailerInterface。 (上 -> 介面)

    2. GmailSender依賴 MailerInterface。 (下 -> 介面)

    3. 依賴的箭頭方向,從「指向下方」變成了「指向介面」。

反轉方向說明

補充說明 - 易混淆概念

依賴注入與依賴反轉的關聯

依賴反轉是一個概念,而依賴注入是一種實作手法。

依賴反轉關心的是:類別之間的依賴與方向關係

依賴注入關心的是:物件如何被建立與賦值

有 DI 不代表有 DIP

class OrderService {
    // 這裡依賴的是「具體」的 GmailService 類別
    public function __construct(GmailService $mailer) {
        $this->mailer = $mailer;
    }
}

Q:是不是 DI?

A:是,因為 GmailService 是注入進去,沒有在裡面 new。

Q:是不是 DIP?

A:不是,因為 OrderService (高層) 依然死死地依賴著 GmailService (低層實作)。如果 Gmail 換掉,OrderService 還是要改程式碼。依賴關係沒有「反轉」,箭頭還是從上指到下。

有 DI,也有 DIP

class OrderService {
    // 這裡依賴的是「抽象」的 MailerInterface
    public function __construct(MailerInterface $mailer) {
        $this->mailer = $mailer;
    }
}

Q:是不是 DI?

A:是,因為 GmailService 是注入進去,沒有在裡面 new。

Q:是不是 DIP?

A:是,OrderService 現在只依賴介面。依賴的箭頭現在指向了 MailerInterface (抽象),達成了依賴反轉。

從Rookie到Junior,一個後端成長的30堂課

Part 10 of 31

“從 Rookie 到 Junior:一個後端成長的 30 堂課” 是一套專為後端新手所設計的成長型技術系列文章。內容以實務為導向,逐步拆解後端工程的核心能力,包括程式語言基礎、架構思維、框架運作原理、業務邏輯設計、資料庫操作、以及常見的開發模式。 本系列的目標是協助讀者從零散的學習堆疊,建立成體系的後端知識框架。讀者能夠理解各語言背後不變的工程思維與設計原則。這套內容旨在讓學習者從「會寫程式」進入「能理解系統設計」的階段,逐步具備勝任 Junior Backend Engineer 的能力。

Up next

Lesson 17: SOLID 實戰篇 (2)-開放封閉 (OCP) - 擁抱變化而不修改舊碼

接下來我們來講講SOLID的O - OCP,開放封閉原則。 核心觀念定義 如果說 SRP 是為了整理程式碼,那麼 OCP 就是為了保護程式碼。它是防止「新需求搞壞舊功能」的最強盾牌。 “Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.” — Bertrand Meyer 「軟體實體應該對『擴充』開放,但對『修改』封閉。...

More from this blog

Lesson 26 : 系統韌性的守護者-限流、熔斷與背壓的設計模式

當這幾個名詞出現後,代表我們進到了一個高併發/大流量的系統了。在這個章節中,我們一起來看看如何透過一些方式來避免高併發導致我們的系統crash掉。 限流(Rate Limiting) 相信大家對這個名詞並不陌生,限流其實就是字面上的含意,限制流量。 限流的目的是保護「接收方」,確保系統不會因為瞬間的高併發請求而癱瘓。 限流通常發生在 API Gateway 或服務的最前端。它像是一個夜店門口的保全

Mar 26, 20262 min read

Lesson 25: 淺談 單體架構、微服務架構與單/多租戶架構

過去我們討論了 要把程式寫在哪、程式要怎麼拆的題目,接下來我們來看看「如何服務不同客戶」,這些架構反映了軟體開發在擴充性與複雜度之間的權衡。 軟體架構深度解析:從系統拆分到商業規模化 在軟體工程的演進中,架構的選擇往往是在「開發效率」、「系統擴充性」與「營運成本」之間尋求平衡。我們可以從兩個核心維度來觀察這些架構:系統如何運行(單體 vs. 分散式) 以及 如何服務客戶(單租戶 vs. 多租戶)。

Mar 26, 20262 min read

Lesson 24: 資料庫擴展術-讀寫分離、複寫機制與快取一致性挑戰

為什麼要讀寫分離? 大多數的 Web 應用都是 「讀多寫少」(例如:看文的人多,發文的人少,Heavy Read System)。當所有的請求都塞給同一台資料庫時,磁碟 I/O 和連線數會成為瓶頸。 Master (主庫): 負責寫入 (Insert/Update/Delete),確保數據一致性。 Slave (從庫): 負責讀取 (Select),可以有多個從庫來分擔讀取壓力。 為什麼讀

Mar 25, 20262 min read

面試經驗談 2025-2026

從2025年3月開始,我陸陸續續參與了從新創到上市櫃公司的Senior - Tech Lead的相關面試,其中有不乏 尊重面試者、展現高度專業的企業(公司),當然也有遇到幾場面試鬼故事,這篇文章主要分享我對於軟體工程師面試的方向分享,以及部分鬼故事,以此警惕自己不要成為這樣的面試官。 AI的洪流,改變了SWE的生態 LLM的發展確確實實的影響到了軟體工程師的生態。 過去受限於算力與資料規模,深度學

Mar 23, 20262 min read

Lesson 23: 系統的緩衝區-Queue 佇列與非同步處理 (Asynchronous)

佇列 佇列的實作工具非常多,舉凡AWS SQS、RabbitMQ、Kafka…等。 佇列的特性,其實是一個非常強大的系統緩衝區,應用層面非常廣。 什麼是佇列? 佇列可以想像成,在既有流程中外,有另一個”水管”,來連接原有的資料流(或邏輯過程),其中 呼叫方將資料 推(Push)到水管中,接受方(監聽) 從水管中將資料拉(Pull)出處理 為什麼佇列是「強大的緩衝區」? 在同步處理中,系統像是一

Mar 23, 20262 min read

Bennett's Tech Blog | 後端架構、系統設計

32 posts

來自台灣的軟體工程師,相信軟體可以改變世界