Thinking in Java(6-1) Java 組合與繼承語法詳解

Thinking in Java(6-1) Java 組合與繼承語法詳解
Photo by orbtal media / Unsplash

在 Java 程式設計中,理解組合(Composition)與繼承(Inheritance)的語法是打造健全且可維護程式碼的基石。本篇文章將深入探討這兩種技術,並解釋物件引用、基礎類別初始化,以及委派模式等相關概念。

組合語法(Composition Syntax)

組合是透過在新類別中包含現有物件的引用來實現的。這種方式允許我們在不繼承其介面的情況下,使用現有類別的功能。

class WaterSource {
  private String s;
  WaterSource() {
    System.out.println("WaterSource()");
    s = "Constructed";
  }
  public String toString() { return s; }
}	

public class SprinklerSystem {
  private String valve1, valve2, valve3, valve4;
  private WaterSource source = new WaterSource();
  private int i;
  private float f;
  public String toString() {
    return
      "valve1 = " + valve1 + " " +
      "valve2 = " + valve2 + " " +
      "valve3 = " + valve3 + " " +
      "valve4 = " + valve4 + "\n" +
      "i = " + i + " " + "f = " + f + " " +
      "source = " + source;
  }	
  public static void main(String[] args) {
    SprinklerSystem sprinklers = new SprinklerSystem();
    System.out.println(sprinklers);
  }
} /* Output:
WaterSource()
valve1 = null valve2 = null valve3 = null valve4 = null
i = 0 f = 0.0 source = Constructed
*/

在上述範例中,SprinklerSystem 類別包含了一個 WaterSource 物件的引用。我們透過覆寫 toString() 方法,讓當前類別的物件能夠以易讀的方式輸出。

關於 toString() 方法

  • 自動調用:每個非基本資料類型的物件都有一個 toString() 方法。當編譯器需要一個 String 但你只有一個物件時,編譯器會自動調用該物件的 toString() 方法。
  • 範例說明:在 SprinklerSystem.toString() 中,"source = " + source 會觸發 sourcetoString() 方法,因為我們試圖將一個 String 與一個物件相加。

初始化物件引用

編譯器不會為每個物件引用自動建立預設對象,因為這可能會帶來不必要的性能負擔。我們有幾種方式來初始化這些引用:

  1. 在定義時初始化:直接在聲明變數時給予初始值。
  2. 在建構子中初始化:在類別的建構子中進行初始化。
  3. 惰性初始化(Lazy Initialization):在實際需要使用物件之前才進行初始化,這有助於提升性能。
  4. 實例初始化區塊(Instance Initialization Block):使用 {} 包裹的程式碼塊,可以在建構子之前執行。

以下是更複雜的範例,展示了上述各種初始化方式:

class Soap {
  private String s;
  Soap() {
    print("Soap()");
    s = "Constructed";
  }
  public String toString() { return s; }
}	

public class Bath {
  private String // Initializing at point of definition:
    s1 = "Happy",
    s2 = "Happy",
    s3, s4;
  private Soap castille;
  private int i;
  private float toy;
  public Bath() {
    print("Inside Bath()");
    s3 = "Joy";
    toy = 3.14f;
    castille = new Soap();
  }	
  // Instance initialization:
  { i = 47; }
  public String toString() {
    if(s4 == null) // Delayed initialization:
      s4 = "Joy";
    return
      "s1 = " + s1 + "\n" +
      "s2 = " + s2 + "\n" +
      "s3 = " + s3 + "\n" +
      "s4 = " + s4 + "\n" +
      "i = " + i + "\n" +
      "toy = " + toy + "\n" +
      "castille = " + castille;
  }	
  public static void main(String[] args) {
    Bath b = new Bath();
    System.out.println(b);
  }
} /* Output:
Inside Bath()
Soap()
s1 = Happy
s2 = Happy
s3 = Joy
s4 = Joy
i = 47
toy = 3.14
castille = Constructed
*/

繼承語法(Inheritance Syntax)

繼承允許我們創建一個新類別,這個新類別不僅包含基礎類別的所有屬性和方法,還可以新增或覆寫現有的方法。

  • 基本語法:使用 extends 關鍵字來指定要繼承的基礎類別。
  • 自動繼承:如果沒有明確指定,所有類別都會默認繼承自 Java 的根類別 Object

以下是一個繼承的範例:

