现在位置: 首页 > Julia 教程 > 正文

Julia 方法与多重派发

多重派发方法选择示意图

多重派发(multiple dispatch)是 Julia 最核心的语言特性:同一个函数可以针对不同参数类型的组合定义多个实现(方法),调用时根据所有参数的类型选择最匹配的实现。

这与传统面向对象语言不同:C++、Java、Python 的方法派发只看第一个参数(this 对象),而 Julia 的函数不属于任何类型,派发时会考虑全部参数。


函数与方法的关系

在 Julia 中,函数(function)是一个名字,方法(method)是具体实现。同一个函数名下可以挂多个方法:

实例

# 方法 1:两个 Float64 参数
julia> f(x::Float64, y::Float64) = 2x + y
f (generic function with 1 method)

# 方法 2:两个 Number 参数(更宽泛)
julia> f(x::Number, y::Number) = 2x - y
f (generic function with 2 methods)

调用时 Julia 选择与参数类型最匹配的那个方法:

实例

julia> f(2.0, 3.0)     # 匹配方法 1(Float64, Float64)
7.0

julia> f(2, 3.0)       # 不匹配方法 1,退而匹配方法 2
1.0

julia> f(2, 3)
1

# 类型不满足任何方法时抛出 MethodError
julia> f("a", "b")
ERROR: MethodError: no method matching f(::String, ::String)

内置的 methods() 函数可以列出一个函数的全部方法:

实例

julia> methods(f)
# 2 methods for generic function "f":
[1] f(x::Float64, y::Float64) in Main at REPL[1]:1
[2] f(x::Number, y::Number) in Main at REPL[2]:1

Julia Base 中的核心函数都有大量方法,例如 + 函数有 180 多个方法,分别处理各种类型的加法。


方法派发的规则

派发时选择最具体(most specific)的方法。如果出现两个方法互不比对方更具体的情况,就是方法歧义(ambiguity),Julia 会报错而不是随机选择:

实例

julia> g(x::Float64, y) = 2x + y
g (generic function with 1 method)

julia> g(x, y::Float64) = x + 2y
g (generic function with 2 methods)

julia> g(2.0, 3)      # 明确匹配第一个
7.0

julia> g(2.0, 3.0)    # 两个方法都匹配且互不更具体 -> 歧义错误
ERROR: MethodError: g(::Float64, ::Float64) is ambiguous.

Candidates:
  g(x, y::Float64)
  g(x::Float64, y)

解决办法是为歧义的参数组合补充一个更具体的方法:

实例

julia> g(x::Float64, y::Float64) = 2x + 2y
g (generic function with 3 methods)

julia> g(2.0, 3.0)
10.0

参数化方法

where 可以让方法只在参数类型满足某种条件时生效:

实例

# 只在两个参数类型完全相同时匹配
julia> same_type(x::T, y::T) where {T} = true
same_type (generic function with 1 method)

julia> same_type(x, y) = false
same_type (generic function with 2 methods)

julia> same_type(1, 2)
true

julia> same_type(1, 2.0)
false

# 类型参数还可以加约束
julia> same_type_numeric(x::T, y::T) where {T<:Number} = true
same_type_numeric (generic function with 1 method)

参数化方法最常见的用法是同时约束容器和元素类型:

实例

# 只能往 Int 向量里追加 Int
julia> myappend(v::Vector{T}, x::T) where {T} = [v..., x]
myappend (generic function with 1 method)

julia> myappend([1, 2, 3], 4)
4-element Vector{Int64}:
 1
 2
 3
 4

julia> myappend([1, 2, 3], 4.5)
ERROR: MethodError: no method matching myappend(::Vector{Int64}, ::Float64)

基于抽象类型的接口设计

多重派发结合抽象类型,可以写出"面向抽象编程"的代码:给抽象类型写通用方法,给具体类型写特化方法。

实例

# 抽象形状
julia> abstract type Shape end

julia> struct Circle <: Shape
           r::Float64
       end

julia> struct Rect <: Shape
           w::Float64
           h::Float64
       end

# 通用方法:任何 Shape 都能算面积,逐个类型派发
julia> area(c::Circle) = pi * c.r^2
area (generic function with 1 method)

julia> area(r::Rect) = r.w * r.h
area (generic function with 2 methods)

# 兜底方法也可以放在抽象类型上
julia> describe(s::Shape) = "面积是 $(area(s))"
describe (generic function with 1 method)

julia> describe(Circle(1))
"面积是 3.141592653589793"

julia> describe(Rect(2, 3))
"面积是 6.0"

提示:这就是 Julia 中的"面向对象":没有类和继承方法的概念,用"抽象类型 + 按类型派发的方法"实现多态,而且比传统 OOP 更灵活——新增一个 Shape 子类型无需修改任何已有代码,直接为它添加 area 方法即可。


操作符也是函数

Julia 中大部分运算符本质上是函数,因此可以为自定义类型重载运算符:

实例

julia> 1 + 2 + 3
6

julia> +(1, 2, 3)      # 运算符的函数形式
6

julia> f = +           # 运算符可以当值传递
f (singleton type of function +, subtype of Function)

julia> f(1, 2)
3

为自定义类型重载 + 运算符:

实例

julia> import Base: +      # 扩展 Base 的函数需要先 import

julia> struct Vec2
           x::Float64
           y::Float64
       end

julia> Base.:+(a::Vec2, b::Vec2) = Vec2(a.x + b.x, a.y + b.y)

julia> Vec2(1, 2) + Vec2(3, 4)
Vec2(4.0, 6.0)

类型海盗(不要做)

别人模块的类型扩展别人模块的函数称为类型海盗(type piracy),例如给 Base 的 Symbol 类型定义 Base 的 * 方法。这会全局改变其他代码的行为,引发难以排查的 bug,务必避免:

实例

# 危险示例,不要模仿!
module Bad
    import Base.*
    *(x::Symbol, y::Symbol) = Symbol(x, y)   # 类型海盗
end

合法的扩展只需要满足"至少一方是自己定义的":要么给自己包的类型扩展 Base 的函数(如上面为 Vec2 重载 +),要么给自己的函数加 Base 类型的方法。


方法设计的常见模式

兜底方法 + promote 模式

标准库处理混合类型的经典做法:先写同类型的快速方法,再用 promote 把不同类型提升为共同类型后转发:

实例

# 具体方法处理同类型
julia> area(multiplier::Float64, x::Float64) = multiplier * x
area (generic function with 1 method)

# 兜底方法:把参数提升为共同类型后重新调用
julia> area(multiplier, x) = area(promote(multiplier, x)...)
area (generic function with 2 methods)

julia> area(2, 3.0)     # Int 和 Float64 被 promote 成 Float64
6.0

正交化设计

当参数组合很多时,与其写 N×M 个方法,不如用转换函数归一到一种类型:

实例

# 不要写 4 个组合方法,写 1 个 + 1 个转换
f(x::A, y::A) = ...
f(x, y) = f(toA(x), toA(y))    # toA 把任意类型转成 A

method 的实际应用建议

建议说明
参数类型宁宽勿窄Integer 而不是 Int64,能兼容 BigInt 等类型
消除歧义要趁早先定义消除歧义的方法,避免中间状态报歧义错误
避免派发抽象容器元素类型f(::Vector{Int}) 这类签名容易和别的包产生歧义,用 f(::Vector{<:Real}) 或不标注元素类型
给接口函数写兜底方法在抽象类型上写通用实现,让子类型开箱即用