Skip to main content
類別: 原則
類型: 系統設計原則
來源: 芭芭拉·利斯科夫,OOPSLA 主題演講 Data Abstraction and Hierarchy,1987 年
別名: LSP;行為子類型;強行為子類型;Liskov Substitution Principle
快速回答里氏替換原則(Liskov Substitution Principle)是指:子類型只有在守住父類型的行為契約時,才能頂替父類型,讓呼叫方不必知道手裡到底是哪一個物件。芭芭拉·利斯科夫在 1987 年 OOPSLA 主題演講裡提出替換性質;她與珍妮特·溫在 1994 年把它形式化。實務檢驗很硬:如果呼叫父方法之前必須先看具體種類,這棵繼承樹已經在說謊。

什麼是里氏替換原則?

里氏替換原則是一條設計規則:子類型必須能用在父類型被期望出現的任何地方,並且不破壞那些呼叫方已經依賴的正確性。
若對類型 S 的每個物件 o1,都存在類型 T 的物件 o2,使得所有按 T 來寫的程式 P 在把 o1 換成 o2 後行為不變,則 S 是 T 的子類型。
這不是編譯器檢查。編譯器對的是名字和簽章。這條原則對的是承諾:父類型允許呼叫方假定的事情,換上子類型之後仍應成立。 日常圖像是代課老師。課程表仍寫著「第二節數學」。能把這節課上下來的代課老師,才是可替換的。不肯點名、或必須原任坐在教室裡才能教的人,不是。學校不該因為椅子上換了人,就把一天重排一遍。 在程式碼裡,正方形繼承長方形是最常被撞上的傷。長方形承諾:改寬不必改高。正方形要保持邊相等,就得兩邊一起改。先設寬再讀高的程式會吃一驚。家譜寫著「是一種」。契約沒有。開放封閉原則需要這條規則:如果舊客戶必須先檢查子類型是哪一種,你就插不進新的子類型。

里氏替換原則的三層理解

  • 入門:自稱能填一個角色,就必須做這個角色已經公開的那些工作。能塞進三號電池槽、電壓卻不同的電芯,形狀一樣,契約不一樣。
  • 實務:動手繼承之前,先列出父類型的承諾——呼叫方可傳入什麼、可期望回傳什麼、什麼事絕不發生。子類型可以更慷慨,不可以更挑剔。
  • 進階:行為子類型既是快照,也是歷史約束。可變物件不得引入父類型已經排除的狀態變化。所以能從中間戳一把的堆疊,即使也有 push 和 pop,仍不是堆疊。

起源

里氏替換原則起於類型層次的問題,不是貼在團隊牆上的口號。 芭芭拉·利斯科夫當時在 MIT 電腦科學實驗室,於 OOPSLA ‘87(奧蘭多,1987 年 10 月 4–8 日)做了主題演講 Data Abstraction and Hierarchy。講稿收入會議增補(ACM,doi 10.1145/62138.62141),後又刊於 ACM SIGPLAN Notices 23(5): 17–34(1988 年 5 月)。她在 1970 年代已用 CLU 語言做資料抽象。在 OOPSLA 上,她主張:Smalltalk 式的實作繼承,並不等於呼叫方可以信任的類型層次。她讀到的一篇論文把堆疊和佇列當成彼此的子類型。這不可能成立:按後進先出寫的程式,拿到先進先出就會壞。 她與 珍妮特·溫(Jeannette Wing)在 A Behavioral Notion of SubtypingACM Transactions on Programming Languages and Systems 16(6): 1811–1841,1994 年 11 月,doi 10.1145/197320.197383)裡給了這想法形式上的牙齒。他們的子類型要求是:凡能對類型 T 的物件證明的性質,對子類型 S 的物件也必須成立。這裡的子類型是規格之間的關係,不是類別檔案之間的關係。 羅伯特·C·馬丁1996 年 3 月C++ Report 工程筆記專欄 “The Liskov Substitution Principle” 裡,把這想法帶進日常物件導向實務。他的工作定義是:
使用基底類別指標或參照的函式,必須能夠在不知情的情況下使用衍生類別的物件。
馬丁 2003 年的《敏捷軟體開發:原則、模式與實務》(Prentice Hall)保留了這一檢驗。大約 2004 年Michael Feathers 把馬丁的「前五條」排成 SOLID 口訣。里氏替換原則是其中的 「L」。利斯科夫因資料抽象、容錯與分散式計算獲得 2008 年 ACM 圖靈獎;獎項比這一條規則更寬,但名字因此留在了工程口語裡。

