歡迎光臨
每天分享高質量文章

如何在 Java 中安全地使用子型別

(給ImportNew加星標,提高Java技能)

 

來自:唐尤華,

https://dzone.com/articles/how-to-safely-subtype-in-java

 

你可能還記得,Liskov 代換原則是關於承諾和契約的規則。但具體是怎樣的承諾呢?為了確保 subtype(子型別)的安全性,意味著必須保證可以合理地從超型別推匯出 subtype,而且這個過程具有傳遞關係。在數學中,對所有 a,b,c ∈ x,如果 aRb 並且 bRc,那麼 aRc。 在面向物件程式設計中,subclass 即對應 subtype,然而這不是正確的開啟方式(這篇文章中 subclass 特指 subtype)。我們必須確保不會違反繼承超類的承諾,我們不會依賴於一些無法控制的東西。如果它發生更改,則可以影響其他物件(這些是不可變物件)。實際上,subclass 甚至可能導致 bug。

 

譯註:Liskov 於1987年提出了一個關於繼承的原則:“繼承必須確保超類所擁有的性質在子類中仍然成立”。也就是說,只有當一個子類的實體應該能夠替換任何其超類的實體時,它們之間才具有 is-A 關係。

 

1. 為什麼要安全地使用subtype(子型別)

 

實際上,subclass 是一種特殊的 subtype,它允許 subtype 重用 supertype 的實現(目的是防止因超類中的小改動導致重新實現)。我們可以認為 subclass 是 subtype,但不能說 subtype,但不能說 subtype 是 subclass。subclass 主要有兩個工作:subtype(多型)和程式碼重用。 subtype 的影響最大,父類中任何 public 或 protected 更改都將影響其子類。subetype 有時候是,但並不總是 Is-A 關係。 實際上,subtype 是一種程式上的程式碼重用技術,也是一種實現動態多型性(dynamic polymorphism)的工具。

 

subclass 只關心實現的內容和方式,而非承諾的內容。如果違背了基類承諾會發生什麼,如何保證它們之間能夠相容? 即使編譯器也無法理解這種錯誤,留給你的會是程式碼中的 bug,比如下麵這個例子:

 

class DoubleEndedQueue {
    void insertFront(Node node) {
        // ...
        // 在佇列的前面插入節點
    }
    void insertEnd(Node node) {
        // ...
        // 在佇列末尾插入節點
    }
    void deleteFront(Node node) {
        // ...
        // 刪除佇列前面的節點
    }
    void deleteEnd(Node node) {
        // ...
        // 刪除佇列末尾的節點
    }
}
class Stack extends DoubleEndedQueue {
    // ...
}

 

如果 Stack 類希望使用 subtype 實現程式碼重用,那麼它可能會繼承一個違反自身原則的行為,比如 insertFront。讓我們接著看另一個程式碼示例:

 

public class DataHashSet extends HashSet {
    private int addCount = 0;
    public function DataHashSet(Collection collection) {
        super(collection);
    }
    public function DataHashSet(int initCapacity, float loadFactor) {
        super(initCapacity, loadFactor);
    }
    public boolean function add(Object object) {
        addCount++;
        return super.add(object);
    }
    public boolean function addAll(Collection collection) {
        addCount += collection.size();
        return super.addAll(collection);
    }
    public int function getAddCount() {
        return addCount;
    }
}

 

上面的示例使用 DataHashSet 類重新實現 HashSet 跟蹤插入操作。DataHashSet 繼承 HashSet 併成為它的一個子類。我們可以在 Java 中傳入 DataHashSet 物件替換 HashSet 物件。此外,我的確重寫(override)了基類中的一些方法。 這在 Liskov 代換原則中合法嗎?由於沒有對基類行為進行任何更改,只是加入跟蹤插入操作程式碼,似乎完全合法。但我認為這顯然是錯誤的 subtype 程式碼。

 

