
3.3 理解多重分派
多重分派是Julia编程语言中最独特的功能之一。它们在Julia的Base库、stdlib和许多开源包中被广泛使用。在本节中,我们将探讨多重分派的工作方式以及如何有效利用它们。
3.3.1 什么是分派
分派是选择函数来执行的过程。你可能想知道为什么选择执行哪个函数存在争议。当我们开发一个函数时,我们给它命名,添加一些参数以及执行的逻辑代码块。如果我们为系统中的所有函数提供唯一的名称,那么就不会有歧义。但是,很多时候我们想重复使用相同的函数名称,并将其应用于不同的数据类型以进行类似的操作。
Julia的Base库中有很多示例。例如,isascii函数具有三个方法,每个方法采用不同的参数类型:

根据参数的类型,分派并执行适当的方法。当我们使用Char对象调用isascii函数时,将分派第一个方法。同样,当我们使用AbstractString的子类型String对象进行调用时,将分派第二个方法。有时,直到运行时才知道传递给该方法的参数的类型,在这种情况下,根据所传递的特定值,分派适当的方法。这种行为我们称为动态分派。
分派是一个关键概念,将反复出现。重要的是,我们需要对与如何分派函数有关的规则有所了解。接下来我们将讨论分派。
3.3.2 匹配最窄类型
如第2章所述,我们可以定义将抽象类型作为参数的函数。分派时,Julia将在参数中找到与最窄类型匹配的方法。
为了说明这个概念,让我们回到本章中关于飞船和小行星的例子!我们将改进数据类型,如下所示:

我们定义了一个抽象类型Thing,它可以是宇宙中存在的任何东西。当设计这种类型时,我们期望它的具体子类型具有标准的position和size字段。因此我们为Thing定义position和size函数。默认情况下,我们不想假设任何形状,因此Thing的shape函数仅返回:unknown符号。
为了使它变得更加有趣,我们将为飞船配备两种类型的武器:激光和导弹。在Julia中,我们可以方便地将它们定义为枚举:

在这里,@enum宏定义了一种称为Weapon的新类型。Weapon类型的唯一值是Laser和Missile。枚举是定义类型常量的好方法。在内部,它们为每个常量定义数字值,因此它的性能应该很高。
现在,我们可以用如下代码定义Spaceship和Asteroid的具体类型:

需要注意的是,作为我们设计约定的一部分,Spaceship和Asteroid都包括position和size字段。此外,我们为Spaceship类型添加了一个weapon字段。因为我们已经设计了像飞碟这样最先进的飞船,所以我们也为Spaceship类型定义了shape函数。让我们测试一下。

现在,我们创建了两个飞船和两个小行星。让我们暂时将注意力转向前面的shape函数调用的结果。当用飞船对象s1调用它时,它被分派到shape(s::Spaceship)并返回:saucer。当用小行星对象调用它时,它被分派到shape(t::Thing),因为Asteroid对象没有其他匹配项。
回顾一下,Julia的分派机制始终在参数中寻找最窄类型的函数。在shape(s::Spaceship)和shape(t::Thing)之间进行判断时,它将选择shape(s::Spaceship)执行Spaceship参数。
你熟悉多重分派吗?如果不熟悉,请不要担心。在3.3.3节中,我们将深入研究Julia中多重分派的工作方式。
3.3.3 分派多个参数
目前为止,我们只看到了采用单个参数的方法的分派示例。我们可以将相同的概念扩展为多个参数,简称为“多重分派”。
那么,当涉及多个参数时,它如何工作?假设我们继续开发能够检测不同对象之间碰撞的太空战争游戏。为了详细研究这一点,我们将通过一个示例实现。
首先,定义可以检查两个矩形是否重叠的函数:

然后我们可以定义一个函数,当两个Thing对象碰撞时返回true。可以为Spaceship和Asteroid对象的任何组合调用此函数:

当然,这是一个非常幼稚的想法,因为我们知道飞船和小行星具有不同的形状,而且可能是非矩形的形状。但是,这不是一个糟糕的默认实现。
在继续之前,让我们进行快速测试。请注意,此处故意取消了返回值的输出,因为它们对于此处的讨论不重要。

碰撞检测逻辑可能会根据对象的类型而有所不同,因此我们可以进一步定义以下方法:

使用这种新方法——基于最窄类型的选择过程,我们可以安全地处理飞船和飞船的碰撞检测。让我们用与前面的代码相同的测试来证明这个主张。

这看起来还不错!如果我们继续定义其余函数,那么所有内容都将涵盖并完善!
多重分派确实是一个简单的概念。本质上,当Julia尝试确定需要分派哪个函数时,会考虑所有函数参数。同样的规则适用于最窄类型!
不幸的是,有时不清楚需要分派哪个函数。接下来,我们将研究这种情况是如何发生的以及如何解决问题。
3.3.4 分派过程中可能存在的歧义
当然,我们总是可以使用具体的类型参数来定义所有可能的方法。但是,在设计软件时,这可能不是最理想的选择。因为参数类型中的组合数量可能不胜枚举,并且通常不必一一列举。在此处的游戏示例中,我们只需要检测两种类型(Spaceship和Asteroid)之间的碰撞。因此,我们只需要定义2×2=4个方法即可。但是,请想象一下,当我们有10种类型的对象时该怎么办。这意味着,我们将必须定义100个方法!
抽象类型可以拯救我们。让我们想象一下,我们确实必须支持10种具体的数据类型。如果其他8种数据类型具有相似的形状,那么我们可以通过接受抽象类型作为参数之一来极大地减少方法的数量。怎么样?让我们来看下面两个函数:

这两个函数提供了用于检测Asteroid与任何Thing之间碰撞的默认实现。第一个函数可以处理第一个参数是Asteroid且第二个参数是Thing的任何子类型的情况。如果总共有10种具体类型,则此方法可以处理10种情况。同样,第二个函数可以处理其他10种情况(即第一个参数是Thing的任何子类型且第二个参数是Asteroid的情况)。让我们快速检查一下。

这两次调用工作正常。让我们完成测试。

等等,当我们尝试检查两个小行星碰撞时发生了什么?好吧,Julia运行时在这里检测到歧义。当我们传递两个Asteroid参数时,不清楚是要执行collide(A::Thing,B::Asteroid)还是collide(A::Asteroid,B::Thing)。两种方法似乎都可以执行任务,但是它们的签名都不比另一个窄,因此它只是放弃并抛出错误。
幸运的是,它实际上建议将修复程序作为错误消息的一部分。可能的解决方法是定义一个新方法collide(A::Asteroid,B::Asteroid),如下所示:

因为它具有最窄类型的函数签名,所以当将两个小行星传递给collide函数时,Julia可以正确地分派此新函数。一旦定义了此函数,将不再有歧义。让我们再试一次。结果如下。

如上所示,当你遇到多个分派的歧义时,可以通过在其参数中创建具有更特定类型的函数来轻松解决该问题。Julia运行时不会尝试猜测你想要做什么。作为开发人员,我们需要向计算机提供清晰的说明。
但是,仅看代码可能就不那么模棱两可了。为了减少在运行时遇到问题的风险,我们可以主动检测代码的哪一部分可能引入此类歧义。幸运的是,Julia已经提供了一种方便的工具来识别歧义。我们将在3.3.5节对此进行研究。
3.3.5 歧义检测
在运行时遇到特定的用例之前,一般很难碰到有歧义的方法。很难想象事情不可预知。像我这样的软件工程师应该都不喜欢在生产环境中遇到这样的“意外惊喜”!
幸运的是,Julia在Test包中提供了一个用于检测歧义的函数。我们可以使用类似的测试来进行尝试。运行下面的代码。

我们在REPL中创建了一个小模块,该模块定义了三个foo方法。这是歧义方法的经典示例——如果我们传递两个整数参数,则不清楚应该执行第二个还是第三个foo方法。现在,让我们使用detect_ambiguities函数,看看它是否可以检测到问题。

结果告诉我们foo(x::Integer,y)和foo(x,y::Integer)函数是有歧义的。由于我们已经学习了解决该问题的方法,因此可以再次进行测试。

实际上,当你的函数是由其他模块扩展的函数时,detect_ambiguities函数将更加有用。在这种情况下,你可以只对要一起检查的模块调用detect_ambiguities函数。通过下面示例里的两个模块来看看它的工作方式。

在这个假设的示例中,Foo4模块导入Foo2.foo函数,并通过添加新方法对其进行扩展。Foo2模块本身是有歧义的,但是将两个模块组合在一起可以清除歧义。
那么我们什么时候应该利用这种强大的检测函数呢?做到这一点的一种好方法是在模块的自动测试套件中添加detect_ambiguities测试,以便在每个构建的连续集成管道中执行该测试。
既然我们知道如何使用这种歧义检测工具,那么我们可以使用多重分派而不必担心!在3.3.6节中,我们将介绍分派的另一个方面,称为动态分派。
3.3.6 理解动态分派
Julia的分派机制之所以独特,不仅是因为它具有多重分派功能,还因为它在决定分派位置时动态地处理函数参数的方式。
假设我们要随机选择两个对象并检查它们是否碰撞。我们可以用如下代码定义函数:

我们运行它看看会发生什么。

我们可以看到,根据two变量中传递的参数类型,调用了不同的collide方法。
这种动态行为与在面向对象的编程语言中的多态非常相似。主要区别在于Julia支持多重分派,利用所有参数在运行时进行分派。相反,在Java中,只有被调用的对象才用于动态分派。一旦确定了适当的类以进行分派,那么当存在多个具有相同名称的重载方法时,方法参数将用于静态分派。
多重分派是一项强大的功能。与自定义数据类型结合使用时,它使开发人员可以控制针对不同场景调用的方法。如果你对多重分派有更多兴趣,可以在YouTube上观看标题为“The Unreasonable Effectiveness of Multiple Dispatch”的视频。这是Stefan Karpinski在JuliaCon 2019会议上的演讲录像。
接下来,我们将研究如何对函数参数进行参数化以提高灵活性和表达能力。