Java基礎-泛型

Java基礎-泛型

1.概述

Java泛型(generics)是JDK 5中引入的一個新特性,允許在定義類和接口的時候使用類型參數(type parameter)。聲明的類型參數在使用時用具體的類型來替換。 可用於泛型類、泛型接口、泛型方法。

如何理解上面的定義呢,類型參數是什麼?

說起參數,都會想到定義方法時要定義形參,調用方法時要傳入實參。當一個方法可以適配多種類型的參數,

可以將形參設爲Object類型,等處理完畢後再強制轉換爲想要的類型,錯誤的類型轉換會在運行時導致程序奔潰。

比如:

static void testGenerics() {
    List list = new ArrayList();
    String a = "A";
    Integer b = 2;
    list.add(a);
    list.add(b);
    for (Object o : list) {
        String tmp = (String) o;
        System.out.println(tmp);
    }
}

將Integer轉Object再轉爲String類型,編譯不會報錯,但運行時會報錯。

Exception in thread "main" java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String

我們希望在寫代碼時儘早發現這類問題,在使用階段可以像指定實參一樣指定類型,泛型在使用階段指訂參事類型,在編譯階段就避免了錯誤的強制轉換。

boolean add(E e);

上面的參數類型E是在初始化List時指定的,在編譯階段就會檢查是否出錯。

參數存在於方法中,類和接口中存一系列相關的方法,這就引出了泛型應用的三種常見使用方式泛型類、泛型接口、泛型方法。

2.相關概念

泛型參數列表<K,V>列表中大寫字母隨意定。

原始類型ArrayList

泛型類型ArrayList<E>

類型參數E

參數化類型ArrayList<Integer>

實際參數類型:Integer

類型綁定<T extends Fruit&Serializable> T 是Fruit的子類並且實現了Serializable接口。

通配符:?

限定通配符的上邊界<? extends Number >

限定通配符的下邊界<? super Integer >

橋接

協變

逆變

3.泛型應用

3.1 泛型方法

定義泛型方法,只需要將泛型參數列表<K,V>置於返回值之前。

public class GenericsMethod {
    public <K, V> V genericsMet(K input) {
        return (V) input;
    }
}

以上就是普通的泛型方法,指定輸入類型爲K,返回類型爲V ,K和V的具體類型要到使用時纔可以確定。

public static void main(String[] args) {
    GenericsMethod genericsMethod = new GenericsMethod();
    int a = genericsMethod.genericsMet(3); 
    String b = genericsMethod.genericsMet("dd");
    //String c = genericsMethod.genericsMet(3);
}

入參爲3 是Integer類型,則K爲Integer類型;承接返回值的a是Integer類型,所以V爲Integer類型,如果a爲String類型那麼V爲String類型。

入參列表中的泛型類型由實參類型決定,而返回值中的泛型類型由承接返回值的變量類型決定。

3.2 泛型類

定義泛型類,只需要在定義類時將泛型參數列表置於類名稱之後。

public class GenericsClass<T> {
    private T args; // 定義泛型類型的成員變量
	public GenericsClass(T args) { // 泛型構造方法的參數類型也爲T
    	this.args = args;
    }
    public T getArgs() { // 注意該方法不是泛型方法
        return args;
    }
    public void setArgs(T args) {
        this.args = args;
    }
}

以上泛型參數T的實際類型可以通過定義GenericsClass實例對象時確定,如下:

// 創建引用時指定泛型參數類型爲Integer
GenericsClass<Integer> stringGenericsClass = new GenericsClass<Integer>();
// 在new對象時可以不指定返程參數類型
GenericsClass<String> stringGenericsClass1 = new GenericsClass<>();

在創建stringGenericsClass引用的時候確定了T爲Integer,new 對象的過程指定的泛型參數要和創建引用時用的泛型參數一致。

當然創建引用時也可以不指定泛型參數,此時在泛型類中使用泛型的方法或成員變量定義的類型可以爲任何的類型

GenericsClass stringGenericsClass = new GenericsClass<>(23);
stringGenericsClass.setArgs(24);
stringGenericsClass.setArgs("abcd");

