首頁 > 軟體

淺談Java中的橋接方法與泛型的逆變和協變

2022-04-07 13:02:29

泛型的協變和逆變是什麼?對應於Java當中,協變對應的就是<? extends XXX>,而逆變對應的就是<? super XXX>

1. 泛型的協變

1.1 泛型協變的使用

當我們有一個有方法,方法的簽名定義成為如下的方式

public static void test(List<Number> list)

這時,如果我們想要給test方法傳入一個List<Double>或者是List<Integer>可以嗎?很顯然不行,因為傳遞引數,肯定是要傳遞它的子類才行,但是List<Double>或者是List<Integer>是它的子類嗎?很明顯不是,這時我們就需要用到泛型的協變。

我們將方法的引數變成如下的這種形式

public static void test(List<? extends Number> list)

這時,我們的泛型,就只需要傳入一個是Number的子型別的泛型即可。因為Integer和Double,它們都是Number的子類,因此很明顯是合法的。

test(new ArrayList<Integer>());
test(new ArrayList<Double>());

在test方法中:

  • 1.如果我們想要去獲取集合當中的某個元素時,因為約定了元素的所有型別都得是Number型別極其子類的,因此我們獲取的元素一定可以用它們的共同父類別Number去進行接收。
  • 2.但是當我們想要往集合當中新增元素時,竟然無法往list當中新增元素?奇奇怪怪的!而且關鍵我們的list,只要求元素的型別是Number或者它的子類型別。但是我們加入的是1,是個Intger型別,很明顯是符合規範的呀!
    public static void test(List<? extends Number> list) {
        Number number = list.get(0);  // right
        list.add(1); // error
    }

1.2 泛型協變存在的問題

泛型的協變,不能讓我們往集合當中新增元素。那麼為什麼不能新增呢?

要知道為什麼,我們首先需要了解Java當中橋接方法的來由。

1.2.1 Java當中橋接方法的來由

我們首先定義如下的自定義ArrayList類,並重寫了它的add方法,

public class MyArrayList extends ArrayList<Double> {

    @Override
    public boolean add(Double e) {
        return super.add(e);
    }
}

首先,我們肯定知道ArrayList類中的add方法的原型是下面這樣的

public boolean add(E e) 

在Java當中,是在編譯時去進行型別擦除的,在執行時並無泛型型別一說。也就是說,該原型方法,會被抹掉成為

public boolean add(Object e) 

但是,我們定義了自己的ArrayList,我們自己的add方法的原型為

public boolean add(Double e) 

這個兩個方法的簽名並不相同,但是當使用下的程式碼建立一個ArrayList時:

ArrayList<Double> list = new MyArrayList();
list.add(1.0);

它實際呼叫的方法的原型是public boolean add(Object e),但是我們子類中的重寫的方法的原型時什麼?public booleab add(Double e)

也就是說,通過父類別的方法呼叫的和子類重寫的方法,並不是同一個方法,因為它們連方法簽名都不同。這時候,就需要要一個方式,將public booleab add(Object e)轉到public booleab add(Double e)當中去執行。這時候,就會涉及到橋接方法的存在了。

Java的實現方式是:通過在Javac編譯器編譯時,為我們生成一個public boolean add(Object e)這樣的方法,而這個方法當中,要做的實際上就是呼叫public booleab add(Double e)這個方法。

    public boolean add(Object o) {
        return add((Double) o);
    }

通過橋接方法的方式,就可以讓我們能在針對泛型方法進行重寫時,可以被JVM執行到。

1.2.2 為什麼泛型協變時,不允許新增元素呢

當我們使用下面的程式碼建立了一個我們自定義的MyArrayList物件。

ArrayList<Double> list = new MyArrayList();

這時,我們呼叫test方法

test(list)

test方法對於list的泛型定義為<? entends Number>,理論上應該是可以往裡面放入任何Number子類型別的元素的。但是別忘了,我們MyArrayList中對於方法的定義,是下面這樣子的!

public boolean add(Object e) {
    return add((Double)e);
}

public boolean add(Double e)  {
    // ......
}

如果我們往集合當中新增一個Integer型別的1,走到橋接方法當中時會有(Double)e這樣的強制型別轉換,這不就是丟擲了ClassCastException異常了嗎?很明顯,是不允許我們這樣乾的。因此Java的做法就是,在編譯期就去禁止這種做法,避免產生執行時的ClassCastException

有的人也許會說

ArrayList<Double> list = new MyArrayList();

我們建立list時,不是約束了泛型型別為Double了嗎,為什麼test方法內就不能預設它是Double的泛型呢?問題就是:我寫test方法時,我怎麼知道你傳遞的是Double型別的泛型,玩意別人傳遞的是Integer的泛型呢?所以很明顯是行不通的。

1.2.3 從Java位元組碼的角度去看橋接方法

我們可以看到,Javac編譯器,在對Java程式碼進行編譯時,其實針對add方法去生成了兩個方法,而它們的存取識別符號並不相同。我們自己的方法的存取識別符號為0x0001[public],而Javac編譯器為我們生成的橋接方法的返回值,為0x1041[pubic synthetic bridge],多了兩個存取識別符號syntheticbridge

我們開啟橋接方法的code位元組碼

 