class Cleanser {
  private String s = "Cleanser";
  public void append(String a) { s += a; }
  public void dilute() { append(" dilute()"); }
  public void apply() { append(" apply()"); }
  public void scrub() { append(" scrub()"); }
  public String toString() { return s; }
  public static void main(String[] args) {
    Cleanser x = new Cleanser();
    x.dilute(); x.apply(); x.scrub();
    print(x);
  }
}	

public class Detergent extends Cleanser {
  // Change a method:
  public void scrub() {
    append(" Detergent.scrub()");
    super.scrub(); // Call base-class version
  }
  // Add methods to the interface:
  public void foam() { append(" foam()"); }
  // Test the new class:
  public static void main(String[] args) {
    Detergent x = new Detergent();
    x.dilute();
    x.apply();
    x.scrub();
    x.foam();
    print(x);
    print("Testing base class:");
    Cleanser.main(args);
  }	
} /* Output:
Cleanser dilute() apply() Detergent.scrub() scrub() foam()
Testing base class:
Cleanser dilute() apply() scrub()
*/

使用 super 關鍵字

  • 意義super 關鍵字用於引用直接父類別的成員方法或變數。
  • 範例:在 Detergent.scrub() 方法中,我們使用 super.scrub() 來調用基礎類別的 scrub() 方法,避免了無限遞迴的問題。

初始化基礎類別

在建立子類別的物件時,基礎類別的建構子會首先被調用。這是因為子類別的建構子可能需要依賴基礎類別的初始化。

class Art {
  Art() { System.out.println("Art constructor"); }
}

class Drawing extends Art {
  Drawing() { System.out.println("Drawing constructor"); }
}

public class Cartoon extends Drawing {
  public Cartoon() { System.out.println("Cartoon constructor"); }
  public static void main(String[] args) {
    Cartoon x = new Cartoon();
  }
} /* Output:
Art constructor
Drawing constructor
Cartoon constructor
*/

在這個範例中,物件的建立順序是從最基礎的類別開始,依序向外擴展。這確保了基礎類別的初始化在子類別使用它之前完成。

帶參數的建構子

如果基礎類別沒有無參數的建構子,或者我們需要調用基礎類別的特定建構子,必須在子類別的建構子中明確地使用 super 關鍵字來調用。

class Game {
  Game(int i) {
    System.out.println("Game constructor");
  }
}

class BoardGame extends Game {
  BoardGame(int i) {
    super(i);
    System.out.println("BoardGame constructor");
  }
}	

public class Chess extends BoardGame {
  Chess() {
    super(11);
    System.out.println("Chess constructor");
  }
  public static void main(String[] args) {
    Chess x = new Chess();
  }
} /* Output:
Game constructor
BoardGame constructor
Chess constructor
*/

委派模式(Delegation)

委派(Delegation)是一種設計模式,允許我們在新類別中包含一個成員物件,並將該物件的方法暴露出來,就像繼承一樣。雖然 Java 並沒有提供直接的語法支援,但我們可以透過手動的方式來實現。

為何使用委派模式?

  • 控制權:委派允許我們對暴露的方法有更多的控制,因為我們可以選擇性地提供某些方法,而不必全部繼承。
  • 避免過度繼承:在某些情況下,繼承會導致類別之間的耦合度過高,使用委派可以降低這種風險。
  • 更靈活的設計:委派使我們可以組合多個物件的功能,而不受限於單一繼承的限制。

委派的實現方式

我們可以透過在新類別中包含一個現有物件的實例,然後在新類別中定義方法,這些方法內部實際上是調用成員物件的對應方法。

以下是使用繼承的方式實現的範例:

public class SpaceShipControls {
  void up(int velocity) {}
  void down(int velocity) {}
  void left(int velocity) {}
  void right(int velocity) {}
  void forward(int velocity) {}
  void back(int velocity) {}
  void turboBoost() {}
}

public class SpaceShip extends SpaceShipControls {
  private String name;
  public SpaceShip(String name) { this.name = name; }
  public String toString() { return name; }
  public static void main(String[] args) {
    SpaceShip protector = new SpaceShip("NSEA Protector");
    protector.forward(100);
  }
}

在上述範例中,SpaceShip 繼承了 SpaceShipControls,因此可以直接使用其所有方法。然而,這意味著 SpaceShip 是一種 SpaceShipControls,這在語意上可能不太合適。

使用委派模式改寫

透過委派,我們可以改寫上述範例,使 SpaceShip 包含一個 SpaceShipControls 的實例,並選擇性地暴露需要的方法。

