方法

我们回想一下,在函数中我们知道函数是这么一个对象,它把一组参数映射成一个返回值,或者当没有办法返回恰当的值时扔出一个异常。对于相同概念的函数或者运算对不同的参数类型有十分不一样的实现这件事是普遍存在的:两个整数的加法与两个浮点数的加法是相当不一样的,整数与浮点数之间的加法也不一样。除开他们实现上的不同,这些运算都归在"加法"这么一个通用概念之下。因此在Julia中这些行为都属于一个对象:+函数。

为了让对同样的概念使用许多不同的实现这件事更顺畅,函数没有必要马上全部都被定义,反而应该是一块一块地定义,为特定的参数类型和数量的组合提供指定的行为。对于一个函数的一个可能行为的定义叫做方法。直到这里,我们只展示了那些只定了一个方法的,对参数的所有类型都适用的函数。但是方法定义的特征是不仅能表明参数的数量,也能表明参数的类型,并且能提供多个方法定义。当一个函数被应用于特殊的一组参数时,能用于这一组参数的最特定的方法会被使用。所以,函数的全体行为是他的不同的方法定义的行为的组合。如果这个组合被设计得好,即使方法们的实现之间会很不一样,函数的外部行为也会显得无缝而自洽。

当一个函数被应用时执行方法的选择被称为分派。Julia允许分派过程来基于给的参数的个数和所有的参数的类型来选择调用函数的哪个方法。这与传统的面对对象的语言不一样,面对对象语言的分派只基于第一参数,经常有特殊的参数语法并且有时是暗含而非显式写成一个参数。[1]使用函数的所有参数,而非只用第一个,来决定调用哪个方法被称为多重分派。多重分派对于数学代码来说特别有用,人工地将运算视为对于其中一个参数的属于程度比其他所有的参数都强的这个概念对于数学代码是几乎没有意义的:x + y中的加法运算对x的属于程度比对y更强?一个数学运算符的实现普遍基于它所有的参数的类型。即使跳出数学运算,多重分派是对于结构和组织程序来说也是一个强大而方便的范式。

[1]
In C++ or Java, for example, in a method call like obj.meth(arg1,arg2), the object obj "receives" the method call and is implicitly passed to the method via the this keyword, rather than as an explicit method argument. When the current this object is the receiver of a method call, it can be omitted altogether, writing just meth(arg1,arg2), with this implied as the receiving object.

定义方法

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

当定义一个函数时,可以视需要来约束可以应用的参数类型,使用在@ref">Composite Types中介绍的::类型断言运算符。

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

This function definition applies only to calls where x and y are both values of type Float64:

  1. julia> f(2.0, 3.0)
  2. 7.0

将其运用于其他任意的参数类型会导致 MethodError:

  1. julia> f(2.0, 3)
  2. ERROR: MethodError: no method matching f(::Float64, ::Int64)
  3. Closest candidates are:
  4. f(::Float64, !Matched::Float64) at none:1
  5. julia> f(Float32(2.0), 3.0)
  6. ERROR: MethodError: no method matching f(::Float32, ::Float64)
  7. Closest candidates are:
  8. f(!Matched::Float64, ::Float64) at none:1
  9. julia> f(2.0, "3.0")
  10. ERROR: MethodError: no method matching f(::Float64, ::String)
  11. Closest candidates are:
  12. f(::Float64, !Matched::Float64) at none:1
  13. julia> f("2.0", "3.0")
  14. ERROR: MethodError: no method matching f(::String, ::String)

如同你所看到的,参数必须精确地是类型Float64。其他的数字类型,比如整数或者32位浮点数值都不会自动转化成64位浮点数,字符串也不会分析成数字。因为Float64是一个具体类型,在Julia中具体类型无法有子类,这样的定义只会被应用于确实是类型Float64的参数。然而这对写声明的参数类型是抽象的通用方法来说是常常有用的:

  1. julia> f(x::Number, y::Number) = 2x - y
  2. f (generic function with 2 methods)
  3. julia> f(2.0, 3)
  4. 1.0

