Thinking in Java(7-1) 多型:向上轉型與方法綁定

Thinking in Java(7-1) 多型:向上轉型與方法綁定
Photo by orbtal media / Unsplash

在物件導向程式設計(OOP)中,多型(Polymorphism)是繼資料抽象和繼承之後的第三個基本特性。

多型透過將「做什麼」與「怎麼做」分離,從另一個角度將介面與實作分離。它不僅能夠改善程式碼的組織結構和可讀性,還能建立可擴充的程式。多型的方法調用允許一種類型表現出與其他相似類型之間的區別,只要它們是從同一個基礎類別衍生而來。

再論向上轉型

public enum Note {
    MIDDLE_C, C_SHARP, B_FLAT; // Etc.
}

class Instrument {
  public void play(Note n) {
    print("Instrument.play()");
  }
}

// Wind objects are instruments
// because they have the same interface:
public class Wind extends Instrument {
  // Redefine interface method:
  public void play(Note n) {
    System.out.println("Wind.play() " + n);
  }
}

public class Music {
  public static void tune(Instrument i) {
    // ...
    i.play(Note.MIDDLE_C);
  }
  public static void main(String[] args) {
    Wind flute = new Wind();
    tune(flute); // Upcasting
  }
} /* Output:
Wind.play() MIDDLE_C
*/

在上述程式中,從 Wind 向上轉型到 Instrument 可能會「縮小」介面,但不會比 Instrument 的完整介面更窄。進行向上轉型時,會刻意「忘記」物件的具體類別。如果讓 tune() 方法接受一個 Wind 類別的參數,似乎更直觀,但這樣會帶來一個重要問題:需要為系統中每一種 Instrument 的子類別都編寫一個新的 tune() 方法。

class Stringed extends Instrument {
  public void play(Note n) {
    print("Stringed.play() " + n);
  }
}

class Brass extends Instrument {
  public void play(Note n) {
    print("Brass.play() " + n);
  }
}

public class Music2 {
  public static void tune(Wind i) {
    i.play(Note.MIDDLE_C);
  }
  public static void tune(Stringed i) {
    i.play(Note.MIDDLE_C);
  }
  public static void tune(Brass i) {
    i.play(Note.MIDDLE_C);
  }
  public static void main(String[] args) {
    Wind flute = new Wind();
    Stringed violin = new Stringed();
    Brass frenchHorn = new Brass();
    tune(flute); // No upcasting
    tune(violin);
    tune(frenchHorn);
  }
} /* Output:
Wind.play() MIDDLE_C
Stringed.play() MIDDLE_C
Brass.play() MIDDLE_C
*/

雖然這種作法可行,但需要撰寫更多的程式碼,並且意味著日後若要添加新的 tune() 方法或從 Instrument 衍生新的類別,仍需要進行大量工作。如果忘記重載某個方法,編譯器也不會報錯。若讓方法只接受基礎類別,而不關心衍生類別,這正是多型所允許的。

動態方法綁定的轉機

tune() 方法接受一個 Instrument 的引用,但編譯器如何知道這個 InstrumentWindBrass 還是其他類別呢?實際上,編譯器無法得知。

方法調用綁定

將一個方法調用與方法主體關聯起來,稱為綁定(binding)

  • 前期綁定:在程式執行前進行綁定,由編譯器和連結器實現。
  • 後期綁定:在執行期間根據物件的類型進行綁定,又稱為動態綁定執行期綁定

在 Java 中,除了被 staticfinal 修飾的方法(private 方法也被隱式地視為 final),其他方法都是後期綁定的。

產生正確的行為

了解 Java 的方法都是透過動態綁定實現多型後,我們可以撰寫只與基礎類別互動的程式碼,並且這些程式碼對所有衍生類別都能正確運行。換句話說,我們發送訊息給某個物件,讓物件自行判斷應該執行什麼操作。

向上轉型的語句可以非常簡潔:

Shape s = new Circle();

雖然看起來將一個引用賦值給不同類型的變數是錯誤的,但編譯器可以接受這種語句,因為透過繼承,Circle 就是一種 Shape。如果調用 s.draw(),直覺上可能認為會調用 Shapedraw() 方法,但由於後期綁定,實際上會正確地調用 Circle.draw()

public class Shape {
  public void draw() {}
  public void erase() {}
}

public class Circle extends Shape {
  public void draw() { print("Circle.draw()"); }
  public void erase() { print("Circle.erase()"); }
}

public class Square extends Shape {
  public void draw() { print("Square.draw()"); }
  public void erase() { print("Square.erase()"); }
}

public class Triangle extends Shape {
  public void draw() { print("Triangle.draw()"); }
  public void erase() { print("Triangle.erase()"); }
}

public class RandomShapeGenerator {
  private Random rand = new Random(47);
  public Shape next() {
    switch(rand.nextInt(3)) {
      default:
      case 0: return new Circle();
      case 1: return new Square();
      case 2: return new Triangle();
    }
  }
}

public class Shapes {
  private static RandomShapeGenerator gen =
    new RandomShapeGenerator();
  public static void main(String[] args) {
    Shape[] s = new Shape[9];
    // Fill up the array with shapes:
    for(int i = 0; i < s.length; i++)
      s[i] = gen.next();
    // Make polymorphic method calls:
    for(Shape shp : s)
      shp.draw();
  }
} /* Output:
Triangle.draw()
Triangle.draw()
Square.draw()
Triangle.draw()
Square.draw()
Triangle.draw()
Square.draw()
Triangle.draw()
Circle.draw()
*/

