方法

回想一下,在函数中我们知道函数是一个把一组参数映射成一个返回值的东西(或者抛出异常)具有相同概念的函数或者运算,经常会根据参数类型的不同而进行有很大差异的实现:两个整数的加法与两个浮点数的加法是相当不一样的,整数与浮点数之间的加法也不一样。除了它们实现上的不同,这些运算都归在「加法」这么一个广义的概念之下,因此在 Julia 中这些行为都属于同一个对象:+ 函数。

为了让对同样的概念使用许多不同的实现这件事更顺畅,函数没有必要马上全部都被定义,反而应该是一块一块地定义,为特定的参数类型和数量的组合提供指定的行为。对于一个函数的一个可能行为的定义叫做方法。直到这里,我们只展示了那些只定了一个方法的,对参数的所有类型都适用的函数。但是方法定义的特征是不仅能表明参数的数量,也能表明参数的类型,并且能提供多个方法定义。当一个函数被应用于特殊的一组参数时,能用于这一组参数的最特定的方法会被使用。所以,函数的全体行为是他的不同的方法定义的行为的组合。如果这个组合被设计得好,即使方法们的实现之间会很不一样,函数的外部行为也会显得无缝而自洽。 当一个函数被应用时,执行方法的选择被称为「分派/派发(dispatch)」。Julia 允许分派过程基于给定的参数个数和所有参数的类型来选择调用函数的哪个方法。这与传统的面对对象的语言不一样,它们分派只基于第一参数,经常有特殊的参数语法,并且有时是暗含而非显式写成一个参数。[2]

Julia 使用函数的所有参数,而非只用第一个,来决定调用哪个方法,这被称为「多重分派/派发」。多重分派对于数学代码来说特别有用,人工地将运算视为对于其中一个参数的属于程度比其他所有的参数都强的这个概念对于数学代码是几乎没有意义的:x + y 中的加法运算对 x 的属于程度比对 y 更强?一个数学运算符的实现普遍基于它所有的参数的类型。即使跳出数学运算,多重分派是对于结构和组织程序来说也是一个强大而方便的范式。

Note

本章中的所有示例都假定是为相同模块中的函数定义模块。 如果你想给另一个模块中的函数添加方法,你必须import它或使用模块名称限定的名称,请参阅模块

定义方法

直到这里,在我们的例子中,我们定义的函数只有一个不限制参数类型的方法。这种函数的行为就与传统动态类型语言中的函数一样。不过,我们已经在没有意识到的情况下已经使用了多重分派和方法:所有 Julia 标准函数和运算符,就像之前提到的 + 函数,都根据参数的类型和数量的不同组合而定义了大量方法

我们在basic中提到过,当定义一个函数时,可以根据介绍的 :: 类型断言运算符来限制参数类型

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

这个函数只在 xy 的类型都是Float64的情况下才会被调用:

julia> f(2.0, 3.0)
7.0

用其它任意的参数类型则会导致MethodError

julia> f(2.0, 3)
ERROR: MethodError: no method matching f(::Float64, ::Int64)
...

julia> f(Float32(2.0), 3.0)
ERROR: MethodError: no method matching f(::Float32, ::Float64)
...

julia> f(2.0, "3.0")
ERROR: MethodError: no method matching f(::Float64, ::String)
...

julia> f("2.0", "3.0")
ERROR: MethodError: no method matching f(::String, ::String)

由于 Float64 是一个具体类型,且在 Julia 中具体类型无法拥有子类,所以这种定义方式只能适用于函数的输入类型精确地是 Float64 的情况,但一个常见的做法是用抽象类型来定义通用的方法:

julia> f(x::Number, y::Number) = 2x - y
f (generic function with 2 methods)

julia> f(2.0, 3)
1.0

用上面这种方式定义的方法可以接收任意一对Number的实例参数,且它们不需要是同一类型的,只要求都是数值。如何根据不同的类型来做相应的处理就可以委托给表达式 2x - y 中的代数运算。

为了定义一个有多个方法的函数,只需简单定义这个函数多次,使用不同的参数数量和类型。函数的第一个方法定义会建立这个函数对象,后续的方法定义会添加新的方法到存在的函数对象中去。当函数被应用时,最符合参数的数量和类型的特定方法会被执行。所以,上面的两个方法定义在一起定义了函数f对于所有的一对虚拟类型Number实例的行为——但是针对一对Float64值有不同的行为。如果一个参数是64位浮点数而另一个不是,f(Float64,Float64)方法不会被调用,而一定使用更加通用的f(Number,Number)方法:

julia> f(2.0, 3.0)
7.0

julia> f(2, 3.0)
1.0

julia> f(2.0, 3)
1.0

julia> f(2, 3)
1

2x + y 定义只用于第一个情况,2x - y 定义用于其他的情况。没有使用任何自动的函数参数的指派或者类型转换:Julia中的所有转换都不是 magic 的,都是完全显式的。然而类型转换和类型提升显示了足够先进的技术的智能应用能够与 magic 不可分辨到什么程度。[Clarke61] 对于非数字值,和比两个参数更多或者更少的情况,函数 f 并没有定义,应用会导致MethodError

julia> f("foo", 3)
ERROR: MethodError: no method matching f(::String, ::Int64)
...

julia> f()
ERROR: MethodError: no method matching f()
...

可以简单地看到对于函数存在哪些方法,通过在交互式会话中键入函数对象本身:

julia> f
f (generic function with 2 methods)

这个输出展示了f有两个方法。为了找到所有这些方法,使用methods函数:

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

这表示f有两个方法,一个接受两个Float64参数一个接受两个Number类型的参数。它也显示了这些方法定义所在的文件和行数(如果这些方法是在REPL中定义的,会得到了表面上的行数REPL[n]:m

没有::的类型声明,方法参数的类型默认为Any,这就意味着没有约束,因为Julia中的所有的值都是抽象类型Any的实例。所以,我们可以为f定义一个接受所有的方法,像这样:

julia> f(x,y) = println("彩蛋")
f (generic function with 3 methods)

julia> methods(f)
# 3 methods for generic function "f":
...

julia> f("foo", 1)
彩蛋

这个接受所有参数类型的方法比其他的对一对参数值的其他任意可能的方法定义更不专用。所以它只会被没有其他方法定义应用的一对参数调用。

注意到第三个方法的签名中并没有指定参数xy的类型。它是f(x::Any, y::Any)的简写。

尽管这看起来很简单,但对类型的多重派发可能是 Julia 语言最强大和最核心的特性。 核心运算通常有几十种方法:

julia> methods(+)
# 190 methods for generic function "+":
[1] +(x::Bool, z::Complex{Bool}) in Base at complex.jl:227
[2] +(x::Bool, y::Bool) in Base at bool.jl:89
[3] +(x::Bool) in Base at bool.jl:86
...
[180] +(a, b, c, xs...) in Base at operators.jl:424

多重分派和灵活的参数类型系统让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, 3.0)
8.0

julia> g(2.0, 3.0)
ERROR: MethodError: g(::Float64, ::Float64) is ambiguous. Candidates:
  g(x::Float64, y) in Main at none:1
  g(x, y::Float64) in Main at none:1
Possible fix, define
  g(::Float64, ::Float64)

这里g(2.0,3.0)的调用使用g(Float64, Any)g(Any, Float64)都能处理,并且两个都不更加专用。在这样的情况下,Julia会扔出MethodError而非任意选择一个方法。你可以通过对交叉情况指定一个合适的方法来避免方法歧义:

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

julia> g(2.0, 3)
7.0

julia> g(2, 3.0)
8.0

julia> g(2.0, 3.0)
10.0

建议先定义没有歧义的方法,因为不这样的话,歧义就会存在,即使是暂时性的,直到更加专用的方法被定义

在更加复杂的情况下,解决方法歧义会会涉及到设计的某一个元素;这个主题将会在下面进行进一步的探索。

参数方法

方法定义可以视需要存在限定特征的类型参数:

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(1.0, 2.0)
true

julia> same_type("foo", 2.0)
false

julia> same_type("foo", "bar")
true

julia> same_type(Int32(1), Int64(2))
false

这样的定义对应着那些类型签名是UnionAll类型的方法

在Julia中这种通过分派进行函数行为的定义是十分常见的,甚至是惯用的

重定义方法

当重定义一个方法或者增加一个方法时,知道这个变化不会立即生效很重要。这是Julia能够静态推断和编译代码使其运行很快而没有惯常的JIT技巧和额外开销的关键。实际上,任意新的方法定义不会对当前运行环境可见,包括Task线程(和所有的之前定义的@generated函数)。让我们通过一个例子说明这意味着什么:

julia> function tryeval()
           @eval newfun() = 1
           newfun()
       end
tryeval (generic function with 1 method)

julia> tryeval()
ERROR: MethodError: no method matching newfun()
The applicable method may be too new: running in world age xxxx1, while current world is xxxx2.
Closest candidates are:
  newfun() at none:1 (method too new to be called from this world context.)
 in tryeval() at none:1
 ...

julia> newfun()
1

在这个例子中看到newfun的新定义已经被创建,但是并不能立即调用。新的全局变量立即对tryeval函数可见,所以你可以写return newfun(没有小括号)。但是你,你的调用器,和他们调用的函数等等都不能调用这个新的方法定义!

但是这里有个例外:之后的在 REPL 中newfun 的调用会按照预期工作,能够见到并调用newfun 的新定义。

但是,之后的 tryeval 的调用将会继续看到 newfun 的定义,因为该定义位于 REPL 的前一个语句中并因此在之后的 tryeval 的调用之前。

你可以试试这个来让自己了解这是如何工作的。

这个行为的实现通过一个「world age 计数器」。这个单调递增的值会跟踪每个方法定义操作。此计数器允许用单个数字描述「对于给定运行时环境可见的方法定义集」,或者说「world age」。它还允许仅仅通过其序数值来比较在两个 world 中可用的方法。在上例中,我们看到(方法 newfun 所存在的)「current world」比局部于任务的「runtime world」大一,后者在 tryeval 开始执行时是固定的。

有时规避这个是必要的(例如,如果你在实现上面的REPL)。幸运的是这里有个简单地解决方法:使用Base.invokelatest调用函数

julia> function tryeval2()
           @eval newfun2() = 2
           Base.invokelatest(newfun2)
       end

tryeval2 (generic function with 1 method)
julia> tryeval2()
2

最后,让我们看一些这个规则生效的更复杂的例子。 定义一个函数f(x),最开始有一个方法:

julia> f(x) = "original definition"
f (generic function with 1 method)

开始一些使用f(x)的运算:

julia> g(x) = f(x)
g (generic function with 1 method)

julia> t = @async f(wait()); yield();

现在我们给f(x)加上一些新的方法:

julia> f(x::Int) = "definition for Int"
f (generic function with 2 methods)

julia> f(x::Type{Int}) = "definition for Type{Int}"
f (generic function with 3 methods)

比较一下这些结果如何不同:

julia> f(1)
"definition for Int"

julia> g(1)
"definition for Int"

julia> fetch(schedule(t, 1))
"original definition"

julia> t = @async f(wait()); yield();

julia> fetch(schedule(t, 1))
"definition for Int"

使用参数方法设计样式

虽然复杂的分派逻辑对于性能或者可用性并不是必须的,但是有时这是表达某些算法的最好的方法。

从超类型中提取出类型参数

以下是一个正确的代码模板,用于返回具有明确定义的元素类型的 AbstractArray 的任意子类型的元素类型 T

# abstract type AbstractArray{T, N} end
eltype(::Type{<:AbstractArray{T}}) where {T} = T

使用所谓的三角派发。 请注意,UnionAll 类型,对于示例 eltype(AbstractArray{T} where T <: Integer),与上述方法不符。 在这种情况下,Baseeltype 的实现为 Any 增加了一个回退方法。

一个常见的错误是试着使用内省来得到元素类型:

eltype_wrong(::Type{A}) where {A<:AbstractArray} = A.parameters[1]

但是创建一个这个方法易失败:

struct BitVector <: AbstractArray{Bool, 1}; end

这里我们已经创建了一个没有参数的类型 BitVector,但是元素类型已经完全指定了,T 等于 Bool

另一个错误是尝试使用 supertype 沿着类型层次结构向上走:

eltype_wrong(::Type{AbstractArray{T}}) where {T} = T
eltype_wrong(::Type{AbstractArray{T, N}}) where {T, N} = T
eltype_wrong(::Type{A}) where {A<:AbstractArray} = eltype_wrong(supertype(A))

这不适用于没有超类型的类型:

