是什么让Julia独树一帜?

看看multiple dispatch是如何将Julia与所有其他编程语言区分开来的

multiple dispatch是 Julia 编程语言的杀手锏,但不幸的是,很少有开发者听说过multiple dispatch。
更少的人知道它是什么或者它是如何工作的。
这并不奇怪,因为很少有语言支持multiple dispatch,而那些支持multiple dispatch的语言往往把它藏得很深。因此,在我大肆宣扬multiple dispatch的威力之前,我必须先解释一下它到底是什么。
我给你一条线索:它与函数的调用方式有关,但让我们退一步来阐述。

当一个程序运行时,遇到一个函数调用,它必须弄清楚要跳到什么代码并执行。
在简单的程序性编程语言中,如C或Pascal,这个过程是直接的。
每个函数都被实现为一个子程序,位于一个独特的内存位置。调用该函数只需要跳转到子程序的内存地址,并执行每一条指令,直到微处理器发出返回指令。

在处理函数指针时,事情变得有点棘手了。
我们跳转到的子程序可以在运行期间改变,因为代码被允许改变存储在函数指针中的子程序地址。
我为什么要提到这些细节呢?因为我想表达这样一个观点:调用一个函数并确定执行什么代码并不总是一件微不足道的事情。

考虑一下在面向对象编程中调用一个方法的复杂性。

warrior.attack(knight)

成员函数attack并不对应于一个特定内存地址的子程序。
当attack方法在战士对象上被调用时,一个确定跳转到哪个子程序的复杂过程被启动了。
我们必须确定什么样的战士在进行攻击。我们可以想象一下不同战士的类型层次,如弓箭手、长矛手和骑士。

因为弓箭手的攻击方式与步兵或骑士不同,每种类型的攻击方法的实现方式也不同。
通过一个被称为单一调度的过程,我们决定调用哪个方法。从低级别的角度来看,我们试图确定在执行warrior.attack(knight)语句时跳到哪个子程序。

单一调度如何工作取决于我们是在处理动态语言还是静态类型的语言。让我们关注它在动态类型语言中是如何工作的,因为我们将把这个过程与Julia进行比较,后者也是一种动态类型的语言。

想象一下,我们有一个战士a在攻击一个战士b,我们的第一步将是确定a是什么类型。
在动态类型的语言中,每个对象都知道他们是什么类型。
例如,在Objective-C中,每个对象都有一个叫做isa的字段,指向一个描述该对象类型的类对象。在下面的图形中,我们假装战士a是Archer类的一个实例。Archer类包含了每个实现方法的函数指针。为了找到正确的方法,我们对 “attack!”这个键做一个字典查询。

几个方法结尾处的感叹号可能看起来很特别。
别担心,这只是一个命名惯例,在Lisp和Julia中流行,用于突变函数。它没有任何语义上的意义。

严格地说,在大多数动态语言中谈论函数指针是错误的。例如,在Ruby中,你实际上并没有用机器码指向任何子程序,而是指向由解析方法产生的抽象语法树(AST)。Ruby解释器解释AST来运行方法中的代码。

我们刚才讨论的被称为单一调度,因为我们根据单一对象来决定调用什么方法。
对象b的类型并不以任何方式影响方法的查找过程。
相比之下,在multiple dispatch中,函数调用中的每一个参数都在决定哪个方法被选中方面起作用。我知道这听起来很古怪,所以让我通过解释单一调度的问题来给你一个拥有multiple dispatch的动机。

multiple dispatch解决了哪些问题?

假设我们有一个用Julia代码编写的battle!函数。
它通过调用attack!函数来模拟两个战士a和b之间的战斗,并根据结果向用户打印出信息。
下面的大部分代码应该是不言自明的。在Julia中,我们用::来分隔变量名和变量类型。
因此,在代码的例子中,a::Warrior,告诉 Julia 战斗!函数有一个名为 a 的 Warrior 类型的参数。

function battle!(a::Warrior, b::Warrior)
    attack!(a, b)
    if a.health == 0 && b.health == 0
        println(a.name, " and ", b.name, " destroyed each other")
    elseif a.health == 0
        println(b.name, " defeated ", a.name)
    elseif b.health == 0
        println(a.name, " defeated ", b.name)
    else
        println(b.name, " survived attack from ", a.name)
    end
end

看看这些代码并问自己这个简单的问题。
类似的代码在C++或Java中也能工作吗?乍一看,这似乎是可能的。
这两种语言都允许你定义多个具有相同名称但不同参数的函数。你可以写一些类似于下面Julia代码的东西。

function attack!(a::Archer, b::Archer)
    if a.arrows > 0
        shoot!(a)
        damage = 6 + rand(1:6)
        b.health = max(b.health - damage, 0)
    end
    a.health, b.health
end