核心要點

里氏替換原則真正值錢的時候,是一種新東西必須填進舊槽位。它不是禁止額外功能。它是一條關於哪些承諾不許收回的規則。
1

「是一種」是契約,不是長得像

共用幾個欄位、或圖個方便去繼承某個父類別,並不構成子類型。父類型的呼叫方有權得到公開行為,而不是你的繼承圖。企鵝繼承了 fly() 再拋例外,並不是「一種特殊的鳥」,而是父類型聲稱得太多。關注點分離告訴你把工作拆開。這條原則告訴你,不要用假家譜再黏回去。
2

不要把門收窄,也不要把結果變弱

子類型可以放寬前置條件(接受更多輸入),加強後置條件(對輸出保證更多)。箭頭不能反轉。父類型說「任意正數金額」,子類型拒絕小於 100 的金額,就不可替換。父類型說「回傳非空清單」,子類型回傳空,就不可替換。多出來的方法可以。把舊方法做得更安靜、更挑剔,不行。
3

不變式和歷史,在替換之後仍須成立

父類型聲稱對每個合法物件都為真的事情,替換後仍須為真。屬性表本應只裝字串,卻能透過父方法塞進整數,不變式已經破了。歷史也要緊:若父類型從不允許把某個值縮小,子類型的 setWidth 順帶砍掉高度,就是在改寫呼叫方以為已經合上的過去。
4

呼叫方必須檢視具體種類,層次就已經失敗

一串針對執行時期類型的 if,或「別在這個特殊子類上呼叫」的註解,就是氣味。每來一個新表親,這些呼叫方都得改——這條原則和開放封閉原則會一起失效。單一職責原則常常壓在底下:一個類型扛了兩份工作,繼承被當成膠水。

應用場景

當一個槽位會被幾種東西填上、而舊客戶不該去讀警告標籤時,再用里氏替換原則。不要用它逼所有相似名詞都掛到同一個父類別上。

在加上下一個表親之前,先劈開說謊的層次

正方形必須保持邊相等,它就不是可以隨便改尺寸的長方形。定期存款不能提領,它就不是父類型已經承諾了 withdraw 的帳戶。做更瘦的父類型——或者不要父類型——只給每種東西它真正能兌現的方法。助教的評分類型也是同一刀:「不能改分的測驗」不是指令碼其餘部分預設可改的「可評分項」。

用崗位本身、而不是用徽章,去核對待崗角色

不能簽採購單的代理經理,替換不了流程已經依賴的經理。要麼給副手同樣的簽字權,要麼改流程,讓簽字不再屬於這個槽位。職位名稱是繼承圖。工作流程才是契約。這裡的意外既是最小驚訝原則的失敗,也是替換的失敗。

寫出一個替補真正能擔任的社團職務

學校或社區團體常常設一個「財務」,然後發現備份的人進不了銀行、交不了報表、也拿不到鑰匙。章程承諾的是一個角色。備份填上的是一個名字。先列出這個職務必須做的事——報帳、保管鑰匙、出席會議——再問誰能頂上。必須原任在場才能轉的值日表,不是值日表。

把「相容」的公共表格當成契約,而不是長得像

辦事窗口處理不了的替代證件、「視同」稅表、或無法按常規窗口辦理的臨時許可,都不可替換。市民用多跑的腿為錯配付錢。若公開表格寫著任何匹配附件都可以,每一份匹配附件就必須真的可以。若有些不行,就把限制印在父表格上;不要藏進一個仍用同一標題的子類型裡。

經典案例

