5.8多型
上面我們瞭解了向上轉型,即一個物件變數可以引用本類及子類的物件例項,這種現象稱為多型(polymorphism)。多型究竟有什麼用呢?我們先學習一個知識點。
5.8.1方法重寫
前面我們學習類與物件的時候,學習過方法過載(overload),方法過載指的是在同一個類,存在多個方法名相同,但是方法簽名不同的現象。
方法重寫是啥呢?我們先看一個問題。Gun類有單發的方法:
public void shoot() { System.out.println("單發"); }
對於狙擊槍AWM來說,射擊的子彈是7.62mm子彈,不想只輸出“單發”兩個字,而是想輸出“發射7.62mm子彈”。那麼怎麼辦呢?可以在AWM類中重新寫定義一個一模一樣的方法,然後重寫方法體:
public void shoot() { System.out.println("發射7.62mm子彈"); }
這種在子類中重新定義一個和超類相同方法簽名的操作,稱為方法重寫(override)。這裡需要注意一個問題,就是構造方法是不能被重寫的,因為構造方法不能被繼承。另外子類的方法不能低於超類方法的可見性。
方法重寫要求方法簽名和返回值都完全一樣,有時候,我們在進行方法重寫的時候,經常會出現方法名相同、引數列表也相同,但是返回值不同的現象,這時候其實就不算方法重寫。不過在Java5.0之後,返回值只要是原返回型別的子類也算方法重寫。其實有個好辦法幫我們檢查方法重寫時候正確,就是在子類的覆蓋方法上加上一個註解:@Override,如果重寫錯誤,編譯器會報錯。關於註解後面會詳細討論。加上註解的方法如下:
@Override public void shoot() { System.out.println("發射7.62mm子彈"); }
當然,這個註解並不是必須的。不過筆者建議在重寫的時候最好是加上。
另外,如果超類中的方法是靜態方法,子類想要重寫該方法,也必須定義為靜態的,如果定義為成員方法,則會編譯報錯。反之亦然。我們可以用一個表總結一下子類中定義了一個和超類方法簽名一樣並且返回值也一樣的情況:
|
超類成員方法 |
超類靜態方法 |
子類成員方法 |
重寫 |
編譯報錯 |
子類靜態方法 |
編譯報錯 |
重寫 |
現在,我們來總結一下方法重寫:
- 方法簽名必須相同
- 方法返回值相同或者為其子類
- 可見性不能低於超類方法
- 可以使用註解@Override幫助檢查重寫是否正確
- 成員方法必須用成員方法重寫,靜態方法必須用靜態方法重寫
5.8.2動態繫結
在上面的例子中,AWM重寫了單發方法,我們給AK47類也重寫單發方法:
@Override public void shoot() { System.out.println("發射5.56mm子彈"); }
然後我們編寫測試方法:
public class ExtendTest { public static void main(String[] args) { Gun gun1 = new AWM("awm", "綠色", "8倍鏡"); Gun gun2 = new AK47("ak47", "黑色", "4倍鏡"); gun1.shoot();// 輸出結果為: 發射7.62mm子彈 gun2.shoot();// 輸出結果為: 發射5.56mm子彈 } }
我們發現,gun1和gun2都是Gun型別變數,但是最終呼叫shoot()方法的結果不一樣。當實際引用的是AWM型別物件,則呼叫AWM的shoot方法,實際引用的AK47型別物件,則呼叫AK47的shoot方法。這種在執行時能夠自動選擇呼叫哪個方法的現象稱為動態繫結(dynamic binding)。
5.8.3多型有什麼用
我們瞭解了方法重寫和動態繫結,那麼多型有什麼用處呢?下面我們用實際例子來演示。我們現在擁有了4個槍類:Gun、AWM、AK47、Gatling。現在我們編寫一個玩家類,我們假設玩家只能拿一把槍,但是玩家可以隨時更換槍支。我們設計一個玩家類如下:
根據設計,我們編寫程式碼如下:
public class Player { private Gun gun; public void changeGun(Gun gun) { this.gun = gun; } public void shoot() { this.gun.shoot(); } }
然後我們編寫測試類:
public class ExtendTest { public static void main(String[] args) { Gun gun1 = new AWM("awm", "綠色", "8倍鏡"); Gun gun2 = new AK47("ak47", "黑色", "4倍鏡"); Player player = new Player(); player.changeGun(gun1); player.shoot();// 輸出 發射7.62mm子彈 player.changeGun(gun2); player.shoot();// 輸出 發射5.56mm子彈 } }
我們看到,對於玩家類,不需要和槍支具體的子類打交道,只需要持有超類Gun物件即可。假如新加了別的槍,只需要編寫新的槍類繼承Gun類,重寫shoot方法,然後呼叫Player類的changeGun方法即可。新增槍完全不需要修改Player類的程式碼。
多型的強大之處就在此,可以很方便的擴充套件子類,而不需要修改基類程式碼和一些呼叫者的類。一般編寫框架和一個專案的核心程式碼,經常會利用繼承和多型的特性,這個等你們經驗豐富了,有機會進入一個專案的框架小組,編寫框架程式碼的時候就會充分體會到多型的強大。
5.9final阻止繼承
我們又一次看到了final關鍵字。前面我們學習類和物件的時候,知道用final修飾的屬性將不能被修改。當時我們提到過,如果用final修飾類型別的屬性時,必須保證該類也是final的。
當我們用final來修飾一個類的時候,那麼這個類就不能被繼承了,不過該類是可以繼承其他類的。例如java.lang.String類就是一個final類:
public final class String
另外,我們還可以用final修飾方法,用final修飾的方法則不能被重寫了。例如我們把Gun類中的getColor()方法定義為final的:
public final String getColor() { return this.color; }
5.10Object類
前面介紹繼承層次的時候,提到過頂級超類java.lang.Object。如果某個類沒有顯示的使用extends關鍵字,則該類是繼承自Object。事實上,在Java中,除了基本資料型別不是物件,其他都是物件,包括陣列。因此陣列也是繼承自Ojbect類的。這裡需要注意的是,即使是基本資料型別的陣列,也是繼承自Object。因此我們可以把一個陣列賦值給一個Object型別的變數:
Object obj; int[] a = new int[] { 1, 2, 3 }; obj = a;
Object類中定義了許多有用的方法,都會被我們繼承下來,因此我們有必要熟悉一下。這裡我們主要介紹3個方法:equals方法、hashCode方法和toString方法。還有一些其他方法我們留在後面討論。下表列出這3個方法的說明:
方法 |
說明 |
equals |
比較兩個物件是否相等,如果相等返回true,否則返回false |
hashCode |
返回該物件的hash值 |
toString |
以字串形式返回該物件的有關資訊 |
5.10.1equals方法
我們在學習String類的時候,就接觸過equals方法了。equals方法是用來比較兩個物件時候相等。不過String類的equals方法是重寫過的。因為Object的equals方法很簡單,僅僅判斷兩個物件的引用是否相等(即兩個物件變數記憶體中的值),實際上和等號(==)沒有區別。但是其實大多數情況下,這種判斷沒有意義。例如對於String類來說,如果僅僅判斷物件引用是否相等,那麼“Java大失叔”和“Java大失叔”很有可能將不相等。更有意義的判斷可能是兩個物件的狀態完全一致(即所有屬性值都一致)。這就要求我們如果有需要,一般都要重寫equals方法。
Java語言規範對equals方法其實是有要求的,需要滿足下面幾個特性:
- 自反性:對於任何非空引用x,x.equals(x)應該返回true
- 對稱性:對於任何引用x和y,如果x.equals(y)返回true,那麼y.equals(x)也應該返回true
- 傳遞性:對於任何引用x、y和z。如果x.equals(y)返回true,y.equals(z)返回true,那麼x.equals(z)也應該返回true
- 一致性:如果x和y引用的物件沒有發生改變,反覆呼叫x.equals(y)返回值不變
- 對於任何非空引用x,xequals(null)應該返回false
根據上面的規範,假如我們認為2個Gun物件名字和顏色都一樣,則2把槍是相等的,可以如下編寫程式碼:
1 @Override 2 public boolean equals(Object otherObj) { 3 if (this == otherObj) { 4 return true; 5 } 6 if (otherObj == null) { 7 return false; 8 } 9 if (this.getClass() != otherObj.getClass()) { 10 return false; 11 } 12 Gun other = (Gun) otherObj; 13 return this.name.equals(other.name) && this.color.equals(other.color); 14 }
注意,第9行用到了Object類的getClass方法,這個方法返回的是一個物件所屬的類,比如一個AWM物件呼叫getClass方法返回是AWM類,一個Gun物件呼叫getClass方法返回的是Gun類。因此下面程式碼返回的結果將是false:
Gun gun1 = new Gun("awm", "綠色"); Gun gun2 = new AWM("awm", "綠色", "4倍鏡"); System.out.println(gun1.equals(gun2));
如果只在Gun中重寫了equals方法,而AWM中不重寫的話,那麼對於2把同樣顏色、但是倍鏡不同的AWM來說,將是不合理的。因此我們還需要在AWM中重寫equals方法:
@Override public boolean equals(Object otherObj) { if (!super.equals(otherObj)) { return false; } AWM other = (AWM) otherObj; return this.gunsight.equals(other.gunsight); }
注意第3句程式碼,首先呼叫超類的equals方法,如果檢測失敗,表示2個物件不可能相等了,直接返回false即可。如果超類equals方法通過,往下只需要比較AWM特有的屬性倍鏡即可。
上面的equals方法編寫幾乎很完美,可以作為equals方法編寫的模板,但是稍微有一點點瑕疵,就是Gun類equals方法第9行使用getClass方法來判斷2個物件是否屬於同一個類,對於某些不需要區分的那麼嚴格的情況下,稍顯嚴格。假如我們不需要關心一把槍是狙擊器還是步槍,只要看名字和顏色一樣,就認為相等(這個例子不是很合理,純粹為了演示),那麼就可以用instanceof關鍵字來判斷,這樣Gun的equals方法可以修改如下:
@Override public boolean equals(Object otherObj) { if (this == otherObj) { return true; } if (otherObj == null) { return false; } // if (this.getClass() != otherObj.getClass()) { // return false; // } if (!(otherObj instanceof Gun)) { return false; } Gun other = (Gun) otherObj; return this.name.equals(other.name) && this.color.equals(other.color); }
當然這時候AWM就不需要重寫equals方法了。這樣修改以後,上面的測試程式碼將返回true。通過這個例子,大家應該看出getClass和instanceof的區別了:
- instanceof比較寬鬆,對於x instanceof y,只要x是y的型別,或者是y的子型別,就返回true。
- getClass是嚴格的判斷,不考慮繼承的型別。
最後,我們可以總結一下equals方法編寫的一個相對完美的模板:
- 使用==檢測this和otherObj是否引用同一個物件,如果是直接返回true。
- 檢測otherObj是否為null,如果是直接返回false。
- 比較this和otherObj是否屬於同一個類;這裡要仔細思考一下是使用getClass方法還是instanceof。如果不需要區分子類,就使用instanceof,同時子類不要重寫equals方法。如果子類需要重新定義,就使用getClass方法
- 把otherObj轉換為本類的型別
- 一般情況下,進行屬性狀態的比較;使用==比較基本資料型別的屬性,使用equals方法比較類型別的屬性。
- 如果是子類,第一句話呼叫super.equals(otherObj)。
5.10.2hashCode方法
hash code叫做雜湊碼,是由物件匯出的一個整型值。Object類的的hashCode方法是一個本地方法:
public native int hashCode();
實際上返回的就是物件的記憶體地址。如果物件x和y是不同的物件,那麼x.hashCode()和y.hashCode()基本是不相同的。
如果一個類重寫了equals方法,一般情況下,必須重寫hashCode方法,以便讓equals與hashCode的定義是一致的:如果x.equas(y)返回true,那麼x.hashCode()和y.hashCode()也要返回同樣的值。比如String類重寫了equals方法,那麼它也重寫了hashCode方法:
public int hashCode() { int h = hash; if (h == 0 && value.length > 0) { char val[] = value; for (int i = 0; i < value.length; i++) { h = 31 * h + val[i]; } hash = h; } return h; }
我們可以看出,String的雜湊碼是通過字串中的字元加上一些演算法匯出的,我們看程式碼:
String s1 = "Java大失叔"; String s2 = "Java大失叔"; System.out.println(s1.equals(s2));// 返回true System.out.println(s1.hashCode() == s2.hashCode());// 返回true
只要2個字串equals相等,那麼hashCode方法返回值也是相等的。
另外需要注意的是,雜湊碼可以返回負數,我們儘量要合理的組織雜湊碼,以便讓不同的物件產生的雜湊碼相對均勻。這裡給出一個編寫hashCode方法的建議:
- 初始化一個整型變數,例如h=17
- 然後選取equals方法中用到的所有屬性,針對每個屬性計算一個hash值,乘以h
- 對於物件型別的屬性x,直接呼叫x.hashCode()計算hash值
- 對於基本資料型別的屬性y,可以用包裝器包裝成對應的物件型別Y,然後呼叫Y.hashCode()計算hash值
- 對於陣列型別的屬性,可以呼叫java.util.Arrays.hashCode()方法計算hash值
- 最後把各個屬性計算後的值相加作為最後的hash值返回
上面提到包裝器類,因為基本資料型別不是物件,為了物件導向,Java對每一個基本資料型別都提供了一個包裝器類,具體我們在後面會介紹。我們按照這個建議,給Gun類重寫hashCode方法,因為Gun類equals方法參與的屬性都是String,因此比較簡單:
@Override public int hashCode() { return 17 * this.name.hashCode() + 17 * this.color.hashCode(); }
對於Gun的子類AWM,重寫hashCode方法可以如下:
@Override public int hashCode() { return super.hashCode() + 31 * this.gunsight.hashCode(); }
5.10.3toString方法
我們經常會用System.out.println()來進行列印。如果我們把一個物件x傳入到該方法中,那麼println方法就會直接呼叫x.toString()方法。例如:
Gun gun = new Gun("awm", "綠色"); System.out.println(gun);// 列印:com.javadss.javase.ch05.Gun@12742ea
另外我們用一個字串通過操作符“+”和一個物件x連線起來,那麼編譯器也會自動呼叫x.toString()方法。例如:
Gun gun = new Gun("awm", "綠色"); System.out.println("這是大失叔兒子的槍:" + gun);// 列印:這是大失叔兒子的槍:com.javadss.javase.ch05.Gun@12742ea
我們看到,預設的Object類的toString()方法只返回物件所屬類名和雜湊碼,如果我們想用toString方法進行除錯,就有必要重寫toString方法,返回物件的狀態的相關資訊。一種比較常見的重寫toString方法的格式為:類名[屬性值列表],比如我們可以在Gun類中重寫toString方法如下:
@Override public String toString() { StringBuilder sb = new StringBuilder(); sb.append(this.getClass().getName()); sb.append("[name=").append(this.name).append(","); sb.append("color=").append(this.color).append("]"); return sb.toString(); }
這樣重寫以後,對於下面程式碼,列印將會友好的多:
Gun gun = new Gun("awm", "綠色"); System.out.println(gun);// 列印:com.javadss.javase.ch05.Gun[name=awm,color=綠色]
我們注意重寫的toString方法的第4句,我們使用了this.getClass().getName()方法,這樣在子類中將會動態輸出子類所屬的類,這也是多型的一種應用。例如列印AWM:
AWM gun = new AWM("awm", "綠色", "4倍鏡"); System.out.println(gun);// 列印:com.javadss.javase.ch05.AWM[name=awm,color=綠色]
當然,如果AWM不重寫toString方法的話,那麼輸出將不會體現倍鏡屬性的狀態,因此我們最好給子類也重寫toString方法,子類重寫的時候,可以充分利用超類中已經重寫的部分:
@Override public String toString() { StringBuilder sb = new StringBuilder(); sb.append(super.toString()); sb.append("[gunsight=").append(this.gunsight).append("]"); return sb.toString(); }
我們可以直接呼叫super.toString(),然後加上自身特有的屬性值即可,這樣輸出值將會變為:
com.javadss.javase.ch05.AWM[name=awm,color=綠色][gunsight=4倍鏡]
事實上,類庫中很多類都重寫了toString方法,不過對於陣列來說,卻提供了一種不是很友好的輸出,例如:
double[] ds = { 1.0d, 2.0d }; System.out.println(ds);// 列印:[D@15db9742
字首[D表示這是一個double陣列,後面是雜湊值。如果我們想獲得友好的輸出,可以使用java.util.Arrays類的toString方法:
double[] ds = { 1.0d, 2.0d }; System.out.println(Arrays.toString(ds));// 列印:[1.0, 2.0]
如果是多維陣列,可以呼叫Arrays.deepToString方法:
double[][] ds = { { 1.0d, 2.0d }, { 3.0d, 4.0d } }; System.out.println(Arrays.deepToString(ds));// 列印:[[1.0, 2.0], [3.0, 4.0]]
筆者非常建議如果時間、條件允許,最好對每一個重要的自定義類都重寫toString方法,這將會對你和呼叫你程式碼的人有很大幫助。