Julia---- 爲什麼要多重派發?(Why multiple dispatch?)

 

爲什麼要多重派發?(Why multiple dispatch?)

爲什麼要多重派發?(Why multiple dispatch?)

 

最近看Julia語言的時候,在Wikipedia的multiple dispatch頁面上看到了一些有意思的東西,首先最令人驚訝的就是,儘管有這麼多語言都能支持multiple dispatch,不過貌似主流語言裏面把multiple dispatch作爲主要買點的也就只有Julia了.(嗯,CLOS也是一個比較出名的,不過畢竟是Lisp,什麼都能做...)

不過更加令我感興趣的是這一句話:

The theory of multiple dispatching languages was first developed by Castagna et al., by defining a model for overloaded functions with late binding.[4][5] It yielded the first formalization of the problem of covariance and contravariance of object-oriented languages[6] and a solution to the problem of binary methods.

多重派發的各種優點都已經在各種地方(包括知乎上,只不過貌似這裏的討論比較少,而且基本都和Julia有關)被討論過了,不過我還是第一次看到multiple dispatch和協變,反變扯上關係,於是我粗略的看了一下上文提到的 Castagna文章,看看到底是怎麼回事.希望這個文章能夠從另外一個角度看多重派發的好處.

協變與反變

這個文章主要說了什麼?主要討論了在面向對象系統中型變子類化的互作,並且提出了multiple dispatch作爲解決方案.

子類化反映了類型之間的關係,繼承可以看做是一種子類化,A是B的子類,可以寫成A<:B,比如說整數Int也是實數Real,反映了Int<:Real

型變是關於怎麼"誘導"子類化的問題?我們都知道,程序員除了直接定義新的類型以外,還可以利用給定的規律從舊的類型中組合出新的類型,比如說:

  • 和類型Union,Union{Missing,Real}表明一個類型,
  • 乘積類型Product,結構體,元組都可以看做是乘積類型
  • 等等

子類化是類型上的一種關係,那麼我們自然要問這些按照規律構造出來的類型之間有什麼子類化關係?(被"誘導"出來),例如說這樣一個規則,告訴我們和類型的子類化該怎麼生成:

如果A<:B,A<:C則A<:Union{B,C}

那麼型變是什麼呢?如果我們把構造新類型的規則看做是一種函數f,而參與構造的類型叫做T,那麼這種規則無外乎有以下幾種情況(這裏假設f爲一元函數,對多元函數可以類推):

  • 協變  A<:B,f(A)<:f(B)
  • 反變  A<:B,f(B)<:f(A)
  • 不變  A<:B,f(A),f(B)之間沒有關係

例如假設有一個泛型叫做向量Vector{T},T代表某一個類型

那麼對應的情況就是

  • 協變  Vector{Int}<:Vector{Real}
  • 反變  Vector{Real}<:Vector{Int}
  • 不變  Vector{Real},Vector{Int}之間沒有關係

爲什麼要關心型變?

我們之前提到了,型變描述了子類化被誘導的3種一般情況,現在讓我們考慮一下一個函數應該有什麼樣的型變,一個函數應該視爲S->T類型,有兩個類型參數S,T,那麼什麼樣的型變有意義呢?答案是:

如果A<:B,C<:D,則函數B->C <: A->D
換言之若f::S->T,對S爲反變的,對T爲協變的

爲什麼會這樣呢,考慮以下兩個情況(想象一個函數爲一個管道,一端爲接收端,另外一端爲產生端):

1.我們固定T相同,即我們想要有若S2<:S1,則S1->T <: S2->T

例子:Int<:Real,則有Real->T<:Int->T,因爲每個可以使用Int->T的地方都可以使用Real->T(因爲函數可以接受更大的類型,就代表它也可以接受更加小的類型,所以Real->T應該作爲子類),所以接收端應該具有反變的性質

2.我們固定S相同,即我們想要有若T1<:T2,則S->T1 <: S->T2

