標籤:

scala 逆變有什麼用?

最近學習scala 協變和逆變的概念倒是理解了,協變就是如果A是B的子類,class N[+T]的話,N[A]也是N[B]的子類。這個想想倒有用處。但是怎麼想也想不到什麼時候需要用到逆變。還請指教


先上圖:

最常見的問題應該是:

class G[+A]{def fun(x: A){}}
Error: covariant type A occurs in contravariant position in type A of value x
class G[+A]{def fun(x: A){}}
^

定義容器類,常常不寫G[A], 而寫G[+A], 然後撞槍口上, contravariant position 逆變點(編譯器對高階類型參數類型變的檢查點),函數參數是逆變點,但此處聲明為+A(協變的)

解決方法是:

class G[+A]{def fun[B &>: A](x: B){}}
//或
class G[-A]{def fun(x: A){}}

不能編譯過了,就完事了,究其原因大概是:

《Effective Java》PECS 原則 (producser-extends, consumer-super)

參考:java泛型的理解

G[+A]類似一個生產者,提供數據。(大部分情況下稱G為容器類型)

G[-A] 是一個消費者,主要用來消費數據。(如上的 Equiv[-A] (其實就是個A =&> Boolean的函數,即Function1[-A, Boolean]))

同理函數的參數為何聲明為逆變,返回值為協變就好理解了

同理

class G[+A]{def fun[B &>: A](x: B){}}

就可理解為,我聲明的是一個容器類型,但是它也有處理(消費數據)能力

當然也可用里氏替換原則來解釋(如 知之的回答)

LSP : Liskov substitution principle

參數逆變:正是因為需要符合里氏替換法則,方法中的參數類型聲明時必須符合逆變(或不變),以讓子類方法可以接收更大的範圍的參數(處理能力增強);而不能聲明為協變,子類方法可接收的範圍是父類中參數類型的子集(處理能力減弱)。

返回值協變:如果結果類型是逆變的,那子類方法的處理能力是減弱的,不符合里氏替換。

《programming in scala》 19.6 節 逆變

子類應該需要的更少,但提供的要更多,「更少」 是指更抽象

目的:

協變和逆變的引入,當然是為了高階類型F[A], F[B]之間, 也能像低階類型A, B那樣能夠有型變的能力,如果List[String] 和 List[Any] 沒什麼卵關係,顯然對不起「Scala是面向對象的」

-------更多關於PECS

Java Generics: What is PECS?


逆變(Contravariance)是為了滿足高階函數中能夠替換參數函數(functional parameter)的需要,就和普通函數中使用多態參數一樣。

比如我們有一個普通函數func:

def func[T](x: T) = {...; x.foo; ...}

這時候不管是OOP還是FP,我們都希望能使用多態參數,即可以使用T類型的任意子類型。因為是用子類型替換,在函數體里調用類型T的foo方法是有保障的。

同樣,當我們有一個高階函數higher的時候:

def higher[P &>: T, R, T](x: T)(func: Function1[P, R]) = func(x)

我們自然也希望這裡第二個參數能使用Function1[P, R]的任意"子類型"。問題是當選擇的func是Function1[P, R]的一個子類型時,需要保證類型T的任何值都依然是該函數合法的參數,這顯然只能通過放寬func函數的參數類型來滿足。亦即,Function1[P1, R1] &<: Function1[P, R]要求P1 &>: P,也就是P1必須是P的父類。

注意上述 P &>: T 和P1 &>: P的作用是不一樣的。前者是higher方法定義時的類型約束,也就是任意選定P後類型T必須是P的子類型。而後者是說,選定P、R和T的具體類型後,higher運行時任何用來放在func位置的函數必須有一個是P的父類的類型P1來做參數。


最好的例子莫過於Scala標準API里的scala/Function1.scala at 2.12.x · scala/scala · GitHub了,部分的源代碼如下:

trait Function1[-T1, +R] extends AnyRef { self =&>
/** Apply the body of this function to the argument.
* @return the result of function application.
*/
def apply(v1: T1): R

override def toString() = "&"
}

可以看到Function1的類型T1是逆變的。為什麼呢?

想想看,假設我有兩個Function1:

scala&> val f1: Int =&> String = x =&> s"Int($x)"
f1: Int =&> String = &

scala&> val f2: Any =&> String = x =&> s"Any($x)"
f2: Any =&> String = &

凡是f1可以使用的地方,f2都是可以用的;但反過來不行。

所以類型Function1[Any, String]應該是Function1[Int, String]的子類。


等價關係

trait Equiv[-A] {
def eq(a: A, b: A): Boolean
}

偏序關係(比較器)

trait PartialOrder[-A] {
def le(a: A, b: A): Boolean
}


協變有多大用逆變就有多大用啊,出參只能協變不變二選一,入參只能逆變不變二選一,這不是對應的嘛,根據PECS的話怎麼用協變就怎麼用逆變呀,我覺得問逆變有什麼用的人八成是還沒搞清楚逆變到底是什麼。

假設 A &< B &< C 那麼對於 f : B =&> (),顯然我們只能傳遞B或者A進去,

相應的如果有 class F[-X] { def set(a: X): Unit } ,

那麼對於 f: F[B] =&> () ,我們只能傳遞F[B]或者F[C]進去,用PECS的方式來說,因為F進去f以後是要消耗B的,但對於F[A],顯然他沒法消耗B。

相應的如果有 class F[+X] { def get(): X } ,

那麼對於 f: F[B] =&> () ,我們只能傳遞F[B]或者F[A]進去,再次用PECS的方式來說,因為F進去f以後是要生產B的,但對於F[C],他生產的結果不一定是B。

