定義行為外觀

June 7, 2022

老闆今天想開發一個海洋樂園遊戲,當中所有東西都會游泳。你想了一下,談到會游的東西,第一個想到的就是魚,之前剛談過繼承,你也知道繼承可以運用多型,也許會定義 Fish 類別中有個 swim 的行為:

public abstract class Fish {
    protected String name;
    public Fish(String name) {
        this.name = name;
    }
    public String getName() {
        return name;
    }
    public abstract void swim();
}

不當的繼承

由於實際上每種魚游泳方式不同,將 swim 定義為 abstract,因此 Fish 也是 abstract。接著定義小丑魚繼承魚:

public class Anemonefish extends Fish {
    public Anemonefish(String name) {
        super(name);
    }

    @Override
    public void swim() {
        System.out.printf("小丑魚 %s 游泳%n", name);
    }
}

Anemonefish 繼承了 Fish,並實作 swim 方法,也許你還定義了鯊魚 Shark 類別繼承 Fish、食人魚 Piranha 繼承 Fish

public class Shark extends Fish {
    public Shark(String name) {
        super(name);
    }
    @Override
    public void swim() {
        System.out.printf("鯊魚 %s 游泳%n", name);
    }
}

public class Piranha extends Fish {
   public Piranha(String name) {
       super(name);
   }
    @Override
    public void swim() {
        System.out.printf("食人魚 %s 游泳%n", name);
    }   
} 

老闆說話了,為什麼都是魚?人也會游泳啊!怎麼沒寫?於是你就再定義 Human 類別繼承 Fish…等一下!Human 繼承 Fish? 不會覺得很奇怪嗎?你會說程式沒錯啊!編譯器也沒抱怨什麼!

對!編譯器是不會抱怨什麼,就目前為止,程式也可以執行,但是請回想之前曾談過,繼承會有是一種(is-a)的關係,所以 Anemonefish 是一種 FishShark 是一種 FishPiranha 是一種 Fish,如果你讓 Human 繼承 Fish,那 Human 是一種 Fish?你會說「美人魚啊!」…@#$%^&

程式上可以通過編譯也可以執行,但邏輯上或設計上有不合理的地方,你可以繼續硬掰下去,如果現在老闆說加個潛水航呢?寫個 Submarine 繼承 Fish 嗎?Submarine 是一種 Fish 嗎?繼續這樣的想法設計下去,你的程式架構會越來越不合理,越來越沒有彈性!

記得嗎?Java 只能繼承一個父類別,更強化了「是一種」關係的限制性。如果今天老闆突發奇想,想把海洋樂園變為海空樂園,有的東西會游泳,有的東西會飛,有的東西會游也會飛,如果用繼承方式來解決,寫個 Fish 讓會游的東西繼承,寫個 Bird 讓會飛的東西繼承,那會游也會飛的怎麼辦?有辦法定義個飛魚 FlyingFish 同時繼承 FishBird 嗎?

使用 interface

重新想一下需求吧!老闆今天想開發一個海洋樂園遊戲,當中所有東西都會游泳。「所有東西」都會「游泳」,而不是「某種東西」都會「游泳」,先前的設計方式只解決了「所有魚」都會「游泳」,只要它是一種魚(也就是繼承 Fish)。

「所有東西」都會「游泳」,代表了「游泳」這個「行為」可以被所有東西擁有,而不是「某種」東西專屬,對於「定義行為」,在 Java 可以使用 interface 關鍵字定義:

package cc.openhome;

public interface Swimmer {
    public abstract void swim();
}

以下程式碼定義了 Swimmer 介面,介面可用於定義行為,但不定義實作,在這邊 Swimmer 中的 swim 方法沒有實作,直接標示為 abstract,而且一定是 public。物件若想擁有 Swimmer 定義的行為,就必須實作 Swimmer 介面。例如 Fish 擁有 Swimmer 行為:

package cc.openhome;

public abstract class Fish implements Swimmer {
    protected String name;
    public Fish(String name) {
        this.name = name;
    }
    public String getName() {
        return name;
    }
    @Override
    public abstract void swim();
}

類別要實作介面,必須使用 implements 關鍵字,實作某介面時,對介面中定義的方法有兩種處理方式,一是實作介面中定義的方法,二是再度將該方法標示為 abstract。在這個範例子中,Fish 並不知道每條魚怎麼游,所以使用第二種處理方式。

目前 AnemonefishSharkPiranha 繼承 Fish 後的程式碼如同先前示範的片段,無需修改。那麼,如果 Human 要能游泳呢?

package cc.openhome;

public class Human implements Swimmer {
    private String name;
    public Human(String name) {
        this.name = name;
    } 
    public String getName() {
        return name;
    }
    
    @Override
    public void swim() {
        System.out.printf("人類 %s 游泳%n", name);
    }
}

Human 實作了 Swimmer,不過這次 Human 可沒有繼承 FishHuman 不是一種 Fish。類似地,Submarine 也有 Swimmer 的行為:

package cc.openhome;

public class Submarine implements Swimmer {
    private String name;
    public Submarine(String name) {
        this.name = name;
    }    
    public String getName() {
        return name;
    }
    
    @Override
    public void swim() {
        System.out.printf("潛水艇 %s 潛行%n", name);
    }
}

Submarine 實作了 Swimmer,不過 Submarine 沒有繼承 FishSubmarine 不是一種 Fish

以 Java 的語意來說,繼承會有「是一種」關係,實作介面則表示「擁有行為」,但不會有「是一種」的關係。HumanSubmarine 實作了 Swimmer,都擁有 Swimmer 定義的行為,但它們沒有繼承 Fish,所以它們不是一種魚,這樣的架構比較合理也較有彈性,可以應付一定程度的需求變化。