這裏比較好理解,因爲T是產生端(產生類型T1,T2),所以產生較小類型的是子類(產生更小的T1,因此可以用於更大的類型T2中),所以產生端應該具有協變的性質

如果上面的都能理解的話,貌似看起來沒什麼毛病,雖然有點驚奇的是,一個函數對兩個參數是分別協變和反變的.那麼到底問題是什麼呢?

...類上重載函數是協變的!

問題在於這個式子:我們前面說過,對於接收端如果Int<:Real,則Real->T <: Int->T

然而這個式子在面向對象的類型系統之中是有問題的,因爲我們之前還沒有談論到重載的函數,所以上述邏輯還是自洽的,但是有了重載函數就不對了,考慮面向對象中的一個簡單的例子:

假設有兩個類Int與Real,其中Int<:Real
Real上定義了一個方法equal::Real->Bool
Int上重載方法equal爲equal::Int->Bool(這裏我們希望特化這個函數,
我們只想要Int與Int之間比較,不想要和Real比較)

我們都知道,面向對象可以一個方法看作是一個結構體的域,比如說,Real可以看成是這樣一個結構體,a.equal就是取出來了一個方法指針來調用這個函數

Real::{value,equal::Int->Bool,...#定義了其他方法}

但是,結構體是對於各個域是協變的,這叫結構兼容性,這很好理解,例如說:

我們有元組{Int,Int}<:{Real,Real},因爲能夠使用{Real,Real}的地方也可以使用{Int,Int}

這導致了一個矛盾:重載的函數要求函數是協變的Int->Bool<:Real->Bool,然而我們上面的subtyping又要求是反變的!

一些疑問

看了上面的推導,你可能會產生幾個疑問(從而覺得這些都不是什麼矛盾):

1.結構體(和結構體)爲什麼是協變的?貌似我用的面向對象的編程語言沒有這種說法,也沒有這種要求

2.(與1密切相關)在上面的例子,Int上重載方法equal爲equal::Int->Bool,在C++和Java(大部分人比較熟悉這兩個語言),靜態函數也重載貌似沒有什麼子類化要求(實際上equal的類型甚至可以亂七八糟的,比如說equal::Bool->Int也可以)虛函數倒是要求類型一致,所以沒看出問題在哪裏?

答案是很簡單的:C++和Java放棄了(誘導的)Subtyping,所以上述問題也就自然消失了.如果沒有子類化,問題2自然就解決了,因爲都沒有子類化這種關係,自然重載就可以任意了.

對於問題1,爲什麼結構體要求爲協變的?原因就是子類化要求的語義:如果A<:B,那麼能夠用B的地方自然也就能夠用A,也就是說我們有以下規律:

1.子類化->能夠用A的地方也能用B
2.類型安全
1+2->結構體協變

考慮原來的Int和Real,假設浮點數Float也爲Real子類,如果有這樣一個代碼:

#r::Real
r.equal(1.5::Float)

根據子類化,如果(任意)一個實數能夠用在上面的比較之中,自然子類Int也可以,然而Int的equal已經被特化成Int->T了,所以上面的代碼將爲類型不正確的!(Int的equal只能接受Int,不能接受Float),之所以不正確,就是因爲結構體沒有協變,Int<:Real,但是Int上的方法equal卻不是協變的(就是我們上面說的協變與反變的矛盾),如果我們改一下上面的例子:

假設有Int<:Real<:Number Float<:Real
Real上面定義了equal::Real->Bool
Int上面定義了equal::Number->Bool
現在Real<:Number,所以Number->Bool<:Real->Bool(因爲函數對接收端反變),結構體協變
因此對於上面的代碼r.equal(1.5::Float),r不管是Real還是Int,類型都是良好的

這樣程序就能夠良好運行了.

也許在這裏你有另外一個疑問:例如在C++,如果equal爲靜態函數(非虛函數),調用哪一個equal,和靜態聲明r爲哪一個類型有關,而與運行時類型無關,所以上面的例子並不成立,也沒有類型錯誤.實際上這正是關鍵所在,如果是靜態函數的話,程序是按照編譯時類型調用方法,從而不會產生矛盾;但假如是虛函數的話,上面的規則就適用了,回想一下,C++中的虛函數要求子類上的函數有相同的簽名(從而型變爲不變的),是符合結構體兼容的規則的,換句話說,我們重新敘述一下上面的規律.

1.子類化<->能夠用A的地方也能用B
2.按運行時類型調用類型
3.類型安全
1+2+3->結構體協變

另外一個說法就是:

除非重載的虛函數滿足結構體協變的要求,否則靜態類型不安全

所以論文指出,在面向對象系統之中,協變與反變的矛盾,實際上是Subtyping與Specilization之間的矛盾.Subtyping要求我們以兼容方式使用父類與子類(表現爲替換Substitution);但是Specilization要求子類有比父類特殊的行爲,從而無法兼容使用(表現爲選擇Selection).

解決辦法-多重派發

其實解決辦法也比較tricky,以前面的例子爲例:

假設有兩個類Int與Real,其中Int<:Real
Real上定義了一個方法equal::Real->Bool
Int上重載方法equal爲equal::Int->Bool
函數的子類化要求Real->Bool<:Int->Bool
但是結構體兼容要求Int->Bool<:Real->Bool
從而引起矛盾

我們發現函數子類化要求Real->Bool<:Int->Bool,是由函數的輸入與輸出類型所要求的;但是另外一方面,結構體兼容要求Int->Bool<:Real->Bool,卻並不是這兩個函數自身特性導致的,僅僅是由於這兩個函數要和對象關聯在一起,所以纔要求有這一個關係.

所以一個自然的解決方案就是,將方法與對象解耦,從而消除結構體兼容引起的這個條件.因此我們就得到了multiple dispatch.換言之我們將類上的同名方法(method)提取出來組合成一個大的函數(Function),方法之間是有子類化關係的(就是之前提到的接收端與產生端規則),但是作爲整體的函數沒有(其實作者提出了一種比較naive的方法來判定作爲整體的函數之間的子類化,考慮到實踐中幾乎無法使用,在此忽略).

例如說還是那個Int和Real的例子,現在equal變成了

equal::{Real->Real->Bool,Int->Int->Bool}

作者提出了一種叫λ&的演算,具體說來就是我們今天意義上的多重派發了:調用函數時,依據所有參數的運行時的類型,選擇一個"最適合"的方法進行調用(因爲我們有Subtyping關係,最適合可以利用這個序關係進行定義),從而完成特化.利用多重派發,我們就可以同時有Subtyping和Specilization了.原文談論的多重派發是一個有些受限的多重派發,因爲作者還額外關心了一下類型安全問題,對函數返回類型有一定的限制,但是在Julia中沒有這些限制,Julia只關心函數參數的類型,而非函數返回值的類型.因爲Julia是動態類型的語言,不是必須考慮這種類型安全的問題的.

注意到幾個問題,多重派發要求"多重"和"派發",多重要求考察所有的參數,而派發要求動態調用."多重"與"單重"之分,來自於下列同構:

對於同一個函數f我們有
面向對象的觀點 f::A->(B->C)
多重派發的觀點 f::AxB->C
AxB爲A,B構成的元組

面向對象爲單重派發的,因爲我們認爲a.f(b)是先調用f(a)返回一個方法,再用這個方法調用b,但是如果我們把方法提升到全局上,我們就要把(a,b)看爲一個整體考慮(看成一個輸入值,而不是先輸入a再輸入b),從而要求我們考察所有的參數的類型(因爲元組的子類化和每個分量都有關),因此多重是自然的結果.

那麼派發呢?派發即"動態的調用"(類似於虛函數),我們已經說過,多重派發的機制是根據運行時類型選擇方法調用,有Subtyping的系統,編譯時類型與運行時類型可以不一致.一個編譯時爲A的類型可以運行時爲其子類B(換言之,運行時類型可以收縮到子類上),但是多重派發要求我們選擇"最適合"的一個.這意味着除非編譯的時候我們已經知道了這就是"最適合"的一個方法,否則編譯器不能靜態決定調用哪一個方法.這就是派發的必要性.一般來說,正如虛函數一般不能隨便去虛化,派發也是必不可少的.

一些問題

讓我們在這裏暫停一下,回憶一下我們之前討論的所有東西:協變和反變的矛盾,來自於面向對象系統Subtyping和Specilization的矛盾,這兩者使得類型系統中會產生矛盾的子類化關係,於是作者提出了multiple dispatch來解決這些問題.

不過我猜有人要問了,實際上multiple dispatch也要把方法調用推遲到運行時才能決定,可是類型仍然不能在靜態解決,所以問題豈不是沒有解決,虛函數不也可以做到運行時決定這一點嗎?

這裏我們要區分兩個問題:類型系統可以劃分爲是靜態或者動態的(是否存在一個算法可以靜態分析程序以確定表達式及子表達式的類型,注意在Subtyping的系統之中,這個確定的類型不一定等於運行時真實的類型,只要求必須是父類),也可以是有矛盾和無矛盾的(即soundness,類型系統有沒有矛盾,比如說我們之前提到的Int->T,Real->T,可以從兩個地方證明他們互爲子類,從而導出一個矛盾).

Multiple dispatch至多隻消除了兩者矛盾性的問題,換言之,一個被證明類型良好的程序不可能運行的時候搞出一個類型錯誤(看之前的例子,r.equal在動態派發的語義下產生了一個類型錯誤),在這裏我忽略了原文的一些細節,原文的Multiple dispatch對函數的返回值有額外的要求,在那個類型系統之中,確實可以使得調用一個函數下不同的方法,返回兼容的值,因此可以進行靜態類型檢查,但是這個檢查發生在Subtyping的語言,不能確保編譯時的類型等於運行時的類型,因此方法仍然是動態派發的,這並不影響這個語言具有一個無矛盾且靜態的類型系統.

如果我們不選擇multiple dispatch,就必須放棄Subtyping或者Specialization,在C++中放棄了Subtyping以後的虛函數,要求虛函數簽名不變,否則就會產生潛在的運行類型錯誤(r.equal(1.5)的例子),換言之,我們削弱了語言的表達能力,以確保靜態類型系統的正確性.可以考慮一些別的方法來繞過這一個限制,比如用visitor模式(仔細想想,本質上就是把方法委託給了另外一個類,變相實現了二元的派發),或者寫很多if-else語句,手動對參數進行派發.例如說我們的Int上的equal可以這樣寫:

class Int
...
Bool equal(r::Real){
  if r的類型是Int{
    #比較整數
  }
   else{
      #拋出類型錯誤
   }  
}

Julia中的Multiple dispatch

不過的話,考慮到這篇文章是作者基於面向對象的Subtyping關係寫的,所以我們順便可以探討一下Julia的Subtyping和多重派發是如何相互作用的.之前說了,派發總是動態的,除非編譯器能夠知道調用的方法是唯一的,但是什麼時候會是唯一的呢?不外乎滿足兩個條件:

1.編譯器證明表達式具有類型A

2.編譯器證明A沒有更多的子類,因此編譯時類型A即爲運行時類型.這裏又分爲兩種可能:

2a)A是類型系統中一種不能再被子類化的類型,例如Julia中的struct,Java中被final修飾的類