function attack!(a::Archer, b::Knight)
    if a.arrows > 0
        shoot!(a)
        damage = rand(1:6)
        if b.mounted
            damage += 3
        end
        b.health = max(b.health - damage, 0)
    end
    a.health, b.health
end

function attack!(a::Knight, b::Knight)
    a.health = max(a.health - rand(1:6), 0)
    b.health = max(b.health - rand(1:6), 0)
    a.health, b.health
end

代码的具体内容并不重要。
我想让你从这个代码例子中得到的是,我们已经定义了三次attack!。
每个定义都接受了不同类型的参数。在C++和Java中,我们把这称为函数重载。在编译时,编译器会通过检查调用处的每个输入参数的类型来选择合适的函数来调用。

这里有一个关键的问题。一个C++编译器不可能猜到在battle!函数中调用哪个attack!函数,因为它不知道参数a和b的具体类型。哪个子类型只有在代码实际运行时才能确定。这是一个无奈之举,因为函数重载只在编译时发挥作用。

在这种情况下,multiple dispatch可以做一些单一调度和函数重载都做不到的事情。它可以在运行时根据参数a和b的类型来选择正确的代码。

multiple dispatch是如何工作的

还记得单次派发是如何通过在运行时查找正确的方法来工作的吗?
multiple dispatch也是要选择正确的方法。
你刚才看到的attack!定义实际上不是函数的定义,而是方法的定义。要在Julia中定义一个attack!函数,你可以写:

function attack! end

为什么没有参数?因为 Julia 函数没有参数,而 Julia 方法有参数。
与面向对象的语言不同,Julia 中的方法是附属于函数的,而不是类。

因此,在 Julia 中,函数的调用是通过首先查找哪个函数被调用来进行的。
在每个函数上,Julia 都注册了一个方法表。这个表被从上到下搜索,以找到一个接受参数类型与函数调用处提供的输入参数类型相匹配的方法。

Julia是一种即时编译(JIT)语言,因此方法的源代码在几个步骤中变成了可执行的机器代码。

  • 当Julia文件被加载到内存中时,每个方法的源代码都会被解析并变成一个抽象语法树(AST)。
  • 每个方法的AST被存储在正确的方法表中,用于正确的功能。
  • 在运行时,当一个方法被定位时,我们首先得到AST。AST被JIT编译器转化为机器代码,并为以后的查找进行缓存。
    这个过程实际上比我在这里展示的要复杂得多。
    你看,一个抽象语法树可以是非常通用的。它可以是一个为数字参数定义的计算。无论参数是16位无符号整数还是32位有符号整数,所进行的计算都是一样的。
    然而,这些情况下的汇编代码看起来就不一样了。
    同一个 AST 可以产生几个机器代码子程序。Julia 将为方法表中的每一种情况添加一个条目。
    因此,一个方法表并不局限于你所写的源代码的方法数量。

是什么使Julia的multiple dispatch变得独特

每当你在Julia中调用一个函数时,都会进行一次方法查找。或者说,从Julia开发者的角度来看,这就是所发生的事情。代码的运行就像每次都会发生一样。

在其他支持multiple dispatch的语言中,这并不是发生的事情。只有以特殊方式标记的函数才能利用multiple dispatch。否则,会进行常规的函数调用。为什么其他语言限制使用multiple dispatch?因为在Julia出现之前,multiple dispatch是非常慢的。

不难想象为什么multiple dispatch会很慢。与其在恒定时间内做一次字典查询,即 O(1),不如在一个大表中做一次线性搜索,即 O(N)。函数可以有巨大的方法表。

Julia 是如何规避这个问题的呢?
Julia 是围绕着保持类型尽可能稳定的想法而设计的。在像 Python 或 JavaScript 这样的语言中,情况并非如此。字段和方法可以在运行时被添加或删除。单个字段的类型可以改变。在 Julia 中,类型在设计上被锁定得更多。当你定义一个复合类型时,你固定了字段的数量和它们的类型。

这种设计选择是如何影响multiple dispatch的呢?
这意味着 Julia JIT 编译器所做的代码分析变得更加容易。代码的行为变得更加可预测,这使得有可能识别更多的情况,当一个函数被调用时,应该被定位的方法变得完全确定和可预测。
记住,如果函数调用的参数类型保持不变,那么Julia将总是查找同一个方法。
如果代码分析能够确定函数的哪些参数永远不会被改变,那么JIT编译器就可以用一个直接的函数调用来代替多次调度的查找。如果代码很短,它甚至可以被内联。

因此,Julia成功地将开始时的性能劣势变成了性能优势。你看正因为如此,Julia 的函数调用一般会比面向对象语言的单次调度调用快得多。

一旦你得到了闪电般的速度,在任何地方使用multiple dispatch,你的编码风格就会改变。始终有多路调度可用,对 Julia 社区的软件工程实践产生了深远的影响。

