Kotlin語言中的泛型設計哲學

歐陽鋒發表於2018-04-16

文 | 歐陽鋒

Kotlin語言的泛型設計很有意思,但並不容易看懂。關於這個部分的官方文件,我反覆看了好幾次,終於弄明白Kotlin語言泛型設計的背後哲學。這篇文章將講述Kotlin泛型設計的整個思考過程及其背後的哲學思想,希望可以解答你心中的疑問。不過,可以預見地,即使看完,你也未必完全明白這篇文章在說什麼,但至少希望你通過這篇文章可以快速掌握Kotlin泛型的用法。

Kotlin泛型的設計初衷

我們認為,Kotlin是一門比Java更優秀的JVM程式語言,Kotlin泛型設計的初衷就是為了解決Java泛型設計中一些不合理的問題。這樣說可能不夠直觀,看下面這個例子:

 List<String> strs = new ArrayList<>();
// 這裡將導致編譯錯誤,Java語言不允許這樣做
 List<Object> objs = strs;
複製程式碼

很明顯,String和Object之間存在著安全的隱式轉換關係。存放字串的集合應該可以自由轉換為物件集合。這很合理,不是嗎?

如果你這樣認為的話,就錯了!繼續往下看,我們擴充套件這個程式:

List<String> strs = new ArrayList<>();
List<Object> objs = strs;
objs.add(1);

String s = strs.get(0);
複製程式碼

很明顯,這不合理!我們在第一個位置存入了整型數值1,卻在取的時候將它當成了字串。strs本身是一個字串集合,用字串接收讀取的資料的邏輯是合理的。卻因為錯誤的型別轉換導致了不安全寫入出現了執行時型別轉換問題,因此,Java語言不允許我們這樣做。

大多數情況下,這種限制沒有問題。可是,在某些情況下,這並不合理。看下面的例子:

interface List<T> {
    void addAll(List<T> t);
}

public void copy(List<String> from, List<Object> to) {
   to.addAll(from);
}
複製程式碼

這是一個型別絕對安全的操作,但在Java語言中這依然是不允許的。原因是,泛型是一個編譯期特性,一旦指定,執行期型別就已經固定了。換而言之,泛型操作的型別是不可變的。這就意味著,List<String>並不是List<Object>的子型別。

為了允許正確執行上述操作,Java語言增加了神奇的萬用字元操作魔法。

interface List<T> {
  void addAll(List<? extends T> t);
}
複製程式碼

? extends T意味著集合中允許新增的型別不僅僅是T還包括T的子類,但這個集合中可以新增的型別在集合引數傳入addAll時就已經確定了。因此,這並不影響引數集合中可以存放的資料型別,它帶來的一個直接影響就是addAll方法引數中終於可以傳入集合泛型引數是T或者T的子類的集合了,即上面的copy方法將不再報錯。

這很有意思,在使用萬用字元之前我們並不能傳入型別引數為子型別的集合。使用萬用字元之後,居然可以了!這個特性在C#被稱之為協變(covariant)。

協變這個詞來源於型別之間的繫結。以集合為例,假設有兩個集合L1、L2分別繫結資料型別F、C,並且F、C之間存在著父子關係,即F、C之間存在著一種安全的從C->F的隱式轉換關係。那麼,集合L1和L2之間是否也存在著L2->L1的轉換關係呢?這就牽扯到了原始型別轉換到繫結型別的集合之間的轉換對映關係,我們稱之為“可變性”。如果原始型別轉換和繫結型別之間轉換的方向相同,就稱之為“協變”。

用一句話總結協變:如果繫結物件和原始物件之間存在著相同方向的轉換關係,即稱之為協變

PS:以上關於協變的概念來自筆者的總結,更嚴謹的概念請參考C#官方文件

文章開頭我們將不可變泛型通過萬用字元使其成為了可變泛型引數,現在我們知道這種行為叫做協變。很明顯,協變轉換中寫入是不安全的。因此,協變行為僅僅用於讀取。如果需要寫入怎麼辦呢?這就牽扯到了另外一個概念逆變(contravariance)。

逆變協變恰恰相反,即如果F、C之間存在著父子轉換關係,L1、L2之間存在著從L1->L2的轉換關係。其繫結物件的轉換關係與原始物件的轉換關係恰好相反。Java語言使用關鍵字super(?super List)實現逆變

舉個例子:假設有一個集合List<? super String>,你將可以安全地使用add(String)或set(Int,String)方法。但你不能通過get(Int)返回String物件,因為你無法確定返回的物件是否是String型別,你最終只能得到Object。

因此,我們認為,逆變可以安全地寫入資料,但並不能安全地讀取,即最終不能獲取具體的物件資料型別。

為了簡化理解,我們引入官方文件中 Joshua Bloch說的一句話:

Joshua Bloch calls those objects you only read from Producers, and those you only write to Consumers. He recommends: "For maximum flexibility, use wildcard types on input parameters that represent producers or consumers"

Joshua Bloch是Java集合框架的創始人,他把那些只能讀取的物件叫做生產者;只能寫入的物件叫做消費者。為了保證最大靈活性,他推薦在那些代表了生產者和消費者的輸入引數上使用萬用字元指定泛型。

相對於Java的萬用字元,Kotlin語言針對協變逆變引入兩個新的關鍵詞outin

out用於協變,是隻讀的,屬於生產者,即用在方法的返回值位置。而in用於逆變,是隻寫的,屬於消費者,即用在方法的引數位置。

用英文簡記為:POCI = Producer Out , Consumer In。

如果一個類中只有生產者,我們就可以在類頭使用out宣告該類是對泛型引數T協變的:

interface Link<out T> {
    fun node(): T
}
複製程式碼