我們來分析下位元組碼

  • 1.aload_0,眾所周知,就是從LocalVariableTable(區域性變數表)獲取this物件的參照,並壓棧。
  • 2.aload_1,自然就是將傳入的元素e的參照壓棧。
  • 3.checkcast #3 <java/lang/Double>,自然是檢查能否執行強制型別轉換。
  • 4.invokevirtual #4 <com/wanna/generics/java/MyArrayList.add : (Ljava/lang/Double;)Z>,做到實際上就是從常數池的4號元素當中拿到要執行的方法,也就是我們自己實現的方法。invokevirtual就是執行目標方法,沒毛病。
  • 5.ireturn,自然就是返回一個int型別的值,為什麼是int型別?而不是boolean型別?因為Java當中,在存放到區域性變數表和棧中的情況下,int/byte/boolean/char,都是使用的int的形式存放的,佔用一個區域性變數表的槽位。

我們通過分析得到的資訊和我們之前的分析一致,就是通過橋接方法橋接一下,去呼叫我們自己實現的方法。我們接下來,嘗試使用反射的方式去獲取到add方法有幾個,方法資訊是什麼。

        Arrays.stream(MyArrayList.class.getMethods()).filter(method -> method.getName().equals("add") && method.getParameterCount() == 1).forEach(method -> {
            System.out.printf("方法名為:%s,方法的返回值型別為:%s,方法的參數列為:%s%n",
                    method.getName(), method.getReturnType(), Arrays.toString(method.getParameterTypes()));
        });

程式碼的最終執行結果為

方法名為:add,方法的返回值型別為:boolean,方法的參數列為:[class java.lang.Double]
方法名為:add,方法的返回值型別為:boolean,方法的參數列為:[class java.lang.Object]

也就是說,生成的橋接方法,是我們可以通過反射拿到的,它是一個真實的方法。

通過反射拿到Method之後,我們還可以通過存取識別符號判斷該方法是否是橋接方法。

method.isBridge() 
method.isSynthetic()

判斷橋接方法,實際上,在Spring框架當中的反射工具類(ReflectionUtils)當中就有用到,用來判斷一個方法是否是使用者定義的方法。

2. 泛型逆變

2.1 泛型逆變的使用

泛型逆變的泛型形式是:<? super XXX>,它的作用是賦值給它的約束容器的泛型型別,只能是XXX以及它的父類別。

那麼我們可以往容器裡放入它的子類嗎?也許會說,上面不是都說了需要放入的是XXX以及它的父類別嗎,那肯定是不能放入它的子類的呀!但是我們需要想到一個問題,那就是XXX的所有子類,其實都是可以隱式轉換為XXX型別,或者可以直接說,它的子類就是XXX型別。

我們依次定義三個類

    static class Person {

    }

    static class User extends Person {

    }

    static class Student extends User {

    }

接著,定義一個使用逆變的泛型引數的方法

public static void test(List<? super User> list)

上面我們說了,可以接收的容器泛型型別是User以及它的父類別,也就是說,容器的泛型可以是User也基於是Person。因此,我們可以傳入下面這樣的容器給test方法。

 test(new ArrayList<Person>());

在test方法當中,我們可以執行下面的才做

list.add(new User()); // 放入User
list.add(new Student());  // 放入User的子類

2.2 泛型逆變會有什麼問題

我們需要想想一個問題:我們使用了逆變約定了,接收的容器的泛型型別是User以及User的父類別。我們往容器當中放入的元素,可以是User以及User的子類。也就是說,我們獲取容器中的元素時,根本不知道是什麼型別,只能用Object去接收從容器中獲取的元素型別,因為只是約定了容器的泛型為User和User的父類別,而Object也是它的父類別,因此我們甚至可以傳入一個容器型別為ArrayList<Object>,我們根本無法決定元素型別的上限,只能用Object去進行接收。

final Object object = list.get(0);

現在又有一個問題:之前協變時,會出現因為執行橋接方法時,發生型別轉換異常,在逆變當中會出現這種情況嗎?

我們仔細想想,接收的容器泛型型別為User以及User的父類別,而可以往容器裡存放的是User以及User的子類,也就是說,我們放入到容器中的元素型別,比你原來約束的型別還嚴格,因為:"User以及User的子類"一定是"User以及User的父類別"的子類。也就是說,逆變當中,並不會因為橋接方法中進行的型別導致ClassCastException,所以允許add。

3.協變與逆變-PECS原則

對於協變和逆變,有這樣的一個原則:稱為PECS(Producer Extends Consumer Super)。也就是說:

  • 1.Extends應該用在生產者的情況,也就是要根據泛型型別去返回物件的形式。
  • 2.Super應該用在消費者的情況,應該傳入一個泛型型別的容器,應該利用該容器對資料進行處理,但是不能根據泛型去進行返回,如果要進行返回,只能返回Object,但是這就失去了泛型的意義。
    public static <T> void testCS(List<? super T> list) {  // Consumer Super
        list.add(...);
    }

    public static <T> T testPE(List<? extends T> list) {  // Producer Extends
        return list.get(0);
    }

到此這篇關於淺談Java中的橋接方法與泛型的逆變和協變的文章就介紹到這了,更多相關Java橋接方法與泛型逆變協變內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com