通过multiple dispatch重用代码

面向对象语言的用户通过继承类和实现接口来重用代码,这使得人们可以将新的代码插入现有的框架中。
Julia的方法是在函数层面上进行重用。
不同的开发者都可以向相同的函数添加方法。我们不是扩展类,而是扩展函数。因为函数存在于较低的粒度水平上,所以我们可以得到更多的代码重用机会。

这种灵活性的一个简单例子是定义在 Julia 标准库中的 show 函数。
它被Julia用来在不同的背景下显示一个值。上下文可以是 REPL(交互式命令行)、笔记本或 IDE 环境。匹配以下两个签名的方法可以被添加到show函数中。

show(io::IO, mime, x)
show(io::IO, x)

io对象代表一个显示值x的目标。io可以是控制台窗口、文件、文本字符串、套接字或图形显示。值x可以是一个简单的数字、日期、文本字符串或更复杂的对象,如字典或数组。

与面向对象的编程语言不同,你可以沿着多个维度扩展显示功能。
你可以为全新的IO子类型添加显示方法,创建这些方法是为了在新的背景下显示现有的值类型。
比如我们创建了特殊的类型来表示温度单位摄氏度、华氏度和开尔文。可以为显示方法添加,这样代表温度的数字就会以正确的单位显示。

请注意,在Julia中可以用等号来定义单行函数。

show(io::IO, t::Celsius)    = print(io, t.value, "°C")
show(io::IO, t::Fahrenheit) = print(io, t.value, "°F")
show(io::IO, t::Kelvin)     = print(io, t.value, "K")

为了理解为什么这种扩展机制如此强大,请允许我指出一些你试图用面向对象的编程来复制这种扩展机制会面临的问题。你可以设计一个系统,每个对象都必须实现一个显示方法才能被显示,但这种选择会导致几个问题。

  • 所有的类都必须继承一个具有显示方法的基类。
  • 每个对象都会在每个IO对象类型上得到相同的表示。

请允许我详细说明一下。许多面向对象的系统最终会出现过于复杂的基类。原因是你想为每个对象支持这么多的功能。

  • 在不同的环境中对一个对象进行可视化,比如在调试器中
  • 用于打印或存储到文件的文本表示法
  • 允许在一个集合中使用该对象的哈希函数
    你可以在Java和Objective-C中找到这种模式,例如。这样的方法是僵化和不灵活的。如果你把基类的设计弄错了,就会对所有依赖的代码产生严重的后果。

更不用说,如果语言设计者忘记添加一个显示方法,那么就没有简单的方法来改造它。只有对标准库的更新才能解决这个问题。你作为一个第三方开发者,是无法改造一个解决方案的。相反,如果Julia标准库没有定义显示函数,你可以很容易地自己定义它,并发布一个为普通对象实现可视化的库,你可以把它发布给其他人。

让我们再谈谈I/O系统的问题。假设你已经创建了一个叫做Vector2D的2D矢量类型。
当在控制台中使用时,你可能想把一个向量显示为[4, 8],而如果I/O对象代表一个图形显示,你会想显示一个箭头。
这两种选择在 Julia 中都是可能的,因为你可以为 io 参数是一个图形显示,而 x 参数是一个 2D 矢量的情况编写一个专门方法。
与此相反,面向对象的语言只能根据io或x的类型来选择执行的方法,而不是两者。
请记住,在单调度中,运行时要调用的方法是根据单个参数的类型来选择的,而不是多个参数。

当然,你可以抛出一个switch-case语句来处理不同的类型,但这是不可扩展的。每次你增加一个新的类型,你都必须修改switch-case语句。这使你无法将你的代码作为一个可重用的库来发布。
库的用户不应该修改第三方库的源代码来扩展它。

multiple dispatch的效用

模拟不同类型的战士之间的战斗或制作一个I/O系统,当然只是multiple dispatch简化编码的少数情况。
我第一次发现自己在为视频游戏的碰撞检测编码时需要这样的东西。
不同的游戏对象将由不同的几何形状来表示。
问题是,计算两个圆形、两个正方形或一个圆形和一个正方形之间的交点是完全不同的。
你不能只看一个参数就确定要使用的算法,你需要两个参数。如果没有multiple dispatch,你的解决方案就会变得很混乱。

multiple dispatch也自然适合于任何数字工作。对数字的操作通常是二进制的。如果只看第一个数字的类型来决定如何组合两个数字,是没有意义的。

简而言之,multiple dispatch就像一把瑞士军刀。它可以帮助程序运行得更快,让你优雅地解决许多问题,并为代码重用提供了一种卓越的手段。
这听起来可能有点夸张,但我真的相信multiple dispatch将定义未来的编程范式。