8、多態

多態是面向對象編程語言中,繼數據抽象和繼承之外的第三個重要特性.

多態提供了另一個維度的接口與實現分離,以解耦做什麼和怎麼做。

封裝通過合併特徵和行爲來創建新的數據類型。隱藏實現通過將細節私有化把接口與實現分離。這種類型的組織機制對於有面向過程編程背景的人來說,更容易理解。而多態是消除類型之間的耦合。

繼承允許把一個對象視爲它本身的類型或它的基類類型。這樣就能把很多派生自一個基類的類型當作同一類型處理,因而一段代碼就可以無差別地運行在所有不同的類型上了。多態方法調用允許一種類型表現出與相似類型的區別,只要這些類型派生自一個基類。這種區別是當你通過基類調用時,由方法的不同行爲表現出來的。

1、方法調用綁定

把一個對象引用當作它的基類引用的做法稱爲向上轉型,因爲繼承圖中基類一般都位於最上方。

  • 前期綁定:將一個方法調用和一個方法主體關聯起來稱作綁定。若綁定發生在程序運行前(如果有的話,由編譯器和鏈接器實現),叫做前期綁定。你可能從來沒有聽說這個術語,因爲它是面向過程語言不需選擇默認的綁定方式,例如在C 語言中就只有前期綁定這一種方法調用。

  • 後期綁定:在運行時根據對象的類型進行綁定。後期綁定也稱爲動態綁定或運行時綁定。
    當一種語言實現了後期綁定,就必須具有某種機制在運行時能判斷對象的類型,從而調用恰當的方法。也就是說,編譯器仍然不知道對象的類型,但是方法調用機制能找到正確的方法體並調用。每種語言的後期綁定機制都不同,但是可以想到,對象中一定存在某種類型信息。

Java 中除了 static 和 final 方法(private 方法也是隱式的 final)外,其他所有方法都是後期綁定。

將一個對象指明爲 final。它可以防止方法被重寫。但更重要的一點可能是,它有效地”關閉了“動態綁定,或者說告訴編譯器不需要對其進行動態綁定。

2、產生正確的行爲

一旦當你知道 Java 中所有方法都是通過後期綁定來實現多態時,就可以編寫只與基類打交道的代碼,而且代碼對於派生類來說都能正常地工作。或者換種說法,你向對象發送一條消息,讓對象自己做正確的事。

在編譯時,編譯器不需要知道任何具體信息以進行正確的調用。

3、陷阱

3.1 不要“重寫”私有方法

只有非 private 方法才能被重寫,但是得小心重寫 private 方法的現象,編譯器不報錯,但不會按我們所預期的執行。爲了清晰起見,派生類中的方法名採用與基類中 private 方法名不同的命名。

如果使用了 @Override 註解,就能檢測出問題。

3.2 屬性與靜態方法沒有多態

只有普通的方法調用可以是多態的。例如,如果你直接訪問一個屬性,該訪問會在編譯時解析:

// polymorphism/FieldAccess.java
// Direct field access is determined at compile time
class Super {
    public int field = 0;

    public int getField() {
        return field;
    }
}

class Sub extends Super {
    public int field = 1;

    @Override
    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())
    }
}

輸出:

sup.field = 0, sup.getField() = 1
sub.field = 1, sub.getField() = 1, sub.getSuperField() = 0

當 Sub 對象向上轉型爲 Super 引用時,任何屬性訪問都被編譯器解析,因此不是多態的。在這個例子中,Super.field 和 Sub.field 被分配了不同的存儲空間,因此,Sub 實際上包含了兩個稱爲 field 的屬性:它自己的和來自 Super 的。然而,在引用 Sub 的 field 時,默認的 field 屬性並不是 Super 版本的 field 屬性。爲了獲取 Super 的 field 屬性,需要顯式地指明 super.field。

如果一個方法是靜態(static)的,它的行爲就不具有多態性:

// polymorphism/StaticPolymorphism.java
// static methods are not polymorphic
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()";
    }
    @Override
    public String dynamicGet() {
        return "Derived dynamicGet()";
    }
}

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

輸出:

Base staticGet()
Derived dynamicGet()

靜態的方法只與類關聯,與單個的對象無關。

4、構造器和多態

4.1、構造器調用順序

