Kotlin入門潛修之類和物件篇—泛型及其原理

Android架構發表於2019-01-29

泛型

如果我們瞭解java中的泛型,那麼本篇文章提到的kotlin泛型我們也不會陌生。但是如果之前沒有接觸過泛型或者沒有真正理解泛型,本篇文章理解起來可能有些困難,不過我會盡量闡述的通俗易懂。

java中的泛型

前面一直有提到,kotlin是執行於jvm上的語言,其對標的語言就是java,因此我們先來講一下java的泛型,瞭解了java泛型的優缺點之後,我們就很容易明白kotlin中泛型的設計初衷了。 首先說下泛型的概念,所謂泛型即是型別的引數化。怎麼理解呢?想一下以前我們所說的方法,如果方法有入參,那麼這些入參前面往往會有型別,這個型別就是為了修飾引數所用。而假如我們在建立型別的時候也為其指定引數,這個引數又是個型別,那麼我們就稱之為泛型。 那麼泛型的作用和意義是什麼?使用泛型能夠像傳遞引數一樣傳遞型別,同時保證執行時的型別安全。型別的傳遞能夠讓我們寫一份程式碼就能滿足各種型別的呼叫;型別安全是指編譯器在編譯程式碼期間會對泛型資訊進行檢查,只有符合規範的才能編譯通過,這樣可以有效避免執行時的ClassCastException異常。這也就是和使用Object相比(所有型別都可以用基類Object表示),泛型的一個優勢所在。泛型和Object的使用對比示例如下:

   public void test(){
        //使用Object的場景
        Map map = new HashMap();
        map.put("test", 1);
        Integer r = (Integer)map.get("test");//正確!get返回的Object型別可以轉換為Integer。因為map中存放的實際型別就是Integer型別。
        String r1 = (String) map.get("test");//錯誤!執行時會報型別轉換異常!因為map中存放的實際型別是Integer型別,而不是String。
        //使用泛型
        Map<String,Integer> map2 = new HashMap<String ,Integer>();
        map2.put("test", 1);
        Integer r3 = map2.get("test");//正確!不用考慮任何型別轉換
        String r2 = map2.get("test");//編譯不通過!因為map2的值只能是Integer,所以返回的是Integer,而不是String
    }
複製程式碼

java中既支援類泛型也支援方法泛型。示例如下:

public class GenericClass<T> {//建立類GenericClass的時候,為其指定了型別引數T。
    void test(T t) {
    }
}

class GenericClass2 {
    <T> void test(T t) {//方法泛型化。宣告方法的時候為其指定了型別引數T。
    }
}
複製程式碼

上例簡單展示了泛型的定義,上面的T可以傳入任何型別進行表示,這就相當於一個入參,只不過這個入參是個型別而已。 由於本章節的目的並不是為了闡述java中泛型的語法,而是想發現java中泛型的弊端。所以,下面我們直接使用jdk提供的泛型庫來演示下java中泛型的限制。 泛型型別是不可協變的,示例如下:

        List<Integer> ints = new ArrayList<>();//正確,生成一個型別是Integer的集合
        List<Object> numbers = ints;//!!!錯誤,List<Integer>不是List<Object>的子類
複製程式碼

按道理來講,Integer是Object的子類,如果一個集合中的元素都是Integer型別的,那麼該集合顯然應該能被放到Object集合中。然而java卻不允許我們這麼做,為什麼? 假如java允許這麼做,那麼會帶來什麼後果?看下面程式碼:

    public static void main(String[] args) {
        List<Integer> ints = new ArrayList<>();
        List<Object> numbers = ints;//假如java允許這樣賦值
        numbers.add("hello");//這句語法顯然是成立的,字串屬於Object型別
        Integer i = (Integer) numbers.get(0);//!!!錯誤,這句程式碼執行的時候會拋ClassCastException異常
    }
複製程式碼

這就是為什麼java不允許我們這麼做的原因,就是為了保證執行時型別安全。同時也說明了,java中的泛型不是協變(下面章節會詳細介紹什麼是協變)的! 上面也說到了,這種限制其實是不合理的。但是我們再來看下面一個例子:

        List<Integer> ints = new ArrayList<>();
        List<Object> numbers = new ArrayList<>();
        numbers.addAll(ints);//!!!正確!
複製程式碼

上面程式碼中最後一句竟然是正確是寫法!可以通過addAll將Integer集合加入到Object集合!按照上面的分析,這顯然是不可能的!比如我們自己來寫一個addAll方法:

