這玩意兒水還是有一點(diǎn)的,看著 Scala 代碼中到處都在飛翔著“+/-”號(hào),撓的你心里癢癢的。
所以花點(diǎn)時(shí)間來(lái)認(rèn)真研究一下所謂的 co/contra variance。
sealed abstract class List[+A] {
def head : A
def ::[B >: A] (x:B) : List[B} = ...
...
+A 表示 A 是一個(gè)協(xié)變體類型參數(shù)(covariant type parameter)。要知道就算你知道了這個(gè)術(shù)語(yǔ),仍然是一頭霧水。所以往下看。
List 是一個(gè)泛型(generic)類型。就是說(shuō),你可以有很多不同的 List 類型——可以 List[Int] 可以 List[MyClass] 等等等等。換句話說(shuō)呢,List[_] 就是一個(gè)類型構(gòu)造器(type constructor),如同函數(shù)那樣接受另一個(gè)具體的類型并產(chǎn)出一個(gè)新的類型。所以,如果你已經(jīng)擁有了一個(gè)類型 X,那么你就可以使用 List 類型構(gòu)造器來(lái)產(chǎn)生一個(gè)新的類型,List[X]。
看一點(diǎn)點(diǎn)范疇論
為了從本質(zhì)上了解這個(gè)褲褲的東西,我們需要從范疇作為起點(diǎn)來(lái)討論。不用怕這里我們并不會(huì)涉及太過(guò)嚇人的范疇論的結(jié)果。范疇 C 就是一些對(duì)象和一些箭頭(稱作是“函數(shù)”)。箭頭從一個(gè)對(duì)象指向另一個(gè)對(duì)象,對(duì)于范疇來(lái)說(shuō)唯一的要求就是你會(huì)對(duì)箭頭有一些二元操作(通常叫做“復(fù)合”),這樣會(huì)產(chǎn)生新的箭頭從正確的地方出發(fā)到另一個(gè)該去的地方;并且對(duì)每個(gè)對(duì)象都有一個(gè)“自指”箭頭。[1] 對(duì)范疇我們最感興趣的其實(shí)是類型范疇:類型就是 Int、Person 和 Map[Foo, Bar] 這些東東,而箭頭就是那些函數(shù)。
另一個(gè)需要的概念是函子(functor)。函子 F: C->D 是范疇之間的映射。不過(guò),我們可以擁有范疇到自身的函子——endofunctors,這些就是我們感興趣的東西。函子必須將源范疇的對(duì)象轉(zhuǎn)換為目標(biāo)范疇的對(duì)象,同樣他們還需要將箭頭轉(zhuǎn)換為新的箭頭。還有,函子必須遵守某些規(guī)則,這里不要太過(guò)關(guān)心。[2]
好了,誰(shuí)最關(guān)心函子呢?答案是類型構(gòu)造器是在類型的范疇上的基本的函子。他們將類型(我們關(guān)心的對(duì)象)轉(zhuǎn)換為其他的類型:檢查一下!但是那些箭頭(也就是那些函數(shù))呢。函子不是同樣需要將這些箭頭進(jìn)行映射么?是的,這也是必須的,但是在 Scala 中我們不會(huì)叫來(lái)自 List 函子的函數(shù)為 List[f],我們稱之為 map(f)。[3]
還沒(méi)有到正文?。?!好好好,最后還有一個(gè)真的很相關(guān)的概念要提一下。范疇間的一些映射看起來(lái)非常像函子,除了他們會(huì)反置箭頭的方向。所以不會(huì)得到 F(f): FX->FY。這樣的東東有個(gè)神奇的名字,逆變 (contravariant) 函子。為了區(qū)分他們,正常的函子被稱為協(xié)變 (covariant) 函子。
看看我們要的東西出來(lái)了吧。但是逆變函子和 Scala 究竟什么關(guān)系呢?
好問(wèn)題。
子類型
Scala 關(guān)鍵特性就是擁有子類型。類(類型)可以是其他類(類型)的子類型或者超類型。這給了我們一個(gè)關(guān)于類層次結(jié)構(gòu)的想法。從數(shù)學(xué)上看看這個(gè)結(jié)構(gòu),我們可以給出一個(gè)類型之間的偏序關(guān)系 <: 。這里就出現(xiàn)了一個(gè)范疇論的第一技巧:我們可以將任何偏序集看做一個(gè)范疇!對(duì)象就是對(duì)象,如果 A <:B 那么 A -> B 就有一個(gè)箭頭。這有點(diǎn)奇怪,因?yàn)槲覀冎粫?huì)在對(duì)象間產(chǎn)生一個(gè)箭頭,他們并不再是函數(shù),但是其他的形式化結(jié)果仍然成立。[4]
現(xiàn)在某些類型構(gòu)造器仍舊看起來(lái)像函子。他們將對(duì)象映射到其他的對(duì)象上,如果這些對(duì)象中的一個(gè)是另一個(gè)的子類型,那么他們可能會(huì)也可能不會(huì)產(chǎn)生映射的對(duì)象之間的關(guān)系。
這里就是 Scala 類型注釋出現(xiàn)的地方。當(dāng)我們聲明 List[+A],就表示 List 由參數(shù) A covariant 的。[5] List 會(huì)接受一個(gè)類型比如說(shuō) Parent 得到一個(gè)新的類型 List[Parent],如果 Child 是 Parent 的子類型,那么 List[Child] 將會(huì)是 List[Parent] 的子類型。如果我們聲明 List 是 contravariant 的 List[-A],那么 List[Child] 會(huì)成為 List[Parent] 超類型。
還剩下一個(gè)可能性。因?yàn)樽宇愋褪瞧?,我們?huì)遇到兩個(gè)類型互不為子類型關(guān)系。原則上,一個(gè)類型構(gòu)造器 T 不能夠以 Parent 和 Child 產(chǎn)生新的完全沒(méi)有關(guān)系的類型是沒(méi)有道理的。在 Scala 中,可能會(huì)有一種情況就是你沒(méi)有在聲明中給出類型的注解;這樣的構(gòu)造器就是由那個(gè)參數(shù) invariant。例如 Array 就有這個(gè)特性。
所以這就是根本上的結(jié)論。也就是這些符號(hào) +/- 對(duì)類型參數(shù)的作用?,F(xiàn)在你應(yīng)該懂了吧。
class GParent
class Parent extends GParent
class Child extends Parent
class Box[+A]
class Box2[-A]
def foo(x : Box[Parent]) : Box[Parent] = identity(x)
def bar(x : Box2[Parent]) : Box2[Parent] = identity(x)
foo(new Box[Child]) // success
foo(new Box[GParent]) // type error
bar(new Box2[Child]) // type error
bar(new Box2[GParent]) // success
但是這些神秘的破玩意兒又是啥?
class Box[+A] {
def set(x : A) : Box[A]
} // won't compile
Scala 中的這些錯(cuò)誤其實(shí)是變體和函數(shù)(或者說(shuō),方法)的關(guān)聯(lián)的微妙之處。我們可以看到 Function trait 的聲明其實(shí)有些奇怪的地方:
trait Function1[-T1, +R] {
def apply(t : T1) : R
...
}
這實(shí)際上非常奇怪。不僅是因?yàn)檫@個(gè) Function1 trait 有兩個(gè)類型參數(shù),其中一個(gè)還是 contravariant 的。真怪異。讓我們來(lái)仔細(xì)看看這個(gè)。
我們有函數(shù) Function1[A,B],這是一種從類型 A 映射到類型 B 一元參數(shù)函數(shù)的類型。因此,這可以是其他(函數(shù))類型的子類型或者超類型。例如,
Function1[GParent, Child] <: Function1[Parent, Parent]
我怎么知道這個(gè)結(jié)果的呢?因?yàn)樵诤瘮?shù) Function1 的變體注解給出這個(gè)答案。第一個(gè)參數(shù)是 contravariant 的,所以可以向上變換,第二個(gè)參數(shù)是 covariant 的,所以就可以向下變換。
Function1 這樣的行為有點(diǎn)微妙,但是如果你思考一下在有子類型的時(shí)候需要有這樣的替換的話,也是有道理的。如果你有一個(gè)函數(shù)從 A 映射到 B,你可以進(jìn)行什么樣的替換呢?這里任何可以放置的東西都必須給出最少的輸入類型要求;因?yàn)槔?,如果函?shù)必須調(diào)用僅僅存在在 A 類型的子類型上的方法。另外,它必須返回一個(gè)最少和 B 類型相容的類型,因?yàn)楹瘮?shù)的調(diào)用者可能會(huì)需要 B 上的所有方法都可以調(diào)用。
Function Functors
這里實(shí)際上是范疇論中的一個(gè)關(guān)于為何要這樣設(shè)計(jì)的有意思的結(jié)果。一般來(lái)說(shuō),對(duì)任何的范疇 C 我們同樣可以構(gòu)造一個(gè) C 的 Hom-set 的范疇。在這些集合中的函數(shù)就會(huì)變成更高階的函數(shù)可以將函數(shù)轉(zhuǎn)換成不同的函數(shù)。所以,這就引出來(lái)一個(gè)自然的函子,Hom(-, -) 以兩個(gè)對(duì)象 A 和 B 為輸入,輸出 Hom(A, B)。這個(gè) Hom-函子 特別之處是 bifunctor:兩個(gè)參數(shù)。最簡(jiǎn)單的處理方式是部分作用 (partially apply) 然后觀察其在每個(gè)參數(shù)上作用的行為。
所以 Hom(A, -) 以對(duì)象 B 為輸入,映射到從 A 到 B 的函數(shù)集合上。如何在函數(shù)上進(jìn)行作用呢?如果我們有一個(gè)同態(tài) f:B -> B' 我們需要一個(gè)函數(shù) Hom(A, f): Hom(A, B) -> Hom(A, B')。其定義如下:
Hom(A, f)(g) = f . g
首先作用 g 得到 A 到 B,然后 f 從 B 到 B'。所以 Hom(A, -) 行為就像 covariant 函子。
另一方面,如果你嘗試并做出 Hom(-, B) 為一個(gè) covariant 函子,祝你好運(yùn)!類型并不會(huì)直接連接起來(lái)就可以工作。實(shí)際上是按照下面的方式進(jìn)行的:
Hom(f, B)(g) = g . f
這里 g 在 Hom(B', B) 中,而不是 Hom(A, B)。所以 Hom(-, B) 實(shí)際上像一個(gè) contravariant 的函子。[6] 這使得 Hom(A, B) 是按照 A contravariant 而按照 B covariant —— 就像 Function1 中那樣![7]
這實(shí)際上是一個(gè)更加一般的結(jié)果,因?yàn)檫@可以用在任何的范疇上,并不僅僅是擁有子類型的類型的范疇上。cool!
回到實(shí)際問(wèn)題
所以 Scala 中的函數(shù)有這些奇怪的變體屬性。但是從一個(gè)理論角度看,方法實(shí)際上就是函數(shù),所以會(huì)有同樣的變體屬性,即使我們還沒(méi)有看到這些具體的例子都能知道(在 Scala 中方法 method 并沒(méi)有一個(gè) trait)
所以我們現(xiàn)在可以看到為什么我們會(huì)得到這樣的奇怪的編譯錯(cuò)誤。我們聲明了 A 在這個(gè)類中是 covariant 的,并且還使用了類型 A 的參數(shù)。但是,對(duì)某些 B <: A,我們可以使用 Box[B] 的實(shí)例來(lái)替換 Box[A] 的實(shí)例,因此也能夠用 Box[B].set(x) 來(lái)替換 Box[A].set(x),其中 x 是 B 類型的。但是 set[A] 并不能用 set[B] 作為參數(shù)替換,因?yàn)樯厦娴脑颍蛔詈檬?contravariant 的。所以這回允許我們做到本不該做到的事情。類似地,如果我們聲明 A 是 contravariant 的,那么我們可能會(huì)得到跟集合的返回類型矛盾的境況。所以看起來(lái)我們需要將 A 設(shè)置為 invariant 的。
另外,這其實(shí)是為何 Java 的 array 是 covariant 的絕對(duì)不好的原因。這意味著你可以寫出下面的代碼:
Integer[] ints = [1,2]
Object[] objs = ints
objs[0] = "I'm an integer!"
這樣寫編譯能夠通過(guò),但是在運(yùn)行時(shí)會(huì)拋出 ArrayStoreException。很好!
實(shí)際上,我們不需要讓包含有類似 append 方法的 container 類型 invariant。Scala 同樣讓我們對(duì)這些進(jìn)行類型綁定。所以如果我們將 Box 改成下面這樣:
class BoundedBox[+A] {
set[B >: A](x:B) : Box[B]
}
就可以進(jìn)行編譯了。這樣也確保 set 方法的輸入類型是正確地 contravariant 的。
這就是所有的講解。對(duì) Scala 需要記住的一點(diǎn)是:任何東西都是一個(gè)方法。所以如果你弄出來(lái)什么特別的變體錯(cuò)誤,可能是哪里的特別的方法要加上一個(gè) lower 類型綁定(lower bound)。
-
In full, the requirements are:A class of objects: CFor every pair of objects, a class of morphisms between them: Hom(A, B)A binary operation . : Hom(B, C) x Hom(A, B) -> Hom(A, C), which is associative and has the identity morphism as its identity. ?
-
These are:F(id{X}) = id{FX}F(f.g) = F(f).F(g) ?
-
The astute reader will have noticed that not all type constructors come with a map function. This does indeed mean that not all type constructors are functors. But pretend that they are for now. ?
-
Crucially, we can use the relation to give us our arrows because it’s transitive, and hence composition will work properly. ?
-
Yes, there can be more than one parameter. Don’t worry about it for now. ?
-
If you’re wondering whether there couldn’t be some other way of mapping the functions that would work, it turns out that there can’t be one that also makes the functor laws work. You can try it yourself if you don’t believe me! ?
-
We actually need to do a little bit more work to show that Hom(-, -) is a true bifunctor (functor on the product category), but it’s not terribly interesting. ?