系列文章
1 定義
里氏原則的英文是Open Closed Principle,縮寫就是OCP。其定義有兩種
定義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。
(所有引用其父類對象方法的地方,都可以透明的使用其子類對象)
這兩種定義方式其實都是一個意思,即:應用程序中任何父類對象出現的地方,我們都可以用其子類的對象來替換,並且可以保證原有程序的邏輯行為和正確性。
如何理解里氏替換與繼承多態
很多人(包括我自己)乍一看,總覺得這個原則和繼承多態的思想差不多。但其實里氏替換和繼承多態有關系,但並不是一回事,我們可以通過一個例子來看一下
public class Cache {
public void set(String key,String value){
// 使用內存Cache
}
}
public class Redis extends Cache {
@Override
public void set(String key,String value){
// 使用Redis
}
}
public class Memcache extends Cache {
@Override
public void set(String key,String value){
// 使用Memcache
}
}
class CacheTest {
@Test
public void set() {
Cache cache = new Cache();
assertTrue(cache.set("testKey", "testValue"));
cache = new Redis();
assertTrue(cache.set("testKey", "testValue"));
cache = new Memcache();
assertTrue(cache.set("testKey", "testValue"));
}
}
我們定義了一個Cache類來實現程序中寫緩存的邏輯,它有兩個子類Redis和Memcache來實現不同的緩存工具,看到這個例子很多人可能會有疑問這不就是利用了繼承和多態的思想嗎?
不錯,的確是這樣的,而且在這個例子中兩個子類的設計完全符合里式替換原則,可以替換父類出現的任何位置,並且原來代碼的邏輯行為不變且正確性也沒有被破壞。
但如果這時我們需要對Redis子類方法中增加對Key長度的驗證。
public class Redis extends Cache {
public void set(String key,String value){
// 使用Redis
if(key==null||key.length<10){
throw new IllegalArgumentException("key長度不能小於10");
}
}
}
class CacheTest {
@Test
public void set() {
Cache cache = new Cache();
assertTrue(cache.set("testKey", "testValue"));
cache = new Redis();
assertTrue(cache.set("testKey", "testValue"));
}
}
此時如果我們在使用父類對象的時候替換成子類對象,那set方法就會有異常拋出。程序的邏輯行為就發生了變化,雖然改造之后的代碼仍然可以通過子類來替換父類 ,但是,從設計思路上來講,Redis子類的設計是不符合里式替換原則的。
繼承和多態是面向對象語言所提供的一種語法,一種代碼實現的思路,而里式替換則是一種思想,是一種設計原則,是用來指導繼承關系中子類該如何設計的,子類的設計要保證在替換父類的時候,不改變原有程序的邏輯以及不破壞原有程序的正確性。
2 規則
其實歷史替換原則的核心就是“約定”,父類與子類的約定。里氏替換原則要求子類在進行設計的時候要遵守父類的一些行為約定。這里的行為約定包括:函數所要實現的功能,對輸入、輸出、異常的約定,甚至包括注釋中一些特殊說明等。
2.1 子類方法不能違背父類方法對輸入輸出異常的約定
-
前置條件不能被加強
前置條件即輸入參數是不能被加強的,就像上面Cache的示例,Redis子類對輸入參數Key的要求進行了加強,此時在調用處替換父類對象為子類對象就可能引發異常。
也就是說,子類對輸入的數據的校驗比父類更加嚴格,那子類的設計就違背了里式替換原則。
-
后置條件不能被削弱
后置條件即輸出,假設我們的父類方法約定輸出參數要大於0,調用父類方法的程序根據約定對輸出參數進行了大於0的驗證。而子類在實現的時候卻輸出了小於等於0的值。此時子類的涉及就違背了里氏替換原則
public void calculatePrice() { Strategy strategy= new Strategy(); BigDecimal price= strategy.getPrice(); if (price <= Decimal.Zero) { throw new ArgumentOutOfRangeException("price", "price must be positive and non-zero"); } // do something }
-
不能違背對異常的約定
在父類中,某個函數約定,只會拋出 ArgumentNullException 異常,那子類的設計實現中只允許拋出 ArgumentNullException 異常,任何其他異常的拋出,都會導致子類違背里式替換原則。
2.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() 排序函數之后,卻是是按照創建日期來進行排序的。那子類的設計就違背里式替換原則。
實際上對於如何驗證子類設計是否符合里氏替換原則其實有一個小技巧,那就是你可以使用父類的單測來運行子類的代碼,如果不可以正常運行,那么你就要考慮一下自己的設計是否合理了!
2.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("我是個玩具槍,驚不驚喜,意不意外,刺不刺激?");
}
}
這時,我們如果把使用父類對象的地方替換為子類對象,顯然是會有問題的(士兵上戰場結果發現自己拿的是個玩具)。
而這種情況不僅僅不滿足里氏替換原則,也不滿足接口隔離原則,對於這種場景可以通過接口隔離+委托的方式來解決。
3 小結
面向對象的編程思想中提供了繼承和多態是我們可以很好的實現代碼的復用性和可擴展性,但繼承並非沒有缺點,因為繼承的本身就是具有侵入性的,如果使用不當就會大大增加代碼的耦合性,而降低代碼的靈活性,增加我們的維護成本,然而在實際使用過程中卻往往會出現濫用繼承的現象,而里式替換原則可以很好的幫助我們在繼承關系中進行父子類的設計。
系列文章
關注下方公眾號,回復“代碼的藝術”,可免費獲取重構、設計模式、代碼整潔之道等提升代碼質量等相關學習資料