面試題系列:用了這麼多年的 Java 泛型,我竟然只知道它的皮毛
面試題:說說你對泛型的理解?
面試考察點
考察目的:了解求職者對於Java基礎知識的掌握程度。
考察範圍:工作1-3年的Java程式設計師。
背景知識
Java中的泛型,是JDK5引入的一個新特性。
它主要提供的是編譯時期類型的安全檢測機制。這個機制允許程式在編譯時檢測到非法的類型,從而進行錯誤提示。
這樣做的好處,一方面是告訴開發者當前方法接收或返回的參數類型,另一方面是避免程式運行時的類型轉換錯誤。
泛型的設計推演
舉一個比較簡單的例子,首先我們來看一下ArrayList
這個集合,部分程式碼定義如下。
public class ArrayList{
transient Object[] elementData; // non-private to simplify nested class access
}
在ArrayList中,存儲元素所使用的結構是一個Object[]
對象數組。意味著可以存儲任何類型的數據。
當我們使用這個ArrayList來做下面這個操作時。
public class ArrayExample {
public static void main(String[] args) {
ArrayList al=new ArrayList();
al.add("Hello World");
al.add(1001);
String str=(String)al.get(1);
System.out.println(str);
}
}
運行程式後,會得到如下的執行結果
Exception in thread "main" java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String
at org.example.cl06.ArrayExample.main(ArrayExample.java:11)
這種類型轉換錯誤,相信大家在開發中有遇到過,總的來說,在沒有泛型的情況下,會有兩個比較嚴重的問題
- 需要對類型進行強制轉換
- 使用不方便,容易出錯
怎麼解決上面這個問題呢?要解決這個問題,就得思考這個問題背後的需求是什麼?
我簡單總結兩點:
- 要能支援不同類型的數據存儲
- 還需要保證存儲數據類型的統一性
基於這兩個點不難發現,對於一個數據容器中要存儲什麼類型的數據,其實是由開發者自己決定的。因此,為了解決這個問題,在JDK5中就引入了泛型的機制。
其定義形式是:ArrayList<E>
,它相當於給ArrayList
提供了一個類型輸入的模板E
,E
可以是任意類型的對象,它的定義方式如下。
public class ArrayList<E>{
transient E[] elementData; // non-private to simplify nested class access
}
在ArrayList這個類的定義中,使用<>
語法,並傳入一個用來表示任意類型的對象E
,這個E
可以隨便定義,你可以定義成A
、B
、C
都可以。
接著,把用來存儲元素的數組elementData
的類型,設置為E
類型。
有了這個配置之後,ArrayList
這個容器中,你想存儲什麼類型的數據,是由使用者自己決定,比如我希望ArrayList
只存儲String
類型,那麼它可以這麼實現
public class ArrayExample {
public static void main(String[] args) {
ArrayList<String> al=new ArrayList();
al.add("Hello World");
al.add(1001);
String str=(String)al.get(1);
System.out.println(str);
}
}
在定義ArrayList
時,傳入一個String
類型,這樣寫意味著後續往ArrayList
這個實例對象al
中添加元素,必須是String
類型,否則會提示如下的語法錯誤。
同理,如果需要保存其他類型的數據,可以這麼寫:
- ArrayList
- ArrayList
總結:所謂泛型定義,其實本質上就是一種類型模板,在實際開發中,我們把一個容器或者一個對象中需要保存的屬性的類型,通過模板定義的方式,給到調用者來決定,從而保證了類型的安全性。
泛型的定義
泛型定義可以從兩個維度來說明:
- 泛型類
- 泛型方法
泛型類
泛型類指的是在類名後面添加一個或多個類型參數,一個泛型參數,也被稱為一個類型變數,是用於指定一個泛型類型名稱的標識符。因為他們接受一個或多個參數,這些類被稱為參數化的類或參數化的類型。
類型變數的表示標記,常用的是:E(element)
,T(type)
、K(key)
,V(value)
,N(number)
等,這只是一個表示符號,可以是任何字元,沒有強制要求。
下面的程式碼是關於泛型類
的定義。
該類接收一個T
標記符的類型參數,該類中有一個成員變數,使用T
類型。
public class Response <T>{
private T data;
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
}
使用方式如下:
public static void main(String[] args) {
Response<String> res=new Response<>();
res.setData("Hello World");
}
泛型方法
泛型方法是指指定方法級別的類型參數,這個方法在調用時可以接收不同的參數類型,根據傳遞給泛型方法的參數類型,編譯器適當地處理每一個方法調用。
下面的程式碼表示泛型方法的定義,用到了JDK提供的反射機制,來生成動態代理類。
public interface IHelloWorld {
String say();
}
定義getProxy
方法,它用來生成動態代理對象,但是傳遞的參數類型是T
,也就是說,這個方法可以完成任意介面的動態代理實例的構建。
在這裡,我們針對IHelloWorld
這個介面,構建了動態代理實例,程式碼如下。
public class ArrayExample implements InvocationHandler {
public <T> T getProxy(Class<T> clazz){
// clazz 不是介面不能使用JDK動態代理
return (T) Proxy.newProxyInstance(clazz.getClassLoader(), new Class<?>[]{ clazz }, ArrayExample.this);
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
return "Hello World";
}
public static void main(String[] args) {
IHelloWorld hw=new ArrayExample().getProxy(IHelloWorld.class);
System.out.println(hw.say());
}
}
運行結果:
Hello World
關於泛型方法的定義規則,簡單總結如下:
- 所有泛型方法的定義,都有一個用
<>
表示的類型參數聲明,這個類型參數聲明部分在方法返回類型之前。 - 每一個類型參數聲明部分包含一個或多個類型參數,參數間用逗號隔開。一個泛型參數,也被稱為一個類型變數,是用於指定一個泛型類型名稱的標識符。
- 類型參數能被用來聲明返回值類型,並且能作為泛型方法得到的實際參數類型的佔位符
- 泛型方法體的聲明和其他方法一樣。注意類型參數只能代表引用型類型,不能是原始類型(像 int、double、char 等)。##
多類型變數定義
上在我們只定義了一個泛型變數T,那如果我們需要傳進去多個泛型要怎麼辦呢?
我們可以這麼寫:
public class Response <T,K,V>{
}
每一個參數聲明符號代表一種類型。
注意,在多變數類型定義中,泛型變數最好是定義成能夠簡單理解具有含義的字元,否則類型太多,調用者比較容易搞混。
有界類型參數
在有些場景中,我們希望傳遞的參數類型屬於某種類型範圍,比如,一個操作數字的方法可能只希望接受Number或者Number子類的實例,怎麼實現呢?
泛型通配符上邊界
上邊界,代表類型變數的範圍有限,只能傳入某種類型,或者它的子類。
我們可以在泛型參數上,增加一個extends
關鍵字,表示該泛型參數類型,必須是派生自某個實現類,示例程式碼如下。
public class TypeExample<T extends Number> {
private T t;
public T getT() {
return t;
}
public void setT(T t) {
this.t = t;
}
public static void main(String[] args) {
TypeExample<String> t=new TypeExample<>();
}
}
上述程式碼,聲明了一個泛型參數T
,該泛型參數必須是繼承Number
這個類,表示後續實例化TypeExample
時,傳入的泛型類型應該是Number
的子類。
所以,有了這個規則後,上面這個測試程式碼,會提示java: 類型參數java.lang.String不在類型變數T的範圍內
錯誤。
泛型通配符下邊界
下邊界,代表類型變數的範圍有限,只能傳入某種類型,或者它的父類。
我們可以在泛型參數上,增加一個super
關鍵字,可以設定泛型通配符的上邊界。實例程式碼如下。
public class TypeExample<T> {
private T t;
public T getT() {
return t;
}
public void setT(T t) {
this.t = t;
}
public static void say(TypeExample<? super Number> te){
System.out.println("say: "+te.getT());
}
public static void main(String[] args) {
TypeExample<Number> te=new TypeExample<>();
TypeExample<Integer> te2=new TypeExample<>();
say(te);
say(te2);
}
}
在say
方法上聲明TypeExample<? super Number> te
,表示傳入的TypeExample
的泛型類型,必須是Number
以及Number
的父類類型。
在上述程式碼中,運行時會得到如下錯誤:
java: 不兼容的類型: org.example.cl06.TypeExample<java.lang.Integer>無法轉換為org.example.cl06.TypeExample<? super java.lang.Number>
如下圖所示,表示Number
這個類的類關係圖,通過super
關鍵字限定後,只能傳遞Number
以及父類Serializable
。
類型通配符?
類型通配符一般是使用 ? 代替具體的類型參數。例如 List<?> 在邏輯上是 List
來看下面這段程式碼的定義,在say
方法中,接受一個TypeExample
類型的參數,並且泛型類型是<?>
,代表接收任何類型的泛型類型參數。
public class TypeExample<T> {
private T t;
public T getT() {
return t;
}
public void setT(T t) {
this.t = t;
}
public static void say(TypeExample<?> te){
System.out.println("say: "+te.getT());
}
public static void main(String[] args) {
TypeExample<Integer> te1=new TypeExample<>();
te1.setT(1111);
TypeExample<String> te2=new TypeExample<>();
te2.setT("Hello World");
say(te1);
say(te2);
}
}
運行結果如下
say: 1111
say: Hello World
同樣,類型通配符的參數,也可以通過extends
來做限定,比如:
public class TypeExample<T> {
private T t;
public T getT() {
return t;
}
public void setT(T t) {
this.t = t;
}
public static void say(TypeExample<? extends Number> te){ //修改,增加extends
System.out.println("say: "+te.getT());
}
public static void main(String[] args) {
TypeExample<Integer> te1=new TypeExample<>();
te1.setT(1111);
TypeExample<String> te2=new TypeExample<>();
te2.setT("Hello World");
say(te1);
say(te2);
}
}
由於say
方法中的參數TypeExample
,在泛型類型定義中使用了<? extends Number>
,所以後續在傳遞參數時,泛型類型必須是Number
的子類型。
因此上述程式碼運行時,會提示如下錯誤:
java: 不兼容的類型: org.example.cl06.TypeExample<java.lang.String>無法轉換為org.example.cl06.TypeExample<? extends java.lang.Number>
注意: 構建泛型實例時,如果省略了泛型類型,則默認是通配符類型,意味著可以接受任意類型的參數。
泛型的繼承
泛型類型參數的定義,是允許被繼承的,比如下面這種寫法。
表示子類SayResponse
和父類Response
使用同一種泛型類型。
public class SayResponse<T> extends Response<T>{
private T ox;
}
JVM是如何實現泛型的?
在JVM中,採用了類型擦除Type erasure generics)
的方式來實現泛型,簡單來說,就是泛型只存在.java源碼文件中,一旦編譯後就會把泛型擦除.
我們來看ArrayExample這個類,編譯之後的位元組指令。
public class ArrayExample implements InvocationHandler {
public <T> T getProxy(Class<T> clazz){
// clazz 不是介面不能使用JDK動態代理
return (T) Proxy.newProxyInstance(clazz.getClassLoader(), new Class<?>[]{ clazz }, ArrayExample.this);
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
return "Hello World";
}
public static void main(String[] args) {
IHelloWorld hw=new ArrayExample().getProxy(IHelloWorld.class);
System.out.println(hw.say());
}
}
通過javap -v ArrayExample.class
查看位元組指令如下。
public <T extends java.lang.Object> T getProxy(java.lang.Class<T>);
descriptor: (Ljava/lang/Class;)Ljava/lang/Object;
flags: ACC_PUBLIC
Code:
stack=5, locals=2, args_size=2
0: aload_1
1: invokevirtual #2 // Method java/lang/Class.getClassLoader:()Ljava/lang/ClassLoader;
可以看到,getProxy
在編譯之後,泛型T
已經被擦除了,參數類型替換成了java.lang.Object.
並不是所有類型都會轉換為java.lang.Object,比如如果是
,則參數類型是java.lang.String。
同時,為了保證IHelloWorld hw=new ArrayExample().getProxy(IHelloWorld.class);
這段程式碼的準確性,編譯器還會在這裡插入一個類型轉換的機制。
下面這個程式碼是ArrayExample.class
反編譯之後的呈現。
IHelloWorld hw = (IHelloWorld)(new ArrayExample()).getProxy(IHelloWorld.class);
System.out.println(hw.say());
泛型類型擦除實現帶來的缺陷
擦除方式實現泛型,還是會存在一些缺陷的,簡單舉幾個案例說明。
不支援基本類型
由於泛型類型擦除後,變成了java.lang.Object類型,這種方式對於基本類型如int/long/float等八種基本類型來說,就比較麻煩,因為Java無法實現基本類型到Object類型的強制轉換。
ArrayList<int> list=new ArrayList<int>();
如果這麼寫,會得到如下錯誤
java: 意外的類型
需要: 引用
找到: int
所以,在泛型定義中,只能使用引用類型。
但是作為引用類型,如果保存基本類型的數據時,又會涉及到裝箱和拆箱的過程。比如
List<Integer> list = new ArrayList<Integer>();
list.add(10); // 1
int num = list.get(0); // 2
在上述程式碼中,聲明了一個List<Integer>
泛型類型的集合,
在標記1
的位置,添加了一個int
類型的數字10,這個過程中,會涉及到裝箱操作,也就是把基本類型int
轉換為Integer
.
在標記2
的位置,編譯器首先要把Object轉換為Integer類型,接著再進行拆箱,把Integer
轉換為int
。因此上述程式碼等同於
List list = new ArrayList();
list.add(Integer.valueOf(10));
int num = ((Integer) list.get(0)).intValue();
增加了一些執行步驟,對於執行效率來說還是會有一些影響。
運行期間無法獲取泛型實際類型
由於編譯之後,泛型就被擦除,所以在程式碼運行期間,Java 虛擬機無法獲取泛型的實際類型。
下面這段程式碼,從源碼上兩個 List 看起來是不同類型的集合,但是經過泛型擦除之後,集合都變為 ArrayList
。所以 if
語句中程式碼將會被執行。
public static void main(String[] args) {
ArrayList<Integer> li = new ArrayList<>();
ArrayList<Float> lf = new ArrayList<>();
if (li.getClass() == lf.getClass()) { // 泛型擦除,兩個 List 類型是一樣的
System.out.println("類型相同");
}
}
運行結果:
類型相同
這就使得,我們在做方法重載時,無法根據泛型類型來定義重寫方法。
也就是說下面這種方式無法實現重寫。
public void say(List<Integer> a){}
public void say(List<String> b){}
另外還會給我們在實際使用中帶來一些限制,比如說我們沒辦法直接實現以下程式碼
public <T> void say(T a){
if(a instanceof T){
}
T t=new T();
}
上述程式碼會存在編譯錯誤。
既然通過擦除的方式實現泛型有這麼多缺陷,那為什麼要這麼設計呢?
要回答這個問題,需要知道泛型的歷史,Java的泛型是在Jdk 1.5 引入的,在此之前Jdk中的容器類等都是用Object來保證框架的靈活性,然後在讀取時強轉。但是這樣做有個很大的問題,那就是類型不安全,編譯器不能幫我們提前發現類型轉換錯誤,會將這個風險帶到運行時。 引入泛型,也就是為解決類型不安全的問題,但是由於當時java已經被廣泛使用,保證版本的向前兼容是必須的,所以為了兼容老版本jdk,泛型的設計者選擇了基於擦除的實現。
問題解答
面試題:說說你對泛型的理解?
回答: 泛型是JDK5提供的一個新特性。它主要提供的是編譯時期類型的安全檢測機制。這個機制允許程式在編譯時檢測到非法的類型,從而進行錯誤提示。
問題總結
深入理解Java泛型是程式設計師最基礎的必備技能,雖然面試很卷,但是實力仍然很重要。
關注[跟著Mic學架構]公眾號,獲取更多精品原創