interface IList<E> {//我們定義了一個泛型介面IList
    void addAll(IList<E> list);//我們定義了一個addAll發光法,用於新增list集合
}
//MyList提供IList的預設實現
class MyList<E> implements IList<E> {
    @Override
    public void addAll(IList<E> list) {
    }
}
public class Main {
    public static void main(String[] args) {
        IList<Integer> ints = new MyList<>();
        IList<Object> numbers = new MyList<>();
        numbers.addAll(ints);//!!!錯誤!
    }
}
複製程式碼

注意上面 numbers.addAll(ints)這句程式碼竟然報錯了!依然提示型別衝突!那麼java list中的addAll為什麼可以呢? 讓我們來看下list中的addAll方法的定義:

boolean addAll(Collection<? extends E> c);
複製程式碼

我們發現addAll方法入參的泛型定義實際上是<?extends E>這個型別,而不是這個型別。這就引出了java中的萬用字元(使用?表示)概念。

著名的PECS法則

上一章節中引出了java中萬用字元的概念,java中的萬用字元可分為三類:

1.無界萬用字元:? 2.子類限定萬用字元:<? extends E> 3.父類限定萬用字元:<? super E>

首先看下這三個萬用字元的使用(請仔細閱讀程式碼中的註釋):

public class Main {
    static void test(List<?> list) {
        //在該方法中測試新增物件,實際上測試的是無界萬用字元作為類泛型引數的場景,因為list的型別是泛型List即List<?>
        list.add(null);//可以
        list.add(1);//無法新增int
        list.add(new Test2());//無法新增自定義Test2型別物件
        list.add("test");//無法新增字串型別
    }

    static void test1(List<? extends Number> list) {
        //在該方法中測試新增物件,list.add實際上測試的是萬用字元作為類泛型引數的場景,因為list的型別是泛型List類即List<? extends Number>
        list.add(null);//可以
        list.add(1);//無法新增int
        list.add(new Test2());//無法新增自定義Test2型別物件
        list.add("test");//無法新增字串型別
    }

    static void test2(List<? super Number> list) {
        //在該方法中測試新增物件,list.add實際上測試的是萬用字元作為類泛型引數的場景,因為list的型別是泛型List類即List<? super Number>
        list.add(null);//可以
        list.add(1);//可以
        list.add(new Test2());//錯誤
        list.add("test");//錯誤
    }
    public static void main(String[] args) {
        List list = new ArrayList();
        List<Integer> list2 = new ArrayList<>();
        List<Object> list3 = new ArrayList<>();
       //test方法的呼叫,實際上測試的是無界萬用字元作為方法形參型別一部分的場景
        test(list);//正確
        test(list2);//正確
        test(list3);//正確
        //test1方法的呼叫,實際上測試的是子類限定萬用字元萬用字元作為方法形參型別一部分的場景
        test1(list);//警告,沒有進行型別檢測。傳入的是List,需要List<? extends Number>型別
        test1(list2);//正確
        test1(list3);//錯誤,需要List<? extends Number>型別,但傳入的是List<Object>
       //test2方法的呼叫,實際上測試的是父類限定萬用字元萬用字元作為方法形參型別一部分的場景
        test2(list);//警告,沒有進行型別檢測,傳入的是List,需要List<? super Number> list型別
        test2(list2);//編譯錯誤,傳入的List<String>,然而需要的是List<? super Number>
        test2(list3);//正確
    }
}
複製程式碼

上面程式碼我們刻意選擇了泛型型別Number以及其子類Integer來進行測試。註釋已經比較詳細,主要描述了萬用字元的應用場景。結合上面程式碼我們可以總結如下:

1.對於賦值操作(引數入參也是賦值的一種情形)。無界萬用字元可以接受任意型別賦值;子類限定萬用字元可以接受泛型型別為其子類、本身或者沒有泛型型別的賦值,其中沒有泛型型別賦值時會有編譯警告。父類限定萬用字元可以接受泛型型別為其超類、本身以及沒有泛型型別的賦值,其中沒有泛型型別賦值時會有編譯警告。 2.對於讀寫操作。無界萬用字元無法新增除了null以外的任何物件。子類限制萬用字元也無法新增除了null外的任何物件,實際上子類萬用字元只可讀不可寫。父類限制萬用字元允許新增其子類,而不允許新增其父類。

總結已經完畢,主要來看兩個點:

1.為什麼無限制萬用字元和子類限制萬用字元只有可讀性沒有可寫性? 2.為什麼父類限制萬用字元允許子類型別寫入?

