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

 

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值