重學Java之泛型的基本使用

北冥有隻魚發表於2023-04-26

前言

本身是打算接著寫JMM、JCStress,然後這兩個是在公司閒暇的時候隨手寫的,沒有推到Github上,但寫點什麼可以讓我獲得寧靜的感覺,所性就從待辦中拎了一篇文章,也就是這篇泛型。這篇文章來自於我朋友提出的一個問題,比如我在一個類裡面宣告瞭兩個方法,兩個方法只有返回型別是int,一個是Integer,像下面這樣,能否透過編譯:

public class DataTypeTest {    
   public int sayHello(){
        return 0;
    }
    public Integer sayHello(){
        return 1;
    }
}

我當時回答的時候是將Integer和int當做不同的型別來思考的,我回答的是可以,但是我的朋友說,這是不行的。後面我想到了泛型擦除,但其實這跟泛型擦除倒是沒關係,問題出在自動裝箱和拆箱上,Java的編譯器將原始型別轉為包裝類,包裝類轉為基本型別。但關於泛型,我用起來的時候,發現有些概念混亂,但是不影響開發速度,再加上平時覺得對我用處不大,所性就一直放在那裡,不去思考。最近也在一些工具類庫,用到了泛型,發現自己對泛型的理解還是有所欠缺,所以今天就重新學習泛型,順帶梳理一下自己對泛型的理解,後面發現都揉在一篇文章裡面,篇幅還是有些過大,這裡就分拆兩篇。

  • 泛型的基本的使用
  • 泛型擦除、實現、向前相容、與其他語言的對比。

泛型的意義

我在學習Java的時候,看的是Oracle出的《Java Tutorials》,地址如下:

在開篇教程如是說:

In any nontrivial software project, bugs are simply a fact of life. Careful planning, programming, and testing can help reduce their pervasiveness, but somehow, somewhere, they'll always find a way to creep into your code. This becomes especially apparent as new features are introduced and your code base grows in size and complexity.

在任何不平凡的軟體工程,bug都是不可避免的事實。仔細的規劃、變成、測試可以幫助減少它們的普遍性,但不知何時,不知何地,它們總會找到一種方式滲入你的程式碼。隨著新功能的引入和程式碼量的增長,這一點變得尤為明顯。

Fortunately, some bugs are easier to detect than others. Compile-time bugs, for example, can be detected early on; you can use the compiler's error messages to figure out what the problem is and fix it, right then and there. Runtime bugs, however, can be much more problematic; they don't always surface immediately, and when they do, it may be at a point in the program that is far removed from the actual cause of the problem.

幸運的是,一些bug更容易發現相對其他型別的bug,例如,編譯時的bug可以在早期發現; 你可以使用編譯器給出的錯誤資訊來找出問題所在,然後在當時就解決它。然而執行時的bug就要麻煩的多,它們並不總是立即復現出來,而且當它們復現出來的時候,可能是在程式的某個點上,與問題的實際原因相去甚遠。

Generics add stability to your code by making more of your bugs detectable at compile time.

泛型可以增加你的程式碼的穩定性,讓更多錯誤可以在編譯時被發現。

總結一下,泛型可以增強我們程式碼的穩定性,讓更多錯誤可以在編譯時就被發現。我一開始用的是JDK 8,在使用這個版本的時候,泛型已經進入Java十年了,泛型對於我來說是很理所當然的,就像魚習慣了水一樣。那Java為什麼要引入泛型呢?

In a nutshell, generics enable types (classes and interfaces) to be parameters when defining classes, interfaces and methods. Much like the more familiar formal parameters used in method declarations, type parameters provide a way for you to re-use the same code with different inputs. The difference is that the inputs to formal parameters are values, while the inputs to type parameters are types. Code that uses generics has many benefits over non-generic code:

簡而言之,泛型可以使得在定義類、介面和方法時可以將型別作為引數。就像在方法中宣告形式引數一樣,型別引數提供了一種方式,讓你可以在不同的輸入使用相同的程式碼。不同之處在於,形式引數輸入的是值,而型別引數的輸入是型別。使用泛型的程式碼相對於非泛型的程式碼有很多優點:

  • Stronger type checks at compile time. A Java compiler applies strong type checking to generic code and issues errors if the code violates type safety. Fixing compile-time errors is easier than fixing runtime errors, which can be difficult to find.

    編譯時進行更強的型別檢查,編譯器會對使用了泛型程式碼進行強型別檢查,如果型別不安全,就會報錯。編譯時的錯誤會比執行時的錯誤,容易修復和查詢。

  • Elimination of casts. The following code snippet without generics requires casting:

    消除轉換,下面程式碼片段是沒有泛型所需的轉換

     List list = new ArrayList();
     list.add("hello world");
     String s = (String) list.get(0);
  • When re-written to use generics, the code does not require casting:

    當我們用泛型重寫, 程式碼就不需要型別轉換

    List<String> list = new ArrayList();
    list.add("hello world");
    String s =  list.get(0);
  • Enabling programmers to implement generic algorithms.

    使得程式設計師能夠通用(泛型)演算法。

    By using generics, programmers can implement generic algorithms that work on collections of different types, can be customized, and are type

    safe and easier to read.

    用泛型,程式設計師能夠可以在不同型別的集合上工作,可以被被定製,並且型別是安全的,更容易閱讀。

簡單總結一下,引入泛型的好處,將型別當做引數,可以讓開發者可以在不同的輸入使用相同的程式碼,我的理解是,提升程式碼的可複用性,在編譯時執行更強的型別檢查,消除型別轉換,用泛型實現通用的演算法。那該怎麼使用呢?

泛型如何使用

Hello World

上面我們提到泛型是型別引數,那我們如何傳遞給一個類,型別呢,類似於方法,我們首先要宣告形式引數,它跟在類名後面,放在<>裡面,在裡面我們可以宣告接收幾個型別引數,如下所示:

class name<T1, T2, ..., Tn> {}

下面是一個簡單的泛型使用示例:

public class Car<T>{
    private T data;
    
    public T getData() {
        return data;
    }
    public void setData(T data) {
        this.data = data;
    }
    public static void main(String[] args) {
        Car<Integer> car = new Car<>();
        car.setData(1);
        Integer result = car.getData();
    }
}

在沒有泛型之前,我們的程式碼如果想實現這樣的效果就只能用Object,在使用的時候進行強制型別轉換像下面這樣:

public class Car{
    private Object data;

    public Object getData() {
        return data;
    }
    public void setData(Object data) {
        this.data = data;
    }
    public static void main(String[] args) {
        Car car = new Car();
        car.setData(1);
        Integer result = (Integer) car.getData();
    }
}

但型別轉換的錯誤通常在執行時才能被發現,如果能在編譯時發現,不是更好嘛。型別引數可以是指定的任何非原始型別: 類型別、介面型別、陣列型別、甚至是另一個型別變數。同樣的規則也可以被應用於泛型介面。

型別命名慣例

按照慣例,型別引數明示是單個大寫字母的,常見的型別引數名稱如下:

  • E- 元素 廣泛被Java集合框架所使用
  • K - key
  • N - 數字
  • Y - 型別
  • V - 值
  • S,U,V etc - 2nd, 3rd, 4th types

原始型別(Raw Type)

泛型類和泛型介面沒有接收型別引數的名字,拿上面的Car類舉例, 為了給傳遞引數型別,我們在建立car物件的時候就會給一個正常的型別:

Car<Integer>  car = new Car<>();

如果未提供型別引數,你將建立一個Car的原始型別:

Car car = new Car();

因此,Car是泛型類Car的原始型別,然而非泛型類、介面就不是原始型別。現在我們有一個類叫Dog, 這個Dog類不接收型別引數, 如下程式碼引數:

class Dog{
    private String name;
   // get/set 構造省略
}

