Kotlin的獨門祕籍Reified實化型別引數(下篇)

極客熊貓發表於2018-10-29

Kotlin系列文章,歡迎檢視:

原創系列:

翻譯系列:

實戰系列:

簡述: 今天我們開始接著原創系列文章,首先說下為什麼不把這篇作為翻譯篇呢?我看了下作者的原文,裡面講到的,這篇部落格都會有所涉及。這篇文章將會帶你全部弄懂Kotlin泛型中的reified實化型別引數,包括它的基本使用、原始碼原理、以及使用場景。有了上篇文章的介紹,相信大家對kotlin的reified實化型別引數有了一定認識和了解。那麼這篇文章將會更加完整地梳理Kotlin的reified實化型別引數的原理和使用。廢話不多說,直接來看一波章節導圖:

Kotlin的獨門祕籍Reified實化型別引數(下篇)

一、泛型型別擦除

通過上篇文章我們知道了JVM中的泛型一般是通過型別擦除實現的,也就是說泛型類例項的型別實參在編譯時被擦除,在執行時是不會被保留的。基於這樣實現的做法是有歷史原因的,最大的原因之一是為了相容JDK1.5之前的版本,當然泛型型別擦除也是有好處的,在執行時丟棄了一些型別實參的資訊,對於記憶體佔用也會減少很多。正因為泛型型別擦除原因在業界Java的泛型又稱偽泛型。因為編譯後所有泛型的型別實參型別都會被替換Object型別或者泛型型別形參指定上界約束類的型別。例如: List<Float>、List<String>、List<Student>在JVM執行時Float、String、Student都被替換成Object型別,如果是泛型定義是List<T extends Student>那麼執行時T被替換成Student型別,具體可以通過反射Erasure類可看出。

雖然Kotlin沒有和Java一樣需要相容舊版本的歷史原因,但是由於Kotlin編譯器編譯後出來的class也是要執行在和Java相同的JVM上的,JVM的泛型一般都是通過泛型擦除,所以Kotlin始終還是邁不過泛型擦除的坎。但是Kotlin是一門有追求的語言不想再被C#那樣噴Java說什麼泛型集合連自己的型別實參都不知道,所以Kotlin藉助inline行內函數玩了個小魔法。

二、泛型擦除會帶來什麼影響?

泛型擦除會帶來什麼影響,這裡以Kotlin舉例,因為Java遇到的問題,Kotlin同樣需要面對。來看個例子

fun main(args: Array<String>) {
    val list1: List<Int> = listOf(1,2,3,4)
    val list2: List<String> = listOf("a","b","c","d")
    println(list1)
    println(list2)
}
複製程式碼

上面兩個集合分別儲存了Int型別的元素和String型別的元素,但是在編譯後的class檔案中的他們被替換成了List原生型別一起來看下反編譯後的java程式碼

@Metadata(
   mv = {1, 1, 11},
   bv = {1, 0, 2},
   k = 2,
   d1 = {"\u0000\u0014\n\u0000\n\u0002\u0010\u0002\n\u0000\n\u0002\u0010\u0011\n\u0002\u0010\u000e\n\u0002\b\u0002\u001a\u0019\u0010\u0000\u001a\u00020\u00012\f\u0010\u0002\u001a\b\u0012\u0004\u0012\u00020\u00040\u0003¢\u0006\u0002\u0010\u0005¨\u0006\u0006"},
   d2 = {"main", "", "args", "", "", "([Ljava/lang/String;)V", "Lambda_main"}
)
public final class GenericKtKt {
   public static final void main(@NotNull String[] args) {
      Intrinsics.checkParameterIsNotNull(args, "args");
      List list1 = CollectionsKt.listOf(new Integer[]{1, 2, 3, 4});//List原生型別
      List list2 = CollectionsKt.listOf(new String[]{"a", "b", "c", "d"});//List原生型別
      System.out.println(list1);
      System.out.println(list2);
   }
}
複製程式碼

