方法
回顾 函数 中的内容,函数是一个将参数元组映射到返回值的对象,或者如果无法返回适当的值则抛出异常。同一个概念函数或操作通常会针对不同类型的参数以完全不同的方式实现:两个整数相加与两个浮点数相加非常不同,这两者都与整数加浮点数不同。尽管它们的实现有所不同,但这些操作都属于“加法”这一通用概念。因此,在 Julia 中,这些行为都属于同一个对象:+
函数。
为了方便流畅地使用同一概念的许多不同实现,函数不必一次性全部定义,而是可以通过为某些参数类型和数量组合提供特定行为来分段定义。函数的一种可能行为的定义称为方法。到目前为止,我们只展示了使用单个方法定义函数的示例,该方法适用于所有类型的参数。但是,方法定义的签名可以添加注释以指示参数的类型以及参数的数量,并且可以提供多个方法定义。当函数应用于特定参数元组时,将应用适用于这些参数的最具体的方法。因此,函数的整体行为是其各种方法定义的行为的拼凑。如果拼凑设计得当,即使方法的实现可能大不相同,函数的外部行为也会显得无缝且一致。
在应用函数时选择执行哪个方法的过程称为分派。Julia 允许分派过程根据给定的参数数量以及函数所有参数的类型来选择调用函数的哪个方法。这与传统的面向对象语言不同,在传统的面向对象语言中,分派仅基于第一个参数,该参数通常具有特殊的参数语法,有时是隐含的,而不是显式地写成参数。[1] 使用函数的所有参数来选择应调用的方法,而不是仅使用第一个参数,这被称为多重分派。多重分派对于数学代码特别有用,因为在数学代码中,人为地认为操作“属于”某个参数而不是其他任何参数是没有意义的:x + y
中的加法运算属于 x
还是 y
?数学运算符的实现通常取决于其所有参数的类型。然而,即使在数学运算之外,多重分派最终也成为一种强大且方便的范式,用于构建和组织程序。
本章中的所有示例都假设您正在为同一模块中的函数定义方法。如果您想向另一个模块中的函数添加方法,则必须将其import
或使用限定模块名称的名称。请参阅有关命名空间管理的部分。
定义方法
到目前为止,在我们的示例中,我们只定义了具有单个方法且参数类型不受约束的函数。此类函数的行为与传统动态类型语言中的行为完全相同。尽管如此,我们几乎一直在不知不觉中使用多重分派和方法:Julia 的所有标准函数和运算符,例如前面提到的 +
函数,都有许多方法定义了它们在各种参数类型和数量组合上的行为。
在定义函数时,可以选择使用在复合类型部分介绍的::
类型断言运算符来约束其适用的参数类型。
julia> f(x::Float64, y::Float64) = 2x + y
f (generic function with 1 method)
此函数定义仅适用于x
和y
均为Float64
类型值的调用。
julia> f(2.0, 3.0)
7.0
将其应用于任何其他类型的参数将导致MethodError
。
julia> f(2.0, 3)
ERROR: MethodError: no method matching f(::Float64, ::Int64)
Closest candidates are:
f(::Float64, !Matched::Float64)
@ Main none:1
Stacktrace:
[...]
julia> f(Float32(2.0), 3.0)
ERROR: MethodError: no method matching f(::Float32, ::Float64)
Closest candidates are:
f(!Matched::Float64, ::Float64)
@ Main none:1
Stacktrace:
[...]
julia> f(2.0, "3.0")
ERROR: MethodError: no method matching f(::Float64, ::String)
Closest candidates are:
f(::Float64, !Matched::Float64)
@ Main none:1
Stacktrace:
[...]
julia> f("2.0", "3.0")
ERROR: MethodError: no method matching f(::String, ::String)
如您所见,参数必须精确地为Float64
类型。其他数值类型,例如整数或 32 位浮点数,不会自动转换为 64 位浮点数,字符串也不会被解析为数字。因为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 中的所有转换都是非魔术的且完全显式的。但是,转换和提升 显示了如何巧妙地应用足够先进的技术,使其与魔术无法区分。[Clarke61]
对于非数值值,以及少于或多于两个参数,函数f
仍然未定义,应用它仍然会导致MethodError
。
julia> f("foo", 3)
ERROR: MethodError: no method matching f(::String, ::Int64)
Closest candidates are:
f(!Matched::Number, ::Number)
@ Main none:1
Stacktrace:
[...]
julia> f()
ERROR: MethodError: no method matching f()
Closest candidates are:
f(!Matched::Float64, !Matched::Float64)
@ Main none:1
f(!Matched::Number, !Matched::Number)
@ Main none:1
Stacktrace:
[...]
您可以通过在交互式会话中输入函数对象本身来轻松查看函数有哪些方法。
julia> f
f (generic function with 2 methods)
此输出告诉我们f
是一个函数对象,具有两个方法。要找出这些方法的签名,请使用methods
函数
julia> methods(f)
# 2 methods for generic function "f" from Main:
[1] f(x::Float64, y::Float64)
@ none:1
[2] f(x::Number, y::Number)
@ none:1
这表明f
有两个方法,一个接受两个Float64
参数,另一个接受类型为Number
的参数。它还指示在何处定义了这些方法的文件和行号:因为这些方法是在REPL中定义的,所以我们得到明显的行号none:1
。
在没有使用::
进行类型声明的情况下,方法参数的类型默认为Any
,这意味着它不受约束,因为Julia中的所有值都是抽象类型Any
的实例。因此,我们可以为f
定义一个通配方法,如下所示
julia> f(x,y) = println("Whoa there, Nelly.")
f (generic function with 3 methods)
julia> methods(f)
# 3 methods for generic function "f" from Main:
[1] f(x::Float64, y::Float64)
@ none:1
[2] f(x::Number, y::Number)
@ none:1
[3] f(x, y)
@ none:1
julia> f("foo", 1)
Whoa there, Nelly.
此通配方法不如任何其他可能的参数值对的方法定义具体,因此它只会在不适用任何其他方法定义的参数对上调用。
请注意,在第三个方法的签名中,没有为参数x
和y
指定类型。这是一种表达f(x::Any, y::Any)
的简写方式。
尽管这看起来是一个简单的概念,但基于值的类型的多重分派可能是Julia语言中最强大和最核心的特性之一。核心操作通常有数十种方法
julia> methods(+)
# 180 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
[4] +(x::Bool, y::T) where T<:AbstractFloat in Base at bool.jl:96
[5] +(x::Bool, z::Complex) in Base at complex.jl:234
[6] +(a::Float16, b::Float16) in Base at float.jl:373
[7] +(x::Float32, y::Float32) in Base at float.jl:375
[8] +(x::Float64, y::Float64) in Base at float.jl:376
[9] +(z::Complex{Bool}, x::Bool) in Base at complex.jl:228
[10] +(z::Complex{Bool}, x::Real) in Base at complex.jl:242
[11] +(x::Char, y::Integer) in Base at char.jl:40
[12] +(c::BigInt, x::BigFloat) in Base.MPFR at mpfr.jl:307
[13] +(a::BigInt, b::BigInt, c::BigInt, d::BigInt, e::BigInt) in Base.GMP at gmp.jl:392
[14] +(a::BigInt, b::BigInt, c::BigInt, d::BigInt) in Base.GMP at gmp.jl:391
[15] +(a::BigInt, b::BigInt, c::BigInt) in Base.GMP at gmp.jl:390
[16] +(x::BigInt, y::BigInt) in Base.GMP at gmp.jl:361
[17] +(x::BigInt, c::Union{UInt16, UInt32, UInt64, UInt8}) in Base.GMP at gmp.jl:398
...
[180] +(a, b, c, xs...) in Base at operators.jl:424
多重分派与灵活的参数类型系统相结合,使Julia能够抽象地表达与实现细节分离的高级算法。
方法特化
当您创建同一函数的多个方法时,这有时被称为“特化”。在这种情况下,您正在通过向函数添加其他方法来特化函数:每个新方法都是函数的一个新特化。如上所示,这些特化由methods
返回。
另一种特化是在没有程序员干预的情况下发生的:Julia的编译器可以自动为使用的特定参数类型特化方法。此类特化未被methods
列出,因为这不会创建新的Method
,但像@code_typed
这样的工具允许您检查此类特化。
例如,如果您创建一个方法
mysum(x::Real, y::Real) = x + y
您已为函数mysum
提供了一种新方法(可能是其唯一方法),并且该方法接受任何一对Real
数字输入。但是,如果您随后执行
julia> mysum(1, 2)
3
julia> mysum(1.0, 2.0)
3.0
Julia将编译mysum
两次,一次用于x::Int, y::Int
,另一次用于x::Float64, y::Float64
。编译两次的目的是提高性能:+
(mysum
使用)调用的方法根据x
和y
的具体类型而异,通过编译不同的特化,Julia可以提前完成所有方法查找。这使得程序能够更快地运行,因为它在运行时不必费心进行方法查找。Julia的自动特化允许您编写通用算法,并期望编译器会生成高效的、专门的代码来处理您需要的每种情况。
在潜在特化数量可能无限的情况下,Julia可能会避免这种默认特化。有关更多信息,请参阅了解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, y::Float64)
@ Main none:1
g(x::Float64, y)
@ Main none:1
Possible fix, define
g(::Float64, ::Float64)
Stacktrace:
[...]
这里调用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
类型的方法(请参阅UnionAll类型)。
这种通过分派定义函数行为的方式在Julia中非常普遍——甚至是惯用的。方法类型参数的使用并不局限于作为参数的类型:它们可以在函数签名或函数体中使用值的任何位置使用。这是一个方法类型参数T
用作方法签名中参数类型Vector{T}
的类型参数的示例
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],2.5)
ERROR: MethodError: no method matching myappend(::Vector{Int64}, ::Float64)
Closest candidates are:
myappend(::Vector{T}, !Matched::T) where T
@ Main none:1
Stacktrace:
[...]
julia> myappend([1.0,2.0,3.0],4.0)
4-element Vector{Float64}:
1.0
2.0
3.0
4.0
julia> myappend([1.0,2.0,3.0],4)
ERROR: MethodError: no method matching myappend(::Vector{Float64}, ::Int64)
Closest candidates are:
myappend(::Vector{T}, !Matched::T) where T
@ Main none:1
Stacktrace:
[...]
如您所见,附加元素的类型必须与它附加到的向量的元素类型匹配,否则会引发MethodError
。在以下示例中,方法类型参数T
用作返回值
julia> mytypeof(x::T) where {T} = T
mytypeof (generic function with 1 method)
julia> mytypeof(1)
Int64
julia> mytypeof(1.0)
Float64
就像您可以在类型声明中对类型参数设置子类型约束一样(请参阅参数类型),您也可以约束方法的类型参数
julia> same_type_numeric(x::T, y::T) where {T<:Number} = true
same_type_numeric (generic function with 1 method)
julia> same_type_numeric(x::Number, y::Number) = false
same_type_numeric (generic function with 2 methods)
julia> same_type_numeric(1, 2)
true
julia> same_type_numeric(1, 2.0)
false
julia> same_type_numeric(1.0, 2.0)
true
julia> same_type_numeric("foo", 2.0)
ERROR: MethodError: no method matching same_type_numeric(::String, ::Float64)
Closest candidates are:
same_type_numeric(!Matched::T, ::T) where T<:Number
@ Main none:1
same_type_numeric(!Matched::Number, ::Number)
@ Main none:1
Stacktrace:
[...]
julia> same_type_numeric("foo", "bar")
ERROR: MethodError: no method matching same_type_numeric(::String, ::String)
julia> same_type_numeric(Int32(1), Int64(2))
false
same_type_numeric
函数的行为与上面定义的same_type
函数非常相似,但仅针对数字对定义。
参数化方法允许与用于编写类型的where
表达式相同的语法(请参阅UnionAll类型)。如果只有一个参数,则可以省略封闭的花括号(在where {T}
中),但通常出于清晰性而更喜欢使用它们。多个参数可以用逗号分隔,例如where {T, S<:Real}
,或者使用嵌套的where
编写,例如where S<:Real where T
。
重新定义方法
在重新定义方法或添加新方法时,务必了解这些更改不会立即生效。这是Julia能够静态推断和编译代码以快速运行的关键,无需通常的JIT技巧和开销。实际上,任何新的方法定义都不会对当前运行时环境可见,包括任务和线程(以及任何先前定义的@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
进行该调用之前。
您可能想自己尝试一下,看看它是如何工作的。
此行为的实现是“世界年龄计数器”。此单调递增的值跟踪每个方法定义操作。这允许将“对给定运行时环境可见的方法定义集”描述为单个数字或“世界年龄”。它还允许通过比较它们的序数值来比较两个世界中可用的方法。在上面的示例中,我们看到“当前世界”(其中存在方法newfun
)比任务本地“运行时世界”大1,而任务本地“运行时世界”是在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)
)与上述方法不匹配。Base
中eltype
的实现为此类情况添加了一个回退方法到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
和output
类型相同的基本示例用法
input = convert(AbstractArray{Eltype}, input)
output = similar(input, Eltype)
作为对此的扩展,在算法需要输入数组副本的情况下,convert
并不够用,因为返回值可能与原始输入产生别名。结合 similar
(创建输出数组)和 copyto!
(用输入数据填充它)是一种通用的方式来表达对输入参数的可变副本的要求。
copy_with_eltype(input, Eltype) = copyto!(similar(input, Eltype), input)
迭代分派
为了分派多级参数化参数列表,通常最好将每个分派级别分离到不同的函数中。这在方法上可能听起来类似于单分派,但正如我们将在下面看到的,它仍然更加灵活。
例如,尝试根据数组的元素类型进行分派通常会遇到歧义的情况。相反,代码通常会首先根据容器类型进行分派,然后根据 eltype 递归到更具体的方法。在大多数情况下,算法方便地适用于这种分层方法,而在其他情况下,必须手动解决这种严格性。例如,在对两个矩阵求和的逻辑中可以观察到这种分派分支。
# First dispatch selects the map algorithm for element-wise summation.
+(a::Matrix, b::Matrix) = map(+, a, b)
# Then dispatch handles each element and selects the appropriate
# common element type for the computation.
+(a, b) = +(promote(a, b)...)
# Once the elements have the same type, they can be added.
# For example, via primitive operations exposed by the processor.
+(a::Float64, b::Float64) = Core.add(a, b)
基于特征的分派
上述迭代分派的自然扩展是在方法选择中添加一层,允许根据与类型层次结构定义的集合无关的类型集合进行分派。我们可以通过编写要查询的类型的 Union
来构建这样的集合,但随后该集合将不可扩展,因为 Union
类型在创建后无法更改。但是,可以使用一种通常称为 "神圣特征" 的设计模式来对这样的可扩展集合进行编程。
此模式通过定义一个通用函数来实现,该函数为函数参数可能属于的每个特征集计算不同的单例值(或类型)。如果此函数是纯函数,则与普通分派相比,对性能没有影响。
上一节中的示例概述了 map
和 promote
的实现细节,它们都根据这些特征进行操作。当遍历矩阵(例如在 map
的实现中)时,一个重要的问题是使用什么顺序遍历数据。当 AbstractArray
子类型实现 Base.IndexStyle
特征时,其他函数(如 map
)可以根据此信息进行分派以选择最佳算法(请参阅 抽象数组接口)。这意味着每个子类型不需要实现 map
的自定义版本,因为通用定义 + 特征类将使系统能够选择最快的版本。这是一个说明基于特征的分派的 map
的玩具实现。
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) = ...
这种基于特征的方法也存在于标量 +
使用的 promote
机制中。它使用 promote_type
,它返回给定操作数的两种类型以计算操作的最佳公共类型。这使得可以将为每对可能的类型参数实现每个函数的问题减少到更小的问题,即从每种类型到公共类型的转换操作的实现,加上首选的成对提升规则表。
输出类型计算
基于特征的提升的讨论为我们接下来的设计模式过渡:计算矩阵操作的输出元素类型。
对于实现基本运算(如加法),我们使用 promote_type
函数来计算所需的输出类型。(如前所述,我们在对 +
的调用中看到了 promote
调用中的工作原理)。
对于矩阵上更复杂的函数,可能需要计算更复杂的操作序列的预期返回类型。这通常通过以下步骤执行
- 编写一个小的函数
op
来表达算法核心执行的操作集。 - 将结果矩阵的元素类型
R
计算为promote_op(op, argument_types...)
,其中argument_types
由应用于每个输入数组的eltype
计算。 - 将输出矩阵构建为
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)
@ Main none:1
Stacktrace:
[...]
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)
@ Main none:1
Stacktrace:
[...]
更有用的是,可以通过参数来约束可变参数方法。例如
function getindex(A::AbstractArray{T,N}, indices::Vararg{Number,N}) where {T,N}
仅当 indices
的数量与数组的维数匹配时才会调用。
当只需要约束提供的参数的类型时,Vararg{T}
可以等效地写成 T...
。例如 f(x::Int...) = x
是 f(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
块。
function emptyfunc end
方法设计和避免歧义
Julia 的方法多态性是其最强大的功能之一,但利用这种强大功能可能会带来设计挑战。特别是,在更复杂的方法层次结构中,出现 歧义 并不罕见。
上面指出,可以通过定义一个方法来解决诸如
f(x, y::Int) = 1
f(x::Int, y) = 2
之类的歧义
f(x::Int, y::Int) = 3
这通常是正确的策略;但是,在某些情况下,不假思索地遵循此建议可能会适得其反。特别是,泛型函数拥有的方法越多,出现歧义的可能性就越大。当您的方法层次结构比这个简单的示例复杂时,仔细考虑替代策略可能值得您花时间。
下面我们将讨论特定的挑战以及解决此类问题的一些替代方法。
元组和 NTuple 参数
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
最有可能需要一个后备定义
g(x::A) = x
相关策略利用 promote
将 x
和 y
提升到一个公共类型
f(x::T, y::T) where {T} = ...
f(x, y) = f(promote(x, y)...)
此设计的一个风险是,如果没有合适的提升方法将 x
和 y
转换为相同的类型,则第二个方法将无限递归自身并触发堆栈溢出。
一次处理一个参数
如果您需要根据多个参数进行分派,并且存在许多回退情况,组合过多以至于定义所有可能的变体不切实际,那么可以考虑引入“名称级联”,例如,根据第一个参数进行分派,然后调用内部方法
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} = ...
通过蛮力解决歧义。
使用默认参数的复杂方法“级联”
如果您正在定义一个提供默认值的方法“级联”,请小心不要删除任何对应于潜在默认值的参数。例如,假设您正在编写一个数字滤波算法,并且您有一个方法通过应用填充来处理信号的边缘
function myfilter(A, kernel, ::Replicate)
Apadded = replicate_edges(A, size(kernel))
myfilter(Apadded, kernel) # now perform the "real" computation
end
这将与提供默认填充的方法冲突
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
与任何其他类型的填充位于同一参数位置,因此它使分派层次结构井然有序,并降低了歧义的可能性。此外,它扩展了“公共”myfilter
接口:希望显式控制填充的用户可以直接调用NoPad
变体。
在局部作用域中定义方法
您可以在局部作用域中定义方法,例如
julia> function f(x)
g(y::Int) = y + x
g(y) = y - x
g
end
f (generic function with 1 method)
julia> h = f(3);
julia> h(4)
7
julia> h(4.0)
1.0
但是,您不应该有条件地或受控制流影响地定义局部方法,例如
function f2(inc)
if inc
g(x) = x + 1
else
g(x) = x - 1
end
end
function f3()
function g end
return g
g() = 0
end
因为不清楚最终将定义哪个函数。将来,以这种方式定义局部方法可能会出错。
对于这种情况,请改用匿名函数
function f2(inc)
g = if inc
x -> x + 1
else
x -> x - 1
end
end