1. 程式人生 > 實用技巧 >2019 CCPC-江西省賽(填坑中)

2019 CCPC-江西省賽(填坑中)

在面向物件的程式設計語言中,多型是繼資料抽象(封裝),繼承之後的第三種基本特徵。

多型通過分離做什麼和怎麼做,從另一角度將介面和實現分離開來。多型不但能夠改善程式碼的組織結構和可讀性,還能建立可擴充套件的程式。
"封裝"通過合併特徵和行為來建立新的資料型別,"實現隱藏"則是通過將細節"私有化",把介面和實現分離開來。而多型的作用則是消除型別之間的耦合關係。

再論向上轉型

物件既可以作為它自己本身的型別使用,也可以作為它的基類型別使用。這種把某個物件的引用視為其基類物件的引用的做法稱為向上轉型。

enum Note{
    MIDDLE_C, C_SHARP, S_FLAT;
}

class Instrument{
    public void play(Note n){
        System.out.println("Instrument.play()");
    }
}

class Wind extends Instrument{
    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);
    }
}

tune()接收一個Instrument型別的引數,而很神奇的是它竟然也能接收Wind型別的引數。這樣做是被允許的,因為Wind繼承於Instrument。我們稱這種做法為向上轉型。

忘記物件型別

為什麼所有人都故意忘記物件型別呢?
在進行向上轉型時就會出現這種情況。但讓tune()接收一個Wind型別的引數好像更為直觀。但這樣做引發一個問題:
如果讓tune()接收一個Wind型別的引數,則需要為系統內Instrument的每種型別都編寫一個tune().
假設你想再加入Stringed和Brass兩種Instrument。

enum Note{
    MIDDLE_C, C_SHARP, S_FLAT;
}

class Instrument{
    public void play(Note n){
        System.out.println("Instrument.play()");
    }
}

class Wind extends Instrument{
    public void play(Note n){
        System.out.println("Wind.play() " + n);
    }
}

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

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

public class Music{
    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 stringed = new Stringed();
        Brass brass = new Brass();
        tune(flute);
        tune(stringed);
        tune(brass);
    }
}

這樣做行得通,但有一個主要缺點,每新增一個新的Instrument類就要為它新增新的tune()。此外,如果我們忘記過載某個方法(你添加了一個新的Instrument類,但你沒有為它編寫tune()方法),編譯器不會返回任何錯誤資訊。

如果我們只寫這樣一個簡單方法,它僅接收基類型別作為引數,而不是那些匯出類。這樣做是不是會更好?反正匯出類可以向上轉型成基類。

enum Note{
    MIDDLE_C, C_SHARP, S_FLAT;
}

class Instrument{
    public void play(Note n){
        System.out.println("Instrument.play()");
    }
}

class Wind extends Instrument{
    public void play(Note n){
        System.out.println("Wind.play() " + n);
    }
}

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

class Brass extends Instrument{
    public void play(Note n){
        System.out.println("Brass.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();
        Stringed stringed = new Stringed();
        Brass brass = new Brass();
        tune(flute);
        tune(stringed);
        tune(brass);
    }
}

這樣你就可以只新增新類而不必為每個新類都定義tune()方法了。

轉機

但問題又來了,

  public static void tune(Instrument i){
        i.play(Note.MIDDLE_C);
    }

tune()接收一個Instrumen型別的引用,那麼編譯器是怎麼知道你傳入的是Wind型別的引用還是Stringed型別的引用亦或是其他型別的引用?事實上編譯器不知道,那麼它是如何輸出正確結果呢?為了深入理解這個問題,有必要研究以下繫結這個話題。

方法呼叫繫結

將一個方法呼叫同一個方法主題關聯起來被稱作繫結。

若在程式執行前進行繫結(如果有的話,由編譯器和連線程式實現)叫做前期繫結。這是面向過程語言中不需要選擇就預設的繫結方式。
而解決上述問題的辦法就是後期繫結:在執行時根據物件的型別進行繫結。後期繫結也叫做動態繫結和執行時繫結。

如果一種語言想實現後期繫結,就必須具有某種機制(Java使用RTTI機制來實現執行時型別檢查),以便在執行時判斷物件型別,從而呼叫恰當的方法。也就是說,編譯器一直不知道物件的型別,但是方法呼叫機制能找到正確的方法體,並加以呼叫。

Java中除了static和final方法(private 屬於final方法),其他方法都是動態繫結。

產生正確的行為

一旦知道Java所有方法都是通過動態繫結實現多型這個事實之後,我們就可以編寫只與基類打交道的程式程式碼了,並且這些程式碼對所有匯出類都可以正確執行。

面向物件程式設計中有一個經典的例子:"幾何形狀"。在這個例子中有一個基類Shape,以及多個匯出類:Circle,Square等等。
我們可以定義這樣的語句:

Shape shape = new Circle();

建立一個Circle物件,並把得到的引用賦值給Shape,向上轉型允許我們這麼做。假如你呼叫基類的一個方法(在匯出類中已經被重寫)

shpae.draw();

由於動態繫結,它不會呼叫Shape的draw()而是正確的呼叫了Circle的draw()。

缺陷:"重寫"私有方法

我們試影象下面這樣做也是無可厚非的:

public class A{
   private void f(){
       System.out.println(" private void f()");
   }
    public static void main(String[] args) {
       A a = new B();
       a.f();
    }
}
class B extends A{
    public void f(){
        System.out.println("public void f()");
    }
}

我們期望它能輸出public void f(),但卻輸出的是private void f()。由於private方法被認為是final方法,而且對匯出類是遮蔽的。因此B中的f()是一個全新的方法。A類中的私有方法在B類中不可見所以也不能被重寫。

缺陷:域與靜態方法

任何域訪問操作都將由編譯器解析,因此不是多型的。
靜態方法是與類,而非與單個物件相關聯的,因此也不具有多型性。

協變返回型別

Java SE5添加了協變返回型別,它表示在匯出類中的被覆蓋的方法可以返回基類方法的返回型別的某種匯出型別。

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

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

class WheatMill{
    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);
        g = new WheatMill().process();
        System.out.println(g);
    }
}

Java較早的版本將強制process()覆蓋版本必須返回Grain,而不能返回Wheat,儘管Wheat是從Grain匯出的。協變返回型別允許返回更具體的Wheat值。