这个方法定义应用于任意一对Number的实例的参数。他们不需要是同一类型的,只要他们都是数字值。操作不同数字类型的问题就委派给了表达式2x - y中的算法运算。

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

  1. julia> f(2.0, 3.0)
  2. 7.0
  3. julia> f(2, 3.0)
  4. 1.0
  5. julia> f(2.0, 3)
  6. 1.0
  7. julia> f(2, 3)
  8. 1

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

  1. julia> f("foo", 3)
  2. ERROR: MethodError: no method matching f(::String, ::Int64)
  3. Closest candidates are:
  4. f(!Matched::Number, ::Number) at none:1
  5. julia> f()
  6. ERROR: MethodError: no method matching f()
  7. Closest candidates are:
  8. f(!Matched::Float64, !Matched::Float64) at none:1
  9. f(!Matched::Number, !Matched::Number) at none:1

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

  1. julia> f
  2. f (generic function with 2 methods)

这个输出告诉我们f是有两个方法的函数对象。为了找出那些方法的特征是什么,使用 methods函数:

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

这表示f有两个方法,一个接受两个Float64参数一个接受两个Number类型的参数。它也显示了这些方法定义所在的文件和行数:因为这些方法是在REPL中定义的,我们得到了表面上的行数none:1.

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

  1. julia> f(x,y) = println("Whoa there, Nelly.")
  2. f (generic function with 3 methods)
  3. julia> f("foo", 1)
  4. Whoa there, Nelly.

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

虽然这像是一个简单的概念,基于值的类型的多重分派可能是Julia语言的一个最强大和中心特性。核心运算符都典型地含有很多方法:

  1. julia> methods(+)
  2. # 180 methods for generic function "+":
  3. [1] +(x::Bool, z::Complex{Bool}) in Base at complex.jl:227
  4. [2] +(x::Bool, y::Bool) in Base at bool.jl:89
  5. [3] +(x::Bool) in Base at bool.jl:86
  6. [4] +(x::Bool, y::T) where T<:AbstractFloat in Base at bool.jl:96
  7. [5] +(x::Bool, z::Complex) in Base at complex.jl:234
  8. [6] +(a::Float16, b::Float16) in Base at float.jl:373
  9. [7] +(x::Float32, y::Float32) in Base at float.jl:375
  10. [8] +(x::Float64, y::Float64) in Base at float.jl:376
  11. [9] +(z::Complex{Bool}, x::Bool) in Base at complex.jl:228
  12. [10] +(z::Complex{Bool}, x::Real) in Base at complex.jl:242
  13. [11] +(x::Char, y::Integer) in Base at char.jl:40
  14. [12] +(c::BigInt, x::BigFloat) in Base.MPFR at mpfr.jl:307
  15. [13] +(a::BigInt, b::BigInt, c::BigInt, d::BigInt, e::BigInt) in Base.GMP at gmp.jl:392
  16. [14] +(a::BigInt, b::BigInt, c::BigInt, d::BigInt) in Base.GMP at gmp.jl:391
  17. [15] +(a::BigInt, b::BigInt, c::BigInt) in Base.GMP at gmp.jl:390
  18. [16] +(x::BigInt, y::BigInt) in Base.GMP at gmp.jl:361
  19. [17] +(x::BigInt, c::Union{UInt16, UInt32, UInt64, UInt8}) in Base.GMP at gmp.jl:398
  20. ...
  21. [180] +(a, b, c, xs...) in Base at operators.jl:424

多重分派和灵活的参数类型系统让Julia有能力抽象地表达高层级算法,而与实现细节解耦,也能生成高效而专用的代码来在运行中处理每个情况。

方法歧义

在一系列的函数方法定义时有可能没有单独的最专用的方法能适用于参数的某些组合:

  1. julia> g(x::Float64, y) = 2x + y
  2. g (generic function with 1 method)
  3. julia> g(x, y::Float64) = x + 2y
  4. g (generic function with 2 methods)
  5. julia> g(2.0, 3)
  6. 7.0
  7. julia> g(2, 3.0)
  8. 8.0
  9. julia> g(2.0, 3.0)
  10. ERROR: MethodError: g(::Float64, ::Float64) is ambiguous. Candidates:
  11. g(x, y::Float64) in Main at none:1
  12. g(x::Float64, y) in Main at none:1
  13. Possible fix, define
  14. g(::Float64, ::Float64)

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

  1. julia> g(x::Float64, y::Float64) = 2x + 2y
  2. g (generic function with 3 methods)
  3. julia> g(2.0, 3)
  4. 7.0
  5. julia> g(2, 3.0)
  6. 8.0
  7. julia> g(2.0, 3.0)
  8. 10.0

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

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

