一般的類和方法,只能使用具體的類型,要么是基本類型,要么是自定義的類。如果要編寫可以應用多中類型的代碼,這種刻板的限制對代碼得束縛會就會很大。
---《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不能實現真正泛型的原因? - RednaxelaFX的回答 - 知乎
深入理解 Java 泛型
java中,數組為什么要設計為協變? - 胖君的回答 - 知乎
java泛型中的自限定類型有什么作用-CSDN問答
如果覺得寫得不錯,歡迎關注微信公眾號:逸游Java ,每天不定時發布一些有關Java進階的文章,感謝關注