julia> eltype_wrong(Union{AbstractArray{Int}, AbstractArray{Float64}})
ERROR: MethodError: no method matching supertype(::Type{Union{AbstractArray{Float64,N} where N, AbstractArray{Int64,N} where N}})
Closest candidates are:
  supertype(::DataType) at operators.jl:43
  supertype(::UnionAll) at operators.jl:48

用不同的类型参数构建相似的类型

当构建通用代码时,通常需要创建一些类似对象,在类型的布局上有一些变化,这就也让类型参数的变化变得必要。 例如,你会有一些任意元素类型的抽象数组,想使用特定的元素类型来编写你基于它的计算。你必须实现为每个 AbstractArray{T} 的子类型实现方法,这些方法描述了如何计算类型转换。从一个子类型转化成拥有一个不同参数的另一个子类型的通用方法在这里不存在。(快速复习:你明白为什么吗?)

AbstractArray 的子类型典型情况下会实现两个方法来完成这个: 一个方法把输入输入转换成特定的 AbstractArray{T,N} 抽象类型的子类型;一个方法用特定的元素类型构建一个新的未初始化的数组。这些的样例实现可以在Julia Base里面找到。这里是一个基础的样例使用,保证 输入输出 是同一种类型:

input = convert(AbstractArray{Eltype}, input)
output = similar(input, Eltype)

作为这个的扩展,在算法需要输入数组的拷贝的情况下,convert 使无法胜任的,因为返回值可能只是原始输入的别名。把 similar(构建输出数组)和 copyto!(用输入数据填满)结合起来是需要给出输入参数的可变拷贝的一个范用方法:

copy_with_eltype(input, Eltype) = copyto!(similar(input, Eltype), input)

迭代分派

为了分派一个多层的参数参量列表,将每一层分派分开到不同的函数中常常是最好的。这可能听起来跟单分派的方法相似,但是你会在下面见到,这个更加灵活。

例如,尝试按照数组的元素类型进行分派常常会引起歧义。相反地,常见的代码会首先按照容易类型分派,然后基于 eltype 递归到更加更加专用的方法。在大部分情况下,算法会很方便地就屈从与这个分层方法,在其他情况下,这种严苛的工作必须手动解决。这个分派分支能被观察到,例如在两个矩阵的加法的逻辑中:

# 首先分派选择了逐元素相加的map算法。
+(a::Matrix, b::Matrix) = map(+, a, b)
# 然后分派处理了每个元素然后选择了计算的
# 恰当的常见元素类型。
+(a, b) = +(promote(a, b)...)
# 一旦元素有了相同类型,它们就可以相加。
# 例如,通过处理器暴露出的原始运算。
+(a::Float64, b::Float64) = Core.add(a, b)

基于 Trait 的分派

对于上面的可迭代分派的一个自然扩展是给方法选择加一个内涵层,这个层允许按照那些与类型层级定义的集合相独立的类型的集合来分派。我们可以通过写出问题中的类型的一个Union 来创建这个一个集合,但是这不能够扩展,因为 Union 类型在创建之后无法改变。但是这么一个可扩展的集合可以通过一个叫做"Holy-trait"的一个设计样式来实现。

这个样式是通过定义一个范用函数来实现,这个函数为函数参数可能属于的每个trait集合都计算出不同的单例值(或者类型)。如果这个函数是单纯的,这与通常的分派对于性能没有任何影响。

