Java 之路 (八) -- 多態(tài)(向上轉(zhuǎn)型、多態(tài)、綁定、構造器與多態(tài)、協(xié)變返回類型、向下轉(zhuǎn)型)

多態(tài) 是繼 數(shù)據(jù)抽象繼承 之后的第三種基本特征。
多態(tài)也稱作動態(tài)綁定、后期綁定或運行時綁定。

多態(tài)的一些具象表現(xiàn)。

  • 允許不同類的對象對同一消息做出響應
  • 同一個行為具有多個不同表現(xiàn)形式或形態(tài)的能力
  • 只有在運行時才會知道引用變量所指向的具體實例對象

封裝:通過合并特征和行為創(chuàng)建新的數(shù)據(jù)類型。
實現(xiàn)隱藏:通過將細節(jié)私有化,把接口和實現(xiàn)分離開來
多態(tài):消除類型之間的耦合關系。


1. 再論向上轉(zhuǎn)型

上一章中我們提到了,對象及可以作為自己本身的類型使用,也可以作為ita的基類型來使用。而這種把對某個對象的引用視為對其基類型的引用的做法被稱作 向上轉(zhuǎn)型。

我們看下面這個例子:

class Instrument {
    public void play(String song){
        System.out.println("Instrument.play() " + song);
    }
}
class Piano extends Instrument{
    //重寫方法
    public void play(String song){
        System.out.println("Piano.play() " + song);
    }
}
public class Music {
    public static void tune(Instrument i){
        //...
        i.play("Fade");
    }
    public static void main(String[] args){
        Piano piano = new Piano;
        tune(piano);
    }
}
//輸出結果為 Piano.play() Fade

我們通過向上轉(zhuǎn)型,將子類 Paino 類型的引用傳入了接收 父類 Instrument 類型引用參數(shù)的 tune() 方法中,這是沒有什么問題的,不過有一個疑問:為什么不讓 tune() 方法直接接收一個 Piano 參數(shù)呢?
乍一看下,似乎讓 tune() 方法接收一個 Piano 引用作為參數(shù)更符合常理,但是這樣會導致一個嚴重的問題:

  • 如果這樣做的話,那么每個 Instrument 的子類型都要寫一個新的 tune() 方法
    • 這會造成大量多余的編程
    • 同時,假如某個子類忘記修改某個方法,編譯器不會報任何錯誤,此時會出現(xiàn)一些隱患

結論是:這種情況下如果我們只寫一個簡單方法,它僅僅接收基類作為參數(shù),而不是特殊的導出類,事情就迎刃而解了。當然這正式 多態(tài) 所允許的。

但是不能只知其然不知其所以然,下面就仔細分析一下上述結論的依據(jù)是什么。


2. 綁定

我們再來分析一下 tune() 方法:

public static void tune(Instrument i){
        //...
        i.play("Fade");
}

前面我們提到 tune() 傳參的時候,將 Piano 向上轉(zhuǎn)型作為 Instrument 使用,但與此同時問題出現(xiàn)了:編譯器是如何知道這個 Instrument 引用指向的是 Piano 對象?嗯,實際上,編譯器無法得知(WTF?)。

為了深入理解這個問題,我們需要研究一下 綁定

2.1 什么是綁定

綁定 即將一個方法調(diào)用同一個方法主題關聯(lián)起來

  • 若在程序執(zhí)行前進行綁定,叫做 前期綁定

    如果有前期綁定的話,是由編譯器和連接程序?qū)崿F(xiàn)

  • 如果在運行時根據(jù)對象的類型進行綁定,叫做 后期綁定/動態(tài)綁定/運行時綁定

    后期綁定使得編譯器一直不知道對象的類型,但是方法調(diào)用機制通過安置的某種“類型信息”,能找到正確的方法體,并加以調(diào)用。

在 Java 中,除了 static 方法和 final 方法以外,其他所有的方法都是后期綁定。這點很關鍵。