GenericsClass stringGenericsClass1 = new GenericsClass<>("abc");
stringGenericsClass1.setArgs(25);
stringGenericsClass1.setArgs(new Person());

3.3 泛型接口

定義泛型接口,只需要在定義接口時,在接口名稱後添加泛型參數列表。這和泛型類定義一樣。

public interface GenericsInterface<T,V> {
    V transform(T arg);
}
  • 在使用泛型接口時,可以在實現類中繼續使用泛型參數,不明確泛型類型。
// 繼續使用接口中的泛型參數,將具體類型的指定留到該類使用階段。
public class GenericsInterfaceImpl<K,V> implements GenericsInterface<K,V> {
    @Override
    public V transform(K arg) {
        return null;
    }
}
  • 當然也可以在接口中指定泛型類型,此時實現類無需添加泛型參數列表,明確泛型類型。
// 在實現接口時就指定<K,V> 的具體類型。
public class GenericsInterfaceImpl implements GenericsInterface<Integer,String> {
    @Override
    public String transform(Integer arg) {
        return null;
    }
}

3.4 類型綁定

以上泛型變量都是派生自Object類,所以在泛型方法的內部實現中,T變量只能使用Object自帶的方法。這大大侷限了泛型變量T能實現的功能。

如何爲泛型變量T添加更多能力呢??? 這就是類型綁定的作用,通過爲T綁定更多接口或者父類,T就可以使用這些接口和父類中的方法。

類型綁定使用extends關鍵字,首先明確這和繼承是不一樣的。

定義綁定<T extends Comparable>

多重綁定<T extends Comparable & Car & Serializable>

示例1:綁定接口

// 先定義一個接口
public interface Comparable<T> {
	public int compareTo(T o);
}

// 在方法泛型參數中綁定該Comparable接口
public <G extends Comparable> G max(G... input) {
    G maxNode = input[0];
    for (G tmp : input) {
        if (maxNode.compareTo(tmp) < 0) {
            maxNode = tmp;
        }
    }
    return maxNode;
}

以上泛型參數類型G就可以使用compareTo方法。

示例2:綁定類