Dog就不是一個原始型別,原因在於Dog沒有接收泛型引數。這裡來講下我的理解,一般方法需要的引數,呼叫方沒有提供,編譯不透過。為什麼泛型沒有引入此設計呢,不傳遞型別引數,那不透過編譯不是更好嘛。那讓我們回憶一下,泛型是從JDK的哪個版本開始引入的?沒錯,JDK 5引入的,也就是說如果我們引入泛型,但是又強制要求泛型類的程式碼,比如集合框架,在使用的時候必須傳遞型別引數,那麼意味著JDK 5之前的專案在升級JDK 之後就會跑不起來,向前相容可是Java的特色,於是Java將原來的框架進行泛型化,為了向前相容,創造了原始型別這個概念,那有泛型的類,不傳遞型別引數,裡面的型別是什麼型別呢?當然是Object。C#引入泛型的時候,也面臨了這個問題,不同於Java的相容從前設計,加入了一套平行於一套泛型化版本的新型別。我們完全沒有可能在一篇文章裡面將泛型設計討論清楚,我們將在後續的文章討論泛型的演進。本篇我們著重於瞭解Java泛型的使用。

在一些老舊的專案中(這裡的老舊指的是JDK 5.0之前的Java專案),你會看見原始型別, 因為在JDK 5.0之前,Java的許多API都沒有泛型化(或通用化), 如集合框架。當使用原始型別的時候,原始型別將獲得泛型之前的行為,像上面的Car物件,在呼叫getData()方法的時候,會返回Object型別,這麼做是為了向後相容,這裡是為了確保新程式碼可以和舊程式碼相互操作,Java編譯器允許在新的程式碼中使用舊版本的程式碼和類庫,Java語言的設計者考慮到了向後相容性。 這裡倒是獲得了一些新的概念,以前我的腦海裡面就沒有向後相容這個概念,只有向前相容,那什麼是向前相容呢? 我也好像只有模糊的概念,我在寫的時候,思考了一下向前相容這個詞,向前面相容,這個是前是指以前,還是前方呢? 上面提到的向後相容指的是,後面的程式碼可以用之前的程式碼,向前相容指的是,JDK 5之前的程式碼可以執行在JDK 5之後的版本上,這也就是二進位制相容性,Java所強調的相容性,是"二進位制向後相容性"。例如說,一個在Java 1.2,1.4版本上可以正常執行的Class檔案,放在Java 5、6、7、8的JRE(包括JVM與標準庫)上仍然要可以正常執行。"Class檔案"這裡就是Java程式的“二進位制表現”。 需要特別強調的是, "二進位制相容性"並不等於"原始碼相容性"(source compatibility)。既然談到了,向前相容、向後相容,我們不妨討論的再仔細一點,軟體是一個很大的詞,某種程度上來說,作業系統也是一個軟體,對於系統的相容性來說,向後相容可以理解為Windows 10系統能夠相容執行Windows 3.1開發的程式上,Windows 10具備向後相容性,這個向後中的後可以理解為過去,而不是以後指未來,backward。我們上面討論的向後相容也就是這個語義。向前相容呢,Forward Compatibility, Windows 3.1能相容執行Windows 10開發的程式,這就可以說明Windows 3.1 具有向前相容性,一般作業系統都向後相容。所以JDK 引入泛型的時候,將以前沒有泛型的程式碼視為原始型別,是一種向後相容的設計,為了Java的承諾,二進位制相容性。所以上面的用詞還是有些問題,討論問題的時候沒有確定主體。

我們在來看下軟體相容,以安卓軟體為例,每年都在發大版本,但是安卓手機現在的版本就是什麼樣的都有,2023年最新的安卓版本是13,但我手機的安卓版本是安卓11,那我去應用市場下載軟體的時候,絲毫不考慮下載的軟體是否能正常執行,原因就在於基本上軟體也保留了一定的向前相容。舉一個例子來說,Android11的儲存許可權變更導致APP無法訪問根目錄檔案,但是為了讓為安卓11開發的軟體能夠跑在低版本的安卓上,這就要求開發者向前相容。

泛型方法 Generic Method

Generic methods are methods that introduce their own type parameters. This is similar to declaring a generic type, but the type parameter's scope is limited to the method where it is declared. Static and non-static generic methods are allowed, as well as generic class constructors.