里氏替換原則作為操作傷口的有數字窗口,是 Java 的 Properties 類別——不是聲稱一個庫類別就是類型論的全部。 JDK 1.01996 年)起,java.util.Properties 表示一份可從串流載入、也可存回串流的持久字串表。它被寫成 Hashtable 的子類別,而 Hashtable 接受任何非空鍵值。父類型的公開契約,比子類型的公開工作更寬。因為這份繼承,putputAll 可以打在 Properties 物件上。官方 Java API 至今仍說「強烈不鼓勵」這樣用,因為呼叫方能塞進非字串的鍵或值。若再對這份「被污染」的物件呼叫 storelist,呼叫會失敗——典型是型別轉換錯誤。子類型並沒有收回父方法。它繼承了一扇自己守不住的門。 人們指著這件事已經幾十年。2016 年 5 月 17 日,OpenJDK 缺陷 JDK-8157123 以 “java.util.Properties class is violating the Liskov’s Substitution Principle (LSP)” 為題提交。建議的修法是組合:在 Properties 裡面持有一張表,而不是成為一張 Hashtable。缺陷以 Won’t Fix 關閉。公開的類型關係是對既有呼叫方的承諾;拿掉 extends 會是相容性斷裂。JDK 92017 年)重做了這個類別,條目不再存在繼承來的那張表裡。發行說明 JDK-8175789 記錄:值放在內部的並行對映中,並且 getter 不再同步,以減少死結。目前 OpenJDK 原始碼的註解仍寫著:Properties 不把值存在繼承來的 Hashtable 裡。這個類別仍然 extends Hashtable。截至 2026 年,這句層次上的謊話還留在公開 API 裡。 案例展示的是原則作為鎖定,而不是風格偏好。一旦你發布「這個是那種」,你可以改內臟,可以寫警告。你不能悄悄收回替換。邊界說明:若使用 setProperty 並保持字串,Properties 仍能工作。失敗的是類型聲稱,不是每一個呼叫點。給新設計的教訓,是 2016 年那條未能採納的繞路:把表組合進來,不要去繼承它。

邊界與失效場景

當父類型的契約已經是一隻雜物袋時,里氏替換原則會失效。Java 的 List 「可選操作」——add 在定長或不可改清單上可以拋例外——是函式庫層級的妥協。於是每個實作者都成了部分子類型。呼叫方必須讀腳註。這不是再加腳註的許可證;它警告父類型畫得太寬。在再堆一個會拋例外的表親之前,先把父類型削瘦,或把介面劈開。 它也會在給已發布的謊言做修補時失效。Properties 就是展品:正確設計是組合,而正確設計已經不可得。把一棵二十年的層次叫做「學習機會」,並不等於可以照抄。抄 2016 年的繞路,不要抄 1996 年的 extends。 常見誤用是一個上帝類型,一半方法寫成 NotSupported。不會飛的鳥、不能提領的帳戶、不能關機的裝置,都不是「特化的子類型」,而是過大父類型下面更挑剔的孩子。另一種誤用是行為已經分叉,卻為了重用程式碼去繼承。那種重用應放進共享助手,這是沒有假「是一種」的 DRY。子類型在父類型承諾安全的呼叫上拋出意外例外,也和快速失敗打架:程式是失敗了,卻失敗在別人以為安全的那一次呼叫裡。

常見誤區

這個中文名容易撞上「能編譯就能替換」「拋不支援就行」,以及把 SOLID 當成給類別做性格測驗。
編譯器檢查名字、類型和可見性。它不檢查 setWidth 是否仍是呼叫方以為的意思,也不檢查屬性表是否仍只裝字串。行為子類型是關於已經能工作的程式的聲稱。綠燈建置是必要的,遠遠不夠。
若父類型公開說過這個方法屬於日常使用,拋例外就是收回承諾。標準函式庫裡的可選操作是已知汙點,不是該抄的模式。要麼這個方法不該在父類型上,要麼每個子類型都必須兌現。寫著「別在這裡呼叫」的註解是契約上的洞,不是設計。
利斯科夫 1987 年的演講用物件,因為那是那場會議。同樣的形狀出現在角色、API、文件和代理人身分上。若槽位寫著「任何財務」「任何附件」「任何支付方式」,頂上的人就必須做這個槽位的工作。一個「相容」卻弄壞舊讀取方的 JSON 欄位,是同一種失敗,只是沒有 class 關鍵字。

相關概念

這些頁面挨著同一個問題:怎樣讓一種新東西填進舊槽位,又不讓已經依賴這個槽位的人吃一驚。

開放封閉原則

新行為作為新零件到來。這條原則檢驗新零件是否真能坐進舊槽位。

單一職責原則

每個模組一個變更理由。假子類型常常出現在一個父類型扛了兩份工作的時候。

關注點分離

把不同決策分開。更瘦的父類型往往是更乾淨的切口,而不是更深的樹。

最小驚訝原則

能編譯、行為卻古怪的替換,天生就是一次驚訝。

快速失敗

在父類型本該安全的地方拋例外的子類型,失敗得太晚,而且失敗在別人的呼叫裡。

迪米特法則

只跟直接鄰居說話。去查看表親內部的呼叫方,已經繞開了父類型的契約。

一句話總結

若新零件需要一張警告、寫著呼叫方不得把它當成父類型,它就不是子類型——把父類型削瘦,或把多出來的行為組合在旁邊。