首先,應該看一下 add 方法到底能做什麼。它把 addCount 屬性加1,並呼叫父類方法。這段程式碼存在一個溜溜球問題。讓為我們看看 addAll 方法。 首先,它把 addCount 的值加上集合大小,然後呼叫父類的 addAll 方法。但是父類的 addAll 方法具體怎麼執行呢?它將多次呼叫 add 方法(迴圈遍歷集合)。問題來了,父類將呼叫哪個 add 方法?是當前子類的 add 還是父類中 add?答案是子類中的 add 方法。因此,count 大小增加兩倍。呼叫 addAll 時,count 增加一次,當父類呼叫子類 add 方法時,count 會再增加一次。這就是為什麼稱之為悠悠球問題。

 

譯註:yo-yo problem 溜溜球問題。在軟體開發中,溜溜球問題是一種反樣式(anti-pattern)。當閱讀和理解一個繼承層次非常長且複雜的程式時,程式員不得不在許多不同的類定義之間切換,以便遵循程式的控制流程。 這種情況經常發生在面向物件程式設計。該術語來自比較彈跳註意的程式員的上下運動的玩具溜溜球。 Ganti、Taenzer 和 Podar 解釋為什麼這麼命名時說道:“當我們試圖理解這些資訊時,常常會有一種騎著溜溜球的感覺”。

 

這裡還有一個例子證明 subtype 是有風險的,看下麵這段程式碼:

 

class A {
    void foo(){
        ...
        this.bar();
        ...
    }
    void bar(){
        ...
    }
}
class B extends A {
    // 重寫 bar
    void bar(){
        ...
    }
}
class C {
    void bazz(){
        B b = new B();
        // 這裡會呼叫哪個 bar 函式?
        B.foo();
    }
}

 

呼叫 bazz 方法時,將呼叫哪個 bar 方法? 當然是 B 類中的 bar 方法。這會帶來什麼問題?問題在於 A 類中的 foo 方法不知道被 B 類中 bar 方法重寫,由於 A 中的 foo 方法認為呼叫的是自己定義的 bar 方法,從而導致類中的不變數和封裝遭到破壞。這個問題也稱為脆弱的基類問題(fragile base-class problem)

 

subtype 會引發更關鍵的耦合問題:程式中的一部分對另一部分產生非預期得依賴(緊耦合)。強耦合的經典案例就是全域性變數。例如,如果修改全域性變數的型別,那麼使用該變數(即與變數耦合)的所有函式都可能受到影響,因此必須檢查、修改和重新測試所有程式碼。此外,所有使用全域性變數的函式都因為它彼此耦合。也就是說,如果變數的值在某個不恰當的時間更改,那麼一個函式可能錯誤地影響另一個函式的行為。在多執行緒程式中,這種問題尤其可怕。

 

2. 如何安全地 subclass

 

subclass 最安全的方法是避免 subtype。如果類設計時並不希望支援 subclass,那麼可以把建構式設為 private 或在類的宣告中加 final 關鍵字防止 subclass。 如果希望支援 subclasss,那麼可以新建一個包裝類(wrapper class)實現程式碼重用。這時,我們需要對程式碼重用進行模組化推理,即在無需瞭解實現細節的情況下重用程式碼的能力。有幾種方法可以做到這一點,這裡介紹其中的一些方法。一種方法是將可重寫(overridable)的功能限制在少數 protected 方法中,避免自我呼叫可重寫方法。例如,透過語言自身機制或規範來防止重寫其他方法。在 DataHashSet 類中,避免 addAll 呼叫 add 方法。另外,避免在類中呼叫可重寫方法減少重寫對其他函式的直接影響。讓我們用前面的例子繼續說明:

class A {
    void foo(){
        ...
        this.insideBar();
        ...
    }
    void insideBar(){
        ...
    }
    void bar(){
        this.insideBar();
    }
}
class B extends A {
    // 重寫 bar
    void bar(){
        ...
    }
}
class C {
    void bazz(){
        B b = new B();
        B.foo();
    }
}

 

在上面的程式碼中,僅僅添加了 insideBar 方法,防止重寫導致不必要的更改,就可以解決問題。大多數情況下,建立包裝類是降低 subtype 風險的好方法。相比 subtype 我更喜歡組合(composition)或委託(delegation)。

 

