方法
我们回想一下,在[函数]
在一系列的函数方法定义时有可能没有单独的最专用的方法能适用于参数的某些组合:
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) in Main at none:1
g(x::Float64, y) 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的方法多态性是其最有力的特性之一,利用这个功能会带来设计上的挑战。特别地,在更加复杂的方法层级中出现[歧义](http://127.0.0.5/@ref man-ambiguities)不能说不常见。
在上面我们曾经指出我们可以像这样解决歧义
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
[正交化你的设计](@id man-methods-orthogonalize)
当你打算根据两个或更多的参数进行分派时,考虑一下,一个「包裹」函数是否会让设计简单一些。举个例子,与其编写多变量:
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
来把x
和y
变成常见的类型:
f(x::T, y::T) where {T} = ...
f(x, y) = f(promote(x, y)...)
这个设计的一个隐患是如果没有合适的把x
和y
转换到同样类型的类型提升方法,第二个方法就可能无限自递归然后引发堆溢出。非输出函数Base.promote_noncircular
可以用作一个替代方案;当类型提升失败它依旧会扔出一个错误,但是有更加特定的错误信息时会失败更快。
一次只根据一个参数分派
如果你你需要根据多个参数进行分派,并且有太多的为了能定义所有可能的变量而存在的组合,而存在很多回退函数,你可以考虑引入"名字级联",这里(例如)你根据第一个参数分配然后调用一个内部的方法:
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
和-
)实现的。这只是建议[正交化](http://127.0.0.5/@ref man-methods-orthogonalize)你的方法的一个更加复杂的变种而已。
当这个方法不可行时,这就值得与其他开发者开始讨论如果解决歧义;只是因为一个函数先定义并不总是意味着他不能改变或者被移除。作为最后一个手段,开发者可以定义"创可贴"方法
-(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
变量。
[^Clarke61]: Arthur C. Clarke, Profiles of the Future (1961): Clarke's Third Law.
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论