在派生類的構造過程中總會調用基類的構造器。初始化會自動按繼承層次結構上移,因此每個基類的構造器都會被調用到。這麼做是有意義的,因爲**構造器有着特殊的任務:檢查對象是否被正確地構造。**由於屬性通常聲明爲 private,你必須假定派生類只能訪問自己的成員而不能訪問基類的成員。只有基類的構造器擁有恰當的知識和權限來初始化自身的元素。**因此,必須得調用所有構造器;否則就不能構造完整的對象。**這就是編譯器強制每個派生類部分必須調用構造器的原因。如果在派生類的構造器主體中沒有顯式地調用基類構造器,編譯器就會默默地調用無參構造器。如果沒有無參構造器,編譯器就會報錯(當類中不含構造器時,編譯器會自動合成一個無參構造器)。

package polymorphism;

class Meal {
    Meal() {
        System.out.println("Meal()");
    }
}

class Bread {
    Bread() {
        System.out.println("Bread()");
    }
}

class Cheese {
    Cheese() {
        System.out.println("Cheese()");
    }
}

class Lettuce {
    Lettuce() {
        System.out.println("Lettuce()");
    }
}

class Lunch extends Meal {
    Lunch() {
        System.out.println("Lunch()");
    }
}

class PortableLunch extends Lunch {
    PortableLunch() {
        System.out.println("PortableLunch()");
    }
}

public class Sandwich extends PortableLunch {
    private Bread b = new Bread();
    private Cheese c = new Cheese();
    private Lettuce l = new Lettuce();

    public Sandwich() {
        System.out.println("Sandwich()");
    }

    public static void main(String[] args) {
        new Sandwich();
    }
}
Meal()
Lunch()
PortableLunch()
Bread()
Cheese()
Lettuce()
Sandwich()

從創建 Sandwich 對象的輸出中可以看出對象的構造器調用順序如下:

  1. 基類構造器被調用。這個步驟被遞歸地重複,這樣一來類層次的頂級父類會被最先構造,然後是它的派生類,以此類推,直到最底層的派生類。
  2. 按聲明順序初始化成員。
  3. 調用派生類構造器的方法體。

在構造器中必須確保所有的成員都已經構建完。唯一能保證這點的方法就是首先調用基類的構造器。接着,在派生類的構造器中,所有你可以訪問的基類成員都已經初始化。另一個在構造器中能知道所有成員都是有效的理由是:無論何時有可能的話,你應該在所有成員對象(通過組合將對象置於類中)定義處初始化它們(例如,例子中的 b、c 和 l)。如果遵循這條實踐,就可以幫助確保所有的基類成員和當前對象的成員對象都已經初始化。

4.2、繼承和清理

在使用組合和繼承創建新類時,大部分時候你無需關心清理。子對象通常會留給垃圾收集器處理。如果你存在清理問題,那麼必須用心地爲新類創建一個 dispose() 方法(這裏用的是我選擇的名稱,你可以使用更好的名稱)。**由於繼承,如果有其他特殊的清理工作的話,就必須在派生類中重寫 dispose() 方法。**當重寫 dispose() 方法時,記得調用基類的 dispose() 方法,否則基類的清理工作不會發生:

// polymorphism/Frog.java
// Cleanup and inheritance
// {java polymorphism.Frog}
package polymorphism;

class Characteristic {
    private String s;

    Characteristic(String s) {
        this.s = s;
        System.out.println("Creating Characteristic " + s);
    }

    protected void dispose() {
        System.out.println("disposing Characteristic " + s);
    }
}

class Description {
    private String s;

    Description(String s) {
        this.s = s;
        System.out.println("Creating Description " + s);
    }

    protected void dispose() {
        System.out.println("disposing Description " + s);
    }
}

class LivingCreature {
    private Characteristic p = new Characteristic("is alive");
    private Description t = new Description("Basic Living Creature");

    LivingCreature() {
        System.out.println("LivingCreature()");
    }

    protected void dispose() {
        System.out.println("LivingCreature dispose");
        t.dispose();
        p.dispose();
    }
}

class Animal extends LivingCreature {
    private Characteristic p = new Characteristic("has heart");
    private Description t = new Description("Animal not Vegetable");

    Animal() {
        System.out.println("Animal()");
    }

    @Override
    protected void dispose() {
        System.out.println("Animal dispose");
        t.dispose();
        p.dispose();
        super.dispose();
    }
}

class Amphibian extends Animal {
    private Characteristic p = new Characteristic("can live in water");
    private Description t = new Description("Both water and land");

    Amphibian() {
        System.out.println("Amphibian()");
    }

    @Override
    protected void dispose() {
        System.out.println("Amphibian dispose");
        t.dispose();
        p.dispose();
        super.dispose();
    }
}

public class Frog extends Amphibian {
    private Characteristic p = new Characteristic("Croaks");
    private Description t = new Description("Eats Bugs");

