聽我說,你的函式不夠函式,你的函式需要逆變
禁止轉載
Java8 引入了函式式介面,從此方法傳參可以傳遞函式了,有人說:
不就是傳一個方法嗎,語法糖!
lambda運算式?語法糖!
你是否認為協變和逆變只是定義了集合間的關系,如果你的回答是是,那么這篇文章會改變你原有的看法,
嘗試回答一下問題:
- 函式和方法有區別嗎?
- 協變是什么,有什么使用場景
- 逆變是什么,有什么使用場景
一、純函式—沒有副作用
純函式的執行不會帶來物件內部引數、方法引數、資料庫等的改變,這些改變都是副作用,比如Integer::sum是一個純函式,輸入為兩個int,輸出為兩數之和,兩個輸入量不會改變,在Java 中可以申明為final int型別,
副作用的執行
Java對于不變類的約束明顯不足,比如final array只能保證參考的指向不變,array內部的值還是可以改變的,如果存在第二個參考指向相同的array,那么將無法保證array不可變;標準庫中的collection常用的還是屬于可變muttable型別,可變型別在使用時很便利,
在函式式思想下,函式是一等公民,函式是有值的,比如Integer::sum就是函式型別BiFunction<Integer, Integer, Integer>的一個值,沒有副作用的函式保證了函式可以看做一個黑盒,一個固定的輸入便有固定的輸出,
那么Java中物件的方法是純函式嗎?
大多數時候不是,物件的方法受到物件的狀態影響,如果物件的狀態不發生改變,同時不對外部產生影響(比如列印字串),可以看做純函式,注意:Java中的final類無法保證內部引數狀態不發生改變,
那么純函式的限制這么多,究竟有什么用呢?相信看到最后你會懂的,
本文之后討論的函式都默認為純函式,
二、協變—更抽象的繼承關系
協變和逆變描述了繼承關系的傳遞特性,協變比逆變更好理解,逆變我放到后面說,
學習這些概念前先不要考慮Java對泛型的實作,它會影響你的正確認識,當你對這些概念理解了之后,再往Java的實作上面去套用,你就很容易理解了,
協變的簡單定義:如果A是B的子類,那么F(A)是F(B) 的子類,F表示的是一直型別變換,
比如:貓是動物,表示為Cat < Animal,那么一群貓是一群動物,表示為List[Cat] < List[Aniaml],
上面的關系很好理解,在面向物件語言中,is-a表示為繼承關系,即貓是動物的子類(subtype),
所以,協變可以這樣表示:
A < B ? F(A) < F(B)
在貓的例子中,F表示集合,
那么如果F是函式呢?
我們定義函式F=Provider,函式的型別定義包括入參和出參,簡單地考慮入參為空,出參為Animal和Cat的情況,簡單理解為方法F定義為獲取貓或動物,
那么Supplier作用Cat和Animal上,原來的型別關系保持嗎?
答案是保持,Supplier[Cat] < Supplier[Animal],也就是說獲取一只貓就是獲取一只動物,轉換成面向物件的語言,Supplier[Cat]是Supplier[Animal]的子類,
在面向物件語言中,子類關系常常表現為不同型別之間的兼容,也就是說傳值的型別必須為宣告的型別的子類,如下面的代碼是好的
List[User] users = List(user1, user2)
List[Animal] animals = cats
Supplier[Animal] supplierWithAnimal = supplierWithCat
// 使用Supplier[Animal],實際上得到的是Cat
Animal animal = supplierWithAnimal.get()
我們來看下百度百科對于里氏替換原則(LSP)的定義:
里氏代換原則(Liskov Substitution Principle LSP)面向物件設計的基本原則之一, 里氏代換原則中說,任何父類可以出現的地方,子類一定可以出現, LSP是繼承復用的基石,只有當子類可以替換掉父類,軟體單位的功能不受到影響時,父類才能真正被復用,而子類也能夠在父類的基礎上增加新的行為,里氏代換原則是對“開-閉”原則的補充,實作“開-閉”原則的關鍵步驟就是抽象化,而子類與父類的繼承關系就是抽象化的具體實作,所以里氏代換原則是對實作抽象化的具體步驟的規范,