關于把一個方法聲明為 final 的作用,在前面一章也提過了:

  1. 防止被覆蓋
  2. 出于性能考慮 -- 有效的關閉動態(tài)綁定
    但是應該從設計方面考慮是否使用 final 方法,而非出于性能的考慮。

2.2 多態(tài)的正確實踐

從上面分析,我們得知:"Java 中的方法都是通過動態(tài)綁定來實現(xiàn)多態(tài)的",這樣一來,我們編寫代碼時就只需要和基類打交道了,這些代碼自然適用于所有導出類。換個說法,發(fā)送消息給某個對象,讓該對象去斷定應該做什么事。

我們來舉個“幾何形狀”的例子:有一個基類 Shape,以及多個導出類,如 Circle、Square、Triangle:

向上轉(zhuǎn)型:Shape s = new Circle()
這里創(chuàng)建了一個 Circle 對象,并把得到的引用立即賦值給 Shape,能這么做是因為通過繼承,Circle 就是一種 Shape。

繼承表示 is-a 的關系

此時如果調(diào)用基類方法 s.draw(),雖然 s 是一個 Shape 引用,但是編譯器實際上并非調(diào)用 Shape.draw(),而是由于后期綁定(多態(tài)),會正確的調(diào)用 Circle.draw() 方法。

在編譯時,編譯器不需要獲得人為添加的任何特殊信息就能進行正確的調(diào)用。后期綁定 會替我們進行正確調(diào)用。

多態(tài)方法調(diào)用允許一種類型表現(xiàn)出與其他相似類型之間的區(qū)別,這種區(qū)別根據(jù)方法行為的不同而表示出來。

2.3 良好的可擴展性

在一個設計良好的 OOP 程序中,大多數(shù)或者所有方法都會只與基類接口通信。這樣的程序是可擴展的,因為可以從通用的基類繼承出新的數(shù)據(jù)類型,從而新添一些功能,那些操縱基類接口的方法不需要任何改動就可以應用于新類。

回到上面的 “樂器”(Instrument) 示例。由于多態(tài)機制,我們可根據(jù)自己需求添加任意多的新類型,而無需改變 tune() 方法。

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
*/

在 main 方法中,我們將引用至于 orchestra 數(shù)組中,會自動轉(zhuǎn)型到 Instrument。通過多態(tài)機制,tune() 方法完全忽略了它周圍代碼的全部變化,依舊運行正常。話句話說,多態(tài)使得程序員能夠 “將改變的事物和未變的事物分離開來”。

2.4 多態(tài)的適用范圍

只有普通的方法調(diào)用可以是多態(tài)的,靜態(tài)方法/域 不具有多態(tài)性。

  1. 域訪問操作會由編譯器解析,因此不是多態(tài)的

    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 包含兩個 field(本身的 和 從父類Super 繼承來的),如果需要得到父類的的 field,必須顯式指明 Super.field。

  2. 靜態(tài)方法的行為不具有多態(tài)性 -- 靜態(tài)方法是與類,而非單個對象相關聯(lián)的

    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()
    */
    

3. 構造器和多態(tài)

構造器是個很特殊的方法,盡管構造器不具備多態(tài)性[1],但還是有必要理解構造器怎樣通過多態(tài)[2]在復雜的層次結構中運作。

1.構造器實際上是 static 方法,隱式聲明了 static,因此不支持多態(tài)。
2.指構造器內(nèi)部調(diào)用的方法,而非構造器本身。

3.1 再再論構造器的調(diào)用順序

關于構造器的調(diào)用順序在第五章進行了簡要說明(最基本),并在第七章再論(加入繼承), 下一節(jié)就再結合 多態(tài) 來進一步補充,本節(jié)先來做一下回顧并討論該順序的意義所在。

Java 之路 (五) -- 初始化和清理(構造器與初始化、方法重載、this、垃圾回收器、枚舉類型)
Java 之路 (七) -- 復用類(組合、繼承、代理、向上轉(zhuǎn)型、final、再談初始化和類的加載)

給出以下例子:

class Meal {
  Meal() { print("Meal()"); }
}

class Bread {
  Bread() { print("Bread()"); }
}