参数方法

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

  1. julia> same_type(x::T, y::T) where {T} = true
  2. same_type (generic function with 1 method)
  3. julia> same_type(x,y) = false
  4. same_type (generic function with 2 methods)

第一个方法应用于两个参数都是同一个具体类型时,不管类型是什么,而第二个方法接受一切,涉及其他所有情况。所以,总得来说,这个定义了一个布尔函数来检查两个参数是否是同样的类型:

  1. julia> same_type(1, 2)
  2. true
  3. julia> same_type(1, 2.0)
  4. false
  5. julia> same_type(1.0, 2.0)
  6. true
  7. julia> same_type("foo", 2.0)
  8. false
  9. julia> same_type("foo", "bar")
  10. true
  11. julia> same_type(Int32(1), Int64(2))
  12. false

这样的定义对应着那些类型特征是UnionAll类型的方法(参见UnionAll Types)。

在Julia中这种通过分派进行函数行为的定义是十分常见的,甚至是惯用的。方法类型参数并不局限于用作参数的类型:他们可以用在任意地方,只要值会在函数或者函数体的特征中。这里有个例子,例子中方法类型参数T用作方法特征中的参数类型Vector{T}的类型参数:

  1. julia> myappend(v::Vector{T}, x::T) where {T} = [v..., x]
  2. myappend (generic function with 1 method)
  3. julia> myappend([1,2,3],4)
  4. 4-element Array{Int64,1}:
  5. 1
  6. 2
  7. 3
  8. 4
  9. julia> myappend([1,2,3],2.5)
  10. ERROR: MethodError: no method matching myappend(::Array{Int64,1}, ::Float64)
  11. Closest candidates are:
  12. myappend(::Array{T,1}, !Matched::T) where T at none:1
  13. julia> myappend([1.0,2.0,3.0],4.0)
  14. 4-element Array{Float64,1}:
  15. 1.0
  16. 2.0
  17. 3.0
  18. 4.0
  19. julia> myappend([1.0,2.0,3.0],4)
  20. ERROR: MethodError: no method matching myappend(::Array{Float64,1}, ::Int64)
  21. Closest candidates are:
  22. myappend(::Array{T,1}, !Matched::T) where T at none:1

如你所看到的,追加的元素的类型必须匹配它追加到的向量的元素类型,否则会引起MethodError。在下面的例子中,方法类型参量T用作返回值:

  1. julia> mytypeof(x::T) where {T} = T
  2. mytypeof (generic function with 1 method)
  3. julia> mytypeof(1)
  4. Int64
  5. julia> mytypeof(1.0)
  6. Float64

就像你能在类型声明时通过类型参数对子类型进行约束一样(参见Parametric Types),你也可以约束方法的类型参数:

  1. julia> same_type_numeric(x::T, y::T) where {T<:Number} = true
  2. same_type_numeric (generic function with 1 method)
  3. julia> same_type_numeric(x::Number, y::Number) = false
  4. same_type_numeric (generic function with 2 methods)
  5. julia> same_type_numeric(1, 2)
  6. true
  7. julia> same_type_numeric(1, 2.0)
  8. false
  9. julia> same_type_numeric(1.0, 2.0)
  10. true
  11. julia> same_type_numeric("foo", 2.0)
  12. ERROR: MethodError: no method matching same_type_numeric(::String, ::Float64)
  13. Closest candidates are:
  14. same_type_numeric(!Matched::T<:Number, ::T<:Number) where T<:Number at none:1
  15. same_type_numeric(!Matched::Number, ::Number) at none:1
  16. julia> same_type_numeric("foo", "bar")
  17. ERROR: MethodError: no method matching same_type_numeric(::String, ::String)
  18. julia> same_type_numeric(Int32(1), Int64(2))
  19. false

same_type_numeric函数的行为与上面定义的same_type函数基本相似,但是它只对一对数定义。

参数方法允许与where表达式同样的语法用来写类型(参见UnionAll Types)。如果只有一个参数,封闭的大括号(在where {T}中)可以省略,但是为了清楚起见推荐写上。多个参数可以使用逗号隔开,例如where {T, S <: Real},或者使用嵌套的where来写,例如where S<:Real where T

重定义方法

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

  1. julia> function tryeval()
  2. @eval newfun() = 1
  3. newfun()
  4. end
  5. tryeval (generic function with 1 method)
  6. julia> tryeval()
  7. ERROR: MethodError: no method matching newfun()
  8. The applicable method may be too new: running in world age xxxx1, while current world is xxxx2.
  9. Closest candidates are:
  10. newfun() at none:1 (method too new to be called from this world context.)
  11. in tryeval() at none:1
  12. ...
  13. julia> newfun()
  14. 1

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