有些書或文件會說,HumanSubmarine 是一種 Swimmer,會有這種說法的作者,應該是有 C++ 程式語言的背景,因為 C++ 中可以多重繼承,也就是子類別可以擁有兩個以上的父類別,若其中一個父類別用來定義為抽象行為,該父類別的作用就類似 Java 中的介面,因為也是用繼承語意來實作,所以才會有是一種的說法。

多重繼承容易因為設計上考量不周而引來不少麻煩,因而 Java 對多重繼承作了限制,就類別的語意來說,Java 限制只能繼承一個父類別,「是一種」的語意更為強烈,我建議將「是一種」的語意保留給繼承,對於介面實作則使用「擁有行為」的語意,如此就不會搞不清楚類別繼承與介面實作的差別,對於何時用繼承,何時用介面也比較容易判斷。

介面與多型

會使用介面定義行為之後,再來當編譯器,看看哪些是合法的多型語法。例如:

Swimmer swimmer1 = new Shark();
Swimmer swimmer2 = new Human();
Swimmer swimmer3 = new Submarine();

這三行程式碼都可以通過編譯,判斷方式是「右邊是不是擁有左邊的行為」,或「是右邊物件是不是實作了左邊介面」。

Shark 擁有 Swimmer 行為嗎?有的!因為 Fish 實作了 Swimmer 介面,也就是 Fish 擁有 Swimmer 行為,Shark 繼承 Fish,當然也擁有 Swimmer 行為,所以通過編譯,HumanSubmarine 也都實作了 Swimmer 介面,所以通過編譯。

更進一步地,來看看底下的程式碼是否可通過編譯?

Swimmer swimmer = new Shark();
Shark shark = swimmer;

第一行要判斷 Shark 是否擁有 Swimmer 行為?是的!可通過編譯,但第二行呢?swimmerSwimmer 型態,編譯器看到該行會想到,有 Swimmer 行為的物件是不是 Shark 呢?這可不一定!也許實際上是 Human 實例!因為有 Swimmer 行為的物件不一定是 Shark,所以第二行編譯失敗!

就上面的程式碼片段而言,實際上 swimmer 是參考至 Shark 實例,你可以加上扮演(Cast)語法:

Swimmer swimmer = new Shark();
Shark shark = (Shark) swimmer;

對第二行的語意而言,就是在告訴編譯器,對!你知道有 Swimmer 行為的物件,不一定是 Shark,不過你就是要它扮演 Shark,編譯器就別再囉嗦了。可以通過編譯,執行時期 swimmer 確實也是參考 Shark 實例,所以也沒有錯誤。

底下的程式片段會在第二行編譯失敗:

Swimmer swimmer = new Shark();
Fish fish = swimmer;

第二行 swimmerSwimmer 型態,編譯器會問,實作 Swimmer 介面的物件是不是繼承 Fish?不一定,也許是 Submarine!因為會實作 Swimmer 介面的並不一定繼承 Fish,所以編譯失敗了。如果加上扮演語法:

Swimmer swimmer = new Shark();
Fish fish = (Fish) swimmer;

第二行告訴編譯器,你知道有 Swimmer 行為的物件,不一定繼承 Fish,不過你就是要它扮演 Fish,編譯器就別再囉嗦了。可以通過編譯,執行時期 swimmer 確實也是參考 Shark 實例,它是一種 Fish,所以也沒有錯誤。

下面這個例子就會拋出 ClassCastException 錯誤:

Swimmer swimmer = new Human();
Shark shark = (Shark) swimmer;

在第二行,swimmer 實際上參考了 Human 實例,你要他扮演鯊魚?這太荒繆了!所以執行時就出錯了。類似地,底下的例子也會出錯:

Swimmer swimmer = new Submarine();
Fish fish = (Fish) swimmer;

在第二行,swimmer 實際上參考了 Submarine 實例,你要他扮演魚?又不是在演哆啦A夢中海底鬼岩城,哪來的機器魚情節! Submarine 不是一種 Fish,執行時期會因為扮演失敗而拋出 ClassCastException

使用 var 的話,可以由編譯器自動推斷區域變數型態,就以下的程式片段:

Swimmer swimmer = new Shark();
Fish fish = (Fish) swimmer;

也可以寫為:

Swimmer swimmer = new Shark();
var fish = (Fish) swimmer;

知道以上的語法,哪些可以通過編譯,哪些可以扮演成功作什麼?來考慮一個需求,寫個 staticswim 方法,讓會游的東西都游起來,在不會使用介面多型語法時,也許你會寫下:

public static void doSwim(Fish fish) {
    fish.swim();
}
public static void doSwim(Human human) {
    human.swim();
}
public static void doSwim(Submarine submarine) {
    submarine.swim();
}

問題是,如果「種類」很多怎麼辦?多了水母、海蛇、蟲等種類呢?每個種類重載一個方法出來嗎?其實在你的設計中,會游泳的東西,都擁有 Swimmer 的行為,都實作了 Swimmer 介面,你只要這麼設計就可以了:

package cc.openhome;

public class Ocean {
    public static void main(String[] args) {
        doSwim(new Anemonefish("尼莫"));
        doSwim(new Shark("蘭尼"));
        doSwim(new Human("賈斯汀"));
        doSwim(new Submarine("黃色一號"));
    }

    static void doSwim(Swimmer swimmer) {
        swimmer.swim();
    }
}

只要是實作 Swimmer 介面的物件,都可以使用範例中的 doSwim 方法,AnemonefishSharkHumanSubmarine 等,都實作了 Swimmer 介面,再多種類,只要物件擁有 Swimmer 行為,你就都不用撰寫新的方法,可維護性顯然提高許多!

分享到 LinkedIn 分享到 Facebook 分享到 Twitter