2b)封閉世界假設,編譯器編譯程序時,收集到所有類型的信息,並且假設運行時類不能再被拓展,也不能被改動

大多數動態語言連1都沒有實現(因爲可以隨便定義變量,隨便改動類的內部結構).假設已經有了1,Julia採用了2a)途徑,而大多數面嚮對象語言採用了2b)途徑(因爲編譯產生一個個應用程序的時候,總是封閉世界的)

Julia採用了2a)途徑,因此一個最大的好處就是,只要一個函數的所有參數都是具體類型的,從而沒有其他子類,編譯器可以立馬將動態派發靜態化.對於採用了2b)途徑的語言來說,函數分成虛函數與非虛函數,非虛函數按編譯類型調用(因此沒有動態派發的問題),而虛函數除非已經收集到了所有的信息,否則不能斷言a.f()到底調用了那個方法(假設f爲虛函數).

嚴格的來說,如果有一個聰明的編譯器,這兩者區別也並不大.以2b)途徑爲例,如果我們並不怎麼派生類的話(換句話說,類型樹很少被改變),編譯器可以假設我們在一個近似封閉的世界中,進行優化.當我們試圖派生一個子類時,並且重載其上的虛函數時,我們可能會違反封閉世界的假設,從而導致一些優化產生錯誤的結果,編譯器只需查找出所有非法的程序重新編譯即可,據我瞭解,Java採用了類似的優化.