Animal animal = new Cat(”kitty”);
在UML圖中,一般父類在上,子類在下,因此,子類賦值到父類宣告的程序可以形象地稱為向上轉型,
總結一下:協變是LSP的體現,形象的理解為向上轉型,
三、逆變—難以理解的概念
與協變的定義相反,逆變可以這樣表示:
A < B ? F(B) < F(A)
最簡單的逆變類是Consumer[T],考慮Consumer[Fruit] 和 Consumer[Apple],榨汁機就是一類Consumer,接受的是水果,輸出的是果汁,我定義的函式accpt為了避免副作用,回傳字串,然后再列印,
下面我用scala寫的示例,其比Java簡潔一些,也是靜態強型別語言,你可以使用網路上的在線運行環境運行scastie.scala-lang.org,
// scala 變數名在前,型別在后,函式回傳型別在括號后,可以省略
class Fruit(val name: String) {}
class Apple extends Fruit("蘋果") {}
class Orange extends Fruit("橙子") {}
// 榨汁機,T表示泛型,<:表示匹配上界(榨汁機只能榨果汁),-T 表示T支持逆變
class Juicer[-T <: Fruit] {
def accept(fruit: T) = s"${fruit.name}汁"
}
val appleJuicer: Juicer[Apple] = Juicer[Fruit]()
println(appleJuicer.accept(Apple()))
// 編譯不通過,因為appleJuicer的型別是Juicer[Apple]
// 雖然宣告appleJuicer時傳遞的值是水果榨汁機,但是編譯器只做型別檢查,Juicer[Apple]型別不能接受其他水果
println(appleJuicer.accept(Orange()))
榨汁機 is-a 榨蘋果汁機,因為榨汁機可以榨蘋果,
逆變難以理解的點就在于逆變考慮的是函式的功能,而不是函式具體的引數,
引數傳參原則上都可以支持逆變,因為對于純函式而言,引數值并不可變,
再舉一個例子,Java8 中stream的map方法需要的引數就是一個函式:
// map方法宣告
<R> Stream<R> map(Function<? super T, ? extends R> mapper);
// 此時方法的引數就是T,我們傳遞的mapper的入參可以為T的父類, 因為mapper支持引數逆變
// 如下程式可以運行
// 你可以對任意一個Stream<T>流使用map(Object::toString),因為在Java中所有類都繼承自Object,
Stream.of(1, 2, 3).map(Object::toString).forEach(System.out::println);
問題可以再復雜一點,如果函式的引數為集合型別,還可以支持逆變嗎?
當然可以,如前所述,逆變考慮的是函式的功能,傳入一個更為一般的函式也可以處理具體的問題,
// Scala中可以使用 ::: 運算子合并兩個List, 下一行是List中對方法:::的宣告
// def ::: [B >: A](prefix: List[B]): List[B]
// 這個方法在Java很難實作,你可以看看ArrayList::addAll的引數, 然后想想曲線救國的方案,下一篇文章我會詳細討論
// usage
val list: List[Fruit] = List(Apple()) ::: (List(Fruit("水果")))
println(list)
// output: List(Playground$Apple@74046e99, Playground$Fruit@8f0fecd)
總結一下:函式的入參可以支持逆變,即引數的繼承關系和函式的繼承關系相反,逆變的函式更通用,
不知道你是否還記得Effective Java中提出的PECS法則,Provider-extends, Consumer-super,這個法則和本文所說的協變、逆變又有什么關系呢?
其實PECS法則從使用角度實作了許多泛型方法,下一篇文章再詳細說吧,
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/465950.html
標籤:Java