上一部分中的示例掩盖了 mappromote 的实现细节,这两个都是依据trait来进行运算的。 在迭代矩阵时,例如在 map 的实现中,一个重要的问题是使用什么顺序遍历数据。 当 AbstractArray 子类型实现 Base.IndexStyle trait 时,map 等其他函数可以根据此信息进行派发以选择最佳算法(请参阅 [抽象数组接口](https://docs.juliacn.com/latest/manual/interfaces/))。这意味着每个子类型不需要实现 map 的自定义版本,因为通用定义+trait类将使系统能够选择最快的版本。 下面是 map 的一个简单实现,说明了基于 trait 的调度:

map(f, a::AbstractArray, b::AbstractArray) = map(Base.IndexStyle(a, b), f, a, b)
# generic implementation:
map(::Base.IndexCartesian, f, a::AbstractArray, b::AbstractArray) = ...
# linear-indexing implementation (faster)
map(::Base.IndexLinear, f, a::AbstractArray, b::AbstractArray) = ...

这个基于trait的方法也出现在promote机制中,被标量+使用。 它使用了 promote_type,这在知道两个计算对象的类型的情况下返回计算这个运算的最佳的常用类型。这就使得我们不用为每一对可能的类型参数实现每一个函数,而把问题简化为对于每个类型实现一个类型转换运算这样一个小很多的问题,还有一个优选的逐对的类型提升规则的表格。

输出类型计算

基于 trait 的类型提升的讨论可以过渡到我们的下一个设计样式:为矩阵运算计算输出元素类型。

为了实现像加法这样的原始运算,我们使用 promote_type 函数来计算想要的输出类型。(像之前一样,我们在 + 调用中的 promote 调用中见到了这个工作)

对于矩阵的更加复杂的函数,对于更加复杂的运算符序列来计算预期的返回类型是必要的。这经常按下列步骤进行:

  1. 编写一个小函数 op 来表示算法核心中使用的运算的集合。

  2. 使用 promote_op(op, argument_types...) 计算结果矩阵的元素类型 R, 这里 argument_types 是通过应用到每个输入数组的 eltype计算的。

  3. 创建类似于 similar(R, dims) 的输出矩阵,这里 dims是输出矩阵的预期维度数。

作为一个更加具体的例子,一个范用的方阵乘法的伪代码是:

function matmul(a::AbstractMatrix, b::AbstractMatrix)
    op = (ai, bi) -> ai * bi + ai * bi
    ## this is insufficient because it assumes `one(eltype(a))` is constructable:
    # R = typeof(op(one(eltype(a)), one(eltype(b))))
    ## this fails because it assumes `a[1]` exists and is representative of all elements of the array
    # R = typeof(op(a[1], b[1]))
    ## this is incorrect because it assumes that `+` calls `promote_type`
    ## but this is not true for some types, such as Bool:
    # R = promote_type(ai, bi)
    # this is wrong, since depending on the return value
    # of type-inference is very brittle (as well as not being optimizable):
    # R = Base.return_types(op, (eltype(a), eltype(b)))
    ## but, finally, this works:
    R = promote_op(op, eltype(a), eltype(b))
    ## although sometimes it may give a larger type than desired
    ## it will always give a correct type
    output = similar(b, R, (size(a, 1), size(b, 2)))
    if size(a, 2) > 0
        for j in 1:size(b, 2)
            for i in 1:size(a, 1)
                ## here we don't use `ab = zero(R)`,
                ## since `R` might be `Any` and `zero(Any)` is not defined
                ## we also must declare `ab::R` to make the type of `ab` constant in the loop,
                ## since it is possible that typeof(a * b) != typeof(a * b + a * b) == R
                ab::R = a[i, 1] * b[1, j]
                for k in 2:size(a, 2)
                    ab += a[i, k] * b[k, j]
                end
                output[i, j] = ab
            end
        end
    end
    return output
end

分离转换和内核逻辑

能有效减少编译时间和测试复杂度的一个方法是将预期的类型和计算转换的逻辑隔离。这会让编译器将与大型内核的其他部分相独立的类型转换逻辑特别化并内联

将更大的类型类转换成被算法实际支持的特定参数类是一个常见的设计样式:

complexfunction(arg::Int) = ...
complexfunction(arg::Any) = complexfunction(convert(Int, arg))
matmul(a::T, b::T) = ...
matmul(a, b) = matmul(promote(a, b)...)

参数化约束的可变参数方法

函数参数也可以用于约束应用于「可变参数」函数的参数的数量。Vararg{T,N} 可用于表明这么一个约束。举个例子:

julia> bar(a,b,x::Vararg{Any,2}) = (a,b,x)
bar (generic function with 1 method)
julia> bar(1,2,3)
ERROR: MethodError: no method matching bar(::Int64, ::Int64, ::Int64)
Closest candidates are:
  bar(::Any, ::Any, ::Any, !Matched::Any) at none:1
julia> bar(1,2,3,4)
(1, 2, (3, 4))
julia> bar(1,2,3,4,5)
ERROR: MethodError: no method matching bar(::Int64, ::Int64, ::Int64, ::Int64, ::Int64)
Closest candidates are:
  bar(::Any, ::Any, ::Any, ::Any) at none:1

更加有用的是,用一个参数就约束可变参数的方法是可能的。例如:

function getindex(A::AbstractArray{T,N}, indices::Vararg{Number,N}) where {T,N}

只会在 indices 的个数与数组的维数相同时才会调用。

当只有提供的参数的类型需要被约束时,Vararg{T}可以写成T...。例如f(x::Int...) = xf(x::Vararg{Int}) = x的简便写法。

可选参数和关键字的参数的注意事项

与在函数中简要提到的一样,可选参数是使用多方法定义语法来实现的。例如,这个定义:

f(a=1,b=2) = a+2b

翻译成下列三个方法:

f(a,b) = a+2b
f(a) = f(a,2)
f() = f(1,2)

这就意味着调用 f() 等于调用 f(1,2)。在这个情况下结果是 5,因为 f(1,2) 使用的是上面 f 的第一个方法。但是,不总是需要是这种情况。如果你定义了第四个对于整数更加专用的方法:

f(a::Int,b::Int) = a-2b

此时 f()f(1,2) 的结果都是 -3。换句话说,可选参数只与函数捆绑,而不是函数的任意一个特定的方法。这个决定于使用的方法的可选参数的类型。当可选参数是用全局变量的形式定义时,可选参数的类型甚至会在运行时改变。

关键字参数与普通的位置参数的行为很不一样。特别地,他们不参与到方法分派中。方法只基于位置参数分派,在匹配得方法确定之后关键字参数才会被处理。

类函数对象

方法与类型相关,所以可以通过给类型加方法使得任意一个 Julia 类型变得"可被调用"。(这个"可调用"的对象有时称为"函子"。)

例如,你可以定义一个类型,存储着多项式的系数,但是行为像是一个函数,可以为多项式求值:

julia> struct Polynomial{R}
           coeffs::Vector{R}
       end

julia> function (p::Polynomial)(x)
           v = p.coeffs[end]
           for i = (length(p.coeffs)-1):-1:1
               v = v*x + p.coeffs[i]
           end
           return v
       end

julia> (p::Polynomial)() = p(5)

注意函数是通过类型而非名字来指定的。如同普通函数一样这里有一个简洁的语法形式。在函数体内,p 会指向被调用的对象。Polynomial 会按如下方式使用:

julia> p = Polynomial([1,10,100])
Polynomial{Int64}([1, 10, 100])

julia> p(3)
931

julia> p()
2551

这个机制也是 Julia 中类型构造函数和闭包(指向其环境的内部函数)的工作原理。

空泛型函数

有时引入一个没有添加方法的范用函数是有用的。这会用于分离实现与接口定义。这也可为了文档或者代码可读性。为了这个的语法是没有参数组的一个空函数块:

function emptyfunc end

方法设计与避免歧义

Julia 的方法多态性是其最有力的特性之一,利用这个功能会带来设计上的挑战。特别地,在更加复杂的方法层级中出现歧义很常见

在上面我们曾经指出我们可以像这样解决歧义

f(x, y::Int) = 1
f(x::Int, y) = 2

f(x::Int, y::Int) = 3 # 定义这样一个方法

这通常是正确的方案; 然而,在某些情况下,盲目地遵循这一建议可能会适得其反。 特别是,泛型函数的方法越多,产生歧义的可能性就越大。 当方法层次结构变得比这个简单的示例更复杂时,仔细考虑替代策略可能是值得的。

下面我们会讨论特别的一些挑战和解决这些挑战的一些可选方法。

元组和N元组参数

Tuple(和 NTuple)参数会带来特别的挑战。例如,

f(x::NTuple{N,Int}) where {N} = 1
f(x::NTuple{N,Float64}) where {N} = 2

是有歧义的,因为存在 N == 0 的可能性:没有元素去确定 Int 还是 Float64 变体应该被调用。为了解决歧义,一个方法是为空元组定义方法:

f(x::Tuple{}) = 3

作为一种选择,对于其中一个方法之外的所有的方法可以坚持元组中至少有一个元素:

f(x::NTuple{N,Int}) where {N} = 1           # this is the fallback
f(x::Tuple{Float64, Vararg{Float64}}) = 2   # this requires at least one Float64

正交化你的设计

当你打算根据两个或更多的参数进行分派时,考虑一下,一个「包裹」函数是否会让设计简单一些。举个例子,与其编写多变量:

f(x::A, y::A) = ...
f(x::A, y::B) = ...
f(x::B, y::A) = ...
f(x::B, y::B) = ...

不如考虑定义

f(x::A, y::A) = ...
f(x, y) = f(g(x), g(y))

这里 g 把参数转变为类型 A。这是更加普遍的正交设计原理的一个特别特殊的例子,在正交设计中不同的概念被分配到不同的方法中去。这里 g 最可能需要一个fallback定义

g(x::A) = x

一个相关的方案使用 promote 来把 xy 变成常见的类型:

f(x::T, y::T) where {T} = ...
f(x, y) = f(promote(x, y)...)

这个设计的一个隐患是:如果没有合适的把 xy 转换到同样类型的类型提升方法,第二个方法就可能无限自递归然后引发堆溢出。

一次只根据一个参数分派

如果你你需要根据多个参数进行分派,并且有太多的为了能定义所有可能的变量而存在的组合,而存在很多回退函数,你可以考虑引入"名字级联",这里(例如)你根据第一个参数分配然后调用一个内部的方法:

f(x::A, y) = _fA(x, y)
f(x::B, y) = _fB(x, y)

接着内部方法 _fA_fB 可以根据 y 进行分派,而不考虑有关 x 的歧义存在。

需要意识到这个方案至少有一个主要的缺点:在很多情况下,用户没有办法通过进一步定义你的输出函数f的具体行为来进一步定制f的行为。相反,他们需要去定义你的内部方法 _fA_fB 的具体行为,这会模糊输出方法和内部方法之间的界线。

抽象容器与元素类型

在可能的情况下要试图避免定义根据抽象容器的具体元素类型来分派的方法。举个例子,

-(A::AbstractArray{T}, b::Date) where {T<:Date}

会引起歧义,当定义了这个方法:

-(A::MyArrayType{T}, b::T) where {T}

最好的方法是不要定义这些方法中的任何一个。相反,使用范用方法 -(A::AbstractArray, b) 并确认这个方法是使用分别对于每个容器类型和元素类型都是适用的通用调用(像 similar-)实现的。这只是建议正交化你的方法的一个更加复杂的变种而已。

当这个方法不可行时,这就值得与其他开发者开始讨论如果解决歧义;只是因为一个函数先定义并不总是意味着他不能改变或者被移除。作为最后一个手段,开发者可以定义"创可贴"方法

-(A::MyArrayType{T}, b::Date) where {T<:Date} = ...

可以暴力解决歧义。

与默认参数的复杂方法"级联"

如果你定义了提供默认的方法"级联",要小心去掉对应着潜在默认的任何参数。例如,假设你在写一个数字过滤算法,你有一个通过应用padding来出来信号的边的方法:

function myfilter(A, kernel, ::Replicate)
    Apadded = replicate_edges(A, size(kernel))
    myfilter(Apadded, kernel)  # now perform the "real" computation
end

这会与提供默认 padding 的方法产生冲突:

myfilter(A, kernel) = myfilter(A, kernel, Replicate()) # replicate the edge by default

这两个方法一起会生成无限的递归,A 会不断变大。

更好的设计是像这样定义你的调用层级:

struct NoPad end  # indicate that no padding is desired, or that it's already applied
myfilter(A, kernel) = myfilter(A, kernel, Replicate())  # default boundary conditions
function myfilter(A, kernel, ::Replicate)
    Apadded = replicate_edges(A, size(kernel))
    myfilter(Apadded, kernel, NoPad())  # indicate the new boundary conditions
end
# other padding methods go here
function myfilter(A, kernel, ::NoPad)
    # Here's the "real" implementation of the core computation
end

NoPad 被置于与其他 padding 类型一致的参数位置上,这保持了分派层级的良好组织,同时降低了歧义的可能性。而且,它扩展了「公开」的 myfilter 接口:想要显式控制 padding 的用户可以直接调用 NoPad 变量。

  • 1

    https://docs.juliacn.com/latest/manual/methods/

  • 2

    例如C++和Java中,调用类似于obj.meth(arg1,arg2)的方法时,obj会「得到」这个方法调用并通过this关键字传递信息

  • Clarke61

    Arthur C. Clarke, Profiles of the Future (1961): Clarke's Third Law.