泛型是JavaSE5引入的一個新概念,但是這個概念在編程語言中卻是很普遍的一個概念。下面,根據以下內容,我們總結下在Java中使用泛型。
一份好程序的一個特點就是這個程序是否具有通用性。Java 使用了多態的機制,讓我們可以把方法參數類型設置為基類,而調用方法時卻可以接受該基類和其子類,讓我們編寫代碼更加通用。後來因為Java單繼承受限太多的原因,我們可以把方法的參數設定為接口,直接面向接口編程。程序的通用性又提高了不少。
但現在我們希望方法接受的參數是不具體的類型,也就是說他不是一個具體的類或者接口,如果能這樣做的話,我們的代碼通用性將大大加強,一份代碼可以根據不同的需求,在實際使用的時候使用不同的類。這個聽起來是不是很熟悉,我們在編寫普通方法的時候,就是根據傳入的參數不同,而實現不同的功能。比如一個擁有輸出學生信息的方法,輸入不同的名字,會有不同的信息結果。那麼我們也可以把類型信息作為參數。
具體而言就是,定義的時候,把類型做一個參數,使用的時候才將這個參數用具體的類型來代替。而實現這種需求就是靠泛型的機制。而這個也是泛型引入的原因之一:創造一份更加通用的代碼
。
泛型引入還有一個原因,就是為容器類提供更加安全的機制。比如在早期泛型還沒有引入到Java的時代,容器類如ArrayList是接受任何一個對象的,因為任何對象放進來之後都會向上轉型成Object,而在取出的時候就會向下轉型成期待的類型。比如我們定義了一個ArrayList 他的名字是teachers,但是由於沒有任何檢查機制,我們不僅可以把teacher對象放進去,同樣,我們也可以放入student對象。這在沒有泛型的時候,是被允許的。但我們定義ArrayList的名字為teachers就是希望這個ArrayList裡面任何一個對象都能做老師才能做的事情,然而當我們取出的對象為student的時候,程序就會報錯。因為student無法向下轉型成teacher類型。這種編譯的時候沒有檢查的機制實在是惱火,所以Java在SE5的時候推出了泛型,讓我們在編譯的時候就能確保這種事情不會發生。
但是對於一個合格的Java程序員而言,犯這種錯誤的機會又會有多少呢,我們有多大的可能在已經看到這個名字為teachers的時候還會把student對象放進去?所以,泛型的出現為了防止這種情況的發生只是一個原因,我認為第二個理由並不是Java推出泛型的主要原因,而主要的原因還是為了編寫更加通用的代碼。
泛型是什麼呢?泛型的字面意思就是泛化的類型,也就是說是適用於很多的類型。上面說到了泛型實現了參數化類型的概念,即這個類型是一個參數,我們在編寫代碼的時候用<>
括住類型參數,一般如下:
<T>
這個T就是代表類型參數,在加入了<T>
以後,我們就可以用T來泛指類型做各種事情。下面,我們分別說一說<T>
都可以用在哪些地方。
我們需要在一個類中,使用參數化類型,因為我們需要這個類中的成員變量的類型不是一個具體的類,而是在使用這個類的時候,才指定他。這個很容易理解,我們的ArrayList<User> users
就是在使用的時候才把ArrayList類裡面的成員變量類型指定為User。
在語法上,我們需要使用泛型類的時候,一般這樣去定義它:
public class MyList<T>{
private T t;
}
在類名後面加入<T>
,聲明一個類型參數,這個T也是整個類中都可以用的,如果不僅僅聲明一個類型參數,則可以用<T,K,······,V>
來聲明多個。
我們看一個完整的例子,來了解泛型類的使用。
public class OneList<T> {
protected T t;
public T getT() {
return t;
}
public void setT(T t) {
this.t = t;
}
}
這個就是一個簡單的泛型類的編寫,我們在類上聲明一個類型參數T,然後在這個類中T就是一種類型,我們聲明了T類型的變量t,然後get方法返回T類型的變量,而set方法則傳入一個T類型的變量。我們看一下這個類怎麼用。
public class Test {
public static void main(String[] args) {
OneList<Integer> oneList = new OneList<>();
oneList.setT(1);
System.out.println(oneList.getT());
}
}
在具體測試的時候,我們給這個泛型類指定了一個具體的類型Integer。然後我們set的時候就只能set這個類型的變量。如果我們set一個String類型的變量,在編譯的時候就會報錯。
Error:(10, 22) java: 不兼容的類型: java.lang.String無法轉換為java.lang.Integer
接下來,我們編寫twoList這個類,同樣這個類也用到泛型,而且這個類是繼承OneList的
public class TwoList<T,W> extends OneList<Integer> {
private W w;
public TwoList(Integer a, W w) {
super(a);
this.w = w;
}
public W getW() {
return w;
}
public void setW(W w) {
this.w = w;
}
public void print(){
System.out.println(getW() + " " + super.getT());
}
}
我們看一下這個類,我們繼承父類的時候具體指定了這個父類的類型參數為Integer,是因為我們需要在顯式調用父類方法的時候,需要知道父類的類型參數,比如這個方法 super.getT()
當然也可以什麼都不加,不過默認是Object。甚至我們可以用子類的泛型。比如下面這麼寫:
public class TwoList<T,W> extends OneList<T>
這是因為,TwoList已經聲明了兩個類型參數T,W,所以OneList就可以直接用這個類型參數。OneList的T就是TwoList<T,W>
中的T,下面類中的代碼,凡是用到T的部分,或者返回類型為T的部分都是TwoList<T,W>
中的T。
在接下來,我們說一些在泛型類類型信息覆蓋的問題。
我們編寫一個ThreeList類,在裡面有一個內部類同樣使用了泛型。
public class ThreeList<T> {
public T t;
public T getT() {
return t;
}
public void setT(T t) {
this.t = t;
}
public class Node<T>{
public T t;
public T getT() {
return ThreeList.this.getT();
}
public void setT(T t) {
this.t = t;
}
}
}
在內部類Node的getT()
的方法中,這樣寫是報錯的,錯誤的原因,如下圖:
這是因為我們在內部類Node中又聲明了一個類型參數為T,這個T與外部的T雖然是一樣的名字,但是確實不同的類型參數,所以內部類裡面用到的T都是Node聲明的T而不是外部類的T。
泛型的出現,可以讓我們編寫更靈活的代碼,讓類的適用性更強,但有時,我們不需要在對類使用泛型,因為我們可能只把泛型用在了類中的一個方法上面。這時,我們可以對使用泛型方法。對一個方法應用泛型和對一個類應用泛型兩者並沒有什麼關系。泛型方法的一般寫法如下:
public staitc/final <T> T getEverything(T[] t){
//doSomething
}
首先,泛型的聲明要放在public static final
等後面,但要在返回值前面。這樣聲明之後,方法中就可以使用類型參數了。比如上面例子的方法,返回值,參數信息都是類型參數。
我們看下完整的例子:
public class OneList<T> {
protected T t;
public T getT() {
return t;
}
public void setT(T t) {
this.t = t;
}
public <S> void getEverything(S[] ses) {
for (S s: ses) {
System.out.println(s);
}
}
}
我們看一個完整的例子:
public class MyList {
public static <T> void getEvetything(T[] numbers){
for (T t: numbers) {
System.out.print(t + " ");
}
}
}
我們看一下這個方法怎麼使用。
public class Test {
public static void main(String[] args) {
MyList.getEvetything(new Integer[]{1,2,3,4,5,6});
}
}
泛型方法是不需要像泛型類一樣,需要明確指定類型參數是什麼。編譯器會自動進行類型推斷。
此外,還有一種情況,是必須使用泛型方法的,那就是static修飾的方法,如果要用到泛型類中的泛型,那麼必須使用泛型方法。因為類還沒有被實例化。但注意這個時候泛型方法中聲明的類型參數和泛型類聲明的類型參數完全是兩個東西,雖然可能他們的名字都是T,但確實不同的所指。
我們除了對類,和方法使用泛型以外,我們還可以對接口使用泛型,即所謂的泛型接口。常見的泛型接口的用法,是用在生成器當中。接口+泛型,讓我們可以設計更加通用的代碼。泛型接口的寫法與泛型類時一樣的,所以就不加多說了。我們直接看例子。
public interface Generator<T> {
T next();
}
這個泛型接口,用來返回下一個值,但是返回的類型,需要實現了這個方法的類來指定。接下來,我們編寫一個NumberGenerator來實現這個接口。
public class NumberGenerator<T> implements Generator<T>{
private T[] numbers;
private int i = 0;
public NumberGenerator(T [] numbers){
this.numbers = numbers;
}
@Override
public T next() {
return numbers[i++];
}
}
這個用法和上面泛型類繼承是一致的,我們把接口的類型參數指定成實現了這個接口類的類型信息,這樣我們可以在使用這個泛型類的時候,在指定類型參數是什麼。
我們看看是如何調用使用這個泛型類的
public static void main(String[] args) {
NumberGenerator<Integer> numberGenerator = new NumberGenerator<>(new Integer[]{10,20,30,40,50,60});
for(int i = 0 ; i < 6;i++) {
System.out.println(numberGenerator.next());
}
}
這樣,我們的next方法就可以根據類型參數的不同,返回不同類型的值,不需要去更改類和接口。我們的代碼就更加的通用。
至此,我們的泛型基本用法就說完了,我們接下來說一下Java泛型的獨特的地方以及其他更復雜的用法。
Java 泛型機制是比較特殊的一種泛型機制的實現。他是采用擦除的形式來實現泛型。
所謂擦除的機制實現泛型指的是Java的泛型是在編譯器這個層次實現的。在生成的字節碼中是不包含泛型中的類型信息的。換句話說,我們編寫了ArrayList<Integer> list
,和ArrayList list
這兩個類,編譯之後都會生成ArrayList.class
這一個類文件。我們單單從類文件上是看不出這兩個類的不同的,因為他們就是一個類文件。這就是擦除,擦除了我們使用泛型的證據。這樣也導致了一些比較困難的事情,比如我們在運行期是沒辦法得到泛型的信息的。我們沒法得知ArrayList<Integer> list
中,list是Integer約束的。
Java 之所以采用泛型擦除來實現泛型其實也是逼不得已的一件事情。我們上文說了Java是在SE5的時候才引入的泛型,這也導致了在JavaSE5以前有很多已經在生產環境運行了很久的代碼了。比如我們有如下的早期代碼:
ArrayList things = new ArrayList();
things.add(Integer.valueOf(1));
things.add("1");
Java 要保證容器類引入泛型之後,這樣的代碼還能夠運行。一般有兩種思路:
- 需要泛型化的類型,以前有的保持不變,然後平行的加一套泛型化版本的新類型。
- 直接把已有的類型泛型化,讓所有需要泛型化的已有類型都原地泛型化,不添加任何平行於已有類型的泛型版本。
第二的思路的意思是將以前的List這種需要泛型化的類,全部變成List這種形式。
而Java設計有一個規則就是向後兼容,指的是早期的版本的代碼是可以在後期的Java環境中完美運行的。而高版本的Java代碼放入低版本的環境一般會失敗。所以Java必須保證低版本的沒有泛型的代碼也可以在高版本用。
如果這些老版本的代碼在新版本的Java中,為了使用Java的新功能而必須做大量源碼層修改,那麼新功能則會大受影響。
所以現在Java就只剩下一條路了,原地將所有需要泛型的類全部轉換成泛型。我們的ArrayList
就變成了ArrayList<E>
這樣的形式,為了使以前使用ArrayList
的代碼在新版本中還能繼續使用,所以采用了唯一的解決辦法 擦除
。
我們先通過一個例子來驗證我們上面所說的內容。還是我們上面的OneList這個泛型類。
OneList<Integer> IList = new OneList<>();
OneList<String> SList = new OneList<>();
System.out.println(IList.getClass() == (SList.getClass()));
結果顯而易見是true。因為擦除的存在,使得這兩個泛型類都被擦除為原始類型(raw type)
原始類型(raw type)
指的是擦除了泛型信息,最後在字節碼中的類型變量的真正類型。類型變量被擦除的原則是沒有使用限定類型的則用Object替換,使用限定類型的則用限定類型替換。
我們OneList的原始類型的代碼如下:OneList
public class OneList{
protected Object t;
public Object getT() {
return t;
}
public void setT(Object t) {
this.t = t;
}
}
這樣的原始類型是因為我們沒有對泛型T進行限定,所以默認用的Object。這樣默認用Object,是有一些弊端的。
比如,我們在A類中有一個方法f(), 我們在編寫泛型的時候oneList的時候,是沒法在泛型類裡面調用f()方法,雖然你知道的這個oneList的泛型要綁定A類。系統只會調用Object擁有的方法。如果要滿足這種需求就要引入邊界這個概念,對泛型T進行限定。
這裡我們用extends 關鍵字,如果我們要對oneList泛型類添加一個邊界,我們可以用如下的語法:
public class oneList<T extends A>{
}
這樣,編譯器在進行泛型擦除的時候就會擦除到邊界A,而不會擦除到Object,這樣我們就可以在泛型類中調用A類的方法。直接看例子:
public class A {
public void f(){
System.out.println("A is a");
}
}
public class OneList<T extends A> {
protected T t;
public OneList(T t){
this.t = t;
}
public void printf() {
t.f();
}
public void setT(T t) {
this.t = t;
}
public static<T> void getEverything(T[] ses) {
for (T t: ses) {
System.out.println(t);
}
}
}
這樣,我們就可以直接在泛型類中調用A類的方法。同時,我們在使用泛型類的時候,綁定的泛型也必須是A類或者A類的子類。
這裡的A類就是泛型的邊界。如果泛型有很多邊界,則擦除的時候則會擦除到第一個邊界,比如<T extends A & B & C>
這時只會擦除到A類。這種通過extends
限定就稱為泛型的上限。他的最明顯的好處就是按照自己的邊界��型調用方法。
上面說的內容都是在聲明泛型的時候,下面的內容則是針對使用泛型的時候。我們根據上面的代碼看下面的一個例子。
public class Test {
public static void main(String[] args) {
List<A> OList = new ArrayList<B>()
}
}
這樣的寫法,編輯器是會報錯的。錯誤的原因是泛型並不具有繼承。即使B是A的子類,但是在泛型上List<A>
與List<B>
並不等價。
為了實現這種向上轉型,我們需要一個新知識:通配符 ?
比如上面的例子,我們可以用通配符來改寫,變成了如下的代碼:
public class Test {
public static void main(String[] args) {
List<?> OList = new ArrayList<B>()
}
}
這裡的?如果不加限制,默認指的任何類型,當然我們也可以給這個通配符添加限制。同樣使用到extends
代碼改寫後如下:
public class Test {
public static void main(String[] args) {
List<? extends A> OList = new ArrayList<B>()
}
}
這裡extends 限定的意義是叫泛型或者通配符的上界。表示類型參數可以是指定類型或者指定類型的子類。這樣List<B>
就是List<?>的子類,就可以實現向上轉型。這樣是有弊端的。如果我們調用Olist的add方法,我們會發現我們無法往OList中添加任何一個值,即使這個對象是new B()得到,或者是new A()。這是因為編譯器知道OList容器接受一個類型A的子類型,但是我們不知道這個子類型究竟是什麼,所以為了保證類型安全,我們是不能往這個類型裡面添加任何元素的,即使是A。從一個角度來看,我們知道這個容器裡面放入的是A的子類型,也就是我們通過這個容器取出的數據都是可以調用A的方法的,得到的數據都是一個A類型的實例。下面這種寫法是成功的。
public class Test {
public static void main(String[] args) {
List<? extends B> OList;
ArrayList<B> BList = new ArrayList<>();
BList.add(new B());
OList = BList;
OList.get(0).f();
}
}
使用通配符的容器則代表了一個確切的類型參數的容器。,我們可以把這個類型參數的容器用到定義方法上面。比如下面的例子。
public class Test<E>{
public static void main(String[] args) {
List<? extends B> OList;
ArrayList<B> BList = new ArrayList<>();
BList.add(new B());
OList = BList;
new Test<A>().getFirstElement(OList).f();
}
public E getFirstElement(List<? extends E> list) {
return list.get(0);
}
}
在getFirstElement方法中,我們接受一個E類型或者其子類的容器,我們知道這個容器中取出的元素一定是E類型或者其子類。當我們給Test綁定泛型參數的時候,我們就可以根據泛型參數調用其相應的方法。
上面的代碼,如果我們用泛型方法也是可以做到的。其代碼如下:
public class Test<E>{
public static void main(String[] args) {
List<? extends B> OList;
ArrayList<B> BList = new ArrayList<>();
BList.add(new B());
OList = BList;
new Test<A>().getFirstElement(OList).f();
}
public <T extends E > E getFirstElement(List<T> list) {
return list.get(0);
}
}
這兩者都是可以達到同樣的效果。
我們回到之前泛型通配符的問題上來,因為使用了通配符。我們無法向其中加入任何元素,只能讀取元素。如果我們一定要加入元素,我們要怎麼辦?這個時候,需要使用通配符的 下界。? super T
表示類型參數是T或者T的父類。這樣,我們就可以向容器中加入元素。比如下面的代碼:
public class Test {
public class Test{
public static void main(String[] args) {
List<? super A> OList = new ArrayList<>();
OList.add(new B());
}
? super A
表示類型是A或者A的子類。根據向上轉型一定安全的原則,我們的類型參數為A的容器中加入其子類B一定是成功的,因為B可以向上轉型成A,但是這個面臨和上面一樣的問題,我們無法加入任何A的父類,比如Object對象。
然而在引用的時候,我們可以把OList引用到一個Object類型的ArrayList中。
List<? super A> OList = new ArrayList<Object>();
就和下面代碼的引用會成功一樣。
List<? extends A> Olist = new ArrayList<A>();
第一個通配符下界表明Olist是具有任何是A父類的列表。
第二個通配符上界表明OList是具有任何是A子類的列表。
自然,我們把一個符合要求的列表引用給他是成功。但是他們在讀取和寫入數據是不同的。擁有下界的列表表明我們可以把A類或者子類的數據寫入到這個列表中,但是我們無法調用任何A類的方法,因為編譯器只知道返回的是A的父類或者A,但是具體是哪一個,他不知道,自然也無法調用除Object以外的任何方法。擁有上界的列表表明我們可以從列表中讀取數據,因為數據一定是A類或者A的子類。但是由於無法確定具體是哪一個類型,我們自然也無法向其加入任何類型。
最後,總結一下,如果我們要讀取數據,則用擁有上界的通配符,如果我們要寫入數據,則用擁有下界的通配符。既讀又寫則不要用通配符。
由於功力有限,而泛型知識博大精深,所以很多關於泛型更深的內容,並沒有寫出來。以後隨著研究的深入,有機會再寫深入的泛型知識。