我們看到編譯後listOf函式接收的是Object型別,不再是具體的String和Int型別了。

Kotlin的獨門祕籍Reified實化型別引數(下篇)

1、型別檢查問題:

Kotlin中的is型別檢查,一般情況不能檢測型別實參中的型別(注意是一般情況,後面特殊情況會細講),類似下面。

if(value is List<String>){...}//一般情況下這樣的程式碼不會被編譯通過
複製程式碼

分析: 儘管我們在執行時能夠確定value是一個List集合,但是卻無法獲得該集合中儲存的是哪種型別的資料元素,這就是因為泛型類的型別實參型別被擦除,被Object型別代替或上界形參約束型別代替。但是如何去正確檢查value是否List呢?請看以下解決辦法

Java中的解決辦法: 針對上述的問題,Java有個很直接解決方式,那就是使用List原生型別。

if(value is List){...}
複製程式碼

Kotlin中的解決辦法: 我們都知道Kotlin不支援類似Java的原生型別,所有的泛型類都需要顯示指定型別實參的型別,對於上述問題,kotlin中可以藉助星投影List<*>(關於星投影后續會詳細講解)來解決,目前你暫且認為它是擁有未知型別實參的泛型型別,它的作用類似Java中的List<?>萬用字元。

if(value is List<*>){...}
複製程式碼

特殊情況: 我們說is檢查一般不能檢測型別實參,但是有種特殊情況那就是Kotlin的編譯器智慧推導(不得不佩服Kotlin編譯器的智慧)

fun printNumberList(collection: Collection<String>) {
    if(collection is List<String>){...} //在這裡這樣寫法是合法的。
}
複製程式碼

分析: Kotlin編譯器能夠根據當前作用域上下文智慧推匯出型別實參的型別,因為collection函式引數的泛型類的型別實參就是String,所以上述例子的型別實參只能是String,如果寫成其他的型別還會報錯呢。

2、型別轉換問題:

在Kotlin中我們使用as或者as?來進行型別轉換,注意在使用as轉換時,仍然可以使用一般的泛型型別。只有該泛型類的基礎型別是正確的即使是型別實參錯誤也能正常編譯通過,但是會丟擲一個警告。一起來看個例子

Kotlin的獨門祕籍Reified實化型別引數(下篇)

package com.mikyou.kotlin.generic


fun main(args: Array<String>) {
    printNumberList(listOf(1, 2, 3, 4, 5))//傳入List<Int>型別的資料
}

fun printNumberList(collection: Collection<*>) {
    val numberList = collection as List<Int>//強轉成List<Int>
    println(numberList)
}
複製程式碼

執行輸出

Kotlin的獨門祕籍Reified實化型別引數(下篇)

package com.mikyou.kotlin.generic


fun main(args: Array<String>) {
    printNumberList(listOf("a", "b", "c", "d"))//傳入List<String>型別的資料
}

fun printNumberList(collection: Collection<*>) {
    val numberList = collection as List<Int>
    //這裡強轉成List<Int>,並不會報錯,輸出正常,
    //但是需要注意不能預設把型別實參當做Int來操作,因為擦除無法確定當前型別實參,否則有可能出現執行時異常
    println(numberList)
}
複製程式碼

執行輸出

Kotlin的獨門祕籍Reified實化型別引數(下篇)

如果我們把呼叫地方改成setOf(1,2,3,4,5)

fun main(args: Array<String>) {
    printNumberList(setOf(1, 2, 3, 4, 5))
}

fun printNumberList(collection: Collection<*>) {
    val numberList = collection as List<Int>
    println(numberList)
}
複製程式碼

執行輸出

Kotlin的獨門祕籍Reified實化型別引數(下篇)

