Thinking in Java(7-3) 深入探討 Java 的協變回傳型別與繼承設計

Thinking in Java(7-3) 深入探討 Java 的協變回傳型別與繼承設計
Photo by orbtal media / Unsplash

在 Java 的多型機制中,協變回傳型別繼承的設計是兩個重要的概念。協變回傳型別允許在覆寫方法時,回傳基礎類別方法回傳型別的子類別。繼承則是物件導向設計的基石,但過度使用繼承可能會導致設計上的複雜性。本篇文章將深入探討這些概念,並透過程式碼範例進行說明。

協變回傳型別(Covariant Return Types)

Java SE5(也就是 Java 5)引入了協變回傳型別的概念,這意味著在衍生類別中覆寫基礎類別的方法時,可以回傳基礎類別回傳型別的子類別。

class Grain {
  public String toString() { return "Grain"; }
}

class Wheat extends Grain {
  public String toString() { return "Wheat"; }
}

class Mill {
  Grain process() { return new Grain(); }
}

class WheatMill extends Mill {
  Wheat process() { return new Wheat(); }
}

public class CovariantReturn {
  public static void main(String[] args) {
    Mill m = new Mill();
    Grain g = m.process();
    System.out.println(g);
    m = new WheatMill();
    g = m.process();
    System.out.println(g);
  }
} /* Output:
Grain
Wheat
*/

在上述範例中,Mill 類別的 process() 方法回傳 Grain 物件。在子類別 WheatMill 中,我們覆寫了 process() 方法,並讓其回傳 Wheat 物件(Grain 的子類別)。在 Java SE5 之前,這種做法會導致編譯錯誤,因為覆寫的方法必須回傳與基礎類別相同的型別。

協變回傳型別允許我們在覆寫方法時,回傳更具體的子類別型別,這提高了程式設計的彈性和表達力。這種特性使得覆寫方法更符合直覺,也讓程式碼更具可讀性。


以繼承進行設計

學習了多型之後,可能會覺得所有東西都可以被繼承。然而,在使用現有類別來建立新類別時,如果優先考慮繼承,可能會增加設計的負擔,導致不必要的複雜度。一種更好的方式是優先使用組合(Composition)。

組合不會強迫程式設計進入繼承的層次結構,因此更靈活,也可以動態選擇型別。相反地,繼承在編譯時就需要知道確切的型別。

以下是一個範例:

class Actor {
  public void act() {}
}

class HappyActor extends Actor {
  public void act() { print("HappyActor"); }
}

class SadActor extends Actor {
  public void act() { print("SadActor"); }
}

class Stage {
  private Actor actor = new HappyActor();
  public void change() { actor = new SadActor(); }
  public void performPlay() { actor.act(); }
}

public class Transmogrify {
  public static void main(String[] args) {
    Stage stage = new Stage();
    stage.performPlay();
    stage.change();
    stage.performPlay();
  }
} /* Output:
HappyActor
SadActor
*/

Stage 類別中,我們透過組合使用了 Actor 類別的物件。Stage 可以在執行期間隨時更換 Actor 物件,進而改變 performPlay() 方法的行為,達到動態靈活性的效果。

與此相反,我們無法在執行期間改變繼承的類別,因為繼承在編譯時就已經確定。

使用繼承與組合的準則

  • 繼承表達行為的差異:透過繼承,我們可以創建不同的子類別,以覆寫方法來表達不同的行為。
  • 組合表達狀態的變化:透過組合,我們可以在類別中包含其他物件,並在執行期間改變這些物件,從而改變類別的行為。

在上述範例中,我們透過繼承創建了 HappyActorSadActor,表達 act() 方法的不同實現。而 Stage 類別透過組合包含了一個 Actor 物件,並能夠在執行期間改變其狀態(actor),進而改變行為。

純替換與擴充(Substitution vs. Extension)

採取「純粹」的方式建立繼承層次結構,意味著衍生類別只覆寫基礎類別中已經存在的方法,不添加新的方法。

這種方式被稱為純替換,因為衍生類別可以完全替代基礎類別,而在使用時不需要知道子類別的任何額外資訊。

在這種設計中,基礎類別可以接收發送給衍生類別的任何訊息,因為它們具有完全相同的介面。我們只需將衍生類別向上轉型為基礎類別,並透過多型來處理。

然而,許多情況下,「像是某種東西」(is-like-a)的關係更適合解決特定問題。也就是說,子類別除了具有基礎類別的介面外,還擁有額外的方法和特性。

但需要注意的是,當我們向上轉型為基礎類別時,子類別中擴充的部分將無法被訪問。如果我們需要使用子類別中特有的方法,必須進行向下轉型

向下轉型與執行時型別識別(RTTI)

向上轉型會遺失物件的具體類型資訊,而向下轉型可以重新獲取這些資訊。然而,向下轉型並非總是安全的,因為我們無法保證一個基礎類別的引用實際上是一個特定的子類別。

在某些程式語言中,需要透過特定的機制來確保向下轉型的正確性。在 Java 中,所有的轉型都會在執行時進行檢查,以確保物件的型別是否符合轉型的要求。如果不符合,將拋出 ClassCastException

這種在執行期間對型別進行檢查的機制稱為 執行時型別識別(Runtime Type Identification,RTTI)。

以下是一個範例:

class Useful {
  public void f() {}
  public void g() {}
}

class MoreUseful extends Useful {
  public void f() {}
  public void g() {}
  public void u() {}
  public void v() {}
  public void w() {}
}	

public class RTTI {
  public static void main(String[] args) {
    Useful[] x = {
      new Useful(),
      new MoreUseful()
    };
    x[0].f();
    x[1].g();
    // Compile time: method not found in Useful:
    //! x[1].u();
    ((MoreUseful)x[1]).u(); // Downcast/RTTI
    ((MoreUseful)x[0]).u(); // Exception thrown
  }
}

在這個例子中,我們嘗試對 x[1] 進行向下轉型,因為我們知道它實際上是 MoreUseful 的實例。然而,對 x[0] 進行同樣的轉型將導致 ClassCastException,因為 x[0]Useful 的實例,並不是 MoreUseful

因此,RTTI 不僅包括轉型處理,還提供了一種方法,讓我們在試圖向下轉型之前,檢查所處理物件的實際類型。


總結

協變回傳型別讓我們在覆寫方法時,可以回傳更具體的子類別型別,增加了程式設計的靈活性。另一方面,繼承和組合是物件導向設計中兩種基本的復用方式。過度使用繼承可能導致複雜的繼承層次結構,建議在設計時優先考慮組合。向下轉型和執行時型別識別(RTTI)允許我們在需要時重新獲取物件的具體類型資訊,但必須謹慎使用,避免轉型錯誤。