    public Frog() {
        System.out.println("Frog()");
    }

    @Override
    protected void dispose() {
        System.out.println("Frog dispose");
        t.dispose();
        p.dispose();
        super.dispose();
    }

    public static void main(String[] args) {
        Frog frog = new Frog();
        System.out.println("Bye!");
        frog.dispose();
    }
}

運行結果:

Creating Characteristic is alive
Creating Description Basic Living Creature
LivingCreature()
Creating Characteristiv has heart
Creating Description Animal not Vegetable
Animal()
Creating Characteristic can live in water
Creating Description Both water and land
Amphibian()
Creating Characteristic Croaks
Creating Description Eats Bugs
Frog()
Bye!
Frog dispose
disposing Description Eats Bugs
disposing Characteristic Croaks
Amphibian dispose
disposing Description Both wanter and land
disposing Characteristic can live in water
Animal dispose
disposing Description Animal not Vegetable
disposing Characteristic has heart
LivingCreature dispose
disposing Description Basic Living Creature
disposing Characteristic is alive

銷燬的順序應該與初始化的順序相反,以防一個對象依賴另一個對象。

然而,一旦某個成員對象被其它一個或多個對象共享時,問題就變得複雜了,不能只是簡單地調用 dispose()。這裏,也許就必須使用引用計數來跟蹤仍然訪問着共享對象的對象數量:

// polymorphism/ReferenceCounting.java
// Cleaning up shared member objects
class Shared {
    private int refcount = 0;
    private static long counter = 0;
    private final long id = counter++;

    Shared() {
        System.out.println("Creating " + this);
    }

    public void addRef() {
        refcount++;
    }

    protected void dispose() {
        if (--refcount == 0) {
            System.out.println("Disposing " + this);
        }
    }

    @Override
    public String toString() {
        return "Shared " + id;
    }
}

class Composing {
    private Shared shared;
    private static long counter = 0;
    private final long id = counter++;

    Composing(Shared shared) {
        System.out.println("Creating " + this);
        this.shared = shared;
        this.shared.addRef();
    }

    protected void dispose() {
        System.out.println("disposing " + this);
        shared.dispose();
    }

    @Override
    public String toString() {
        return "Composing " + id;
    }
}

public class ReferenceCounting {
    public static void main(String[] args) {
        Shared shared = new Shared();
        Composing[] composing = {
            new Composing(shared),
            new Composing(shared),
            new Composing(shared),
            new Composing(shared),
            new Composing(shared),
        };
        for (Composing c: composing) {
            c.dispose();
        }
    }
}

運行結果:

Creating Shared 0
Creating Composing 0
Creating Composing 1
Creating Composing 2
Creating Composing 3
Creating Composing 4
disposing Composing 0
disposing Composing 1
disposing Composing 2
disposing Composing 3
disposing Composing 4
Disposing Shared 0

在將一個 shared 對象附着在類上時,必須記住調用 addRef(),而 dispose() 方法會跟蹤引用數,以確定在何時真正地執行清理工作。使用這種技巧需要加倍細心,但是如果正在共享需要被清理的對象,就沒有太多選擇了。

4.3、構造器內部多態方法行爲

如果在構造器中調用了正在構造的對象的動態綁定方法,會發生什麼呢?
在普通的方法中,動態綁定的調用是在運行時解析的,因爲對象不知道它屬於方法所在的類還是類的派生類。
如果在構造器中調用了動態綁定方法,就會用到那個方法的重寫定義。

從概念上講,構造器的工作就是創建對象(這並非是平常的工作)。**在構造器內部,整個對象可能只是部分形成——只知道基類對象已經初始化。**如果構造器只是構造對象過程中的一個步驟,且構造的對象所屬的類是從構造器所屬的類派生出的,那麼派生部分在當前構造器被調用時還沒有初始化。

然而,一個動態綁定的方法調用向外深入到繼承層次結構中,它可以調用派生類的方法。如果你在構造器中這麼做,就可能調用一個方法,該方法操縱的成員可能還沒有初始化——這肯定會帶來災難。

// polymorphism/PolyConstructors.java
// Constructors and polymorphism
// don't produce what you might expect
class Glyph {
    void draw() {
        System.out.println("Glyph.draw()");
    }

    Glyph() {
        System.out.println("Glyph() before draw()");
        draw();
        System.out.println("Glyph() after draw()");
    }
}

class RoundGlyph extends Glyph {
    private int radius = 1;

    RoundGlyph(int r) {
        radius = r;
        System.out.println("RoundGlyph.RoundGlyph(), radius = " + radius);
    }