分析: 仔細想下,得到這樣的結果也很正常,我們知道泛型的型別實參雖然在編譯期被擦除,泛型類的基礎型別不受其影響。雖然不知道List集合儲存的具體元素型別,但是肯定能知道這是個List型別集合不是Set型別的集合,所以後者肯定會拋異常。至於前者因為在執行時無法確定型別實參,但是可以確定基礎型別。所以只要基礎型別匹配,而型別實參無法確定有可能匹配有可能不匹配,Kotlin編譯採用丟擲一個警告的處理。

注意: 不建議這樣的寫法容易存在安全隱患,由於編譯器只給了個警告,並沒有卡死後路。一旦後面預設把它當做強轉的型別實參來操作,而呼叫方傳入的是基礎型別匹配而型別實參不匹配就會出問題。

package com.mikyou.kotlin.generic


fun main(args: Array<String>) {
    printNumberList(listOf("a", "b", "c", "d"))
}

fun printNumberList(collection: Collection<*>) {
    val numberList = collection as List<Int>
    println(numberList.sum())
}
複製程式碼

執行輸出

Kotlin的獨門祕籍Reified實化型別引數(下篇)

三、什麼是reified實化型別引數函式?

通過以上我們知道Kotlin和Java同樣存在泛型型別擦除的問題,但是Kotlin作為一門現代程式語言,他知道Java擦除所帶來的問題,所以開了一扇後門,就是通過inline函式保證使得泛型類的型別實參在執行時能夠保留,這樣的操作Kotlin中把它稱為實化,對應需要使用reified關鍵字。

1、滿足實化型別引數函式的必要條件

  • 必須是inline行內函數,使用inline關鍵字修飾
  • 泛型類定義泛型形參時必須使用reified關鍵字修飾

2、帶實化型別引數的函式基本定義

inline fun <reified T> isInstanceOf(value: Any): Boolean = value is T 
複製程式碼

對於以上例子,我們可以說型別形參T是泛型函式isInstanceOf的實化型別引數。

3、關於inline函式補充一點

我們對inline函式應該不陌生,使用它最大一個好處就是函式呼叫的效能優化和提升,但是需要注意這裡使用inline函式並不是因為效能的問題,而是另外一個好處它能是泛型函式型別實參進行實化,在執行時能拿到型別實參的資訊。至於它是怎麼實化的可以接著往下看

四、實化型別引數函式的背後原理以及反編譯分析

我們知道型別實化引數實際上就是Kotlin變得的一個語法魔術,那麼現在是時候揭開魔術神祕的面紗了。說實在的這個魔術能實現關鍵得益於行內函數,沒有行內函數那麼這個魔術就失效了。

1、原理描述

我們都知道行內函數的原理,編譯器把實現行內函數的位元組碼動態插入到每次的呼叫點。那麼實化的原理正是基於這個機制,每次呼叫帶實化型別引數的函式時,編譯器都知道此次呼叫中作為泛型型別實參的具體型別。所以編譯器只要在每次呼叫時生成對應不同型別實參呼叫的位元組碼插入到呼叫點即可。 總之一句話很簡單,就是帶實化引數的函式每次呼叫都生成不同型別實參的位元組碼,動態插入到呼叫點。由於生成的位元組碼的型別實參引用了具體的型別,而不是型別引數所以不會存在擦除問題。

2、reified的例子

帶實化型別引數的函式被廣泛應用於Kotlin開發,特別是在一些Kotlin的官方庫中,下面就用Anko庫(簡化Android的開發kotlin官方庫)中一個精簡版的startActivity函式

inline fun <reified T: Activity> Context.startActivity(vararg params: Pair<String, Any?>) =
        AnkoInternals.internalStartActivity(this, T::class.java, params)
複製程式碼

通過以上例子可看出定義了一個實化型別引數T,並且它有型別形參上界約束Activity,它可以直接將實化型別引數T當做普通型別使用

3、程式碼反編譯分析

為了好反編譯分析單獨把庫中的那個函式拷出來取了startActivityKt名字便於分析。