class Cheese {
  Cheese() { print("Cheese()"); }
}

class Lettuce {
  Lettuce() { print("Lettuce()"); }
}

class Lunch extends Meal {
  Lunch() { print("Lunch()"); }
}

class PortableLunch extends Lunch {
  PortableLunch() { print("PortableLunch()");}
}

public class Sandwich extends PortableLunch {
  private Bread b = new Bread();
  private Cheese c = new Cheese();
  private Lettuce l = new Lettuce();
  public Sandwich() { print("Sandwich()"); }
  public static void main(String[] args) {
    new Sandwich();
  }
} /* Output:
   * Meal()
   * Lunch()
   * PortableLunch()
   * Bread()
   * Cheese()
   * Lettuce()
   * Sandwich()
   */

從結果中我們可以看出這調(diào)用構造器遵循一下的順序:

  1. 調(diào)用基類構造器
  2. 調(diào)用成員的初始化方法
  3. 調(diào)用導出類構造器的

這一過程會反復遞歸,首先構造根基類,然后是下一層導出類,等等,知道最底層的導出類。

3.1.1 基類的構造器總是在導出類的構造器中被調(diào)用

如標題所言,這么做的意義何在?

首先構造器擔負著檢查對象是否被正確構造的任務,同時導出類只能訪問自己的成員,而不能訪問基類的成員(基類成員通常為 private),這就導致了只有基類的構造器能夠?qū)ψ约旱脑剡M行初始化。因此,必須令所有構造器都得到調(diào)用,否則不可能正確構造完整對象。

這也是為什么編譯器強制每個導出類構造器都必須調(diào)用基類構造器的原因。

如果導出類沒有明確指定調(diào)用基類構造器,就會自動調(diào)用默認構造器;如果不存在默認構造器,編譯器會報錯。

其次,當進行繼承時,我們獲取了基類的一切,并可以訪問基類中 public 和 protected 的成員。這就意味著導出類中,必須假定基類的所有成員都有效。通常做法就是在構造器內(nèi)部確保所要使用的成員都構建完畢,因此,唯一的辦法就是首先調(diào)用基類構造器,這樣在進入導出類構造器時,積累中可供我們訪問的成員就都已被初始化。

3.1.2 清理順序

如果某一子對象依賴于其他對象,銷毀的順序應該和初始化順序相反

  1. 對于類的成員,意味著與聲明的順序相反,因為成員的初始化是按照聲明的順序進行的
  2. 對于基類,應該首先銷毀其導出類,然后才是基類。這是因為導出類的清理可能會調(diào)用基類的某些方法,所以需要使基類中的構建仍起作用。

關于上述第2條,進行補充說明:

例子:假定父類 A 的清理方法為 clean(),用來清理 A 中的數(shù)據(jù),然后子類 B 繼承 A,由于繼承的原因,需要重寫 clean() 方法,用來清理 B 中的數(shù)據(jù)。此時,我們需要 B.clean() 方法中調(diào)用 super.clean()。這樣在清理子類時,也會清理父類。反之,如果沒有調(diào)用父類的 clean() 方法,父類的清理動作就不會 發(fā)生。

將上面的例子一般化:子類覆蓋父類的某個方法后(比如為 method()),如果需要調(diào)用父類的 method() 方法,必須顯式調(diào)用 super.method()。

method() -> 子類.method()
super.method() -> 父類.method()

3.2 構造器內(nèi)部的多態(tài)方法

雖然前面關于調(diào)用順序已經(jīng)分析的很清楚了,但是加入多態(tài)之后,新的問題又產(chǎn)生了:如果一個構造器的內(nèi)部 調(diào)用正在構建的對象的某個動態(tài)綁定方法,會發(fā)生什么情況?

由于動態(tài)綁定的調(diào)用在運行時才決定,因此對象無法知道它是屬于方法所在的那個類,還是屬于其導出類。如果要調(diào)用構造器內(nèi)部的一個動態(tài)綁定方法,那么就要用到那個方法的被覆蓋之后的定義。但是被覆蓋的方法在對象被完全構造之前就會被調(diào)用,這就會造成一些錯誤。