只不過Julia採用2a)途徑有一些別的好處:

1.不區分虛函數與非虛函數,所有函數默認都是動態派發的("都是虛函數"),只不過編譯器可以做type inference消除掉動態派發的函數.在這個語義下,一個函數虛不虛.不是函數內在的性質,而是與調用參數有關的性質(在這一點上實際上和別的動態語言,例如Python也差不多).

2.Julia經常交互式地使用,因此封閉世界假設常常被違反,所以重新編譯發生的次數更高一些,因此代價更高,基於此,2b)途徑並不顯得有吸引力.而且Julia的Base和Core有很大一部分使用Julia自舉的,所以重新編譯起來非常費勁.

Julia Subtyping: a rational reconstruction​dl.acm.org

(圖上面是Julia標準庫中所有類型構成的類型樹)

爲什麼Multiple dispatch不常使用?

考慮到multiple dispatch有這麼多優點,所以自然要問爲什麼multiple dispatch並不是多數編程語言的編程範式?

答案也很簡單,主要有三點:1.Type checking 2.Performance 3. Subtyping

第一點 multiple dispatch沒辦法做type checking(除非multiple dispatch受限,或者說只能夠做很弱的type checking),最本質的問題是由於函數是沒有類型,而方法有.這就意味着大多數以安全爲目的設計的編程語言(尤其是靜態編譯的)都不會考慮multiple dispatch.而且對於編程語言設計者而言,multiple dispatch實現起來也比較麻煩,

