多重派发的本质是一个函数名字能包括(变量个数^type个数::Int)个函数?

我能理解到的巧妙之处在于:多重派发将一个线性变换过程从变量数据类型的束缚中抽象了出来的同时保证了计算机的计算效率。

还有更多吗?

学习julie的第二天,为自己的无知呜呜

2 个赞

这门语言叫 Julia 而不是 Julie,祝你好运。


派发 dispatch 本质上是一系列的根据输入信息进行的 if 判断,例如在其他语言里面,你可能会看到类似于下面的这种代码:

function do_something(X, Y)
    if ndims(X) ==  1 && ndims(Y) == 1
        do_something_1d(X, Y)
    elseif ndims(X) == 2 && ndims(Y) == 2
        do_something_2d(X, Y)
    else
        do_something_fused(X, Y)
    end
end

# 然后再分别实现不同的具体算法: do_something_1d, do_something_2d, do_something_fused...

do_something 并没有做多少实质性的计算工作,只是检查数据的基本信息然后丢到不同的具体算法下去做。在这个意义上我们说这是一个派发函数 (dispatcher),因为它只负责分配 (dispatch) 任务。

关于这种代码模式有两个问题是可以思考的:

  • 这种代码能否避免手写,比如说让机器自动去实现?
  • 如果 A 想要向 B 写的 do_something 里面添加一个新的实现,该怎么做?

先说第一个问题,答案是可以。一些面向对象的编程语言采用的是单重派发,例如在 Python 中:

class MyVector:
    def do_something(self, Y):
        if Y.ndims == self.ndims:
            ...
        else:
            ...
class MyMatrix:
    def do_something(self, Y):
        if Y.ndims == self.ndims:
            ...
        else:
            ...

这里有两个 do_something 方法,X.do_something(Y) 具体调用哪个由 X 的类型来决定,这就是所谓的单重派发 (single dispatch),因为只由第一个参数的类型决定。

Julia 的多重派发正如你所理解的,由全部位置参数的类型来决定(关键词参数不参与多重派发):

function do_something(X::AbstractVector, Y::AbstractVector)
    ...
end
function do_something(X::AbstractMatrix, Y::AbstractMatrix)
    ...
end
function do_something(X::AbstractArray, Y::AbstractArray)
    ...
end

多重派发的本质是一个函数名字能包括(变量个数^type个数::Int)个函数

虽然是这么说没错,但是关于多重派发有两点需要了解:

  1. type 可以是 Any,所以你并不真正知道 type 个数为多少,所以我认为这个问题本身没有太大的价值。
  2. 在我给的这个例子里面,定义了 do_something 函数(function) 的三个方法 (methods)。当我们让 Julia 调用 do_something(rand(4, 4), rand(4, 4)) 的时候,它执行了 do_something(X::Matrix{Float64}, Y::Matrix{Float64}) 这个方法的实例 (method instance)。方法实例不是我们写的,而是由 Julia 根据我们的方法自动生成的,这里也可以类比 C++ 里面的 template 概念去理解。

第二个问题, 如果你想要向我写的 do_something 里面添加一个新的实现,你该怎么做?如果我觉得你写的代码太垃圾或者任何其他原因(比如说我太懒)拒绝接受你写的代码,又该怎么办?

在其他语言里面,你除了在现有的 do_something 的基础上再封装一层 my_do_something 以外没有任何别的办法:

# 这种简单函数也叫 wrapper
function my_do_something(X, Y)
    if should_call_my_super_method(X, Y)
        # 新的方法
        my_super_do_something_method(X, Y)
    else
        # 旧的方法
        do_something(X, Y)
    end
end

通过这样做,你可以部分地解决这个问题,但是这又带来一个新的问题:你竟然又写了一个新的 dispatch 函数!

之所以说是部分地解决这个问题,是因为:在真实场景下,更多的时候我们是想要替换掉整个代码生态的一个小环节,例如:

function complicated_algorithm(x, y)
    y = do_something(x, y)
    z = do_something_else(x, y)
end

如果你真正想要改善的是 complicated_algorithm 里的某一个环节 do_something 的话,那么你会发现,如果采用添加一个新函数 my_do_something 的方式,并不会改变 compilcated_algorithm 的实现。你说你这个也想要改变?那再套一个新的函数 my_complicated_algorithm 吧。

于是,在其它语言里,与其扩展其他人的算法实现,不如自己重新写一套轮子。 所以在 Python 下,你可以看到 numpy,可以看到 jax, 可以看到 CuPy,以及 pytorch,然后发现他们似乎长得都一样?在这个意义上,虽然 Python 生态本身是开源的,但是 Python 生态是割裂的。

但是,Julia 的多重派发允许你作为用户去扩充其他人实现的函数,例如,你只需要添加一个新方法:

do_something(X::CuArray, Y::CuArray) = ...

就可以让整个 complicated_algorithm 能够直接接入到关于 CuArray(GPU 矩阵)的优化实现上,从而避免了手写 dispatch 以及 wrapper 的需求。