所以協變逆變講的是X, Y, F[X], F[Y]之間的關係,很多講協變逆變的文章開頭都是

對於 f : -A -&> +B 這裡神奇的AB到底怎麼變的nyannyan(((φ(◎ロ◎;)φ)))nyannyan(((φ(◎ロ◎;)φ)))nyannyan。

結果一堆人在這裡糾結了半天AB而不是f,實際上對於給定的A,函數f這裡你只能傳A的子類型進去。


你們說的都太複雜了,其實contravariant 和 trait stack 是一種很好玩的組合,而且解決了很多實踐問題。而不是一種純粹的理論花招。

trait stack 的可視面是有先後性的。利用contravariant的逆繼承性,你可以使更深的子類能在更靠前的時候使用到。這對如GUI一類的編程避免了如visitor pattern 一類的重複性代碼,也不用做 match/case 這樣的類型檢測。

當然了,這個東西還是需要理論跟實踐結合的。你沒有碰到那種需求的話,你不會需要使用這種工具。contravariant 不是 Scala特有的,Java也有。Scala只是把這個工具做的更好用了。


如果A is-a B,那麼任何需要F(A)的地方你都可以給一個F(B)對不對?所以你可以說F(B) is-a F(A)對不對?


假設A &<: B, 可以簡單認為A是B的子類(具體定義是任何B滿足的性質,A都滿足)

C[A] &<: C[B] C 是 協變的 covariant

C[A] &>: C[B] C 是 逆變的 contravariant

C[A]和C[B]都不是彼此的子類 C是 非變的 nonvariant

C是AnyRef,即引用類,C[A]代表C的元素是A類對象,C[A]&<:C[B]代表C[B]是C[A]基類,因此C[B]可以賦值給C[A]

Scala可以用下面的方式更簡單聲明容器類的性質

Class C[+A] {..} C 是 協變的 covariant

Class C[-A] {…} C 是 逆變的 contravariant

Class C[A]{…} C是 非變的 nonvariant

例子

Trait List[+T]{

Def perpend(elem:
T):List[T] = new Cons(elem, this)

}

這個定義在當objectA 是List[nonempty], objectA.prepend(empty)時會導致類型錯誤

prepend是屬於trait List[+T]的,因此假設此時this是一個List[nonempty],

則List[T]中的T是nonEmpty類,所以如果this.prepend(empty)會導致類型錯誤,因為empty和nonEmpty都是InSet的子類,但是empty不是nonEmpty的子類,不能賦值給需要nonEmpty的參數位置。

Def perpend[U:&>T](elem: U) :List[U] =
new Cons(elem, this)

U是T的基類,因此傳入的元素要求是Inset類型,empty是Inset子類當然可以傳入,得到的new Cons(empty, this)也是List[Inset],毫無問題

應用

1 ).選擇支持協變的容器類:

1. Inset class 中有NoEmpty 和 empty兩個子類。

2. Array不是協變,即定義abstract
class Array[T] extends Seq[T]。

3. 如果Array換成List, 因為定義abstract
class List[+T] extends Seq[T],List是協變,則NonEmpty是Inset的子類,List[NonEmpty]是List[Inset]的子類,第二行的a可以賦值給b。類似於c++的多態,子類指針和子類引用是可以分別賦值給基類指針和基類引用

2). 定義新的函數對象,例如

object addOne extends Function1[Int,
Int]{

def apply(m:Int):Int
= m + 1

}

利用函數的協變:

If A2 &<: A1 並且 B1&<:B2 則有:

A1
=&> B1 &<: A2 =&> B2

因此scala中的Function1特性的定義如下:

Package scala

Trait Function1[-T, +U] {

def apply(x:T) : U

}

假設有兩個Function1:

scala&> val f1: Int =&> String = x
=&> s"Int($x)"

f1: Int =&> String = &

scala&> val f2: Any =&> String = x
=&> s"Any($x)"

f2: Any =&> String = &

凡是f1可以使用的地方,f2都是可以用的(考慮基類子類的關係:基類可以使用的場景,子類都可以使用);但反過來不行。

所以類型Function1[Any, String]應該是Function1[Int, String]的子類,
是Trait Function1[-T, +U]

的定義賦予了Function1類更豐富的繼承關係。

逆變的傳入類型,允許子類在重寫基類的函數時,傳入參數的類型比基類原函數定義的傳入參數類型更廣泛。

而協變的返回類型,允許了子類在重寫基類的函數時,可以返回比基類原函數定義的返回類型更加具體的類型。

來自iamiman的博客 - 博客頻道 - CSDN.NET


函數參數必須逆變,而函數本身也是有類型的,還是個范型類。

建議去看下eric lippert關於協變和逆變的博客,用csharp舉例說明的。手機回復就不貼鏈接給你了。


排第一的答案中圖的出處: Covariance and Contravariance of Hosts and Visitors 等我看完在補全一下

以及原來Stackoverflow的問題 https://stackoverflow.com/questions/27414991/contravariance-vs-covariance-in-scala


正好看到一篇講得挺好的

Typelevel.scala


怪我太笨,看了一個多小時協變型變逆變硬是沒看懂,感覺沒救了


上面二位的回答,也只是說了下scala中contravariance的用法。

至於contravariance有哪些功能是covariance所沒有的或不可替代的則沒有提到。

我覺得搞清楚引入contravariance的目的才是本質。

一般,對輸入要求contravariance,對輸出要求covariance。

就應用而言,協變和逆變能允許我們定義更多的通用類型。

更深層的理論,我也不知道。以後有機會再研究吧!

PS:嚴格來講,知之的「Function1的類型T1是逆變的」這一說法並不準確,逆變的不是T1而是Function1[]。


推薦閱讀:

TAG:Scala |