1. 程式人生 > >【Java編程思想】8.多態

【Java編程思想】8.多態

類對象 信息 8.4 不可 一個 get() tlist 導出類 基本類

在面向對象的程序設計語言中,多態是繼數據抽象繼承之後的第三種基本特征。
多態分離了“做什麽”和“怎麽做”,讓接口和實現分離開,改善了代碼的可讀性和組織結構,創建了可拓展的程序。

  • 封裝,通過合並特征和行為來創建新的數據類型。
  • 實現隱藏,通過將細節“私有化”把接口和實現分離開來。
  • 多態,消除類型之間的耦合聯系。多態方法調用允許一種類型表現出與其他相似類型之間的區別,只要他們都是從同一基類導出來的。-->這種區別是根據方法行為的不同而表示出來的,雖然這種方法都可以通過同一個基類來調用。

8.1 再論向上轉型

像第七章所說的那樣,對象既可以作為他自己本身的類型使用,也可以作為他的基本類型使用,把這種對某個對象的引用視為對其基類類型的引用的做法稱之為向上轉型

(因為在 UML 繼承樹的畫法中,基類是在上方的)。

在發生向上轉型的時候,我們可以很清楚的感受到,系統和編寫者都在刻意的去忽視傳遞對象的類型-->導出類的接口向上轉型到基類,可能會“縮小”接口,但是一定不會比基類的全部接口更窄。
這意味著在我們編寫方法時,只接收基類作為參數,那麽這樣就可以不必為每一種導出類編寫對應的接受參數方法。這就是多態的一種方式。


8.2 轉機

下面我們開始更進一步的討論。

在向上轉型的時候,方法接收基類的引用參數。那麽編譯器是如何得知引用是指向某一個特定導出類的呢。實際上編譯器也無法得知,我們需要進一步了解綁定。

  • 將一個方法調用同一個方法主體關聯起來被稱為綁定
  • 若在程序執行前進行綁定(如果有的話,由編譯器和連接程序實現),就叫做前期綁定。前期綁定是面向過程的語言中的默認的綁定方式
  • 而在運行時根據對象的類型進行綁定,被稱為後期綁定,也叫做動態綁定運行時綁定。一種語言想實現後期綁定,就必須具有一種能在運行時能判斷對象的類型從而調用恰當方法的機制。

回到上面的問題,編譯器是如何得知引用是指向某一個特定導出類的呢。我們之所以有疑問,就在於如果使用前期綁定,那麽當編譯器只有一個基類引用的時候,他是不應該知道應該調用哪個方法才對。但是在後期綁定中,編譯器其實不知道,也不需要知道導出類對象的類型,方法調用機制能夠找到正確的方法體並加以調用。


Java 中,除了 static 方法和 final 方法(private 方法屬於 final 方法),其他所有方法,都是後期綁定-->就是說,通常情況下不需要去判定是否應該進行後期綁定(他會自動發生)。
final 方法其實是有效地關閉了動態綁定(後期綁定),或者是告訴了編譯器不需要對一個方法做動態綁定。

建立在上面的基礎,我們可以很容易的理解:

List<Integer> intList = new ArrayList();
intList.get(0);

看起來 get() 方法調用的是 List 的引用,其實由於後期綁定(多態),調用的是 ArrayList.get() 方法。

編譯器不需要獲取任何特殊信息就能進行正確的調用,對方法的所有調用都是通過動態綁定進行的


在一個設計良好的 OOP 程序中,大多數或者所有方法,都會遵循特定方法的模型,而且只與基類接口通信,這樣的程序就是可拓展的。-->可以從通用的基類繼承出新的數據類型,操縱基類接口的方法也不需要任何改動就可以應用於新類。
多態最後的目的,就是希望能修改代碼後,不會對程序中其他不受影響的部分受到破壞。


關於多態的缺陷:

  1. ”覆蓋“私有方法:非 private 方法才能被覆蓋,如果覆蓋了 private 方法,那這個方法並不會被在導出類中被重載。因此,導出類中,對基類中的 private 方法最好不要采用相同的名字。
  2. 域與靜態方法:其實只有普通的方法調用是多態的。其他情況,例如直接訪問某個域,這個訪問在編譯器就會被解析。
    不過通常我們會將所有的域設置為 private,讓他們不能被直接訪問。
    另外也不會對基類中的域和導出類中的域賦予相同的名字。
    靜態方法的行為不會具有多態性。靜態方法是與類關聯的,而不是和單個對象關聯的。

8.3 構造器和多態

基類的構造器總是在導出類的構造過程中被調用,而且按照繼承層次逐漸向上鏈接-->每個基類的構造器都能得到調用。
這樣做的意義在於:構造器能檢查對象是否被正確構造,導出類只能訪問自己的成員,基類的成員需要基類自己才能訪問。只有在每個構造器都得到調用的情況下,才能保證無論在任何情況都能正確創造出完成的對象。

復雜對象調用構造器的順序(不嚴謹的):

  1. 調用基類構造器。這個步驟是遞歸的,首先是構造這種層次結構的根,然後是下一層導出類,最後知道最低層的導出類。
  2. 按聲明順序調用成員的初始化方法。
  3. 調用導出類構造器主體。