同樣地,如果一個類中只有消費者,我們就可以在類頭使用in宣告該類是對泛型引數T逆變的:

interface Repo<in T> {
    fun add(t: T)
}
複製程式碼

out 等價於Java端的 ? extends List 萬用字元,而 in 等價於Java端的 ? super List 萬用字元。因此,類似下面的轉換是合理的:

interface Link<out T> {
    fun node(): T
}

fun f1(linkStr: Link<String>) {
    // 這是一個合理的協變轉換
    val linkAny: Link<Any> = linkStr
}

interface Repo<in T> {
    fun add(t: T)
}

fun f2(repoAny: Repo<Any>) {
    // 這是一個合理的逆變轉換
    val repoStr: Repo<String> = repoAny
}
複製程式碼

小結:協變和逆變

協變逆變對於Java程式設計師來說是一個全新的概念,為了便於理解,我用一個表格做一個簡單的總結:

- 協變 逆變
關鍵字 out in
讀寫 只讀 可寫
位置 返回值 引數
角色 生產者 消費者

型別投影

在上面的例子中,我們直接在類體宣告瞭泛型引數的協變或逆變型別。在這種情況下,就嚴格限制了該類中只允許出現該泛型引數的消費者或者生產者。很顯然,這種場景並不多見,大多數情況下,一個類中既存在著消費者又存在著生產者。為了適應這種場景,我們可以將協變或逆變宣告寫在方法引數中。Kotlin官方將這種方式叫做 型別投影(Type Projection)

這裡我們直接使用官方文件的例子:

class Array<T>(val size: Int) {
    fun get(index: Int): T { /* ... */ }
    fun set(index: Int, value: T) { /* ... */ }
}

fun copy(from: Array<Any>, to: Array<Any>) {
    assert(from.size == to.size)
    for (i in from.indices)
        to[i] = from[i]
}

val ints: Array<Int> = arrayOf(1, 2, 3)
val any = Array<Any>(3) { "" } 

// 由於泛型引數的不變性,這裡將出現問題
copy(ints, any) 
複製程式碼

很明顯,我們希望from引數可以接收元素為Any或其子類的任意元素,但我們並不希望修改from,以防止出現類似文章開頭的問題。因此,我們可以在from引數中新增out修飾,使其協變:

fun copy(from: Array<out Any>, to: Array<Any>) {
}
複製程式碼

一旦新增out修飾符,你就會發現,當你嘗試呼叫set方法的時候,編譯器將會提示你在out修飾的情況下禁止呼叫該方法。

注:Java語言在使用”協變“的情況下,from引數依然可以呼叫set方法。從這裡可以看出,Kotlin語言在泛型安全控制上比Java更加精細。

星號投影

除了上述明確的型別投影方式之外,還有一種非常特殊的投影方式,稱之為星號投影(star projection)。

在某些情況下,我們並不知道具體的型別引數資訊。為了適應這種情況,Java語言中我們會直接忽略掉型別引數:

class Box<T> {
     public void unPack(T t) {
          ...
     }
}

// 在不確定型別引數的情況下,我們會這樣做
Box box = new Box();
複製程式碼

在Kotlin語言中,我們使用星號對這種情況進行處理。因為,Kotlin針對泛型有嚴格的讀寫區分。同樣地,使用*號將限制泛型介面的讀寫操作:

  • Foo<out T: TUpper>,這種情況下,T是協變型別引數,上邊界是TUpper。Foo<*>等價於Foo<out TUpper>,這意味著你可以安全地從Foo<*>讀取TUpper型別。
  • Foo<in T>,在這種情況下,T是逆變型別引數,下邊界是T。Foo<*>等價於Foo<in Nothing>,這意味著在T未知的情況下,你將無法安全寫入Foo<*>。
  • Foo<T: TUpper>,在這種情況下,T是不可變的。Foo<*>等價於你可以使用Foo<out TUpper>安全讀取值,寫入等價於Foo<in Nothing>,即無法安全寫入。

泛型約束

在泛型約束的控制上,Kotlin語言相對於Java也技高一籌。在大多數情況下,泛型約束需要指定一個上邊界。這同Java一樣,Kotlin使用冒號代替extends:

fun <T: Animal> catch(t: T) {}
複製程式碼

在使用Java的時候,經常碰到這樣一個需求。我希望泛型引數可以約束必須同時實現兩個介面,但遺憾的是Java語言並沒有給予支援。令人驚喜的是,Kotlin語言對這種場景給出了自己的實現:

fun <T> swap(first: List<T>, second: List<T>) where T: CharSequence, 
                                                    T: Comparable<T> {
    
} 
複製程式碼

可以看到,Kotlin語言使用where關鍵字控制泛型約束存在多個上邊界的情況,此處應該給Kotlin鼓掌。

總結

Kotlin語言使用協變逆變來規範可變泛型操作,out關鍵字用於協變,代表生產者。in關鍵字用於逆變,代表消費者。out和in同樣可以用於方法引數的泛型宣告中,這稱之為型別投影。在針對泛型型別約束的處理上,Kotlin增加了多個上邊界的支援。

Kotlin語言最初是希望成為一門編譯速度比Scala更快的JVM程式語言!為了更好地設計泛型,我們看到它從C#中引入了協變逆變的概念。這一次,我想,它至少同時站在了Scala和C#的肩膀上。

歡迎加入Kotlin交流群

如果你也喜歡Kotlin語言,歡迎加入我的Kotlin交流群: 329673958 ,一起來參與Kotlin語言的推廣工作。

程式設計,我們是認真的!

關注歐陽鋒工作室,與歐陽鋒同行!

歐陽鋒工作室

相關文章