很久沒寫部落格了,因為項目和一些個人原因。最近複習找工作,看書+回想項目後有一些心得,加上部落格停更這麼長時間以來的積累,很是有些東西可寫。從今兒開始,慢慢把之前積累的東西補上來,友善以後查漏補缺。
先從最近的開始。昨天看到Java泛型相關的内容,有些疑惑,查資料之後發現這部分很有些有意思的東西,比如類型擦除帶來的重寫問題等等,一并記錄在這篇文章裡。
1. 泛型定義
看了很多泛型的解釋百度百科,解釋1,解釋2,都不是我想要的“以用為本”答案(沒講明白泛型的作用或者說設計目的),這裡我自己總結一下:
泛型程式設計是一種通過參數化的方式将資料處理與資料類型解耦的技術,通過對資料類型施加限制(比如Java中的有界類型)來保證資料處理的正确性,又稱參數類型或參數多态性。
泛型最著名的應用就是容器,C++的STL、Java的Collection Framework。
2. 泛型的實作方式
不同的語言在實作泛型時采用的方式不同,C++的模闆會在編譯時根據參數類型的不同生成不同的代碼,而Java的泛型是一種違反型,編譯為位元組碼時參數類型會在代碼中被擦除,單獨記錄在Class檔案的attributes域内,而在使用泛型處做類型檢查與類型轉換。
假設參數類型的占位符為T,擦除規則如下:
- <T>擦除後變為Obecjt
- <? extends A>擦除後變為A
- <? super A>擦除後變為Object
上述擦除規則叫做
保留上界
。
3. 擦除帶來的問題
對于<? extends A>和<? super A>的擦除,因為
保留上界
,是以擦除後并沒有破壞
裡氏替換原則
。
設有類Super與Sub:
class Super{}
class Sub extends Super{}
對于
有界類型
的協變性與逆變性:
List<? extends Super> list = new ArrayList<Sub>(); //協變
List<? super Sub> list2 = new ArrayList<Super>(); //逆變
類型擦除後,等價于:
List<Super> list = new ArrayList<Sub>();
List<Object> list2 = new ArrayList<Super>();
可以看出,參數類型的擦除并沒有破壞裡氏替換原則,這也是
保留上界
的原因。
感謝 Java中的逆變與協變這篇博文的作者,讓我很好了解了協變與逆變、PECS規則。有機會我會再寫一篇自己的總結。
對于<T>的擦除,根據T在類中出現位置的不同,分以下5種情況讨論:
- T是成員變量的類型
- T是泛型變量(無論成員變量還是局部變量)的類型參數,常見如Class<T>,List<T>。
- T是方法抛出的Exception(要求<T extends Exception>)
- T是方法的傳回值
- T是方法的參數
情況1的擦除不會有任何影響,因為編譯器會在泛型被調用的地方加上類型轉換;
情況2的擦除也不會有問題,這個問題有點像“要實作不可變類,就要保證成員變量中引用指向的類型也是不可變的”,是個遞歸定義;
情況3的擦除,我認為讨論這種情況意義不大。想在方法中抛出T,那得先執行個體化T,而如何通過泛型進行執行個體化,原諒我不知道怎麼能做到(有人說反射能做到,怪我反射學的不好……);假設現在得到并可以抛出泛型T的執行個體,來看一下會出現什麼情況。
設有類Super與Sub:
class Super<T extends SQLException>{
public void test() throws T{} //别懷疑,這段代碼是可以編譯通過的......
}
class Sub extends Super<BatchUpdateException>{
@Override
public void test() throws BatchUpdateException{} //這裡必須與參數類型保持一緻,否則編譯不通過。
}
Super的參數類型被擦除之後,變成了:
class Super<SQLException>{
public void test() throws SQLException{}
}
與Sub類對比後,發現并沒有違背Java中方法重寫(Override)的規則。
Java中Override的規則有一個好記的口訣,叫“兩同兩小一大”(其實叫“兩同兩窄一寬”我覺得更好),說的是子類方法與父類方法的異同:
- 子類方法的方法名&參數清單與父類方法的相同。
- 子類方法的傳回類型是父類方法傳回類型的子類(協變傳回類型,範圍更窄);
- 子類方法抛出的異常少于父類方法抛出的異常(範圍更窄);
- 子類方法的通路控制權限大于父類方法(通路範圍更寬)。
這個規則可以很友善的用裡氏替換原則反推出來。顯然這裡類型擦除後并沒有違反重寫時對異常的規定。
情況4是講T作為傳回類型時的被擦除,因為協變傳回類型的存在,它同樣不會有問題。
設有兩個類Super與Sub:
class Super<T>{
T test(){}
}
class Sub extends Super<String>{
@Override
protected String test(){} //這裡抖個包袱:protected擁有比package更高的通路權限,可以被同一包内的類通路
}
類型擦除後,Super變為:
class Super{
Object test(){}
}
與Sub類對比後,能看到它并沒有違反“兩同兩小一大”口訣,是以也不會有問題。
這個叫做協變傳回類型,即子類方法的傳回值是父類方法的子類(繞密碼一樣…)。JVM在實作它時用到了橋方法(ACC_BRIDGE),後面會有介紹。
情況1,2,3,4都做了分析,發現在現有的語言規範下,類型擦除并不會帶來影響,而情況5會有些不一樣。
設有類Super與Sub
class Super<T>{
public void test(T arg){}
}
class Sub extends Super<String>{
@Override
public void test(String str){}
}
再來看Super的參數類型被擦除後:
class Super{
public void test(Object arg){}
}
class Sub extends Super{
@Override
public void test(String str){}
}
這次我連Sub一并寫出來了,是為了友善對比:上述代碼編譯時不通過的,因為子類重寫方法的參數清單與父類的不一緻了!子類是
String
而父類是
Object
。
但是,我們按照類型擦除前的寫法來寫,編譯器并沒有報錯,執行結果也證明我們真的重寫了方法,那麼Java(準确的說編譯器)是怎麼做到的呢?請看下面一張圖,是Sub類的位元組碼:
注意到,Sub類有兩個test方法,一個的參數類型是
String
,這是Sub中重寫的方法;另外一個的參數類型是
Object
,并且flags中多了
ACC_BRIDGE
與
ACC_SYNTHETIC
兩個标簽。檢視
深入了解Java虛拟機 6.3.6節 表6-12
,
ACC_BRIDGE
表示這是由編譯器生成的橋方法,
ACC_SYNTHETIC
表示這個方法是由編譯器自動生成的。注意看這個方法都做了什麼:
checkcast #19
invokevirtual #21
Sub的常量池見下表:
可以看到,橋方法首先判斷了
Object
到
String
的類型轉換是否正确。
invokevirtual
是調用對象方法的指令,根據對象的實際類型進行分派。從常量池中可以看出,橋方法調用了Sub類中原有的重寫方法。這樣就保證了情況5下的類型擦除不會破壞方法重寫的語義。
4. 協變傳回類型的橋方法
協變傳回類型
也是使用橋方法來實作的,下圖是位元組碼:
有趣的是:一個Class檔案中出現了兩個簽名一樣,隻是傳回值不一樣的方法。如果是在Java源代碼中出現這種情況,編譯是不會通過的。為什麼編譯之後的Class檔案中就可以了呢?
仔細想一下,Java源代碼中之是以不允許這麼重載方法,是為了避免方法調用時産生歧義,比如:
public Object test(){
return "obj";
}
public String test(){
return "str";
}
public static void main(String[] args){
System.out.println(new Super().test());
}
此時編譯器是無法确定調用哪個
test()
的,是以幹脆禁止出現這種情況。而在運作期,JVM有足夠的方法去區分這種二義性(比如用
ACC_BRIDGE
或
ACC_SYNTHETIC
這兩個flag),是以就可以允許這種情況存在了。
5. 總結
通篇講了兩個問題:Java泛型是如何實作的(簡單講),這種實作會帶來什麼問題(着重在講)。據此牽扯出了
裡氏替換原則
、
協變與逆變
、
協變傳回類型
、
兩同兩小一大口訣
(就這個名字最low…)、
橋方法
這些概念。
這篇博文旨在講解原理,對實際應用太多幫助。後面還會陸續整理一些博文,比如Arrays.sort的源碼、比如泛型如何實作協變與逆變。