這就是我們要講的PECS原則。什麼是PECS?PECS的全稱可以理解為Producer-Extends-Consumer-Super,即其描述了子類限制符和父類限制符的使用原則。

1.<? extends T>子類限制符,用於生產者場景(Producer),表示可以從容器中取元素。 2.<?super T>父類限制符,用於消費者場景(Consumer),表示可以向容器中存入元素。 3.如果既要存元素又要取元素,那麼萬用字元無法滿足需求。 為什麼會有上面限制?其實上面已經有所描述。這裡再來闡述下。

首先,對於<? extends T>來說,表示的是T及其子型別,如果我們允許向容器中新增元素,那麼我們無法確定子型別具體是什麼型別,這樣在取出元素的時候就有可能報型別轉換異常,故為了執行時安全考慮,java直接禁止了元素寫入。 對於<? super T>來說,表示的是T及其T的超類型別,如果是T的子類那麼一定也是T的超類的子類,所以將子類元素新增到容器是允許的,因為取出來的時候一定符合T或者T的超類型別。但是如果是T的超類那麼是不允許像容器中新增元素的,因為我們無法確定T的超類具體是什麼型別,取出來的時候就可能引起型別轉換錯誤。程式碼示例如下:

        List<? super Number> l = new ArrayList<>();
        l.add(1);//正確,Integer是Number的子類
        l.add(1.1);//正確,Double是Number的子類
        l.add(new Object());//錯誤,Object不是Number的子類

        List<? extends Number> l2 = new ArrayList<>();
        l2.add(1);//錯誤,子類限制萬用字元禁止寫
        l2.add(new Object());//錯誤,子類限制萬用字元禁止寫
複製程式碼

至此,我們將java中的泛型大概過了一遍。下面來看下kotlin中的泛型。

kotlin中的泛型

宣告處變數(Declaration-site variance)

想了解宣告處變數是什麼,先回到上文提到的java中的泛型問題:

//定義 一個泛型介面IList
interface IList<E> {
    E getE();//只有一個getE方法,返回了E型別
}
//定義了一個test方法,該方法接收元素是String型別的集合
void test(IList<String> strs) {
        IList<Object> objs = strs;//這裡我們將String集合賦值於Object集合,這在java中顯然是不允許的!
}
複製程式碼

上面test方法中的寫法在java中顯然是不允許的,如果要允許我們就必須使用萬用字元寫法:

void test(IList<String> strs) {
        IList<? extends Object> objs = strs;//正確,這裡採用了子類限制萬用字元寫法
}
複製程式碼

這裡問題就來了,子類限制萬用字元實際上是限制寫的,但這裡我們並沒有寫入任何元素(IList也只有一個getE方法,只是java編譯器不知道而已),按理講不使用子類限制萬用字元也應該能編譯才對,然而java卻沒有通過編譯,這就是java泛型中的一個弊端。 kotlin為了解決上面問題,就引入了宣告處變數。宣告處變數的作用就是在泛型型別引數前新增特定修飾符,來保證只會返回特定元素(即PECS中的生產),而不會消費任何元素(PECS中的消費)。

interface IList<out E> {//注意這裡使用out修飾,這就是宣告處變數
    fun getE(): E//注意這個介面只有get方法返回了E,沒有其他任何寫入的方法。所以我們使用out修飾了IList介面
}
fun test(strs: IList<String>) {
    val objs: IList<Any> = strs//正確!
}
複製程式碼

上面就是kotlin宣告處變數的使用,解決了java在沒有消費場景的時候無法賦值的問題。 這裡可以這麼理解,IList在修飾時是協變的,或者說E是個協變型別引數;IList是E的生產者,而不是E的消費者。 什麼是協變?所謂協變就是隻要引數型別具有繼承關係就認為整個泛型型別也有“繼承”關係:比如上例中,String繼承於Any,那麼我們就可以認為IList是IList的子型別,這樣就可以讓IList型別的變數賦值於IList型別變數,這就是協變。 上面語法中的out被稱為變數註解,因為out被定義在型別引數的宣告側(如IList)所以就稱為宣告處變數。這正是相對於java的“使用側變數”定義而言的(比如java想要達到這種效果,就必須要在接收處宣告為萬用字元泛型,而不是在IList的定義處: IList<? extends Object> objs = strs;) 對比於out修飾符,kotlin還提供了另一個修飾符:in,in修飾符和out修飾符的作用剛好相反,in修飾符主要用於生產者場景,即可以寫入。來看下面一個例子:

//這裡定義另一個普通的泛型介面
interface Comparable<T> {
    operator fun compareTo(other: T): Int
}
//test測試方法
fun test(x: Comparable<Number>) {
    val y: Comparable<Double> = x//錯誤!這裡想要進行寫操作,kotlin是不允許的!!!
}

那麼如何解決呢?使用我們的in修飾符即可:
interface Comparable<in T> {
    operator fun compareTo(other: T): Int
}
//test測試方法
fun test(x: Comparable<Number>) {
    val y: Comparable<Double> = x//正確!in修飾符允許我們寫
}
複製程式碼

這種情況叫做逆變,即我們當型別引數具有繼承關係的時候,我們可以認為整個泛型也有繼承關係,而使用in修飾後,可以允許父型別變數賦值於子型別變數,如上面程式碼中,將Comparable型別變數x賦值給了 Comparable型別變數y,這就是逆變。 kotlin中的宣告處變數可以相對於java中的PECS理解:可簡稱為CIPO。C即是Consumer,I表示in,P表示生產者,O表示out。CIPO和java中的PECS一致。

型別對映(Type projections)

型別對映是屬於使用側定義的變數。先來看個例子:

//這裡定義了一個陣列copy的方法
fun copy(from: Array<Any>, to: Array<Any>) {
//假設這裡我們就是正常完成了from元素copy到to元素中
//這顯然是合情合理的
}
fun test(){
    val ints: Array<Int> = arrayOf(1, 2, 3)
    val any = Array<Any>(3) { "" }
    copy(ints, any)//!!!錯誤,需要Array<Any>型別,但是傳入的是Array<Int>型別
}
複製程式碼

上面的程式碼又復現了經典的問題,即泛型型別是不變因子,即Array不是Array的子類,為什麼要這麼限制?道理和上面一樣,kotlin認為我們有可能會對from進行寫操作,比如我們在copy中為from中的一個元素賦值了一個字串(雖然我們按正常邏輯不會這麼寫,我們只需要完成copy的功能就行,但是kotlin不這麼認為)!這就會引起型別轉換異常!所以kotlin對這種情形進行了限制。 解決方法就是禁止從from寫入,告訴編譯器我只讀取from即可!如下所示:

fun copy(from: Array<out Any>, to: Array<Any>) {//這裡將from宣告為了<out Any>泛型,表示不可寫,只可讀。
}
fun test(){
    val ints: Array<Int> = arrayOf(1, 2, 3)
    val any = Array<Any>(3) { "" }
    copy(ints, any)//正確,編譯器已經知道from只可讀不可寫,所以允許我們這麼傳入。
}
複製程式碼

上面這種寫法就是型別對映。目的就是可以使用讀操作,而不使用寫操作。 當然,我們也可以使用in操作符進行修飾,表示可以使用寫操作,如下所示:

//from使用了in修飾,表示可寫,類似於java中的<? super T>
//接收String及其超類。
fun copy(from: Array<in String>, to: Array<Any>) {
}

fun test(){
    val strs: Array<CharSequence> = arrayOf("1")
    val any = Array<Any>(3) { "" }
    copy(strs, any)//正確,CharSequence是String的超類,符合<in String>的限制
}
複製程式碼

上面程式碼需要注意的是,呼叫方法傳遞引數時,實際上進行的是賦值操作,這個並不是上面提到的類似於add的這種寫操作。in作用於賦值操作時,只允許超類型別或自身型別賦值於其子類型別,而作用於add等寫操作時,只允許寫入子類型別或者自身型別。

星號對映(Star-projections)

有些時候,我們並不知道型別引數到底是什麼,但是我們依然想安全的使用這些型別引數,該怎麼辦? 正式基於上面的考慮,kotlin為我們提供了星號對映,其修飾符為*。 星號對映的對應的幾種泛型別使用場景闡述如下(假設現在我們為類GenericClass定義了幾種泛型):

對於GenericClass這種泛型來講,GenericClass<>等價於GenericClass,這意味著,如果T型別是未知的,你可以安全的從GenericClass<>中讀取TUpper值。 對於GenericClass這種泛型來講,GenericClass<>等價於GenericClass,這就意味著當T為未知型別時,你無法安全的向GenericClass<>型別中寫入任何資料。 對於GenericClass這種泛型來講,GenericClass<*>在讀的時候,相當於GenericClass;在寫的時候,相當於GenericClass

如果泛型有多個入參型別,比如 GenericClass<in T, out U>,那麼星對映對應的場景描述如下:

GenericClass<*, String>等價於GenericClass<in Nothing,String>; GenericClass<Int, > 等價於GenericClass<Int, out Any?>; GenericClass<, *>等價於GenericClass<in Nothing, out Any?>。