另外,多重派发是一种编译期的派发方式,因此这个派发的“if 判断”实际上是放在代码编译的时候进行的。在编译期间进行派发的好处是,即使是对那些最最基础的函数来说,派发也不会带来运行时开销:

Base.:+(x::Int, y::Int) = ...
Base.:+(x::Float64, y::Float64) = ...

那么顺便来说一下,Python 为什么慢呢?因为 Python 做了非常多类似于

function Base.:+(x, y)
    if isa(x, Int) && isa(y, Int)
        ...
    elseif isa(x, Float64) && isa(y, Float64)
        ...
    end
end

的操作,这里面所有的 if 计算都发生在函数调用的时候,因此当函数本身的计算开销还不如进行派发的 if 判断开销来的大的时候,Python 程序慢就是很自然而然的一件事情了。

当然,四则运算这些已知的函数在 Python 下肯定不是这样做的,这里仅仅只是举这样一个例子来说明运行时派发对程序性能的影响。

6 个赞

Julia Julia Julia 对不起。

感谢您慷慨的回答!

  1. 我现在能理解我问题为啥没价值了!type是可以自定义嚯!去扒了一遍文档里的【类型】。
  2. 更认识派发这个行为了。

我现在的(童言无忌的)体会是: 多重派发可以解释为,对“在已有认知上衍生出新的认知”这一抽象过程的实现。

我今天晚上的报告会再介绍一次多重派发,如果有兴趣的话可以来听。

“Julia 代码中的典型设计模式 ” 时间调整到了今晚。

请问多重派发的特性,和c++的函数中指定参数类型有什么明显的区别吗,这似乎就是c++中function overloading?

我不懂 C++ 所以我解释的很可能是错误的;我尝试解释一下

  • function overloading 和 multiple dispatch 的模式不太一样:Julia 做多重派发的时候背后不会发生隐式类型转换
  • C++ 如果需要做 multiple dispatch 的话需要采用虚函数的方式,但是那等于是在 runtime 进行。Julia 的多重派发发生在代码的编译期(虽然是 JIT)。

这里贴上 JuliaCon 2019 JuliaCon 2019 | The Unreasonable Effectiveness of Multiple Dispatch | Stefan Karpinski 的代码以供参考:

// pets.cpp
#include <string>
#include <iostream>

using namespace std;

class Pet {
    public:
        string name;
};

string meets(Pet a, Pet b) { return "FALLBACK"; }

void encounter(Pet a, Pet b) {
    string verb = meets(a, b);
    cout << a.name << " meets "
         << b.name << " and " << verb << endl;
}

class Dog : public Pet {};
class Cat : public Pet {};

string meets(Dog a, Dog b) { return "sniffs"; }
string meets(Dog a, Cat b) { return "chases"; }
string meets(Cat a, Dog b) { return "hisses"; }
string meets(Cat a, Cat b) { return "slinks"; }

int main(){
    Dog fido;     fido.name     = "Fido";
    Dog rex;      rex.name      = "Rex";
    Cat whiskers; whiskers.name = "Whiskers";
    Cat spots;    spots.name    = "Spots";

    encounter(fido, rex);
    encounter(fido, whiskers);
    encounter(whiskers, rex);
    encounter(whiskers, spots);

    return 0;
}
# pets.jl
abstract type Pet end;
struct Dog <: Pet
    name::String
end
struct Cat <: Pet
    name::String
end

function encounter(a::Pet, b::Pet)
    verb = meets(a, b)
    println(a.name, " meets ", b.name, " and ", verb)
end

meets(a::Dog, b::Dog) = "sniffs"
meets(a::Dog, b::Cat) = "chases"
meets(a::Cat, b::Dog) = "hisses"
meets(a::Cat, b::Cat) = "slinks"


fido = Dog("Fido")
rex = Dog("Rex")
whiskers = Cat("Whiskers")
spots = Cat("Spots")

encounter(fido, rex)
encounter(fido, whiskers)
encounter(whiskers, rex)
encounter(whiskers, spots)
(base) jc@JC-win11:~/playgrounds$ c++ pets.cpp -o pets
(base) jc@JC-win11:~/playgrounds$ ./pets
Fido meets Rex and FALLBACK
Fido meets Whiskers and FALLBACK
Whiskers meets Rex and FALLBACK
Whiskers meets Spots and FALLBACK
(base) jc@JC-win11:~/playgrounds$ julia pets.jl
Fido meets Rex and sniffs
Fido meets Whiskers and chases
Whiskers meets Rex and hisses
Whiskers meets Spots and slinks

我的理解是:在 C++ 的 function overloading 版本里,调用 encounter(Pet a, Pet b) 时其实已经进行了一次隐式类型转换到 Pet 类型,从而失去了原始的类型信息。

也许 @cherichy 对这个问题的了解更清楚一些。

2 个赞

很显然,C++中,这一句调用的时候,a和b的类型都是Pet,所以必然会调用的是fallback。