Kotlin語(yǔ)言的泛型設(shè)計(jì)很有意思屿讽,但并不容易看懂勇哗。關(guān)于這個(gè)部分的官方文檔铺敌,我反復(fù)看了好幾次烫罩,終于弄明白Kotlin語(yǔ)言泛型設(shè)計(jì)的背后哲學(xué)刽射。這篇文章將講述Kotlin泛型設(shè)計(jì)的整個(gè)思考過(guò)程及其背后的哲學(xué)思想屈芜,希望可以解答你心中的疑問(wèn)拍棕。不過(guò)揪荣,可以預(yù)見(jiàn)地宇葱,即使看完瘦真,你也未必完全明白這篇文章在說(shuō)什么,但至少希望你通過(guò)這篇文章可以快速掌握Kotlin泛型的用法黍瞧。
Kotlin泛型的設(shè)計(jì)初衷
我們認(rèn)為诸尽,Kotlin是一門(mén)比Java更優(yōu)秀的JVM編程語(yǔ)言,Kotlin泛型設(shè)計(jì)的初衷就是為了解決Java泛型設(shè)計(jì)中一些不合理的問(wèn)題印颤。這樣說(shuō)可能不夠直觀您机,看下面這個(gè)例子:
List<String> strs = new ArrayList<>();
// 這里將導(dǎo)致編譯錯(cuò)誤,Java語(yǔ)言不允許這樣做
List<Object> objs = strs;
很明顯年局,String和Object之間存在著安全的隱式轉(zhuǎn)換關(guān)系际看。存放字符串的集合應(yīng)該可以自由轉(zhuǎn)換為對(duì)象集合。這很合理矢否,不是嗎仲闽?
如果你這樣認(rèn)為的話,就錯(cuò)了僵朗!繼續(xù)往下看赖欣,我們擴(kuò)展這個(gè)程序:
List<String> strs = new ArrayList<>();
List<Object> objs = strs;
objs.add(1);
String s = strs.get(0);
很明顯屑彻,這不合理!我們?cè)诘谝粋€(gè)位置存入了整型數(shù)值1顶吮,卻在取的時(shí)候?qū)⑺?dāng)成了字符串社牲。strs本身是一個(gè)字符串集合,用字符串接收讀取的數(shù)據(jù)的邏輯是合理的悴了。卻因?yàn)殄e(cuò)誤的類型轉(zhuǎn)換導(dǎo)致了不安全寫(xiě)入出現(xiàn)了運(yùn)行時(shí)類型轉(zhuǎn)換問(wèn)題膳沽,因此,Java語(yǔ)言不允許我們這樣做让禀。
大多數(shù)情況下,這種限制沒(méi)有問(wèn)題陨界⊙沧幔可是,在某些情況下菌瘪,這并不合理腮敌。看下面的例子:
interface List<T> {
void addAll(List<T> t);
}
public void copy(List<String> from, List<Object> to) {
to.addAll(from);
}
這是一個(gè)類型絕對(duì)安全的操作俏扩,但在Java語(yǔ)言中這依然是不允許的糜工。原因是,泛型是一個(gè)編譯期特性录淡,一旦指定捌木,運(yùn)行期類型就已經(jīng)固定了。換而言之嫉戚,泛型操作的類型是不可變的刨裆。這就意味著,List<String>并不是List<Object>的子類型彬檀。
為了允許正確執(zhí)行上述操作帆啃,Java語(yǔ)言增加了神奇的通配符操作魔法。
interface List<T> {
void addAll(List<? extends T> t);
}
? extends T意味著集合中允許添加的類型不僅僅是T還包括T的子類窍帝,但這個(gè)集合中可以添加的類型在集合參數(shù)傳入addAll時(shí)就已經(jīng)確定了努潘。因此,這并不影響參數(shù)集合中可以存放的數(shù)據(jù)類型坤学,它帶來(lái)的一個(gè)直接影響就是addAll方法參數(shù)中終于可以傳入泛型參數(shù)是T或者T的子類的集合了疯坤,即上面的copy方法將不再報(bào)錯(cuò)。
這很有意思拥峦,在使用通配符之前我們并不能傳入類型參數(shù)為子類型的集合贴膘。使用通配符之后,居然可以了略号!這個(gè)特性在C#被稱之為協(xié)變(covariant)刑峡。
協(xié)變這個(gè)詞來(lái)源于類型之間的綁定洋闽。以集合為例,假設(shè)有兩個(gè)集合L1突梦、L2分別綁定數(shù)據(jù)類型F诫舅、C,并且F宫患、C之間存在著父子關(guān)系刊懈,即F、C之間存在著一種安全的從C->F的隱式轉(zhuǎn)換關(guān)系娃闲。那么虚汛,集合L1和L2之間是否也存在著L2->L1的轉(zhuǎn)換關(guān)系呢?這就牽扯到了原始類型轉(zhuǎn)換到綁定類型的集合之間的轉(zhuǎn)換映射關(guān)系皇帮,我們稱之為“可變性”卷哩。如果原始類型轉(zhuǎn)換和綁定類型之間轉(zhuǎn)換的方向相同,就稱之為“協(xié)變”属拾。
用一句話總結(jié)協(xié)變:如果綁定對(duì)象和原始對(duì)象之間存在著相同方向的轉(zhuǎn)換關(guān)系将谊,即稱之為協(xié)變。
PS:以上關(guān)于協(xié)變的概念來(lái)自筆者的總結(jié)渐白,更嚴(yán)謹(jǐn)?shù)母拍钫?qǐng)參考C#官方文檔尊浓。
文章開(kāi)頭我們將不可變泛型通過(guò)通配符使其成為了可變泛型參數(shù),現(xiàn)在我們知道這種行為叫做協(xié)變纯衍。很明顯栋齿,協(xié)變轉(zhuǎn)換中寫(xiě)入是不安全的。因此襟诸,協(xié)變行為僅僅用于讀取褒颈。如果需要寫(xiě)入怎么辦呢?這就牽扯到了另外一個(gè)概念逆變(contravariance)励堡。
逆變與協(xié)變恰恰相反谷丸,即如果F、C之間存在著父子轉(zhuǎn)換關(guān)系应结,L1刨疼、L2之間存在著從L1->L2的轉(zhuǎn)換關(guān)系。其綁定對(duì)象的轉(zhuǎn)換關(guān)系與原始對(duì)象的轉(zhuǎn)換關(guān)系恰好相反鹅龄。Java語(yǔ)言使用關(guān)鍵字super(揩慕?super List)實(shí)現(xiàn)逆變。
舉個(gè)例子:假設(shè)有一個(gè)集合List<? super String>扮休,你將可以安全地使用add(String)或set(Int迎卤,String)方法。但你不能通過(guò)get(Int)返回String對(duì)象玷坠,因?yàn)槟銦o(wú)法確定返回的對(duì)象是否是String類型蜗搔,你最終只能得到Object劲藐。
因此,我們認(rèn)為樟凄,逆變可以安全地寫(xiě)入數(shù)據(jù)聘芜,但并不能安全地讀取,即最終不能獲取具體的對(duì)象數(shù)據(jù)類型缝龄。
為了簡(jiǎn)化理解汰现,我們引入官方文檔中 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"說(shuō)的一句話。
Joshua Bloch是Java集合框架的創(chuàng)始人叔壤,他把那些只能讀取的對(duì)象叫做生產(chǎn)者瞎饲;只能寫(xiě)入的對(duì)象叫做消費(fèi)者。為了保證最大靈活性炼绘,他推薦在那些代表了生產(chǎn)者和消費(fèi)者的輸入?yún)?shù)上使用通配符指定泛型企软。
相對(duì)于Java的通配符,Kotlin語(yǔ)言針對(duì)協(xié)變和逆變引入兩個(gè)新的關(guān)鍵詞out和in饭望。
out用于協(xié)變,是只讀的形庭,屬于生產(chǎn)者铅辞,即用在方法的返回值位置。而in用于逆變萨醒,是只寫(xiě)的斟珊,屬于消費(fèi)者,即用在方法的參數(shù)位置富纸。
用英文簡(jiǎn)記為:POCI = Producer Out , Consumer In囤踩。
如果一個(gè)類中只有生產(chǎn)者,我們就可以在類頭使用out聲明該類是對(duì)泛型參數(shù)T協(xié)變的:
interface Link<out T> {
fun node(): T
}
同樣地晓褪,如果一個(gè)類中只有消費(fèi)者堵漱,我們就可以在類頭使用in聲明該類是對(duì)泛型參數(shù)T逆變的:
interface Repo<in T> {
fun add(t: T)
}
out等價(jià)于Java端的? extends List通配符,而in等價(jià)于Java端的? super List通配符涣仿。因此勤庐,類似下面的轉(zhuǎn)換是合理的:
interface Link<out T> {
fun node(): T
}
fun f1(linkStr: Link<String>) {
// 這是一個(gè)合理的協(xié)變轉(zhuǎn)換
val linkAny: Link<Any> = linkStr
}
interface Repo<in T> {
fun add(t: T)
}
fun f2(repoAny: Repo<Any>) {
// 這是一個(gè)合理的逆變轉(zhuǎn)換
val repoStr: Repo<String> = repoAny
}
小結(jié):協(xié)變和逆變
協(xié)變和逆變對(duì)于Java程序員來(lái)說(shuō)是一個(gè)全新的概念,為了便于理解好港,我用一個(gè)表格做一個(gè)簡(jiǎn)單的總結(jié):
- | 協(xié)變 | 逆變 |
---|---|---|
關(guān)鍵字 | out | in |
讀寫(xiě) | 只讀 | 可寫(xiě) |
位置 | 返回值 | 參數(shù) |
角色 | 生產(chǎn)者 | 消費(fèi)者 |
類型投影
在上面的例子中愉镰,我們直接在類體聲明了泛型參數(shù)的協(xié)變或逆變類型。在這種情況下钧汹,就嚴(yán)格限制了該類中只允許出現(xiàn)該泛型參數(shù)的消費(fèi)者或者生產(chǎn)者丈探。很顯然,這種場(chǎng)景并不多見(jiàn)拔莱,大多數(shù)情況下碗降,一個(gè)類中既存在著消費(fèi)者又存在著生產(chǎn)者隘竭。為了適應(yīng)這種場(chǎng)景,我們可以將協(xié)變或逆變聲明寫(xiě)在方法參數(shù)中遗锣。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) { "" }
// 由于泛型參數(shù)的不變性,這里將出現(xiàn)問(wèn)題
copy(ints, any)
很明顯精偿,我們希望from參數(shù)可以接收元素為Any或其子類的任意元素弧圆,但我們并不希望修改from,以防止出現(xiàn)類似文章開(kāi)頭的問(wèn)題笔咽。因此搔预,我們可以在from參數(shù)中添加out修飾,使其協(xié)變:
fun copy(from: Array<out Any>, to: Array<Any>) {
}
一旦添加out修飾符叶组,你就會(huì)發(fā)現(xiàn)拯田,當(dāng)你嘗試調(diào)用set方法的時(shí)候,編譯器將會(huì)提示你在out修飾的情況下禁止調(diào)用該方法甩十。
注:Java語(yǔ)言在使用”協(xié)變“的情況下船庇,from參數(shù)依然可以調(diào)用set方法。從這里可以看出侣监,Kotlin語(yǔ)言在泛型安全控制上比Java更加精細(xì)鸭轮。
星號(hào)投影
除了上述明確的類型投影方式之外,還有一種非常特殊的投影方式橄霉,稱之為星號(hào)投影(star projection)窃爷。
在某些情況下,我們并不知道具體的類型參數(shù)信息姓蜂。為了適應(yīng)這種情況按厘,Java語(yǔ)言中我們會(huì)直接忽略掉類型參數(shù):
class Box<T> {
public void unPack(T t) {
...
}
}
// 在不確定類型參數(shù)的情況下,我們會(huì)這樣做
Box box = new Box();
在Kotlin語(yǔ)言中钱慢,我們使用星號(hào)對(duì)這種情況進(jìn)行處理逮京。因?yàn)椋琄otlin針對(duì)泛型有嚴(yán)格的讀寫(xiě)區(qū)分束莫。同樣地造虏,使用*號(hào)將限制泛型接口的讀寫(xiě)操作:
-
Foo<out T: TUpper>
,這種情況下麦箍,T是協(xié)變類型參數(shù)漓藕,上邊界是TUpper。Foo<*>等價(jià)于Foo<out TUpper>挟裂,這意味著你可以安全地從Foo<*>讀取TUpper類型享钞。 -
Foo<in T>
,在這種情況下,T是逆變類型參數(shù)栗竖,下邊界是T暑脆。Foo<*>等價(jià)于Foo<in Nothing>,這意味著在T未知的情況下狐肢,你將無(wú)法安全寫(xiě)入Foo<*>添吗。 -
Foo<T: TUpper>
,在這種情況下份名,T是不可變的碟联。Foo<*>等價(jià)于你可以使用Foo<out TUpper>安全讀取值,寫(xiě)入等價(jià)于Foo<in Nothing>僵腺,即無(wú)法安全寫(xiě)入鲤孵。
泛型約束
在泛型約束的控制上,Kotlin語(yǔ)言相對(duì)于Java也技高一籌辰如。在大多數(shù)情況下普监,泛型約束需要指定一個(gè)上邊界。這同Java一樣琉兜,Kotlin使用冒號(hào)代替extends:
fun <T: Animal> catch(t: T) {}
在使用Java的時(shí)候凯正,經(jīng)常碰到這樣一個(gè)需求。我希望泛型參數(shù)可以約束必須同時(shí)實(shí)現(xiàn)兩個(gè)接口豌蟋,但遺憾的是Java語(yǔ)言并沒(méi)有給予支持廊散。令人驚喜的是,Kotlin語(yǔ)言對(duì)這種場(chǎng)景給出了自己的實(shí)現(xiàn):
fun <T> swap(first: List<T>, second: List<T>) where T: CharSequence,
T: Comparable<T> {
}
可以看到夺饲,Kotlin語(yǔ)言使用where關(guān)鍵字控制泛型約束存在多個(gè)上邊界的情況,此處應(yīng)該給Kotlin鼓掌施符。
總結(jié)
Kotlin語(yǔ)言使用協(xié)變和逆變來(lái)規(guī)范可變泛型操作往声,out關(guān)鍵字用于協(xié)變,代表生產(chǎn)者戳吝。in關(guān)鍵字用于逆變浩销,代表消費(fèi)者。out和in同樣可以用于方法參數(shù)的泛型聲明中听哭,這稱之為類型投影慢洋。在針對(duì)泛型類型約束的處理上,Kotlin增加了多個(gè)上邊界的支持陆盘。
Kotlin語(yǔ)言最初是希望成為一門(mén)編譯速度比Scala更快的JVM編程語(yǔ)言普筹!為了更好地設(shè)計(jì)泛型,我們看到它從C#中引入了協(xié)變和逆變的概念隘马。這一次太防,我想,它至少同時(shí)站在了Scala和C#的肩膀上酸员。
我是歐陽(yáng)鋒蜒车,關(guān)于逆變和協(xié)變的故事讳嘱,我還沒(méi)有說(shuō)完...