如果不清楚可以按照下面的例子查看。

class Meal {
    Meal() { print("Meal()"); // 1}
}
class Bread {
    Bread() { print("Bread()"); }
}
class Cheese {
    Cheese() { print("Cheese()"); }
}
class Lettuce {
    Lettuce() { print("Lettuce()"); }
}
class Lunch extends Meal {
    Lunch() { print("Lunch()"); // 2}
}
class PortableLunch extends Lunch {
    PortableLunch() { print("PortableLunch()"); // 3}
}

public class Sandwich extends PortableLunch {
    private Bread b = new Bread(); // 4
    private Cheese c = new Cheese(); // 5
    private Lettuce l = new Lettuce(); // 6
    public Sandwich() { print("Sandwich()"); }
    public static void main(String[] args) {
        new Sandwich(); // 7
    }

輸出如下:

1 Meal()
2 Lunch()
3 PortableLunch()
4 Bread()
5 Cheese()
6 Lettuce()
7 Sandwich()

通過組合和繼承來創建新類,不用擔心對象的清理問題。
不過加入面臨這方面問題時(自定義對象清理)一定要註意,

  • 萬一某個子對象要依賴於其他對象,銷毀的順序應該和初始化的順序相反。
  • 對於字段,則意味著和聲明的順序相反(因為字段的初始化是按照聲明的順序進行的)。
  • 對於基類,應該先對其導出類進行清理,然後才是基類(因為導出類的清理可能會調用積累中的某些方法,要使方法可用則基類不能提前被銷毀)。

當成員對象中存在於其他一個或者多個對象共享的情況,或許需要額外的引用計數來跟蹤仍舊訪問著的共享對象的對象數量。
可以使用由 tatic long 修飾一個靜態成員,幫助跟蹤所創建的類的實例的數量,還可以為 id 提供數值。


問題:如果在一個構造器內部調用正在構造的對象的某個動態綁定方法,那麽對象無法知道自己是屬於方法所在的那個類,還是屬於那個類的導出類。
舉例

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);
    }
}

輸出

Glyph() before draw()
RoundGlyph.draw(), radius = 0
Glyph() after draw()
RoundGlyph.RoundGlyph(), radius = 5

分析
Glyph.draw() 方法被設計為將要被覆蓋,這種覆蓋在 RoundGlyph 中發生。但是 Glyph 中調用了這個方法,導致了對 RoundGlyph.draw() 的調用。但是這個調用是有問題的,RoundGlyph 根本沒有開始初始化,所以 radius 的值是默認初始值0.
結論
構造器初始化的實際過程是:

  1. 在其他任何事物發生之前,將分配給對象的存儲空間初始化成二進制零。
  2. 調用基類構造器。
  3. 按照聲明的順序調用成員的初始化方法。
  4. 調用導出類的構造器主體。

為了避免發生上述這類不可控的風險,在編寫構造器時,要用盡可能簡單的方法使對象進入正常狀態;如果可以的話盡量避免其他方法。在構造器中唯一能夠安全調用的方法,是基類的 final 方法(也適用於 private 方法),這些方法不能被覆蓋,也就不會出現上面的蛋疼問題。


8.4 協變返回類型

Java SE5中添加了協變返回類型,他表示在導出類中的被覆蓋方法,可以返回基類方法的返回類型的某種導出類型。
就是說協變返回類型允許重寫方法返回更具體的導出類型(如果基類方法返回的是基類的話)。


8.5 用繼承進行設計

與繼承相比,組合其實要更靈活,因為他可以動態選擇類型(也就是選擇了行為)。
而繼承在編譯時就要明確類型,不能再運行期間決定繼承不同的對象。

一條通用的準則就是:用繼承表達行為間的差異,並用字段表達狀態上的變化。


如下圖的繼承方式,可以說是純粹的繼承層次關系-->只有在基類中已經建立的方法,才可以在導出類被覆蓋。
技術分享圖片

像這種純繼承模式,也被稱作純粹的“is-a”關系(是一個)-->這個類的接口已經確定了他應該是什麽,導出類的接口絕對不會少於基類。
這種設計,保證基類可以介紹發送給導出類的任何消息(因為二者有著完全相同的接口)。只需要進行向上轉型,就不需要知道正在處理的對象的確切類型。
技術分享圖片

但是實際使用中,我們更傾向於使用“is-like-a”關系(像一個),因為導出類就像一個基類-->有著相同的基本接口,還具有額外的其他方法。但是這麽做的話,當使用向上轉型時,拓展接口就不能被調用了。
技術分享圖片


使用向上轉型的時候會丟失具體的類型信息。
使用向下轉型可以獲取到具體的類型信息。不過,向下轉型是不安全的,因為基類接口會小於等於導出類的接口。

為了確保安全,Java 中所有的轉型都會得到檢查。如果檢查未通過,會返回 ClassCastException(類型轉換異常)
這種在運行期間對類型進行檢查的行為被稱為“運行時類型識別”,簡稱 RTTI(Run-time type information)。

【Java編程思想】8.多態