第二點,在編譯時無法確定類型的語言中,multiple dispatch在每個函數調用的時候都要檢查每個參數類型,然後查表,這是一個非常耗時的過程,考慮到上個世紀電腦的算力,這種性能的浪費幾乎是不可忍受的.所以和動態語言也相性不好.

第三點,我們之前看到了multiple dispatch和Subtyping是緊密相關,沒有Subtyping就沒有multiple dispatch.例如說C語言,在現有C語言的類型系統之中,不可能弄出什麼multiple dispatch,這沒有什麼意義,因爲C語言編譯時每個類型都是確定的,互不相交的,一個類型爲A的東西總是爲A的,不可能是別的B,因此也沒有派發的必要,換言之,我們總是可以在語法層面上做替換,而消除掉所有所謂的multiple dispatch(變爲靜態的overload).

不過有意思的地方在於,Julia的類型系統並不是爲了安全設計的,而僅僅是爲了性能,而Julia的一些設計使得Julia能夠infer出程序的類型,將動態調用消除,所以意外的Julia和multiple dispatch非常搭配呢.

相關討論:Multiple Dispatch in Practice

Multiple Dispatch in Practice​lambda-the-ultimate.org

 

最後是一些術語

dispatch:根據一定規則選擇,同名函數的不同實現,static dispatch表示根據編譯時類型選擇,dynamic dispatch根據運行時類型選擇;single dispatch表示根據函數第一個參數的類型選擇;multiple dispatch表示根據函數所有參數類型選擇

根據上述規則我們有:

C++/Java: multiple static dispatch + single dynamic dispatch

Python/Ruby: single dynamic dispatch

Julia:mutiple dynamic dispatch

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章