所謂泛型方法指的就是方法上引入引數型別的方法,這與宣告泛型類似。但是型別引數的範圍僅於宣告的範圍。允許靜態和非靜態方法,也允許泛型建構函式。

下面是一個泛型靜態方法:

// 例子來自於: The Java™ Tutorials
public class Pair<K,V> {
    private K key;
    private V value;
    // 泛型建構函式
    public Pair(K key, V value) {
        this.key = key;
        this.value = value;
    }
    // getters, setters etc.
}
public class CompareUtil {
    // 靜態泛型方法
    public static <K,V> boolean compare(Pair<K,V> p1,Pair<K,V> p2){
        return p1.getKey().equals(p2.getKey()) && p1.getValue().equals(p2.getValue());
    }
    // 泛型方法
      // 返回值左側宣告接收幾個型別引數
    public <T1,T2> void compare(T1 t1,T2 t2){

    }
}

使用示例如下:

 Pair<Integer, String> p1 = new Pair<>(1, "apple");
 Pair<Integer, String> p2 = new Pair<>(1, "apple");
 //  但是往往沒人這麼寫,compare左側的實際型別引數可以透過p1,p2推斷出來。
 boolean isSame = CompareUtil.<Integer, String>compare(p1, p2);

我們更習慣的寫法如下:

boolean isSame = CompareUtil.compare(p1,p2);

上面的特性, 我們稱之為型別推斷(type,inference) ,允許開發者將一個泛型方法作為普通方法來呼叫,而不需要在角括號中指定一個型別。更詳細的討論見下方的型別推斷。

有邊界的型別引數(Bounded type Parmeters)

有的時候我們希望對泛型進行限制,比如我寫了一個比較方法,但是這個比較方法想限制傳遞進來的實際型別引數,只能為數字型別,這就需要對傳入的型別引數加以限制,像下面這樣:

public <U extends Number> boolean compare(U u){
        return false;
}

U extends Number,compare接收的引數只能是Number或Number子類的例項,extends後面跟上界。我們傳入String型別,編譯就會不透過:

// IDE 中會直接報錯
CompareUtil.compare("2");

說到這裡想起了《數學分析》上的確界原理: 任一有上界的非空實數集必有上確界(最小上界);同樣任一有下界的非空實數集必有下確界(最大下界)

當我們限制了泛型的上界,那我們就可以在泛型方法裡面呼叫上界類的方法, 像下面這樣:

public static  <U extends Number> boolean compare(U u){
   u.intValue();
   return false;
}

但有的時候一個上界可能還不夠,我們希望有多個上界:

<T extends B1 & B2 & B3>

Java中雖然不支援多繼承,但是可以實現多個介面,但是如果多個上界中某個上界是類,那麼這個類一定要出現在第一個位置,如下所示:

class A {}
interface B {}
interface C {}
class D <T extends A & B & C>

如果A不在第一個位置,就會編譯報錯。

有界型別引數和泛型方法

有界型別引數是實現通用演算法的關鍵,思考下面一個方法,該方法計算陣列中大於指定元素elem的元素數量, 我們可能這麼寫:

public static <T> int countGreaterThen(T[] anArray,T elem){
     int count = 0;
     for (T t : anArray) {
         if (t > elem ){
            count++;
         }
     }
    return count;
}

但由於你沒有限制泛型引數的範圍,上面的方法報錯原因也很簡單,原因在於運算子號(>)只能用於基本資料型別,比如short,int,double,long,float,byte,char。物件之間不能使用(>),但這些資料型別都有包裝類,包裝類都實現了Comparable介面,我們就可以這麼寫:

public static <T extends Comparable> int countGreaterThen(T[] anArray,T elem){
  int count = 0;
  for (T t : anArray) {
      if (t.compareTo(elem) > 0){
           count++;
       }
   }
   return count;
}

泛型,繼承,子型別

我想你也知道,如果型別相容,你可以將一個型別的物件引用指向另一個型別的物件,例如你可以將Object的引用指向Integer物件,原因在於Integer是Object類的子類:

Object someObject = new Object();
Integer someInteger = new Integer(10);
someObject = someInteger;

在物件導向的術語中,這種被稱為“is a” 關係,因為Integer是一種Object,所以允許賦值,但是Integer 也是Number的一種,所以下面的程式碼也是有效的:

public void someMethod(Number n){};
someMethod(new Integer(10));
someMethod(new Double(10.1)); // ok

現在我們來看下面這個方法:

public void boxTest(Car<Number> n);

這個方法接收哪些型別引數? 單純看方法簽名, 我們可以看到,它接收的是Box\<Number>型別的引數,那它能接收Box\<Integer>、Box\<Double>之類的引數嘛,當然是不允許的:

// 編譯不會透過
CompareUtil.boxTest(new Car<Integer>());

當我們使用泛型程式設計的時候,這是一個常見的誤解,但這是一個需要學習的重要概念:

generics-subtypeRelationship

給兩個具體型別A和B,比如Number和Integer,MyClass\<A>和MyClass\<B>之間是沒關係的,但不管A和B是否有關係,MyClass\<A>和MyClass\<B>都有一個共同父類叫Object。

泛型類和子型別

我們可以實現或繼承一個泛型類和介面,兩個泛型類、介面之間的關係由繼承和實現的語句決定。用集合框架的例子來講就是ArrayList\<E> implements List\<E>, and List\<E> extends Collection\<E>。所以ArrayList\<String>是List\<String>的一個子型別,而List\<String>是Collection\<String>的一個子型別。如果我們想定義自己的List介面,它將一個泛型P的可選值和List的每個元素都關聯起來。它的宣告可能像下面這樣:

interface PayloadList<E,P> extends List<E> {
  void setPayload(int index, P val);
}

下面引數型別是List<String>的子型別:

  • PayloadList<String,String>
  • PayloadList<String,Integer>
  • PayloadList<String,Exception>

萬用字元

In generic code, the question mark (?), called the wildcard, represents an unknown type. The wildcard can be used in a variety of situations: as the type of a parameter, field, or local variable; sometimes as a return type (though it is better programming practice to be more specific). The wildcard is never used as a type argument for a generic method invocation, a generic class instance creation, or a supertype.

在泛型程式碼中 ,?被稱為萬用字元,代表未知型別。萬用字元可以在各種情況下使用: 作為引數、欄位或區域性變數的型別;有時作為返回型別(儘管更好的程式設計實際是更具體的)。萬用字元從不用作泛型方法的呼叫,泛型類示例建立或父型別的型別引數。 《Java Tutorial》

其實看到這塊的時候,我對這個萬用字元是有點不瞭解的,我將這個符號理解為和T、V一樣的泛型引數名,但是我用?去取代T的時候,發現IDEA裡面出現了錯誤提示。那代表?號是特殊的一類泛型符號,有專門的含義。 假如我們想製作一個處理List\<Number>的方法,我們希望限制集合中的元素只能是Number的子類,我們看了上面的有界型別引數就可能會很自然的寫出下面的程式碼:

public static <T extends Number> int processNumberList(List<T> anArray) {
     // 省略處理邏輯
     return 0;
}

但有了萬用字元之後,事實上我們可以這麼宣告:

public static int processNumberList(List<? extends  Number> numberList ) {
    return 0;
}

事實上編譯器會認為這兩個方法是一樣的,IDEA上會給出提示是:

'processNumberList(List<? extends Number>)' clashes with 'processNumberList(List<T>)'; both methods have same erasure

兩個方法擁有相同的泛型擦除

我們將在下文專門討論泛型擦除 , 我們這裡還是熟悉泛型的基本使用。

? extends Number

這種語法我們稱之為上界型別萬用字元(Upper Bounded Wildcards),表示的是傳入的List中的元素只能是Number例項、或Number子型別的例項。在遍歷中可以呼叫上界的方法。

下界萬用字元

有上界萬用字元對應的就有下界萬用字元,上界萬用字元限制的是傳入的型別必須是限制型別或限制型別的子型別,而下界型別則限制傳入型別是限制型別或限制型別的父型別。舉個例子,你只想傳入的型別是List\<Integer>,List\<Number>, List\<Object>,或任何容納Integer型別的List 。我們就可以如下寫:

public static void addNumbers(List<? super Integer> list) {
    for (int i = 1; i <= 10; i++) {
        list.add(i);
    }
}

但值得注意的是,上界下界不能同時出現。

無界萬用字元

在《Java Tutorial》中給出了兩個萬用字元的經典使用場景:

  • If you are writing a method that can be implemented using functionality provided in the Object class.
如果你正在編寫的方法可以用Object類提供的方法進行實現。
  • When the code is using methods in the generic class that don't depend on the type parameter. For example, List.size or List.clear. In fact, Class<?> is so often used because most of the methods in Class<T> do not depend on T.
類中的程式碼不依賴型別引數,例如List.size、List.clear。事實上,Class\<?> 經常被使用,原因在於,Class\<T>的大部分方法都不依賴於型別引數T。

考慮下面的方法:

public static void printList(List<Object> list) {
    for (Object elem : list)
        System.out.println(elem + " ");
    System.out.println();
}

這個方法的意圖是列印任意List元素,但是這麼寫的話,你再呼叫的時候只能傳遞List\<Object>型別的引數,不能傳遞List\<Integer>型別的引數,原因也是在我們討論過的,List\<Integer> 並不是List\<Object>的子型別。 這個時候我們就可以用到 ? 萬用字元。

public static void printList(List<?> list) {
    for (Object elem: list)
        System.out.print(elem + " ");
    System.out.println();
}

因為任意型別A,List\<A>都是List\<?>的子型別。值得注意的是List\<Object>和List\<?> 並不相同,在List\<Object>裡面你可以插入一切例項,但是在List\<?>你就只能新增null值。

萬用字元和子型別化

現在我們有兩個類A和B,關係如下:

class A {}
class B extends A{}

B是A的子類,所以我們可以寫出這樣的程式碼:

B b = new B();
A a = b;

這種寫法我們一般稱之為向上轉型,但是下面的程式碼就不會編譯透過:

List<B> lb = new ArrayList<>();
List<A> la = lb;   // compile-time error

Integer是Number的子型別,List\<Integer>、List\<Number> 之間的聯絡如下:

diagram showing that the common parent of List<Number> and List<Integer> is the list of unknown type

儘管Integer是Number的子型別,但是List\<Integer>卻不是List\<Number>的子型別,事實上,這兩種型別並沒有關係。它們的共同父類是List<?>, 為了讓List\<Integer>和List\<Number>之間產生關係,我們可以藉助上界萬用字元:

List<? extends Integer> intList = new ArrayList<>();
List<? extends Number>  numList = intList;  // OK. List<? extends Integer> is a subtype of List<? extends Number>

下面這張圖宣告瞭用上界和下界萬用字元宣告的幾個List類之間的關係:

diagram showing that List<Integer> is a subtype of both List&lt;? extends Integer&gt; and List&lt;?super Integer&gt;. List&lt;? extends Integer&gt; is a subtype of List&lt;? extends Number&gt; which is a subtype of List&lt;?&gt;. List<Number> is a subtype of List&lt;? super Number&gt; and List&gt;? extends Number&gt;. List&lt;? super Number&gt; is a subtype of List&lt;? super Integer&gt; which is a subtype of List&lt;?&gt;.

該怎麼理解這幅關係圖呢? Integer是Number的子類,所以List\<? extends Integer> 是 List\<? extends Number>的子類,有沒有更嚴格的理解呢,我在理解這個關係的時候,嘗試將這種父子關係抽象為區間,所以 ? extends Number <=> [oo,Number] , ? extends Integer <=> [oo,Integer], 那用到了數學的區間,我們不妨將Number和Integer兌換為數字,越是抽象的數字越大,因為表現能力更豐富,所以我們姑且將Number理解為5,Integer理解為4。 這樣的話, 好像也能理解的動:

? extends   Number <=> [oo,5] 
? extends  Integer <=> [oo,4] 
? super  Integer  <=> [4,oo] 
? extends  Number <=>  [5,oo]   