對以上再進行補充:任何構造器內(nèi)部,整個對象可能只是部分形成,我們只能保證基類對象已經(jīng)進行初始化。如果構造器只是在構建對象過程中的一個步驟,并且該對象所屬的類是從這個構造器所屬的類導出的,那么導出部分在當前構造器被調(diào)用的時刻仍舊是沒有被初始化的。然而,一個動態(tài)綁定的方法調(diào)用卻會向外深入到繼承層次結構內(nèi)部,它可以調(diào)用導出類那里的方法。如果我們在構造器內(nèi)部這樣做,那么就可能會調(diào)用某個方法,而這個方法所操作的成員可能還未初始化,這肯定會招致災難。

上面這段話讀起來可能很拗口,舉個例子:

class Glyph {
  void draw() { print("Glyph.draw()"); }
  Glyph() {
    print("Glyph() before draw()");
    draw();
    print("Glyph() after draw()");
  }
}   

class RoundGlyph extends Glyph {
  private int radius = 1;
  RoundGlyph(int r) {
    radius = r;
    print("RoundGlyph.RoundGlyph(), radius = " + radius);
  }
  void draw() {
    print("RoundGlyph.draw(), radius = " + radius);
  }
}   

public class PolyConstructors {
  public static void main(String[] args) {
    new RoundGlyph(5);
  }
} /* Output:
Glyph() before draw()
RoundGlyph.draw(), radius = 0
Glyph() after draw()
RoundGlyph.RoundGlyph(), radius = 5
*/

可以看到,RoundGlyph 覆蓋了 draw() 方法,在 Glyph 的構造器中調(diào)用 RoundGlyph.draw() 方法時發(fā)生了錯誤:輸出時 radius 的值不是默認初始值 1 ,而是 0。

Glyph 構造器調(diào)用 draw() 方法時,RoundGlyph 的成員還未進行初始化。

實際上一節(jié)的初始化順序并不完整。初始化的實際過程是:

  1. 在其他任何事物發(fā)生之前,將分配給對象的存儲空間初始化成二進制的零。
  2. 如前所述那樣調(diào)用基類構造器。此時,調(diào)用被覆蓋后的draw()方法(要在調(diào)用RoundGlyph構造器之前調(diào)用),由于步驟 1 的緣故,我們此時會發(fā)現(xiàn) radius 的值為 0。
  3. 按照聲明的順序調(diào)用成員的初始化方法。
  4. 調(diào)用導出類的構造器

這樣的好處就是所有東西至少初始化為零,而不是僅僅留作垃圾

因此,我們得出以下結論:構造器內(nèi)唯一能夠安全調(diào)用的方法是基類中的 final 方法(包含 private 方法,因為 private 屬于 final 方法),這些方法無法被覆蓋,也就不會出現(xiàn)上述問題。


4. 協(xié)變返回類型

協(xié)變返回類型指的是:導出類中的被覆蓋方法可以返回基類方法的返回類型的某種導出類型。

換個說法:導出類 覆蓋(即重寫) 基類 方法時,返回的類型可以是基類方法返回類型的子類。

舉個例子:

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
*/

5. 用繼承進行設計

5.1 再論組合與繼承

進行設計時,首選組合。組合可以動態(tài)選擇類型(因此也就選擇了行為);相反,繼承再編譯時就需要知道確切類型。

看一個例子:

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
*/

設計的通用準則:用繼承表達行為間的差異,用成員表達狀態(tài)上的變化。

上例中,通過繼承得到兩個不同的類,用于表達 act() 方法的差異;而 Stage 通過組合是自己的狀態(tài)發(fā)生變化。這種情況下,這種狀態(tài)的改變也就產(chǎn)生了行為的改變。

5.2 純繼承與擴展

純粹的"is-a"關系:基類和導出類具有相同的接口

  • 圖示:
  • 也可以認為這是一種純替代,即導出類可以完全代替基類,基類可以接收發(fā)送給導出類的任何消息。
  • 我們只需從導出類向上轉(zhuǎn)型,永遠不要知道正在處理的對象的確切類型。