emm... 上面巴拉巴拉一大堆,說的是什麼玩意? 確實,上面的描述枯燥難耐,很難有人能細心看下去,最敞亮的方式,還是要上幾個例子,演示下星號對映的使用場景。

class GenericClass<in T, out E>(t: T, val e: E) {
    fun set(t: T) {
        println(t)
    }

    fun get(): E {
        return e
    }
}
//測試方法,詳見註釋
    fun test() {
        val g1: GenericClass<*, String> = GenericClass(1, "hello")//*代替了in修飾的型別,表示In Nothing
        val g2: GenericClass<Number, *> = GenericClass(1, "hello")//*代替了out修飾的型別,表示out Any?
        val g3: GenericClass<*, *> = GenericClass(1, "hello")

        g1.set(1)//錯誤。由於*代替了in修飾的型別,表示in Nothing,故沒有辦法寫
        val result: String = g1.get()//正確,該方法實際返回String型別

        g2.set(1)//正確,in修飾的型別,可以傳入其子型別,這裡為Int,繼承與Number
        g2.set(Any())//錯誤,in修飾的型別,無法傳入其超類型別,Any是Number的超類
        val result2: Any? = g2.get()//由於*代替了out修飾的型別,表示out Any?可以讀出String的任意父類。換句話說,這個方法本身返回了Any?

        g3.set(1)//同g1,不能寫
        val result3: Any? = g3.get()//同g2
    }
複製程式碼

泛型方法

泛型的概念前面已經介紹很多了,這裡簡單演示下kotlin中泛型方法的使用:

class GenericClass<in T, out E> {
    fun m1(t: T) {//可以在泛型類中定義方法,只需要方法的入參泛型化即可。
        println(t)
    }
}

class GenericTest {
    companion object {
        fun <T> m1(t: T) {//在普通類中定義方法,除泛型化入參之外,還應該在方法名前增加<T>修飾。
        }
        @JvmStatic fun main(args: Array<String>) {
            m1(1)//呼叫方法
            m1<Int>(1)//可以顯示指定泛型型別,但是沒有必要,直接m1(1)即可。
        }
    }
}
複製程式碼

泛型約束

我們來看一個例子:

    companion object {
        fun <T : Comparable<T>> sort(list: List<T>) {//sort方法,入參只能是Comparable<T>子類
        }

        @JvmStatic
        fun main(args: Array<String>) {
            sort(listOf(1, 2, 3)) // 正確,Int是Comparable<Int>的子類
            sort(listOf(HashMap<Int, String>())) // !!!錯誤,HashMap<Int, String> 不是Comparable<HashMap<Int, String>>的子類
        }
    }
複製程式碼

上面展示了超類限制型別的場景,雖然我們期望可以對 HashMap<Int, String>進行排序,但是因為HashMap<Int, String> 沒有實現Comparable<HashMap<Int, String>>介面,所以不允許呼叫sort方法。 在kotlin中,預設的超類型別上限是Any?,在定義超型別的時候,只能指定一個超類,比如<T: SupperT>中只能指定T的超類上限是SupperT,而不能指定多個。但是有些時候我們確實需要指定多個超類型別,該怎麼辦? 為了解決這種情況,kotlin為我們提供了where語句,示例如下:

fun <T> copyWhenGreater(list: List<T>, threshold: T): List<String>
                where T : CharSequence,//T必須是CharSequence型別的子型別或者CharSequence型別
                      T : Comparable<T> {//同時T必須是Comparable<T>型別的子型別或者Comparable<T>型別
            return list.filter { it > threshold }.map { it.toString() }
        }
複製程式碼

泛型原理

kotlin中的泛型同java一樣,都是“假”泛型,為什麼這麼說?是因為kotlin中的泛型資訊同java一樣,只在編譯器間有,用於編譯器做型別檢查,而在執行的時候泛型資訊就被擦除了,也就是說GenericClass和GenericClass在執行時是無差別的,等同於GenericClass<*>。 所以,我們無法在執行時獲取任何泛型資訊,也無法在執行時做任何型別轉換檢查。比如:

fun <T : Comparable<T>> sort(list: List<T>) {
            if(list is List<String>){//錯誤,在執行時泛型資訊已經被擦除(list的型別在執行時都是List<*>),無法使用is進行型別判斷
            }
        }
複製程式碼

至此,我們已經講完了kotlin中的泛型。本篇文章有點枯燥,體會泛型的最佳路徑還是在實踐中多用用泛型,用多了自然而然就明白了。

相關文章