這是一種理解方式,《The Java™ Tutorials》在介紹多型的時候,指出多型首先是一個生物學上的概念,那關於這種父子關係,我想到了生物的譜系:

img

我們將Number理解為牛亞科,Integer理解為羚羊亞科,那所有羚羊亞科的下級科都是牛亞科的下級科,所有牛亞科的上機科目都是羚羊亞科的上級科目。這樣理解似乎更自然。

萬用字元捕獲和輔助方法

在某些情況下,編譯器會嘗試推斷萬用字元的型別。例如一個List被定為List\<?>,編譯器執行表示式的時候,編譯器會從程式碼中推斷出一個具體的型別。這種情況被稱為萬用字元捕獲。大部分情況下,你都不需要擔心萬用字元捕獲的問題,除非你看到包含"捕獲" 這一短語的錯誤資訊。萬用字元錯誤通常發生在編譯器:

public class WildcardError {
    void foo(List<?> i) {
        i.set(0, i.get(0));
    }
}

這段程式碼就無法透過編譯。那我們在使用泛型的時候,何時使用上界萬用字元,何時使用下界萬用字元。下面是一些通用的一些設計原則。

萬用字元使用指南

首先我們將變數分為兩種功能:

  • 輸入變數
輸入變數向程式碼提供資料。想象一個有兩個引數的複製方法: copy(src,desc), src引數提供了要複製的資料,所以他是輸入引數.
  • 輸出變數
輸出變數儲存資料以便在其他地方使用,在複製的例子中,copy(src,dest),dest接收要複製的資料,所以他是輸出引數。

你可以使用"輸入"和"輸出" 原則來決定是否使用萬用字元以及什麼型別的萬用字元合適,下面的列表提供了遵循的準則:

  • An "in" variable is defined with an upper bounded wildcard, using the extends keyword.
入參用上界萬用字元,使用extends關鍵字。
  • An "out" variable is defined with a lower bounded wildcard, using the super keyword.
輸出變數用下界萬用字元, 使用super關鍵字
  • In the case where the "in" variable can be accessed using methods defined in the Object class, use an unbounded wildcard.
如果需要使用入參可以使用定義在Object類中的方法時,使用無界萬用字元。
  • In the case where the code needs to access the variable as both an "in" and an "out" variable, do not use a wildcard.
當程式碼需要將變數同時用作輸入和輸出時,不要使用無界萬用字元。

泛型擦除

Generics were introduced to the Java language to provide tighter type checks at compile time and to support generic programming.

泛型被引入Java, 在編譯時提供了強型別檢查,支援了通用泛型程式設計。

To implement generics, the Java compiler applies type erasure to:

Java選擇用泛型擦除實現泛型
  • Replace all type parameters in generic types with their bounds or Object if the type parameters are unbounded. The produced bytecode, therefore, contains only ordinary classes, interfaces, and methods.
如果泛型的型別引數是有邊界的,則用邊界來替換,如果是無界的,就用Object來替換。所以最後的位元組碼,還是普通的類、方法、介面。
  • Insert type casts if necessary to preserve type safety.
必要時插入型別轉換確保型別安全
  • Generate bridge methods to preserve polymorphism in extended generic types.
生成橋接方法以保留擴充套件泛型型別中的多型性。

Erasure of Generic Types

首先我們宣告一個泛型類:

public class Node<T>{
  private T data;
  private Node<T> next;
  
  public Node(T data , Node<T> next) {
      this.data = data;
      this.next = next;
  }
  public T getData(){return data};
}

型別引數沒有限界,編譯器會將T替換為Object:

public class Node{
  private Object data;
  private Node next;
  
  public Node(Object data , Node next) {
      this.data = data;
      this.next = next;
  }
  public Object getData(){return data};
}

如果我們對型別引數進行了限制:

public class Node<T extends Comparable<T>> {

    private T data;
    private Node<T> next;

    public Node(T data, Node<T> next) {
        this.data = data;
        this.next = next;
    }
    public T getData() { return data; }
}    