public class SpaceShipDelegation {
  private String name;
  private SpaceShipControls controls = new SpaceShipControls();
  public SpaceShipDelegation(String name) {
    this.name = name;
  }
  // 委派方法
  public void back(int velocity) { controls.back(velocity); }
  public void down(int velocity) { controls.down(velocity); }
  public void forward(int velocity) { controls.forward(velocity); }
  public void left(int velocity) { controls.left(velocity); }
  public void right(int velocity) { controls.right(velocity); }
  public void turboBoost() { controls.turboBoost(); }
  public void up(int velocity) { controls.up(velocity); }
  public static void main(String[] args) {
    SpaceShipDelegation protector = new SpaceShipDelegation("NSEA Protector");
    protector.forward(100);
  }
}

在這個改寫的範例中:

  • 成員物件SpaceShipDelegation 包含了一個 SpaceShipControls 的實例 controls
  • 委派方法:我們在 SpaceShipDelegation 中定義了與 SpaceShipControls 相同的方法,這些方法內部實際上是調用 controls 的對應方法。
  • 語意更清晰SpaceShipDelegation 並不是一種 SpaceShipControls,而是擁有一個控制裝置,這更符合實際情況。

深入理解委派模式

委派模式的關鍵在於將行為委派給另一個物件。這使得我們可以在不改變介面的情況下,更改內部的實現。例如,如果未來我們需要更換控制裝置,只需替換成員物件,而不必改變類別的介面。

優點

  • 高可維護性:修改或替換委派的物件,不會影響使用該類別的其他部分。
  • 靈活性:可以動態地決定要委派哪些方法,甚至可以在運行時改變委派的物件。
  • 避免繼承的缺點:繼承有時會造成類別之間過度緊密的耦合,委派可以減少這種風險。

缺點

  • 額外的撰寫工作:需要手動撰寫委派的方法,可能會增加程式碼的量。
  • 效能開銷:每次方法調用都需要經過一層轉發,可能會有輕微的效能損耗。

何時使用委派模式?

  • 需要更好的控制權:當你需要對某些方法的存取進行限制或改變其行為時。
  • 避免不必要的繼承:當繼承會導致語意不清或過度耦合時,委派是更好的選擇。
  • 動態行為改變:當你需要在運行時更改物件的行為時,可以使用委派模式。

實際應用範例

假設我們有一個音樂播放器應用程式,需要支援多種音訊格式。我們可以定義一個 AudioPlayer 類別,並使用委派模式來處理不同的音訊格式。

interface AudioDecoder {
  void decode(String fileName);
}

class MP3Decoder implements AudioDecoder {
  public void decode(String fileName) {
    System.out.println("Decoding MP3 file: " + fileName);
  }
}

class WAVDecoder implements AudioDecoder {
  public void decode(String fileName) {
    System.out.println("Decoding WAV file: " + fileName);
  }
}

public class AudioPlayer {
  private AudioDecoder decoder;
  public void setDecoder(AudioDecoder decoder) {
    this.decoder = decoder;
  }
  public void play(String fileName) {
    decoder.decode(fileName);
    System.out.println("Playing audio file: " + fileName);
  }
  public static void main(String[] args) {
    AudioPlayer player = new AudioPlayer();
    player.setDecoder(new MP3Decoder());
    player.play("song.mp3");

    player.setDecoder(new WAVDecoder());
    player.play("song.wav");
  }
}

在這個範例中:

  • 委派物件AudioPlayer 委派了解碼的功能給 AudioDecoder
  • 靈活性:可以在運行時改變 decoder,以支援不同的音訊格式。
  • 分離關注點AudioPlayer 不需要知道解碼的細節,只需調用 decoder.decode()

結合使用組合與繼承

在實際應用中,我們經常需要同時使用組合和繼承來達到最佳的設計效果。

class Plate {
  Plate(int i) {
    print("Plate constructor");
  }
}

class DinnerPlate extends Plate {
  DinnerPlate(int i) {
    super(i);
    print("DinnerPlate constructor");
  }
}	

class Utensil {
  Utensil(int i) {
    print("Utensil constructor");
  }
}

class Spoon extends Utensil {
  Spoon(int i) {
    super(i);
    print("Spoon constructor");
  }
}

class Fork extends Utensil {
  Fork(int i) {
    super(i);
    print("Fork constructor");
  }
}	

class Knife extends Utensil {
  Knife(int i) {
    super(i);
    print("Knife constructor");
  }
}

