Java 乾貨之深入理解Java泛型
一般的類和方法,只能使用具體的型別,要麼是基本型別,要麼是自定義的類。如果要編寫可以應用多中型別的程式碼,這種刻板的限制對程式碼得束縛會就會很大。
---《Thinking in Java》
泛型大家都接觸的不少,但是由於Java 歷史的原因,Java 中的泛型一直被稱為偽泛型,因此對Java中的泛型,有很多不注意就會遇到的“坑”,在這裡詳細討論一下。對於基礎而又常見的語法,這裡就直接略過了。
什麼是泛型
自JDK 1.5 之後,Java 通過泛型解決了容器型別安全這一問題,而幾乎所有人接觸泛型也是通過Java的容器。那麼泛型究竟是什麼?
泛型的本質是引數化型別
也就是說,泛型就是將所操作的資料型別作為引數的一種語法。
public class Paly<T>{
T play(){}
}
其中T
就是作為一個型別引數在Play
被例項化的時候所傳遞來的引數,比如:
Play<Integer> playInteger=new Play<>();
這裡T
就會被例項化為Integer
泛型的作用
- 使用泛型能寫出更加靈活通用的程式碼
泛型的設計主要參照了C++的模板,旨在能讓人寫出更加通用化,更加靈活的程式碼。模板/泛型程式碼,就好像做雕塑時的模板,有了模板,需要生產的時候就只管向裡面注入具體的材料就行,不同的材料可以產生不同的效果,這便是泛型最初的設計宗旨。
- 泛型將程式碼安全性檢查提前到編譯期
泛型被加入Java語法中,還有一個最大的原因:解決容器的型別安全,使用泛型後,能讓編譯器在編譯的時候藉助傳入的型別引數檢查對容器的插入,獲取操作是否合法,從而將執行時ClassCastException
轉移到編譯時比如:
List dogs =new ArrayList();
dogs.add(new Cat());
在沒有泛型之前,這種程式碼除非執行,否則你永遠找不到它的錯誤。但是加入泛型後
List<Dog> dogs=new ArrayList<>();
dogs.add(new Cat());//Error Compile
會在編譯的時候就檢查出來。
- 泛型能夠省去型別強制轉換
在JDK1.5之前,Java容器都是通過將型別向上轉型為Object
型別來實現的,因此在從容器中取出來的時候需要手動的強制轉換。
Dog dog=(Dog)dogs.get(1);
加入泛型後,由於編譯器知道了具體的型別,因此編譯期會自動進行強制轉換,使得程式碼更加優雅。
泛型的具體實現
我們可以定義泛型類,泛型方法,泛型介面等,那泛型的底層是怎麼實現的呢?
從歷史上看泛型
由於泛型是JDK1.5之後才出現的,在此之前需要使用泛型(模板程式碼)的地方都是通過Object
向上轉型以及強制型別轉換實現的,這樣雖然能滿足大多數需求,但是有個最大的問題就在於型別安全。在獲取“真正”的資料的時候,如果不小心強制轉換成了錯誤型別,這種錯誤只能在真正執行的時候才能發現。
因此Java 1.5推出了“泛型”,也就是在原本的基礎上加上了編譯時型別檢查的語法糖。Java 的泛型推出來後,引起來很多人的吐槽,因為相對於C++等其他語言的泛型,Java的泛型程式碼的靈活性依然會受到很多限制。這是因為Java被規定必須保持二進位制向後相容性,也就是一個在Java 1.4版本中可以正常執行的Class檔案,放在Java 1.5中必須是能夠正常執行的:
在1.5之前,這種型別的程式碼是沒有問題的。
public static void addRawList(List list){
list.add("123");
list.add(2);
}
1.5之後泛型大量應用後:
public static void addGenericList(List<String> list){
list.add("1");//Only String
list.add("2");
}
雖然我們認為addRawList()
方法中的程式碼不是型別安全的,但是某些時候這種程式碼是有用的,在設計JDK1.5的時候,想要實現泛型有兩種選擇:
- 需要泛型化的型別(主要是容器(Collections)型別),以前有的就保持不變,然後平行地加一套泛型化版本的新型別;
- 直接把已有的型別泛型化,讓所有需要泛型化的已有型別都原地泛型化,不新增任何平行於已有型別的泛型版。
什麼意思呢?也就是第一種辦法是在原有的Java庫的基礎上,再新增一些庫,這些庫的功能和原本的一模一樣,只是這些庫是使用Java新語法泛型實現的,而第二種辦法是保持和原本的庫的高度一致性,不新增任何新的庫。
在出現了泛型之後,原本沒有使用泛型的程式碼就被稱為raw type
(原始型別)
Java 的二進位制向後相容性使得Java 需要實現前後相容的泛型,也就是說以前使用原始型別的程式碼可以繼續被泛型使用,現在的泛型也可以作為引數傳遞給原始型別的程式碼。
比如
List<String> list=new ArrayList<>();
List rawList=new ArrayList();
addRawList(list);
addGenericList(list);
addRawList(rawList);
addGenericList(rawList);
上面的程式碼能夠正確的執行。
Java 設計者選擇了第二種方案
C# 在1.1過渡到2.0中增加泛型時,使用了第一種方案。
為了實現以上功能,Java 設計者將泛型完全作為了語法糖加入了新的語法中,什麼意思呢?也就是說泛型對於JVM來說是透明的,有泛型的和沒有泛型的程式碼,通過編譯器編譯後所生成的二進位制程式碼是完全相同的。
這個語法糖的實現被稱為擦除
擦除的過程
泛型是為了將具體的型別作為引數傳遞給方法,類,介面。
擦除是在程式碼執行過程中將具體的型別都抹除。
前面說過,Java 1.5 之前需要編寫模板程式碼的地方都是通過Object
來儲存具體的值。比如:
public class Node{
private Object obj;
public Object get(){
return obj;
}
public void set(Object obj){
this.obj=obj;
}
public static void main(String[] argv){
Student stu=new Student();
Node node=new Node();
node.set(stu);
Student stu2=(Student)node.get();
}
}
這樣的實現能滿足絕大多數需求,但是泛型還是有更多方便的地方,最大的一點就是編譯期型別檢查,於是Java 1.5之後加入了泛型,但是這個泛型僅僅是在編譯的時候幫你做了編譯時型別檢查,成功編譯後所生成的.class
檔案還是一模一樣的,這便是擦除
1.5 以後實現
public class Node<T>{
private T obj;
public T get(){
return obj;
}
public void set(T obj){
this.obj=obj;
}
public static void main(String[] argv){
Student stu=new Student();
Node<Student> node=new Node<>();
node.set(stu);
Student stu2=node.get();
}
}
兩個版本生成的.class檔案:
Node:
public Node();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public java.lang.Object get();
Code:
0: aload_0
1: getfield #2 // Field obj:Ljava/lang/Object;
4: areturn
public void set(java.lang.Object);
Code:
0: aload_0
1: aload_1
2: putfield #2 // Field obj:Ljava/lang/Object;
5: return
}
Node
public class Node<T> {
public Node();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public T get();
Code:
0: aload_0
1: getfield #2 // Field obj:Ljava/lang/Object;
4: areturn
public void set(T);
Code:
0: aload_0
1: aload_1
2: putfield #2 // Field obj:Ljava/lang/Object;
5: return
}
可以看到泛型就是在使用泛型程式碼的時候,將型別資訊傳遞給具體的泛型程式碼。而經過編譯後,生成的.class
檔案和原始的程式碼一模一樣,就好像傳遞過來的型別資訊又被擦除了一樣。
泛型語法
Java 的泛型就是一個語法糖,而語法糖最大的好處就是讓人方便使用,但是它的缺點也在於如果不剝開這顆語法糖,有很多奇怪的語法就很難理解。
- 型別邊界
前面說過,泛型在最終會擦除為Object
型別。這樣導致的是在編寫泛型程式碼的時候,對泛型元素的操作只能使用Object
自帶的一些方法,但是有時候我們想使用其他型別的方法呢?
比如:
public class Node{
private People obj;
public People get(){
return obj;
}
public void set(People obj){
this.obj=obj;
}
public void playName(){
System.out.println(obj.getName());
}
}
如上,程式碼中需要使用obj.getName()
方法,因此比如規定傳入的元素必須是People
及其子類,那麼這樣的方法怎麼通過泛型體現出來呢?
答案是extend
,泛型過載了extend
關鍵字,可以通過extend
關鍵字指定最終擦除所替代的型別。
public class Node<T extend People>{
private T obj;
public T get(){
return obj;
}
public void set(T obj){
this.obj=obj;
}
public void playName(){
System.out.println(obj.getName());
}
}
通過extend
關鍵字,編譯器會將最後型別都擦除為People
型別,就好像最開始我們看見的原始程式碼一樣。
泛型與向上轉型的概念
先講一講幾個概念:
- 協變:子類能向父類轉換
Animal a1=new Cat();
- 逆變: 父類能向子類轉換
Cat a2=(Cat)a1;
- 不變: 兩者均不能轉變
對於協變,我們見得最多的就是多型,而逆變常見於強制型別轉換。
這好像沒什麼奇怪的。但是看以下程式碼:
public static void error(){
Object[] nums=new Integer[3];
nums[0]=3.2;
nums[1]="string"; //執行時報錯,nums執行時型別是Integer[]
nums[2]='2';
}
因為陣列是協變的,因此Integer[]
可以轉換為Object[]
,在編譯階段編譯器只知道nums
是Object[]
型別,而執行時nums
則為Integer[]
型別,因此上述程式碼能夠編譯,但是執行會報錯。
這就是常見的人們所說的陣列是協變的。這裡帶來一個問題,為什麼陣列要設計為協變的呢?既然不讓執行,那麼通過編譯有什麼用?
答案是在泛型還沒出現之前,陣列協變能夠解決一些通用的問題:
public static void sort(Object[] a) {
if (LegacyMergeSort.userRequested)
legacyMergeSort(a);
else
ComparableTimSort.sort(a, 0, a.length, null, 0, 0);
}
/**
* 摘自JDK 1.8 Arrays.equals()
*/
public static boolean equals(Object[] a, Object[] a2) {
//...
for (int i=0; i<length; i++) {
Object o1 = a[i];
Object o2 = a2[i];
if (!(o1==null ? o2==null : o1.equals(o2)))
return false;
}
//..
return true;
}
可以看到,只運算元組本身,而關心陣列中具體儲存的原始,或則是不管什麼元素,取出來就作為一個Object
儲存的時候,只用編寫一個Object[]
就能寫出通用的陣列引數方法。比如:
Arrays.sort(new Student[]{...})
Arrays.sort(new Apple[]{...})
等,但是這樣的設計留下來的詬病就是偶爾會出現對陣列元素有具體的操作的程式碼,比如上面的error()
方法。
泛型的出現,是為了保證型別安全的問題,如果將泛型也設計為協變的話,那也就違背了泛型最初設計的初衷,因此在Java中,泛型是不變的,什麼意思呢?
List<Number>
和List<Integer>
是沒有任何關係的,即使Integer
是Number
的子類
也就是對於
public static void test(List<Number> nums){...}
方法,是無法傳遞一個List<Integer>
引數的
逆變一般常見於強制型別轉換。
Object obj="test";
String str=(String)obj;
原理便是Java 反射機制能夠記住變數obj
的實際型別,在強制型別轉換的時候發現obj
實際上是一個String
型別,於是就正常的通過了執行。
泛型與向上轉型的實現
前面說了這麼多,應該關心的問題在於,如何解決既能使用陣列協變帶來的方便性,又能得到泛型不變帶來的型別安全?
答案依然是extend
,super
關鍵字與萬用字元?
泛型過載了extend
,super
關鍵字來解決通用泛型的表示。
注意:這句話可能比較熟悉,沒錯,前面說過
extend
還被用來指定擦除到的具體型別,比如<E extend Fruit>
,表示在執行時將E
替換為Fruit
,注意E
表示的是一個具體的型別,但是這裡的extend
和萬用字元連續使用<? extend Fruit>
這裡萬用字元?
表示一個通用型別,它所表示的泛型在編譯的時候,被指定的具體的型別必須是Fruit
的子類。比如List<? extend Fruit> list= new ArrayList<Apple>
,ArrayList<>
中指定的型別必須是Apple
,Orange
等。不要混淆。
概念麻煩,直接看程式碼:
協變泛型
public static void playFruit(List < ? extends Fruit> list){
//do somthing
}
public static void main(String[] args) {
List<Apple> apples=new ArrayList<>();
List<Orange> oranges=new ArrayList<>();
List<Food> foods =new ArrayList<>();
playFruit(apples);
playFruit(oranges);
//playFruit(foods); 編譯錯誤
}
可以看到,引數List < ? extend Fruit>
所表示是需要一個List<>
,其中尖括號所指定的具體型別必須是繼承自Fruit
的。
這樣便解決了泛型無法向上轉型的問題,前面說過,陣列也能向上轉型,但是存取元素有問題啊,這裡繼續深入,看看泛型是怎麼解決這一問題的。
public static void playFruit(List < ? extends Fruit> list){
list.add(new Apple());
}
向傳入的list
新增元素,你會發現編譯器直接會報錯
逆變泛型
public static void playFruitBase(List < ? super Fruit> list){
//..
}
public static void main(String[] args) {
List<Apple> apples=new ArrayList<>();
List<Food> foods =new ArrayList<>();
List<Object> objects=new ArrayList<>();
playFruitBase(foods);
playFruitBase(objects);
//playFruitBase(apples); 編譯錯誤
}
同理,引數List < ? super Fruit>
所表示是需要一個List<>
,其中尖括號所指定的具體型別必須是Fruit
的父類型別。
public static void playFruitBase(List < ? super Fruit> list){
Object obj=list.get(0);
}
取出list
的元素,你會發現編譯器直接會報錯
思考: 為什麼要這麼麻煩要區分開到底是xxx的父類還是子類,不能直接使用一個關鍵字表示麼?
前面說過,陣列的協變之所以會有問題是因為在對陣列中的元素進行存取的時候出現的問題,只要不對陣列元素進行操作,就不會有什麼問題,因此可以使用萬用字元?
達到此效果:
public static void playEveryList(List < ?> list){
//..
}
對於playEveryList
方法,傳遞任何型別的List
都沒有問題,但是你會發現對於list
引數,你無法對裡面的元素存和取。這樣便達到了上面所說的安全型別的協變陣列的效果。
但是覺得多數時候,我們還是希望對元素進行操作的,這就是extend
和super
的功能。
<? extend Fruit>
表示傳入的泛型具體型別必須是繼承自Fruit
,那麼我們可以裡面的元素一定能向上轉型為Fruit
。但是也僅僅能確定裡面的元素一定能向上轉型為Fruit
public static void playFruit(List < ? extends Fruit> list){
Fruit fruit=list.get(0);
//list.add(new Apple());
}
比如上面這段程式碼,可以正確的取出元素,因為我們知道所傳入的引數一定是繼承自Fruit
的,比如
List<Apple> apples=new ArrayList<>();
List<Orange> oranges=new ArrayList<>();
都能正確的轉換為Fruit
,
但是我們並不知道里面的元素具體是什麼,有可能是Orange
,也有可能是Apple
,因此,在list.add()
的時候,就會出現問題,有可能將Apple
放入了Orange
裡面,因此,為了不出錯,編譯器會禁止向裡面加入任何元素。這也就解釋了協變中使用add
會出錯的原因。
同理:
<? super Fruit>
表示傳入的泛型具體型別必須是Fruit
的父類,那麼我們可以確定只要元素是Fruit
以及能轉型為Fruit
的,一定能向上轉型為對應的此型別,比如:
public static void playFruitBase(List < ? super Fruit> list){
list.add(new Apple());
}
因為Apple
繼承自Fruit
,而引數list最終被指定的型別一定是Fruit
的父類,那麼Apple
一定能向上轉型為對應的父類,因此可以向裡面存元素。
但是我們只能確定他是Furit
的父類,並不知道具體的“上限”。因此無法將取出來的元素統一的型別(當然可以用Object
)。比如
List<Eatables> eatables=new ArrayList<>();
List<Food> foods=new ArrayList<>();
除了
Object obj;
obj=eatables.get(0);
obj=foods.get(0);
之外,沒有確定型別可以修飾obj
以達到類似的效果。
針對上述情況。我們可以總結為:PECS原則,Producer-Extend,Customer-Super
,也就是泛型程式碼是生產者,使用Extend
,泛型程式碼作為消費者Super
泛型的陰暗角落
通過擦除而實現的泛型,有些時候會有很多讓人難以理解的規則,但是瞭解了泛型的真正實現又會覺得這樣做還是比較合情合理。下面分析一下關於泛型在應用中有哪些奇怪的現象:
擦除的地點---邊界
static <T> T[] toArray(T... args) {
return args;
}
static <T> T[] pickTwo(T a, T b, T c) {
switch(ThreadLocalRandom.current().nextInt(3)) {
case 0: return toArray(a, b);
case 1: return toArray(a, c);
case 2: return toArray(b, c);
}
throw new AssertionError(); // Can't get here
}
public static void main(String[] args) {
String[] attributes = pickTwo("Good", "Fast", "Cheap");
}
這是在《Effective Java》中看到的例子,編譯此程式碼沒有問題,但是執行的時候卻會型別轉換錯誤:Exception in thread "main" java.lang.ClassCastException: [Ljava.lang.Object; cannot be cast to [Ljava.lang.String;
當時對泛型並沒有一個很好的認識,一直不明白為什麼會有Object[]
轉換到String[]
的錯誤。現在我們來分析一下:
- 首先看
toArray
方法,由本章最開始所說泛型使用擦除實現的原因是為了保持有泛型和沒有泛型所產生的程式碼一致,那麼:
static <T> T[] toArray(T... args) {
return args;
}
和
static Object[] toArray(Object... args){
return args;
}
生成的二進位制檔案是一致的。
進而剝開可變陣列的語法糖:
static Object[] toArray(Object[] args){
return args;
}
static <T> T[] pickTwo(T a, T b, T c) {
switch(ThreadLocalRandom.current().nextInt(3)) {
case 0: return toArray(a, b);
case 1: return toArray(a, c);
case 2: return toArray(b, c);
}
throw new AssertionError(); // Can't get here
}
和
static Object[] pickTwo(Object a, Object b, Object c) {
switch(ThreadLocalRandom.current().nextInt(3)) {
case 0: return toArray(new Object[]{a,b});//可變引數會根據呼叫型別轉換為對應的陣列,這裡a,b,c都是Object
case 1: return toArray(new Object[]{a,b});
case 2: return toArray(new Object[]{a,b});
}
throw new AssertionError(); // Can't get here
}
是一致的。
那麼呼叫pickTwo
方法實際編譯器會幫我進行型別轉換
public static void main(String[] args) {
String[] attributes =(String[])pickTwo("Good", "Fast", "Cheap");
}
可以看到,問題就在於可變引數那裡,使用可變引數編譯器會自動把我們的引數包裝為一個陣列傳遞給對應的方法,而這個陣列的包裝在泛型中,會最終翻譯為new Object
,那麼toArray
接受的實際型別是一個Object[]
,當然不能強制轉換為String[]
上面程式碼出錯的關鍵點就在於泛型經過擦除後,型別變為了Object
導致可變引數直接包裝出了一個Object
陣列產生的型別轉換失敗。
基類劫持
public interface Playable<T> {
T play();
}
public class Base implements Playable<Integer> {
@Override
public Integer play() {
return 4;
}
}
public class Derived extend Base implements Playable<String>{
...
}
可以發現在定義Derived
類的時候編譯器會報錯。
觀察Derived
的定義可以看到,它繼承自Base
那麼它就擁有一個Integer play()
和方法,繼而實現了Playable<String>
介面,也就是它必須實現一個String play()
方法。對於Integer play()
和String play()
兩個方法的函式簽名相同,但是返回型別不同,這樣的方法在Java 中是不允許共存的:
public static void main(String[] args){
new Derived().play();
}
編譯器並不知道應該呼叫哪一個play()
方法。
自限定型別
自限定型別簡單點說就是將泛型的型別限制為自己以及自己的子類。最常見的在於實現Compareable
介面的時候:
public class Student implements Comparable<Student>{
}
這樣就成功的限制了能與Student
相比較的型別只能是Student
,這很好理解。
但是正如Java 中返回型別是協變的:
public class father{
public Number test(){
return nll;
}
}
public class Son extend father{
@Override
public Interger test(){
return null;
}
}
有些時候對於一些專門用來被繼承的類需要引數也是協變的。比如實現一個Enum
:
public abstract class Enum implements Comparable<Enum>,Serializable{
@Override
public int compareTo(Enum o) {
return 0;
}
}
這樣是沒有問題的,但是正如常規所說,假如Pen
和Cup
都繼承於Enum
,但是按道理來說筆和杯子之間相互比較是沒有意義的,也就是說在Enum
中compareTo(Enum o)
方法中的Enum
這個限定詞太寬泛,這個時候有兩種思路:
- 子類分別自己實現
Comparable
介面,這樣就可以規定更詳細的引數型別,但是由於前面所說,會出現基類劫持的問題 - 修改父類的程式碼,讓父類不實現
Comparable
介面,讓每個子類自己實現即可,但是這樣會有大量一模一樣的程式碼,只是傳入的引數型別不同而已。
而更好的解決方案便是使用泛型的自限定型別:
public abstract class Enum<E extend Enum<E>> implements Comparable<E>,Serializable{
@Override
public int compareTo(E o) {
return 0;
}
}
泛型的自限定型別比起傳統的自限定型別有個更大的優點就是它能使泛型的引數也變成協變的。
這樣每個子類只用在整合的時候指定型別
public class Pen extends Enum<Pen>{}
public class Cup extends Cup<Cup>{}
便能夠在定義的時候指定想要與那種型別進行比較,這樣達到的效果便相當於每個子類都分別自己實現了一個自定義的Comparable
介面。
自限定型別一般用在繼承體系中,需要引數協變的時候。
相關文章
- 深入理解 Java 泛型Java泛型
- 深入理解Java泛型Java泛型
- Java基礎——深入理解泛型Java泛型
- java泛型之泛型方法。Java泛型
- 關於Java泛型深入理解小總結Java泛型
- java泛型之泛型陣列。Java泛型陣列
- Java泛型理解與使用Java泛型
- 我理解的 Java 泛型Java泛型
- 夯實Java基礎系列13:深入理解Java中的泛型Java泛型
- 深入解析Java中的泛型Java泛型
- JAVASE之JAVA泛型篇Java泛型
- Java基礎之泛型方法Java泛型
- Java核心之細說泛型Java泛型
- Java 泛型Java泛型
- Java泛型Java泛型
- 【java】【泛型】泛型geneticJava泛型
- 重溫Java泛型,帶你更深入地理解它,更好的使用它!Java泛型
- Java反射—方法的反射、深入瞭解泛型Java反射泛型
- Java中泛型的詳細解析,深入分析泛型的使用方式Java泛型
- Java基礎之淺談泛型Java泛型
- 【Java面試題】之泛型相關Java面試題泛型
- Java泛型的理解與等價實現Java泛型
- java中泛型之型別萬用字元(?)Java泛型型別字元
- Java 泛型原理Java泛型
- Java+泛型Java泛型
- java泛型一二Java泛型
- Java(7)泛型Java泛型
- Java-泛型Java泛型
- java泛型剖析Java泛型
- JAVA泛型類Java泛型
- 【Java反射】Java 泛型基礎Java反射泛型
- java乾貨筆記之變數4Java筆記變數
- Java中的泛型程式設計:深入理解型別引數與型別邊界的使用Java泛型程式設計型別
- 一. 重識Java之夯實泛型Java泛型
- 重學Java之泛型的基本使用Java泛型
- java泛型之萬用字元的使用。Java泛型字元
- java基礎學習之十三:泛型Java泛型
- JAVA基礎之九-泛型(通用型別)Java泛型型別