class SplashActivity : BizActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.biz_app_activity_welcome)
        startActivityKt<AccountActivity>()//只需這樣就直接啟動了AccountActivity了,指明瞭型別形參上界約束Activity
    }
}

inline fun <reified T : Activity> Context.startActivityKt(vararg params: Pair<String, Any?>) =
        AnkoInternals.internalStartActivity(this, T::class.java, params)
複製程式碼

編譯後關鍵程式碼

//函式定義反編譯
 private static final void startActivityKt(@NotNull Context $receiver, Pair... params) {
      Intrinsics.reifiedOperationMarker(4, "T");
      AnkoInternals.internalStartActivity($receiver, Activity.class, params);//注意點一: 由於泛型擦除的影響,編譯後原來傳入型別實參AccountActivity被它形參上界約束Activity替換了,所以這裡證明了我們之前的分析。
   }
//函式呼叫點反編譯
protected void onCreate(@Nullable Bundle savedInstanceState) {
      super.onCreate(savedInstanceState);
      this.setContentView(2131361821);
      Pair[] params$iv = new Pair[0];
      AnkoInternals.internalStartActivity(this, AccountActivity.class, params$iv);
      //注意點二: 可以看到這裡函式呼叫並不是簡單函式呼叫,而是根據此次呼叫明確的型別實參AccountActivity.class替換定義處的Activity.class,然後生成新的位元組碼插入到呼叫點。
}
複製程式碼

讓我們稍微在函式加點輸出就會更加清晰

class SplashActivity : BizActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.biz_app_activity_welcome)
        startActivityKt<AccountActivity>()
    }
}

inline fun <reified T : Activity> Context.startActivityKt(vararg params: Pair<String, Any?>) {
    println("call before")
    AnkoInternals.internalStartActivity(this, T::class.java, params)
    println("call after")
}
複製程式碼

反編譯後

private static final void startActivityKt(@NotNull Context $receiver, Pair... params) {
      String var3 = "call before";
      System.out.println(var3);
      Intrinsics.reifiedOperationMarker(4, "T");
      AnkoInternals.internalStartActivity($receiver, Activity.class, params);
      var3 = "call after";
      System.out.println(var3);
   }

   protected void onCreate(@Nullable Bundle savedInstanceState) {
      super.onCreate(savedInstanceState);
      this.setContentView(2131361821);
      Pair[] params$iv = new Pair[0];
      String var4 = "call before";
      System.out.println(var4);
      AnkoInternals.internalStartActivity(this, AccountActivity.class, params$iv);//替換成確切的型別實參AccountActivity.class
      var4 = "call after";
      System.out.println(var4);
   }
   
複製程式碼

五、實化型別引數函式的使用限制

這裡說的使用限制主要有兩點:

1、Java呼叫Kotlin中的實化型別引數函式限制

明確回答Kotlin中的實化型別引數函式不能在Java中的呼叫,我們可以簡單的分析下,首先Kotlin的實化型別引數函式主要得益於inline函式的內聯功能,但是Java可以呼叫普通的行內函數但是失去了內聯功能,失去內聯功能也就意味實化操作也就化為泡影。故重申一次Kotlin中的實化型別引數函式不能在Java中的呼叫

2、Kotlin實化型別引數函式的使用限制

  • 不能使用非實化型別形參作為型別實參呼叫帶實化型別引數的函式
  • 不能使用實化型別引數建立該型別引數的例項物件
  • 不能呼叫實化型別引數的伴生物件方法
  • reified關鍵字只能標記實化型別引數的行內函數,不能作用與類和屬性。
Kotlin的獨門祕籍Reified實化型別引數(下篇)

歡迎關注Kotlin開發者聯盟,這裡有最新Kotlin技術文章,每週會不定期翻譯一篇Kotlin國外技術文章。如果你也喜歡Kotlin,歡迎加入我們~~~

相關文章