// A cultural way of doing something:
class Custom {
  Custom(int i) {
    print("Custom constructor");
  }
}	

public class PlaceSetting extends Custom {
  private Spoon sp;
  private Fork frk;
  private Knife kn;
  private DinnerPlate pl;
  public PlaceSetting(int i) {
    super(i + 1);
    sp = new Spoon(i + 2);
    frk = new Fork(i + 3);
    kn = new Knife(i + 4);
    pl = new DinnerPlate(i + 5);
    print("PlaceSetting constructor");
  }
  public static void main(String[] args) {
    PlaceSetting x = new PlaceSetting(9);
  }
} /* Output:
Custom constructor
Utensil constructor
Spoon constructor
Utensil constructor
Fork constructor
Utensil constructor
Knife constructor
Plate constructor
DinnerPlate constructor
PlaceSetting constructor
*/

在這個範例中,PlaceSetting 同時繼承了 Custom 類別,並組合了多個餐具物件。我們需要注意的是,編譯器不會自動初始化成員物件,因此必須手動在建構子中進行初始化。

名稱遮蔽(Name Hiding)

在繼承中,如果基礎類別有多個同名但參數不同的方法(方法多載),在子類別中定義同名方法並不會遮蔽基礎類別的其他版本。

class Homer {
  char doh(char c) {
    print("doh(char)");
    return 'd';
  }
  float doh(float f) {
    print("doh(float)");
    return 1.0f;
  }
}

class Milhouse {}

class Bart extends Homer {
  void doh(Milhouse m) {
    print("doh(Milhouse)");
  }
}

public class Hide {
  public static void main(String[] args) {
    Bart b = new Bart();
    b.doh(1);
    b.doh('x');
    b.doh(1.0f);
    b.doh(new Milhouse());
  }
} /* Output:
doh(float)
doh(char)
doh(float)
doh(Milhouse)
*/

在這個例子中,Bart 新增了一個接受 Milhouse 物件的方法 doh(Milhouse m),但並未遮蔽 Homer 類別中的其他 doh() 方法。

使用 @Override 註解

為了避免意外的名稱遮蔽,Java 提供了 @Override 註解,用於明確表示我們希望覆寫基礎類別的方法。如果編譯器發現沒有相應的方法可供覆寫,會產生錯誤。

// {CompileTimeError} (Won't compile)
// Message: method does not override a method from its superclass

class Lisa extends Homer {
  @Override void doh(Milhouse m) {
    System.out.println("doh(Milhouse)");
  }
}

在組合與繼承之間選擇

  • 組合(Has-a):適用於想要在新類別中使用現有類別的功能,但不需要其介面的情況。
  • 繼承(Is-a):適用於新類別需要成為基礎類別的一種特殊形式的情況。

組合範例

class Engine {
  public void start() {}
  public void rev() {}
  public void stop() {}
}

class Wheel {
  public void inflate(int psi) {}
}

class Window {
  public void rollup() {}
  public void rolldown() {}
}

class Door {
  public Window window = new Window();
  public void open() {}
  public void close() {}
}

public class Car {
  public Engine engine = new Engine();
  public Wheel[] wheel = new Wheel[4];
  public Door
    left = new Door(),
    right = new Door(); // 2-door
  public Car() {
    for(int i = 0; i < 4; i++)
      wheel[i] = new Wheel();
  }
  public static void main(String[] args) {
    Car car = new Car();
    car.left.window.rollup();
    car.wheel[0].inflate(72);
  }
}

在這個範例中,Car 類別組合了 EngineWheelWindowDoor 等物件,構成了複雜的物件模型。

總結

理解組合與繼承的語法和應用場景,對於編寫高效、可維護的 Java 程式至關重要。組合允許我們在新類別中使用現有物件的功能,而繼承則讓我們能夠建立基礎類別的特化版本。委派模式則提供了一種靈活的方法來結合兩者的優點。

關鍵要點

  • 組合:使用現有物件的功能,適合 "Has-a" 關係。
  • 繼承:建立基礎類別的特化版本,適合 "Is-a" 關係。
  • 委派:在新類別中包含現有物件,並暴露其方法,取得繼承的便利性和組合的靈活性。
  • 初始化:注意物件引用的初始化時機和方式,確保物件在使用前已正確建立。
  • 名稱遮蔽:了解方法覆寫與多載的差異,使用 @Override 註解來避免潛在的錯誤。

透過對這些概念的深入理解,我們可以在開發中做出更明智的設計選擇,提高程式的品質和可維護性。