有些時候必須不惜一切代價避免 subtype。如果有不止一種方法實現 subtype,那麼最好使用委託。或者父類中包含一些沒有呼叫的方法時,意味著不需要使用繼承(Liskov 代換原則)。同樣的規則對 class 也適用。我的意思是不應該在啟用共享類(shared class)的時候對重用該類。

 

譯註:shared class 共享類技術。Java5 平臺的 IBM 實現中新的共享類特性提供了一種完全透明和動態的方法,可以共享已經裝載的所有類,而不會對共享類資料的 JVM 施加限制。這個特性為減少虛擬記憶體佔用和改進啟動時間提供了一個簡單且靈活的解決方案,大多數應用程式都能夠因此受益。

 

3. subtype 委託

 

subtype 樣式可以把類看做模板,它定義了所有實體的結構。每個實體都具備類屬性與行為,但不包含屬性值。因為類的所有實體(包括子類的實體)都使用類定義的屬性,所以對類屬性的任何更改都將影響到所有實體。

 

一個實體包含了父類(superclass)和子類所有資訊的組合。subtype 呈一種線性的上下關係(Java 與 C++ 不同,不能有多個子類)。值儲存在實體中,而不是類中,並且不支援共享。在子類中,實體之間互相獨立,更改一個實體的狀態值不會影響任何其他實體,而且每個實體都有自己的父物件。

 

委託表示使用另一個物件進行方法呼叫。在委託實體中,不透過類共享屬性或行為,通常稱之為無類實體。要重用某個類,可以使用它的一個實體。假設有一個面積計算器類,能夠接受不同形狀並傳回其計算的面積。只要建立一個面積計算器物件,呼叫不同的面積計算類。在子類中,針對每種型別的面積計算,必須建立一個帶有父型別的獨立物件。

 

如果計算器物件將一個方法或變數委託給一個原型,那麼修改任何屬性或值都將同時影響物件和原型。使用這種方式,委託關係中的物件會互相依賴。在委託實現中,需要啟動多個物件。與 subtype 相反,物件可以是不同型別。此外,還需要用正確的方式組合實體,以滿足類的需要。

 

由於沒有父類,因此不能直接使用物件屬性。在 subtype 中,子類可以使用父類中的屬性或方法;在委託中,必須先定義才能訪問。

 

在委託中,只需要建立同這些類的連線,一個重用類(reuse class)可以重覆使用多個重用類,這些類都包含在同一個實體中。但在 subtype 中,重用類必須是其他重用類的子類(具備繼承關係)。

 

讓我們用委託來解決 DataHashSet 中的問題:

 

public class DataHashSet implements Set {
    private int addCount = 0;
    private Set set;
    public
        function DataHashSet(Set set) {
        This.set = set;
    }
    public boolean
    function add(Object object) {
        addCount++;
        return This.set.add(object);
    }
    public boolean
    function addAll(Collection collection) {
        addCount += collection.size();
        return This.set.addAll(collection);
    }
    public int
    function getAddCount() {
        return addCount;
    }
}

 

4. 如何使用 Skeletal 樣式?

 

Skeletal 樣式既不損失靈活性,又能享受 subtype 的優點。它為每個介面提供一個實現該介面的抽象類,不指定基礎方法(primitive method)。這意味著將方法設為 abstract 由子類實現,同時它還定義了非基礎方法。然後,由使用該介面的開發者實現介面,負責框架實現。 它不如包裝類靈活,比如組合或委託。 為了增加其靈活性,可以使用包裝類將呼叫委託給框架實現的匿名子類物件。

 

譯註:有關 Skeletal 樣式實體,可以參閱 Favor Skeletal Implementation in Java 

https://dzone.com/articles/favour-skeletal-interface-in-java

 

看完本文有收穫?請轉發分享給更多人

關註「ImportNew」,提升Java技能

喜歡就點「好看」唄~

 

    贊(0)

    分享創造快樂