// 先定義一個基類
public class Car {
    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

// 定義兩個繼承類
class ACar extends Car {
    public ACar() {
        setName("ACar");
    }
    @Override
    public String getName() {
        return "AType" + super.getName();
    }
}
class BCar extends Car {
    public BCar() {
        setName("BCar");
    }
    @Override
    public String getName() {
        return "BType" + super.getName();
    }
}

定義一個綁定類的泛型方法。

public static <T extends Car> String getCarName(T car) {
    return car.getName();
}

調用泛型方法。

String aCarName = getCarName(new ACar());
String bCarName = getCarName(new BCar());
System.out.println(aCarName);
System.out.println(bCarName);

3.5 泛型通配符

通配符只能在創建泛型類的引用中使用,用於填充泛型T,不能用在泛型定義過程中。

無邊界通配符<?>

通配符的意義就是它是一個未知的符號,可以是代表任意的類。 通配符是用來在創建引用時填充泛型T的。

// 在創建泛型引用時,需要明確類型,後面接對應泛型類實例。
GenericsClass<String> stringGenericsClass2 = new GenericsClass<>("abc");
GenericsClass<Integer> stringGenericsClass3 = new GenericsClass<Integer>(23);

// 使用通配符?的引用,可以接多種泛型類實例。
GenericsClass<?> stringGenericsClass = new GenericsClass<>(23);
stringGenericsClass = new GenericsClass<String>("abc");
stringGenericsClass = new GenericsClass<Integer>(33);

// 不指定泛型類型時,同樣可以接多種泛型類實例。
GenericsClass stringGenericsClass1 = new GenericsClass<>("abc");
stringGenericsClass1 = new GenericsClass<String>("abc");
stringGenericsClass1 = new GenericsClass<Integer>(23);

因爲一個泛型類,如果省略了填充類型,默認填充的是無邊界通配符。

限定通配符的上邊界 <? extends Number > 上邊界只能讀不能寫

<? extends Number >限定了通配符的上邊界,限定了上邊界的泛型變量,只能接受上邊界及其子類的泛型實例。

// 定義一個繼承體系
public class Person {}
public class Employee extends Person {}
public class Manager extends Employee {}
public class CTO extends Manager{}

這裏我們看下具體使用

public static void topLimitDemo(){
    // 泛型上界規定了list只能持有T爲Manager及其子類的容器實例。
    List<? extends Manager> list;
    
    // list 無法指向T爲 Person 或 Employee的容器實例。
    // list = new ArrayList<Person>();
    // list = new ArrayList<Employee>();
    
    // list 只能指向Manager及其子類的容器實例。
    list = new ArrayList<Manager>();
    list = new ArrayList<CTO>();
}

規定了上邊界的泛型變量只能讀不能存。

// 存入元素
// list.add(new Manager());
// list.add(new CTO());

// 讀取時因爲子類對象向上轉型爲Manager所以是可以成功的。
Manager tmp = list.get(0);

無法存入元素

單看list只知道它指向的是Manager及其子類的容器實例,但無法確定具體類型,所以add時編譯器無法確定是否能正確轉型。 假設list指向的是new ArrayList();是無法將一個Manager對象轉爲CTO對象,所以無法添加成功。

可以讀取元素

讀取元素是按向上轉型的,讀取時因爲子類對象向上轉型爲Manager所以是可以成功的。

限定通配符的下邊界:<? super Integer > 下邊界只能寫不能讀

<? super Integer > 限定了通配符的下邊界,限定了下邊界的泛型變量,只能接受下邊界及其父類的泛型實例。

// 泛型下界規定了list只能持有T爲Manager及其父類的容器實例。
List<? super Manager> list;

// list 只能持有Manager及其父類的容器實例。
list = new ArrayList<Person>();
list = new ArrayList<Employee>();
list = new ArrayList<Manager>();
// list 無法指向T爲Manager子類的容器實例。
//list = new ArrayList<CTO>();

上面可以看出同一個list 可以接受T爲Manager及其父類的容器實例。

可以存入元素

// 存入元素只能存入Manager或者Manager子類的元素。
list.add(new CTO());
list.add(new Manager());
// 無法存入父類元素
// list.add(new Employee());
// list.add(new Person());

無法讀取元素,這裏指編譯器無法判斷得到實例元素的具體類型,只會被認定爲Object。

// 讀取時無法得到容器內元素具體類型,返回爲Object類型
Object o = list.get(0);
// CTO cto = list.get(0);
// Manager manager = list.get(1);

小結:

  • 如果你想從一個數據類型裏獲取數據,使用 ? extends 通配符(能取不能存)
  • 如果你想把對象寫入一個數據結構裏,使用 ? super 通配符(能存不能取)
  • 如果你既想存,又想取,那就別用通配符。

4.泛型實現原理

要理解泛型的實現原理得先從類型擦除(Type Erasure)講起

4.1 類型擦除

java的泛型基本上完全在編譯器中實現,用於編譯器執行類型檢查和類型判斷,然後生成普通的非泛型的字節碼,這種實現技術爲“擦除”(erasure) 。如何理解呢?看下面的例子

示例1:

public static void main(String[] args) {
    Class a = new ArrayList<String>().getClass();
    Class b = new ArrayList<Integer>().getClass();
    System.out.println(a == b);
    // out: true
}

明明是兩個不同的泛型容器,但實際生成的字節碼是相同的,如下:

public static void main(String[] args) {
    Class a = (new ArrayList()).getClass();
    Class b = (new ArrayList()).getClass();
    System.out.println(a == b);
}

在生成的Java字節代碼中是不包含泛型中的類型信息的。使用泛型的時候加上的類型參數,會被編譯器在編譯的時候去掉。這個過程就稱爲類型擦除。

示例2:

我們知道java在編譯階段進行類型檢查,向一個Integer容器添加String是無法通過編譯的。

List<Integer> list = new ArrayList<Integer>();
list.add(33);
// list.add("abc"); 無法通過編譯

try {
    Method method = list.getClass().getMethod("add", Object.class);
    method.invoke(list,"abc");
} catch (Exception e) {
    e.printStackTrace();
}
System.out.println(list); // out:[33, abc]

因爲填充的泛型類型Integer在字節碼中被擦除了,被替換爲Object;在運行時只要是一個Object類型就可以add進List。反射是在運行時調用方法,跳過了編譯器的類型檢查,所以可以將String添加到一個指明類型是Integer的List中。

類型擦除的過程

首先找到用來替換類型參數的具體類,一般是Object,如果指定了類型參數的上界則使用上界類型。將代碼中的類型參數都替換成具體類,去掉類型聲明,如:T get()變成Object get() 去掉<> 。最後由於類型擦除後少了部分方法,則需要生成一些橋接方法作爲補充。

類型擦除的缺陷

泛型類型由於類型擦除,源代碼中添加的類型信息都被移除了,所以有些運行期的操作無法實現,比如:轉型,instanceof 和 new。

public class Erased<T> {
    private static final int SIZE = 100;
    public static void f(Object arg) {
        //編譯不通過
        if (arg instanceof T) {}
        //編譯不通過
        T var = new T();
        //編譯不通過
        T[] array = new T[SIZE];
        //編譯不通過
        T[] array = (T) new Object[SIZE];
    }
}

類型判斷解決辦法

通過使用定義一個工具類,使用Class的isInstance可以解決泛型實例類型比較的問題。

public class ClassType<T> {
    Class<T> typeClass;
    public ClassType(Class<T> typeClass) {
        this.typeClass = typeClass;
    }
    public boolean isInstance(Object obj) {
        return typeClass.isInstance(obj);
    }
}

使用方式如下

public static void main(String[] args) {
    ClassType<Employee> classType =new ClassType<>(Employee.class);
    System.out.println(classType.isInstance(new Manager()));
    System.out.println(classType.isInstance(new Employee()));
    System.out.println(classType.isInstance(new Person()));
}

4.2 橋方法

因爲 java 在編譯源碼時, 會進行 類型擦除, 導致泛型類型被替換限定類型(無限定類型就使用 Object). 因此爲保持繼承和重載的多態特性, 編譯器會生成 橋方法.

什麼是橋方法? 下面看個例子就清楚了。

public class Car<T> {
    // 車裝的貨物
    private T goods;
    public T getGoods() {
        return goods;
    }
    public void setGoods(T goods) {
        this.goods = goods;
    }
}

public class Truck extends Car<String>{
    @Override
    public void setGoods(String goods) {
        
    }
    @Override
    public String getGoods() {
        return super.getGoods();
    }
}

因爲類型擦除,所以父類的泛型T被替換成Object,本來繼承重載了

下面是class文件反編譯得出。

public class Truck extends Car{
    public Truck() { }
    // 子類中方法
    public void setGoods(String goods) {
        
    }
    public String getGoods() {
        return (String)super.getGoods();
    }
    // 父類中方法,底層調用了子類中的方法,這就是 橋方法
    public volatile void setGoods(Object obj) {
        setGoods((String)obj);
    }
    public volatile Object getGoods() {
        return getGoods();
    }
}

父類中的setGoods方法,底層依舊調用的是子類中的setGoods方法。

4.3 協變、逆變

逆變和協變需要從java中繼承機制說起,子類繼承父類那麼可以在使用父類的時候使用子類替換。

如果B類是A類的派生類,那麼B類的引用可以賦值給A類的引用。

java中使用賦值一般有兩個地方,

(1)使用運算符顯式賦值

Person person = new Employee();

使用父類型引用person持有子類型實例new Employee() 對象的引用。

(2)函數傳參賦值

static void createHat(Person person){
	System.out.println(person);
}

createHat(new Employee());

createHat 顯示接收一個Person類型參數,我們傳入一個子類型實例對象new Employee()同樣可以編譯通過正常運行。

小結:所以,Java中賦值操作一般左右類型相同,或者引用類型是實例類型的父類。實參是

但還有一類常見的賦值操作並不符合,即數組和容器的賦值。

Person[] peoples = new Employee[5];
List<? extends Person> personList = new ArrayList<Employee>();
List<? super Employee> personList1 = new ArrayList<Person>();
// Employee[] employees = new Person[5];
// List<Person> personList = new ArrayList<Employee>();
// List<Employee> employeeList = new ArrayList<Person>();

不同類型的數組、不同類型的容器爲什麼可以相互兼容? 這裏就要涉及到協變和逆變的概念。

模式定義:假設F(x)是Java中的一種代碼模式,x是其中可變的部分。

協變:如果B是A的子類,F(B)也能享受F(A)的待遇,子類實例享受父類待遇,那麼F模式是協變的。

逆變:如果B是A的子類,F(A)也能享受F(B)的待遇,父類實例享受子類待遇,那麼F模式是逆變的。

不變:如果F(A)和F(B)不享受任何繼承待遇,那麼F模式是不變的。

Java中的協變和逆變

協變:如果一個父類型容器引用可以持有一個子類型容器實例對象,則稱發生了協變。如:

數組

Person[] peoples = new Employee[3];

Person 是Employee的父類,persons引用可以持有Employee數組實例,因爲在Java中,數組是自帶協變的。

雖然數組是協變的,但實際運行時添加元素到數組中去,依舊會做類型檢查;如下面語句編譯時不會報錯,但在運行時會拋出錯誤。

Person[] peoples = new Employee[4];
peoples[0] = new Person();// 運行報錯,子類實例類型的數組不能添加一個父類對象。
peoples[1] = new Manager();// Employee類型的數組實例可以接受 Employee及其子類Manager對象。

數組的協變設計,沒有在編譯階段發現潛在問題,而將錯誤拋出延遲到了運行階段,這是其爲人詬病的地方。不過數組支持協變後,java.util.Arrays#equals(java.lang.Object[], java.lang.Object[])這種類型的函數就不需要爲每種可能的數組類型去分別實現一次了。數組的協變設計有歷史版本兼容性方面的考慮等,Java的每一個設計可能不是最優的,但確實是設計者在當時的情況下可以做出的最好選擇。

列表

// List<Person> personList = new ArrayList<Employee>(); 無法編譯

泛型容器沒有自帶協變所以personList不能持有Employee類型的泛型容器。

通過使用泛型通配符上邊界,容器類可以實現協變。

// 可以通過編譯
List<? extends Person> personList = new ArrayList<Employee>();

但限定了通配符上邊界會導致該容器只能讀不能寫,讀到的元素也會被統一爲上邊界元素。

// 不能寫 ,可以添加null
// personList.add(new Person()); 無法編譯
// personList.add(new Employee()); 無法編譯

// 只能讀
Object object = personList.get(0);
Person person = personList.get(0);
Employee employee = (Employee) personList.get(0);
Manager manager = (Manager) personList.get(0);

不能寫是因爲,List在編譯時已經將泛型擦除成Object,運行階段無法做類型檢查,只能根據變量聲明在編譯階段進行類型檢查,而List<? extends Person> 代表可以容納任何Person子類,無法得出具體的類型,所以插入任何類型都是不安全的。

讀的時候可以將子類實例對象轉爲上邊界類型,轉到具體子類需要強制轉換。

逆變:如果一個父類型容器引用持有父類型容器實例,可以向其添加子類型實例變量。則稱爲逆變。

List<? super Employee> a = new ArrayList<Employee>();
List<? super Person> b = new ArrayList<Person>();
a = b;// 子類型可以接受父類型 實例容器

PECS原則 Producer Extends,Consumer Super

因爲使用<? extends T>後,如果泛型參數作爲返回值,用T接收一定是安全的,也就是說使用這個函數的人可以知道你生產了什麼東西;

而使用<? super T>後,如果泛型參數作爲入參,傳遞T及其子類一定是安全的,也就是說使用這個函數的人可以知道你需要什麼東西來進行消費。

比如Java8新增的函數接口java.util.function.Consumer#andThen方法就體現了Consumer Super這一原則。

參考:

Java泛型詳解, by jamesehng

java 泛型詳解-絕對是對泛型方法講解最詳細的,沒有之一, by ViVieLeieLei

java基礎鞏固筆記(2)-泛型, by brianway

Java 泛型進階, by 於曉飛93

夯實JAVA基本之一——泛型詳解(2):高級進階, by 啓艦

java泛型:擦除/橋方法/協變(不要在新代碼中使用原生態類型) ---- effective java notes,by soullines

Java進階知識點:協變與逆變, by 愛養花的碼農

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章