    @Override
    void draw() {
        System.out.println("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() 方法。

初始化的實際過程是:

  1. 在所有事發生前,分配給對象的存儲空間會被初始化爲二進制 0。
  2. 如前所述調用基類構造器。此時調用重寫後的 draw() 方法(是的,在調用 RoundGraph 構造器之前調用),由步驟 1 可知,radius 的值爲 0。
  3. 按聲明順序初始化成員。
  4. 最終調用派生類的構造器。

編寫構造器有一條良好規範:做盡量少的事讓對象進入良好狀態。如果有可能的話,儘量不要調用類中的任何方法。在基類的構造器中能安全調用的只有基類的 final 方法(這也適用於可被看作是 final 的 private 方法)。這些方法不能被重寫,因此不會產生意想不到的結果。你可能無法永遠遵循這條規範,但應該朝着它努力。

Java 5 中引入了協變返回類型,這表示派生類的被重寫方法可以返回基類方法返回類型的派生類型。

// polymorphism/CovariantReturn.java
class Grain {
    @Override
    public String toString() {
        return "Grain";
    }
}

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

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

class WheatMill extends Mill {
    @Override
    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);
    }
}

5、使用繼承設計

如果利用已有類創建新類首先選擇繼承的話,事情會變得莫名的複雜。
更好的方法是首先選擇組合,特別是不知道該使用哪種方法時。組合不會強制設計是繼承層次結構,而且組合更加靈活,因爲可以動態地選擇類型(因而選擇相應的行爲),而繼承要求必須在編譯時知道確切類型

class Actor {
    public void act() {}
}

class HappyActor extends Actor {
    @Override
    public void act() {
        System.out.println("HappyActor");
    }
}

class SadActor extends Actor {
    @Override
    public void act() {
        System.out.println("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();
    }
}

輸出:

HappyActor
SadActor

Stage 對象中包含了 Actor 引用,該引用被初始化爲指向一個 HappyActor 對象,這意味着 performPlay() 會產生一個特殊行爲。但是既然引用可以在運行時與其他不同的對象綁定,那麼它就可以被替換成對 SadActor 的引用,performPlay() 的行爲隨之改變。這樣你就獲得了運行時的動態靈活性(這被稱爲狀態模式)。與之相反,我們不能在運行時決定繼承不同的對象,那在編譯時就完全確定下來了。

有一條通用準則:使用繼承表達行爲的差異,使用屬性表達狀態的變化。
在上個例子中,兩者都用到了。通過繼承的到的兩個不同類在 act() 方法中表達了不同的行爲,Stage 通過組合使自己的狀態發生變化。這裏狀態的改變產生了行爲的改變。

5.1、替代 vs 擴展

如果只繼承而不擴展,那麼可以描述爲“是一種”
如果繼承後要擴展,那麼可以描述爲“像是一種”,派生類中接口的擴展部分在基類中不存在(不能通過基類訪問到這些擴展接口),因此一旦向上轉型,就不能通過基類調用這些新方法。

5.2、向下轉型與運行時類型信息

由於向上轉型(在繼承層次中向上移動)會丟失具體的類型信息,那麼爲了重新獲取類型信息,就需要在繼承層次中向下移動,使用向下轉型。

向上轉型永遠是安全的,因爲基類不會具有比派生類更多的接口。爲了解決這個問題,必須得有某種方法確保向下轉型是正確的,防止意外轉型到一個錯誤類型,進而發送對象無法接收的消息。這麼做是不安全的。

在某些語言中(如 C++),必須執行一個特殊的操作來獲得安全的向下轉型。
但是在 Java 中,每次轉型都會被檢查!所以即使只是進行一次普通的加括號形式的類型轉換,在運行時這個轉換仍會被檢查,以確保它的確是希望的那種類型。如果不是,就會得到 ClassCastException (類轉型異常)。這種在運行時檢查類型的行爲稱作運行時類型信息。

小結

在本章中,你可以看到,如果不使用數據抽象和繼承,就不可能理解甚至創建多態的例子。多態是一種不能單獨看待的特性(比如像 switch 語句那樣),它只能作爲類關係全景中的一部分,與其他特性協同工作。

爲了在程序中有效地使用多態乃至面嚮對象的技術,就必須擴展自己的編程視野,不能只看到單一類中的成員和消息,而要看到類之間的共同特性和它們之間的關係。儘管這需要很大的努力,但是這麼做是值得的。它能帶來更快的程序開發、更好的代碼組織、擴展性更好的程序和更易維護的代碼。

但是記住,多態可能被濫用。仔細分析代碼以確保多態確實能帶來好處。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章