面試題系列:用了這麼多年的 Java 泛型,我竟然只知道它的皮毛

跟著Mic學架構發表於2021-11-05

面試題:說說你對泛型的理解?

面試考察點

考察目的:瞭解求職者對於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)

這種型別轉換錯誤,相信大家在開發中有遇到過,總的來說,在沒有泛型的情況下,會有兩個比較嚴重的問題

  1. 需要對型別進行強制轉換
  2. 使用不方便,容易出錯

怎麼解決上面這個問題呢?要解決這個問題,就得思考這個問題背後的需求是什麼?

我簡單總結兩點:

  1. 要能支援不同型別的資料儲存
  2. 還需要保證儲存資料型別的統一性

基於這兩個點不難發現,對於一個資料容器中要儲存什麼型別的資料,其實是由開發者自己決定的。因此,為了解決這個問題,在JDK5中就引入了泛型的機制。

其定義形式是:ArrayList<E>,它相當於給ArrayList提供了一個型別輸入的模板EE可以是任意型別的物件,它的定義方式如下。

public class ArrayList<E>{
   transient E[] elementData; // non-private to simplify nested class access
}

在ArrayList這個類的定義中,使用<>語法,並傳入一個用來表示任意型別的物件E,這個E可以隨便定義,你可以定義成ABC都可以。

接著,把用來儲存元素的陣列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型別,否則會提示如下的語法錯誤。

image-20211104235945672

同理,如果需要儲存其他型別的資料,可以這麼寫:

  1. ArrayList
  2. ArrayList

總結:所謂泛型定義,其實本質上就是一種型別模板,在實際開發中,我們把一個容器或者一個物件中需要儲存的屬性的型別,通過模板定義的方式,給到呼叫者來決定,從而保證了型別的安全性。

泛型的定義

泛型定義可以從兩個維度來說明:

  1. 泛型類
  2. 泛型方法

泛型類

泛型類指的是在類名後面新增一個或多個型別引數,一個泛型引數,也被稱為一個型別變數,是用於指定一個泛型型別名稱的識別符號。因為他們接受一個或多個引數,這些類被稱為引數化的類或引數化的型別。

型別變數的表示標記,常用的是: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

關於泛型方法的定義規則,簡單總結如下:

  1. 所有泛型方法的定義,都有一個用<>表示的型別引數宣告,這個型別引數宣告部分在方法返回型別之前。
  2. 每一個型別引數宣告部分包含一個或多個型別引數,引數間用逗號隔開。一個泛型引數,也被稱為一個型別變數,是用於指定一個泛型型別名稱的識別符號。
  3. 型別引數能被用來宣告返回值型別,並且能作為泛型方法得到的實際引數型別的佔位符
  4. 泛型方法體的宣告和其他方法一樣。注意型別引數只能代表引用型型別,不能是原始型別(像 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

image-20211105101105344

型別萬用字元?

型別萬用字元一般是使用 ? 代替具體的型別引數。例如 List<?> 在邏輯上是 List,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學架構]公眾號,獲取更多精品原創

相關文章