但是这里有个例外:来自REPL的未来的newfun的调用会按照预期工作,能够见到并调用newfun

但是对于tryeval的未来的调用会继续能见到newfun的定义,就像它在REPL中的前一个语句中,在tryeval的调用之前一样。

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

这个行为的实现方法是一个"世界年龄计数器"。这个单调增加的值追踪每个方法定义运算。这允许把"对于给定的运行环境可见的方法定义集合"描述为一个数,或称为"世界年龄"。这也允许比较在两个世界中的可用的方法,仅仅通过依次比较他们的值。在上面的例子中,我们看到"当前世界"(方法newfun存在的世界)比当tryeval的执行开始时是固定的对任务是局部的"运行世界"大一。

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

  1. julia> function tryeval2()
  2. @eval newfun2() = 2
  3. Base.invokelatest(newfun2)
  4. end
  5. tryeval2 (generic function with 1 method)
  6. julia> tryeval2()
  7. 2

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

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

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

  1. julia> g(x) = f(x)
  2. g (generic function with 1 method)
  3. julia> t = @async f(wait()); yield();

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

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

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

  1. julia> f(1)
  2. "definition for Int"
  3. julia> g(1)
  4. "definition for Int"
  5. julia> fetch(schedule(t, 1))
  6. "original definition"
  7. julia> t = @async f(wait()); yield();
  8. julia> fetch(schedule(t, 1))
  9. "definition for Int"

使用参数方法设计样式

虽然复杂的分派逻辑对于性能或者可用性并不是必须的,但是有时这是表达某些算法的最好的方法。 这里有一些常见的设计样式,在以这个方法使用分派时有时会出现。

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

这里是一个正确地代码模板,它返回AbstractArray的任意子类型的元素类型T:

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

使用了所谓的三角分派。注意如果T是一个UnionAll类型,比如eltype(Array{T} where T <: Integer),会返回Any(如同Base中的eltype一样)。

另外一个方法,这是在Julia v0.6中的三角分派到来之前的唯一正确方法,是:

  1. abstract type AbstractArray{T, N} end
  2. eltype(::Type{AbstractArray}) = Any
  3. eltype(::Type{AbstractArray{T}}) where {T} = T
  4. eltype(::Type{AbstractArray{T, N}}) where {T, N} = T
  5. eltype(::Type{A}) where {A<:AbstractArray} = eltype(supertype(A))

另外一个可能性如下例,这可以对适配那些参数T需要更严格匹配的情况有用:

  1. eltype(::Type{AbstractArray{T, N} where {T<:S, N<:M}}) where {M, S} = Any
  2. eltype(::Type{AbstractArray{T, N} where {T<:S}}) where {N, S} = Any
  3. eltype(::Type{AbstractArray{T, N} where {N<:M}}) where {M, T} = T
  4. eltype(::Type{AbstractArray{T, N}}) where {T, N} = T
  5. eltype(::Type{A}) where {A <: AbstractArray} = eltype(supertype(A))

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

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

但是创建一个这个方法会失败的情况不难:

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

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

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

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

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

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

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

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

迭代分派

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

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

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

基于 Trait 的分派

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

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

