面試題:說說你對泛型的理解?
面試考察點
考察目的:瞭解求職者對於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學架構]公眾號,獲取更多精品原創