Java編譯器會用型別引數的第一個限界來替換,實際擦除之後,變成了下面這樣:

public class Node {

    private Comparable data;
    private Node next;

    public Node(Comparable data, Node next) {
        this.data = data;
        this.next = next;
    }

    public Comparable getData() { return data; }
    // ...
}

泛型方法擦除

現在我們宣告一個泛型方法,如下所示:

public static <T> int count(T[] anArray, T elem) {
    int cnt = 0;
    for (T e : anArray)
        if (e.equals(elem))
            ++cnt;
        return cnt;
}

泛型引數未被限制,經過Java編譯器的處理,T會被替換為Object。

public static  int count(Object[] anArray, Object elem) {
    int cnt = 0;
    for (T e : anArray)
        if (e.equals(elem))
            ++cnt;
        return cnt;
}

對泛型引數進行限制:

class Shape { /* ... */ }
class Circle extends Shape { /* ... */ }
class Rectangle extends Shape { /* ... */ }
public static <T extends Shape> void draw(T shape) { /* ... */ }

Java的編譯器會用shape替換T:

public static void draw(Shape shape) { /* ... */ }

型別擦除的影響和橋接方法

有時,型別擦除會導致預料之外的事情發生,下面的例子顯示了這種情況是如何發生的:

public class Node<T> {

    public T data;

    public Node(T data) { this.data = data; }

    public void setData(T data) {
        System.out.println("Node.setData");
        this.data = data;
    }
}

public class MyNode extends Node<Integer> {
    public MyNode(Integer data) { super(data); }

    public void setData(Integer data) {
        System.out.println("MyNode.setData");
        super.setData(data);
    }
}
MyNode mn = new MyNode(5);
Node n = mn; // 原始型別會給一個警告
n.setData("Hello"); // 這裡會丟擲一個型別轉換異常
Integer x = mn.data;

編譯器在編譯泛型類或泛型介面的時候,編譯器可能會建立一種方法,我們稱之為橋方法。通常不需要擔心橋方法,但如果它出現在堆疊中,可能你會感到困惑。型別擦除之後,Node和MyNode會變成下面這樣:

public class Node {

    public Object data;

    public Node(Object data) { this.data = data; }

    public void setData(Object data) {
        System.out.println("Node.setData");
        this.data = data;
    }
}

public class MyNode extends Node {
    public MyNode(Integer data) { super(data); }

    public void setData(Integer data) {
        System.out.println("MyNode.setData");
        super.setData(data);
    }
}

在型別擦除之後,父類和子類的簽名不一致,Node.setData(T )方法變成`Node.setData(Object) 。因此,MyNode.setData(T)方法並沒有覆蓋Node.setData(Object)方法, 為了維護泛型的多型,Java編譯器產生了橋接方法,以便讓子型別也能繼續工作。按照我們對泛型的理解,Node中的setData方法入參也應當是Integer, 如果沒有橋接方法,那麼MyNode中就會繼承一個setData(Object data)方法。

總結一下

Java為什麼要引入泛型呢,原因大致有這麼幾個: 增強程式碼複用性、讓錯誤在編譯的時候就顯現出來。Java的泛型機制事實上將泛型分為兩類:

  • 型別引數 type Parameter
  • 萬用字元 Wildcard

型別引數作用在類和介面上,萬用字元作用於方法引數上。為了保持向後相容,Java選擇了泛型擦除來實現泛型,這一實現機制在早期的我來看,這種實現並不好,我認為這種實現影響了Java的效能,我甚至認為這不能稱之為真正的泛型, 比不上C#,但是在重學泛型的過程中, 事實上Java的實現也泛型,詳細的可以參看下面這個連結:

https://www.zhihu.com/question/28665443/answer/1873474818

寫本篇的時候本來是想將仔細討論下泛型的,比如泛型的實現,Java中泛型的未來,對比其他語言,但是後面發現越寫越多,索性就拆成兩篇了。本篇基本上可以理解為《The Java™ Tutorials》中泛型這一章節的翻譯,也加入了自己的理解。

參考資料

相關文章