擴展的"is-like-a"關系:導出類有著和基類相同的基本接口,同時還具有由額外方法實現(xiàn)的其他特性

  • 是更為有用且明智的方法
  • 問題在于,當進行向上轉(zhuǎn)型時,不能使用擴展部分(基類無法訪問導出類的擴展部分)

5.3 向下轉(zhuǎn)型與運行時類型識別

5.3.1 運行時類型識別

運行時類型識別(RTTI)指的是在運行期間對類型進行檢查的行為。

Java 語言中,所有轉(zhuǎn)型都會在運行期時對其進行檢查,以便保證它的確是我們希望的那種類型。如果不是,就會返回一個 ClassCastException。

RTTI 不僅僅包括轉(zhuǎn)型處理。比如它提供一種方法,使我們在試圖向下轉(zhuǎn)型之前,查看索要處理的類型。

5.3.2 向下轉(zhuǎn)型

由于向上轉(zhuǎn)型會丟失具體的類型信息,所以希望通過向下轉(zhuǎn)型(在繼承層次中向下移動)重新獲取類型信息。

通過例子來講解向下轉(zhuǎn)型的要點:

class Fruit {
    void name(){
        System.out.println("Fruit");
    }
}
class Apple extends Fruit{
    @Override
    void name(){
        System.out.println("Apple");
    }
    
    void color(String c){
        System.out.println("This apple's color is " + c);
    }
}
  1. 正確的向下轉(zhuǎn)型:
    先進行向上轉(zhuǎn)型,然后再進行向下轉(zhuǎn)型。此時會轉(zhuǎn)型成功,可以調(diào)用子類的特殊方法。

    Fruit a = new Apple();//先向上轉(zhuǎn)型
    a.name();
    
    Apple apple = (Apple)a;//再向下轉(zhuǎn)型,不會出錯(正確的)
    apple.name();
    apple.color("red");
    
    //輸出:
    //Apple
    //Apple
    //This apple's color is red
    
  1. 不安全的向下轉(zhuǎn)型:
    不經(jīng)過向上轉(zhuǎn)型,直接向下轉(zhuǎn)型。此時編譯不會報錯,但運行時會拋出ClassCastException 異常

    Fruit f = new Fruit();
    Apple apple = (Apple)f;//此處異常
    

總結

這一張內(nèi)容看起來比較多,實際上都是圍繞多態(tài)展開。

多態(tài)是一種不能單獨來看待的特性,相反它只能作為類關系”全景“的一部分,與其他特性協(xié)同工作。

另外,面向?qū)ο蟮木幊趟枷脒€需打磨。

就這樣吧,共勉。

最后編輯于
?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

  • 這是16年5月份編輯的一份比較雜亂適合自己觀看的學習記錄文檔,今天18年5月份再次想寫文章,發(fā)現(xiàn)簡書還為我保存起的...
    Jenaral閱讀 3,115評論 2 9
  • Swift1> Swift和OC的區(qū)別1.1> Swift沒有地址/指針的概念1.2> 泛型1.3> 類型嚴謹 對...
    cosWriter閱讀 11,621評論 1 32
  • 今天早上醒來后刷微博,看到一個營銷大號轉(zhuǎn)發(fā)了一個微商廣告然后配了一段話:萬物皆上漲,唯有臉下垂,你還在心疼買護膚品...
    麗妃娘娘閱讀 3,829評論 0 2
  • 記得在我小學三四年級的時候就很喜歡畫畫,甚至夢想著長大后能成為一名畫家。后來媽媽給我報了個美術興趣班,小小的我開始...
    金子的小確幸閱讀 367評論 2 1
  • 收拾起丟落滿地的行囊,曾思考初心是否依然,卻忘記為什么出發(fā),該去向何處。早知道長不過執(zhí)念,短不過善變,終已看透習慣...
    憶留無閱讀 630評論 8 9

友情鏈接更多精彩內(nèi)容