Julia 方法与多重派发
多重派发(multiple dispatch)是 Julia 最核心的语言特性:同一个函数可以针对不同参数类型的组合定义多个实现(方法),调用时根据所有参数的类型选择最匹配的实现。
这与传统面向对象语言不同:C++、Java、Python 的方法派发只看第一个参数(this 对象),而 Julia 的函数不属于任何类型,派发时会考虑全部参数。
函数与方法的关系
在 Julia 中,函数(function)是一个名字,方法(method)是具体实现。同一个函数名下可以挂多个方法:
实例
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 选择与参数类型最匹配的那个方法:
实例
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() 函数可以列出一个函数的全部方法:
实例
# 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 会报错而不是随机选择:
实例
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)
解决办法是为歧义的参数组合补充一个更具体的方法:
实例
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)
参数化方法最常见的用法是同时约束容器和元素类型:
实例
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 中大部分运算符本质上是函数,因此可以为自定义类型重载运算符:
实例
6
julia> +(1, 2, 3) # 运算符的函数形式
6
julia> f = + # 运算符可以当值传递
f (singleton type of function +, subtype of Function)
julia> f(1, 2)
3
为自定义类型重载 + 运算符:
实例
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 个方法,不如用转换函数归一到一种类型:
实例
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}) 或不标注元素类型 |
| 给接口函数写兜底方法 | 在抽象类型上写通用实现,让子类型开箱即用 |
