定義行為外觀
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
是一種 Fish
,Shark
是一種 Fish
,Piranha
是一種 Fish
,如果你讓 Human
繼承 Fish
,那 Human
是一種 Fish
?你會說「美人魚啊!」…@#$%^&
程式上可以通過編譯也可以執行,但邏輯上或設計上有不合理的地方,你可以繼續硬掰下去,如果現在老闆說加個潛水航呢?寫個 Submarine
繼承 Fish
嗎?Submarine
是一種 Fish
嗎?繼續這樣的想法設計下去,你的程式架構會越來越不合理,越來越沒有彈性!
記得嗎?Java 只能繼承一個父類別,更強化了「是一種」關係的限制性。如果今天老闆突發奇想,想把海洋樂園變為海空樂園,有的東西會游泳,有的東西會飛,有的東西會游也會飛,如果用繼承方式來解決,寫個 Fish
讓會游的東西繼承,寫個 Bird
讓會飛的東西繼承,那會游也會飛的怎麼辦?有辦法定義個飛魚 FlyingFish
同時繼承 Fish
跟 Bird
嗎?
使用 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
並不知道每條魚怎麼游,所以使用第二種處理方式。
目前 Anemonefish
、Shark
與 Piranha
繼承 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
可沒有繼承 Fish
,Human
不是一種 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
沒有繼承 Fish
,Submarine
不是一種 Fish
。
以 Java 的語意來說,繼承會有「是一種」關係,實作介面則表示「擁有行為」,但不會有「是一種」的關係。Human
與 Submarine
實作了 Swimmer
,都擁有 Swimmer
定義的行為,但它們沒有繼承 Fish
,所以它們不是一種魚,這樣的架構比較合理也較有彈性,可以應付一定程度的需求變化。
有些書或文件會說,Human
與 Submarine
是一種 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
行為,所以通過編譯,Human
與 Submarine
也都實作了 Swimmer
介面,所以通過編譯。
更進一步地,來看看底下的程式碼是否可通過編譯?
Swimmer swimmer = new Shark();
Shark shark = swimmer;
第一行要判斷 Shark
是否擁有 Swimmer
行為?是的!可通過編譯,但第二行呢?swimmer
是 Swimmer
型態,編譯器看到該行會想到,有 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;
第二行 swimmer
是 Swimmer
型態,編譯器會問,實作 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;
知道以上的語法,哪些可以通過編譯,哪些可以扮演成功作什麼?來考慮一個需求,寫個 static
的 swim
方法,讓會游的東西都游起來,在不會使用介面多型語法時,也許你會寫下:
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
方法,Anemonefish
、Shark
、Human
、Submarine
等,都實作了 Swimmer
介面,再多種類,只要物件擁有 Swimmer
行為,你就都不用撰寫新的方法,可維護性顯然提高許多!