上一节的例子掩盖了mappromote的实现细节,这两个都是依据trait来进行运算的。当对一个矩阵进行迭代,比如map的实现中,一个重要的问题是按照什么顺序去遍历数据。当AbstractArray的子类型实现了Base.IndexStyletrait,其他函数,比如map就可以根据这个信息进行分派,以选择最好的算法(参见@ref man-interface-array">抽象数组接口)。这意味着每个子类型就没有必要去实现对应的map版本,因为通用的定义加trait类就能让系统选择最快的版本。这里一个玩具似的map实现说明了基于trait的分派:

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

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

输出类型计算

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

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

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

  • 编写一个小函数op来表示算法核心中使用的运算的集合。
  • 使用promote_op(op, argument_types…)计算结果矩阵的元素类型R, 这里argument_types是通过应用到每个输入数组的eltype计算的。
  • 创建类似于similar(R, dims)的输出矩阵,这里dims是输出矩阵的预期维度数。
    作为一个更加具体的例子,一个范用的方阵乘法的伪代码是:
  1. function matmul(a::AbstractMatrix, b::AbstractMatrix)
  2. op = (ai, bi) -> ai * bi + ai * bi
  3. ## this is insufficient because it assumes `one(eltype(a))` is constructable:
  4. # R = typeof(op(one(eltype(a)), one(eltype(b))))
  5. ## this fails because it assumes `a[1]` exists and is representative of all elements of the array
  6. # R = typeof(op(a[1], b[1]))
  7. ## this is incorrect because it assumes that `+` calls `promote_type`
  8. ## but this is not true for some types, such as Bool:
  9. # R = promote_type(ai, bi)
  10. # this is wrong, since depending on the return value
  11. # of type-inference is very brittle (as well as not being optimizable):
  12. # R = Base.return_types(op, (eltype(a), eltype(b)))
  13. ## but, finally, this works:
  14. R = promote_op(op, eltype(a), eltype(b))
  15. ## although sometimes it may give a larger type than desired
  16. ## it will always give a correct type
  17. output = similar(b, R, (size(a, 1), size(b, 2)))
  18. if size(a, 2) > 0
  19. for j in 1:size(b, 2)
  20. for i in 1:size(b, 1)
  21. ## here we don't use `ab = zero(R)`,
  22. ## since `R` might be `Any` and `zero(Any)` is not defined
  23. ## we also must declare `ab::R` to make the type of `ab` constant in the loop,
  24. ## since it is possible that typeof(a * b) != typeof(a * b + a * b) == R
  25. ab::R = a[i, 1] * b[1, j]
  26. for k in 2:size(a, 2)
  27. ab += a[i, k] * b[k, j]
  28. end
  29. output[i, j] = ab
  30. end
  31. end
  32. end
  33. return output
  34. end

分离转换和内核逻辑

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

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

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

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

函数参数也可以用于约束应用于"可变参数"函数(@ref">Varargs Functions)的参数的数量。Vararg{T,N}可用于表明这么一个约束。举个例子:

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

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

  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的简便写法。

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

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

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

翻译成下列三个方法:

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

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

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

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

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

类函数对象

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

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

  1. julia> struct Polynomial{R}
  2. coeffs::Vector{R}
  3. end
  4. julia> function (p::Polynomial)(x)
  5. v = p.coeffs[end]
  6. for i = (length(p.coeffs)-1):-1:1
  7. v = v*x + p.coeffs[i]
  8. end
  9. return v
  10. end
  11. julia> (p::Polynomial)() = p(5)

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

  1. julia> p = Polynomial([1,10,100])
  2. Polynomial{Int64}([1, 10, 100])
  3. julia> p(3)
  4. 931
  5. julia> p()
  6. 2551

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

空范用函数

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

  1. function emptyfunc
  2. end

方法设计与避免歧义

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

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

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

靠定义一个方法

  1. f(x::Int, y::Int) = 3

这是经常使用的对的方案;但是有些环境下盲目地遵从这个建议会适得其反。特别地,范用函数有的方法越多,出现歧义的可能性越高。当你的方法层级比这下简单的例子更加复杂时,就值得你花时间去仔细想想其他的方案。

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

元组和N元组参数

元组(和N元组)参数会带来特别的挑战。例如,

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

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

  1. f(x::Tuple{}) = 3

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

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

正交化你的设计

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

  1. f(x::A, y::A) = ...
  2. f(x::A, y::B) = ...
  3. f(x::B, y::A) = ...
  4. f(x::B, y::B) = ...

不如考虑定义

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

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

  1. g(x::A) = x

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

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

这个设计的一个隐患是如果没有合适的把xy转换到同样类型的类型提升方法,第二个方法就可能无限自递归然后引发堆溢出。非输出函数Base.promote_noncircular可以用作一个替代方案;当类型提升失败它依旧会扔出一个错误,但是有更加特定的错误信息时会失败更快。

一次只根据一个参数分派

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

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

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

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

抽象容器与元素类型

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

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

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

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

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

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

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

可以暴力解决歧义。

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

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

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

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

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

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

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

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

NoPad会被用到其他padding类型的同样的参数位置上,所以这保持了分派层级是有很好组织的,却降低了歧义的可能性。而且,它扩展了"公开"的myfilter接口:想要显式空值padding的用户可以直接调用NoPad变量。

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

原文: https://juliacn.github.io/JuliaZH.jl/latest/manual/methods/