設計模式原則 之 里氏替換原則(LSP)
有多少小夥伴是不知道里式替換原則的? 我們寫了好多年的程式碼, 天天都在用繼承, 子類. 可是, 卻不知道里式替換原則? 趕緊來看看吧.
一. 什麼是裡式替換原則
1.1 裡式替換原則定義
裡式替換原則是用來幫助我們在繼承關係中進行父子類的設計。
里氏替換原則(Liskov Substitution principle)是對子型別的特別定義的. 為什麼叫裡式替換原則呢?因為這項原則最早是在1988年,由麻省理工學院的一位姓裡的女士(Barbara Liskov)提出來的。
里氏替換原則主要闡述了有關繼承的一些原則,也就是什麼時候應該使用繼承,什麼時候不應該使用繼承,以及其中蘊含的原理。里氏替換原是繼承複用的基礎,它反映了基類與子類之間的關係,是對開閉原則的補充,是對實現抽象化的具體步驟的規範。
裡式替換原則有兩層定義:
定義1
If S is a subtype of T, then objects of type T may be replaced with objects of type S, without breaking the program。
如果S是T的子類,則T的物件可以替換為S的物件,而不會破壞程式。
定義2:
Functions that use pointers of references to base classes must be able to use objects of derived classes without knowing it。
所有引用其父類物件方法的地方,都可以透明的替換為其子類物件
這兩種定義方式其實都是一個意思,即:應用程式中任何父類物件出現的地方,我們都可以用其子類的物件來替換,並且可以保證原有程式的邏輯行為和正確性。
1.2 里氏替換原則有至少有兩種含義
-
里氏替換原則是針對繼承而言的,如果繼承是為了實現程式碼重用,也就是為了共享方法,那麼共享的父類方法就應該保持不變,不能被子類重新定義。子類只能通過新新增方法來擴充套件功能,父類和子類都可以例項化,而子類繼承的方法和父類是一樣的,父類呼叫方法的地方,子類也可以呼叫同一個繼承得來的,邏輯和父類一致的方法,這時用子類物件將父類物件替換掉時,當然邏輯一致,相安無事。
-
如果繼承的目的是為了多型,而多型的前提就是子類覆蓋並重新定義父類的方法,為了符合LSP,我們應該將父類定義為抽象類,並定義抽象方法,讓子類重新定義這些方法,當父類是抽象類時,父類就是不能例項化,所以也不存在可例項化的父類物件在程式裡。也就不存在子類替換父類例項(根本不存在父類例項了)時邏輯不一致的可能。
不符合LSP的最常見的情況是,父類和子類都是可例項化的非抽象類,且父類的方法被子類重新定義,這一類的實現繼承會造成父類和子類間的強耦合,也就是實際上並不相關的屬性和方法牽強附會在一起,不利於程式擴充套件和維護。
二. 使用裡式替換原則的目的
採用里氏替換原則就是為了減少繼承帶來的缺點,增強程式的健壯性,版本升級時也可以保持良好的相容性。即使增加子類,原有的子類也可以繼續執行。
三. 裡式替換原則與繼承多型之間的關係
裡式替換原則和繼承多型有關係, 但是他倆並不是一回事. 我們來看看下面的案例
public class Cache {
public void set(String key, String value) {
}
}
public class Redis extends Cache {
@Override
public void set(String key, String value) {
}
}
public class Memcache extends Cache {
@Override
public void set(String key, String value) {
}
}
public class CacheTest {
public static void main(String[] args) {
// 父類物件都可以接收子類物件
Cache cache = new Cache();
cache.set("key123", "key123");
cache = new Redis();
cache.set("key123", "key123");
cache = new Memcache();
cache.set("key123", "key123");
}
}
通過上面的例子, 可以看出Cache是父類, Redis 和 Memcache是子類, 他們繼承自Cache. 這是繼承和多型的思想. 而且這兩個子類目前為止也都符合裡式替換原則.可以替換父類出現的任何位置,並且原來程式碼的邏輯行為不變且正確性也沒有被破壞。
看最後的CacheTest類, 我們使用父類的cache可以接收任何一種型別的快取物件, 包括父類和子類.
但如果我們對Redis中的set方法做了長度校驗
public class Redis extends Cache{
@Override
public void set(String key, String value) {
if (key == null || key.length() < 10 || key.length() > 100) {
System.out.println("key的長度不符合要求");
throw new IllegalArgumentException(key的長度不符合要求);
}
}
}
public class CacheTest {
public static void main(String[] args) {
// 父類物件都可以接收子類物件
Cache cache = new Cache();
cache.set("key123", "key123");
cache = new Redis();
cache.set("key123", "key123");
}
}
如上情況, 如果我們使用父類物件時替換成子類物件, 那麼就會丟擲異常. 程式的邏輯行為就發生了變化,雖然改造之後的程式碼仍然可以通過子類來替換父類 ,但是,從設計思路上來講,Redis子類的設計是不符合裡式替換原則的。
繼承和多型是面嚮物件語言所提供的一種語法,是程式碼實現的思路,而裡式替換則是一種思想,一種設計原則,是用來指導繼承關係中子類該如何設計的,子類的設計要保證在替換父類的時候,不改變原有程式的邏輯以及不破壞原有程式的正確性。
四. 裡式替換的規則
裡式替換原則的核心就是“約定”,父類與子類的約定。里氏替換原則要求子類在進行設計的時候要遵守父類的一些行為約定。這裡的行為約定包括:函式所要實現的功能,對輸入、輸出、異常的約定,甚至包括註釋中一些特殊說明等。
4.1 子類方法不能違背父類方法對輸入輸出異常的約定
1. 前置條件不能被加強
前置條件即輸入引數是不能被加強的,就像上面Cache的示例,Redis子類對輸入引數Key的要求進行了加強,此時在呼叫處替換父類物件為子類物件就可能引發異常。
也就是說,子類對輸入的資料的校驗比父類更加嚴格,那子類的設計就違背了裡式替換原則。
2. 後置條件不能被削弱
後置條件即輸出,假設我們的父類方法約定輸出引數要大於0,呼叫父類方法的程式根據約定對輸出引數進行了大於0的驗證。而子類在實現的時候卻輸出了小於等於0的值。此時子類的涉及就違背了里氏替換原則
3. 不能違背對異常的約定
在父類中,某個函式約定,只會丟擲 ArgumentNullException 異常, 那子類的設計實現中只允許丟擲 ArgumentNullException 異常,任何其他異常的丟擲,都會導致子類違背裡式替換原則。
4.2 子類方法不能違背父類方法定義的功能
public class Product {
private BigDecimal amount;
private Calendar createTime;
public BigDecimal getAmount() {
return amount;
}
public void setAmount(BigDecimal amount) {
this.amount = amount;
}
public Calendar getCreateTime() {
return createTime;
}
public void setCreateTime(Calendar createTime) {
this.createTime = createTime;
}
}
public class ProductSort extends Sort<Product> {
public void sortByAmount(List<Product> list) {
//根據時間進行排序
list.sort((h1, h2)->h1.getCreateTime().compareTo(h2.getCreateTime()));
}
}
父類中提供的 sortByAmount() 排序函式,是按照金額從小到大來進行排序的,而子類重寫這個 sortByAmount() 排序函式之後,卻是是按照建立日期來進行排序的。那子類的設計就違背裡式替換原則。
實際上對於如何驗證子類設計是否符合里氏替換原則其實有一個小技巧,那就是你可以使用父類的單測來執行子類的程式碼,如果不可以正常執行,那麼你就要考慮一下自己的設計是否合理了!
4.3 子類必須完全實現父類的抽象方法
如果你設計的子類不能完全實現父類的抽象方法那麼你的設計就不滿足裡式替換原則。
// 定義抽象類槍
public abstract class AbstractGun{
// 射擊
public abstract void shoot();
// 殺人
public abstract void kill();
}
比如我們定義了一個抽象的槍類,可以射擊和殺人。無論是步槍還是手槍都可以射擊和殺人,我們可以定義子類來繼承父類
// 定義手槍,步槍,機槍
public class Handgun extends AbstractGun{
public void shoot(){
// 手槍射擊
}
public void kill(){
// 手槍殺人
}
}
public class Rifle extends AbstractGun{
public void shoot(){
// 步槍射擊
}
public void kill(){
// 步槍殺人
}
}
但是如果我們在這個繼承體系內加入一個玩具槍,就會有問題了,因為玩具槍只能射擊,不能殺人。但是很多人寫程式碼經常會這麼寫。
public class ToyGun extends AbstractGun{
public void shoot(){
// 玩具槍射擊
}
public void kill(){
// 因為玩具槍不能殺人,就返回空,或者直接throw一個異常出去
throw new Exception("我是個玩具槍,驚不驚喜,意不意外,刺不刺激?");
}
}
這時,我們如果把使用父類物件的地方替換為子類物件,顯然是會有問題的(士兵上戰場結果發現自己拿的是個玩具)。
而這種情況不僅僅不滿足里氏替換原則,也不滿足介面隔離原則,對於這種場景可以通過 ** 介面隔離+委託** 的方式來解決。
四. 里氏替換原則的作用
- 里氏替換原則是實現開閉原則的重要方式之一。
- 它克服了繼承中重寫父類造成的可複用性變差的缺點。
- 它是動作正確性的保證。即類的擴充套件不會給已有的系統引入新的錯誤,降低了程式碼出錯的可能性。
- 加強程式的健壯性,同時變更時可以做
到非常好的相容性,提高程式的維護性、可擴充套件性,降低需求變更時引入的風險。
儘量不要從可例項化的父類中繼承,而是要使用基於抽象類和介面的繼承。
五. 里氏替換原則的實現方法
里氏替換原則通俗來講就是:子類可以擴充套件父類的功能,但不能改變父類原有的功能。也就是說:子類繼承父類時,除新增新的方法完成新增功能外,儘量不要重寫父類的方法。
根據上述理解,對里氏替換原則的定義可以總結如下:
- 子類可以實現父類的抽象方法,但不能覆蓋父類的非抽象方法
- 子類中可以增加自己特有的方法
- 當子類的方法過載父類的方法時,方法的前置條件(即方法的輸入引數)要比父類的方法更寬鬆
- 當子類的方法實現父類的方法時(重寫/過載或實現抽象方法),方法的後置條件(即方法的的輸出/返回值)要比父類的方法更嚴格或相等
通過重寫父類的方法來完成新的功能寫起來雖然簡單,但是整個繼承體系的可複用性會比較差,特別是運用多型比較頻繁時,程式執行出錯的概率會非常大。
如果程式違背了里氏替換原則,則繼承類的物件在基類出現的地方會出現執行錯誤。這時其修正方法是:取消原來的繼承關係,重新設計它們之間的關係。
關於里氏替換原則的例子,最有名的是“正方形不是長方形”。當然,生活中也有很多類似的例子,例如,企鵝、鴕鳥和幾維鳥從生物學的角度來劃分,它們屬於鳥類;但從類的繼承關係來看,由於它們不能繼承“鳥”會飛的功能,所以它們不能定義成“鳥”的子類。同樣,由於“氣球魚”不會游泳,所以不能定義成“魚”的子類;“玩具炮”炸不了敵人,所以不能定義成“炮”的子類等。
六. 案例分析
案例一: 兩數相減
當使用繼承時,遵循里氏替換原則。類B繼承類A時,除新增新的方法完成新增功能P2外,儘量不要重寫父類A的方法,也儘量不要過載父類A的方法。
繼承包含這樣一層含義:父類中凡是已經實現好的方法(相對於抽象方法而言),實際上是在設定一系列的規範和契約,雖然它不強制要求所有的子類必須遵從這些契約,但是如果子類對這些非抽象方法任意修改,就會對整個繼承體系造成破壞。而里氏替換原則就是表達了這一層含義。
繼承作為物件導向三大特性之一,在給程式設計帶來巨大便利的同時,也帶來了弊端。比如使用繼承會給程式帶來侵入性,程式的可移植性降低,增加了物件間的耦合性,如果一個類被其他的類所繼承,則當這個類需要修改時,必須考慮到所有的子類,並且父類修改後,所有涉及到子類的功能都有可能會產生故障。
class A{
public int func1(int a, int b){
return a-b;
}
}
public class Client{
public static void main(String[] args){
A a = new A();
System.out.println("100-50="+a.func1(100, 50));
System.out.println("100-80="+a.func1(100, 80));
}
}
執行結果:
100-50=50
100-80=20
後來,我們需要增加一個新的功能:完成兩數相加,然後再與100求和,由類B來負責。即類B需要完成兩個功能:
- 兩數相減。
- 兩數相加,然後再加100。
由於類A已經實現了第一個功能,所以類B繼承類A後,只需要再完成第二個功能就可以了,程式碼如下:
class B extends A{
public int func1(int a, int b){
return a+b;
}
public int func2(int a, int b){
return func1(a,b)+100;
}
}
public class Client{
public static void main(String[] args){
B b = new B();
System.out.println("100-50="+b.func1(100, 50));
System.out.println("100-80="+b.func1(100, 80));
System.out.println("100+20+100="+b.func2(100, 20));
}
}
類B完成後,執行結果:
100-50=150
100-80=180
100+20+100=220
我們發現原本執行正常的相減功能發生了錯誤。原因就是類B在給方法起名時無意中重寫了父類的方法,造成所有執行相減功能的程式碼全部呼叫了類B重寫後的方法,造成原本執行正常的功能出現了錯誤。在本例中,引用基類A完成的功能,換成子類B之後,發生了異常。在實際程式設計中,我們常常會通過重寫父類的方法來完成新的功能,這樣寫起來雖然簡單,但是整個繼承體系的可複用性會比較差,特別是運用多型比較頻繁時,程式執行出錯的機率非常大。如果非要重寫父類的方法,比較通用的做法是:原來的父類和子類都繼承一個更通俗的基類,原有的繼承關係去掉,採用依賴、聚合,組合等關係代替。
案例二: "幾維鳥不是鳥"
需求分析: 鳥通常都是會飛的, 比如燕子每小時120千米, 但是紐西蘭的幾維鳥由於翅膀退化不會飛. 假如要設計一個例項,計算這兩種鳥飛行 300 千米要花費的時間。顯然,拿燕子來測試這段程式碼,結果正確,能計算出所需要的時間;但拿幾維鳥來測試,結果會發生“除零異常”或是“無窮大”,明顯不符合預期,其類圖如圖 1 所示。
原始碼如下:
/**
* 鳥
*/
public class Bird {
// 飛行的速度
private double flySpeed;
public void setFlySpeed(double flySpeed) {
this.flySpeed = flySpeed;
}
public double getFlyTime(double distance) {
return distance/flySpeed;
}
}
/**
* 燕子
*/
public class Swallow extends Bird{
}
/**
* 幾維鳥
*/
public class Kiwi extends Bird {
@Override
public void setFlySpeed(double flySpeed) {
flySpeed = 0;
}
}
/**
* 測試飛行耗費時間
*/
public class BirdTest {
public static void main(String[] args) {
Bird bird1 = new Swallow();
Bird bird2 = new Kiwi();
bird1.setFlySpeed(120);
bird2.setFlySpeed(120);
System.out.println("如果飛行300公里:");
try {
System.out.println("燕子花費" + bird1.getFlyTime(300) + "小時.");
System.out.println("幾維花費" + bird2.getFlyTime(300) + "小時。");
} catch (Exception err) {
System.out.println("發生錯誤了!");
}
}
}
執行結果:
如果飛行300公里:
燕子花費2.5小時.
幾維花費Infinity小時。
程式執行錯誤的原因是:幾維鳥類重寫了鳥類的 setSpeed(double speed) 方法,這違背了里氏替換原則。正確的做法是:取消幾維鳥原來的繼承關係,定義鳥和幾維鳥的更一般的父類,如動物類,它們都有奔跑的能力。幾維鳥的飛行速度雖然為 0,但奔跑速度不為 0,可以計算出其奔跑 300 千米所要花費的時間。其類圖如圖 2 所示。
原始碼實現如下
/**
* 動物
*/
public class Animal {
private double runSpeed;
public double getRunTime(double distance) {
return distance/runSpeed;
}
public void setRunSpeed(double runSpeed) {
this.runSpeed = runSpeed;
}
}
/**
* 鳥
*/
public class Bird {
// 飛行的速度
private double flySpeed;
public void setFlySpeed(double flySpeed) {
this.flySpeed = flySpeed;
}
public double getFlyTime(double distance) {
return distance/flySpeed;
}
}
/**
* 燕子
*/
public class Swallow extends Bird {
}
/**
* 幾維鳥
*/
public class Kiwi extends Animal {
@Override
public void setRunSpeed(double runSpeed) {
super.setRunSpeed(runSpeed);
}
}
/**
* 測試飛行耗費時間
*/
public class BirdTest {
public static void main(String[] args) {
Bird bird1 = new Swallow();
Animal bird2 = new Kiwi();
bird1.setFlySpeed(120);
bird2.setRunSpeed(110);
System.out.println("如果飛行300公里:");
try {
System.out.println("燕子花費" + bird1.getFlyTime(300) + "小時.");
System.out.println("幾維鳥花費" + bird2.getRunTime(300) + "小時。");
} catch (Exception err) {
System.out.println("發生錯誤了!");
}
}
}
執行結果
如果飛行300公里:
燕子花費2.5小時.
幾維鳥花費2.727272727272727小時。
總結:
物件導向的程式設計思想中提供了繼承和多型是我們可以很好的實現程式碼的複用性和可擴充套件性,但繼承並非沒有缺點,因為繼承的本身就是具有侵入性的,如果使用不當就會大大增加程式碼的耦合性,而降低程式碼的靈活性,增加我們的維護成本,然而在實際使用過程中卻往往會出現濫用繼承的現象,而裡式替換原則可以很好的幫助我們在繼承關係中進行父子類的設計。