在上述程式中,RandomShapeGenerator 隨機產生不同類型的 Shape,注意向上轉型在 return 語句中發生。從輸出結果可以看到,調用 draw() 方法時,會正確地執行衍生類別中的 draw()RandomShapeGenerator 是一種物件的「工廠」(factory),負責產生物件。

多型帶來的可擴充性

多型使得程式具有良好的可擴充性,我們可以在不修改現有代碼的情況下,添加新的子類別。

class Instrument {
  void play(Note n) { print("Instrument.play() " + n); }
  String what() { return "Instrument"; }
  void adjust() { print("Adjusting Instrument"); }
}

class Wind extends Instrument {
  void play(Note n) { print("Wind.play() " + n); }
  String what() { return "Wind"; }
  void adjust() { print("Adjusting Wind"); }
}	

class Percussion extends Instrument {
  void play(Note n) { print("Percussion.play() " + n); }
  String what() { return "Percussion"; }
  void adjust() { print("Adjusting Percussion"); }
}

class Stringed extends Instrument {
  void play(Note n) { print("Stringed.play() " + n); }
  String what() { return "Stringed"; }
  void adjust() { print("Adjusting Stringed"); }
}

class Brass extends Wind {
  void play(Note n) { print("Brass.play() " + n); }
  void adjust() { print("Adjusting Brass"); }
}

class Woodwind extends Wind {
  void play(Note n) { print("Woodwind.play() " + n); }
  String what() { return "Woodwind"; }
}	

public class Music3 {
  // Doesn't care about type, so new types
  // added to the system still work right:
  public static void tune(Instrument i) {
    // ...
    i.play(Note.MIDDLE_C);
  }
  public static void tuneAll(Instrument[] e) {
    for(Instrument i : e)
      tune(i);
  }	
  public static void main(String[] args) {
    // Upcasting during addition to the array:
    Instrument[] orchestra = {
      new Wind(),
      new Percussion(),
      new Stringed(),
      new Brass(),
      new Woodwind()
    };
    tuneAll(orchestra);
  }
} /* Output:
Wind.play() MIDDLE_C
Percussion.play() MIDDLE_C
Stringed.play() MIDDLE_C
Brass.play() MIDDLE_C
Woodwind.play() MIDDLE_C
*/

透過向上轉型後的物件,tune() 方法不需關心其周圍程式碼的變化,仍能正常運行。我們所做的任何修改都不會對不應該受到影響的部分產生破壞。換句話說,多型是「將變化的事物與不變的事物分離」的重要技術。

覆蓋私有方法的陷阱

public class PrivateOverride {
  private void f() { print("private f()"); }
  public static void main(String[] args) {
    PrivateOverride po = new Derived();
    po.f();
  }
}

class Derived extends PrivateOverride {
  public void f() { print("public f()"); }
} /* Output:
private f()
*/

在這個例子中,我們可能期望輸出 public f(),但由於 private 方法被隱式地視為 final,並且對子類別是不可見的,因此在子類別 Derived 中的 f() 方法是全新的方法。基礎類別的 f() 方法在子類別中不可見,無法被覆寫。非 private 方法才能被覆寫,雖然編譯器不會報錯,但執行結果並不符合預期。

欄位與靜態方法的多型限制

只有普通的方法調用是多型的,如果直接訪問某個欄位,這個訪問會在編譯期就進行解析,並非多型。

class Super {
  public int field = 0;
  public int getField() { return field; }
}

class Sub extends Super {
  public int field = 1;
  public int getField() { return field; }
  public int getSuperField() { return super.field; }
}

public class FieldAccess {
  public static void main(String[] args) {
    Super sup = new Sub(); // Upcast
    System.out.println("sup.field = " + sup.field +
      ", sup.getField() = " + sup.getField());
    Sub sub = new Sub();
    System.out.println("sub.field = " +
      sub.field + ", sub.getField() = " +
      sub.getField() +
      ", sub.getSuperField() = " +
      sub.getSuperField());
  }
} /* Output:
sup.field = 0, sup.getField() = 1
sub.field = 1, sub.getField() = 1, sub.getSuperField() = 0
*/

在上述例子中,當 Sub 物件被轉型為 Super 引用時,任何對欄位的訪問都由編譯器解析,因此不具有多型性。Super.fieldSub.field 分配了不同的儲存空間,實際上 Sub 包含了兩個名為 field 的欄位。為了取得 Super.field,必須明確地使用 super.field

同樣地,靜態方法也不具備多型性:

class StaticSuper {
  public static String staticGet() {
    return "Base staticGet()";
  }
  public String dynamicGet() {
    return "Base dynamicGet()";
  }
}

class StaticSub extends StaticSuper {
  public static String staticGet() {
    return "Derived staticGet()";
  }
  public String dynamicGet() {
    return "Derived dynamicGet()";
  }
}

public class StaticPolymorphism {
  public static void main(String[] args) {
    StaticSuper sup = new StaticSub(); // Upcast
    System.out.println(sup.staticGet());
    System.out.println(sup.dynamicGet());
  }
} /* Output:
Base staticGet()
Derived dynamicGet()
*/

如果方法是靜態的,就不會具有多型性。以上程式中,staticGet() 方法的調用結果取決於引用的編譯期類型,而非執行期類型。


總結

多型是物件導向程式設計中強大的特性,它允許我們撰寫更具彈性和可擴充性的程式碼。透過向上轉型和動態方法綁定,我們可以讓基礎類別的引用操作衍生類別的物件,而不必關心物件的具體類型。然而,需要注意的是,私有方法、欄位和靜態方法不具備多型性,在使用時需特別小心。