参数化方法的疑问

在Julia的帮助文档中提出如下描述:

julia> function myappend(v::Vector{T}, x::T) where {T}
           return [v..., x]
       end
myappend (generic function with 1 method)

The type parameter T in this example ensures that the added element x is a subtype of the existing eltype of the vector v .

通过在REPL中测试,发现上述描述是正确的,这就打破了我的固有认识了。我原来一直认为上述代码表达了:新添加元素x只有与向量v中的元素类型精确相同时,才会触发该方法的调用。比如如下例子:

julia> same_type(x::T, y::T) where {T} = true
same_type (generic function with 1 method)

请各位帮忙解释第一段代码及其描述的合理性,以及我应该如何理解这种行为。非常感谢!

1 个赞

我以为我懂,貌似我也没弄懂,问了下 Chat GPT 也没整明白,又发了个英文的帖子看看有没有其它人能给解释下。

1 个赞

非常感谢您的回复!期待这个疑问的解答,感谢。 :+1:

问题似乎已在About the type constraint of `where` - #8 by Benny - New to Julia - Julia Programming Language 中解决。

这个叫 diagonal dispatch, 在 Julia 的官方文档中对这个有详细解答:More about types · The Julia Language

类似的还有 “triangular dispatch”

1 个赞

我看了一下英文社区的解释以及文档的解释,似乎有一点太硬了,不是特别容易理解。

让我来尝试解释的话,可以用下面三个例子作为对比:

# case 1: T 是具体类型:v 的元素类型以及 x 和 y 的类型严格相同
# case 2: T 是抽象类型,等价于 `myappend_1(v::Vector{Real}, x::Real, y::Real)`
function myappend_1(v::Vector{T}, x::T, y::T) where {T}
    return [v..., x, y]
end

# v 的元素类型以及 x 和 y 的类型严格相同
# 注:抽象类型是无法实例化的,因此 Julia 编译的函数体中 x, y 所对应的 T 一定是具体类型
function myappend_2(v::Vector{<:T}, x::T, y::T) where {T}
    return [v..., x, y]
end

# x, y 的元素类型必须相同
function myappend_3(v::Vector{T}, x::S, y::S) where {T, S<:T}
    return [v..., x, y]
end

julia> myappend_1(Real[1, 2, 3], 4.0, 5)
5-element Vector{Float64}:
 1.0
 2.0
 3.0
 4.0
 5.0

julia> myappend_2(Real[1, 2, 3], 4.0, 5)
ERROR: MethodError: no method matching myappend_2(::Vector{Real}, ::Float64, ::Int64)

Closest candidates are:
  myappend_2(::Vector{<:T}, ::T, ::T) where T
   @ Main REPL[3]:1

Stacktrace:
 [1] top-level scope
   @ REPL[6]:1

julia> myappend_3(Real[1, 2, 3], 4.0, 5)
ERROR: MethodError: no method matching myappend_3(::Vector{Real}, ::Float64, ::Int64)

Closest candidates are:
  myappend_3(::Vector{T}, ::S, ::S) where {T, S<:T}
   @ Main REPL[4]:1

Stacktrace:
 [1] top-level scope
   @ REPL[7]:1

可以开一个新的 Julia 进程,然后用 MethodAnalaysis 来看一下第一个方法的两种编译结果:

julia> using MethodAnalysis

julia> function myappend_1(v::Vector{T}, x::T, y::T) where {T}
           return [v..., x, y]
       end
myappend_1 (generic function with 1 method)

julia> methodinstances(myappend_1)
Core.MethodInstance[]

julia> myappend_1(Real[1, 2, 3], 4.0, 5);

julia> methodinstances(myappend_1)
1-element Vector{Core.MethodInstance}:
 MethodInstance for myappend_1(::Vector{Real}, ::Float64, ::Int64)

会发现 Julia 编译的函数体是 myappend_1(::Vector{Real}, ::Float64, ::Int64).
如果我们将 T 理解成占位符的话,它的结果其实等价于直接调用下面函数定义的结果:

function myappend_1(v::Vector{Real}, x::Real, y::Real)
    return [v..., x, y]
end

Side note

  1. 在实际应用中,第二种写法是最普遍的。

因为它与 Julia 的性能最佳实践是一致的:鼓励用 Vector{Int} 而不是 Vector{Real}.

function myappend_2(v::Vector{<:T}, x::T, y::T) where {T}
    return [v..., x, y]
end

像第一种场景中 myappend_1(Real[1, 2, 3], 4.0, 5) work 或者不 work,在实际应用中我基本都不会关心。

  1. 对于一些容易搞混的跨层级类型约束的声明,尽量引入辅助派发来逐层分解。例如:
myappend_1(v::Vector{T}, x::T, y::T) where {T} = ...

可以拆分为两层派发:

myappend_1(v::AbstractArray, x, y) = _myappend_1(eltype(v), v, x, y)
_myappend_1(::Type{T}, v::AbstractArray, x::T, y::T) where {T} = [v..., x, y]

即:第一层派发将矩阵的元素类型提取出来,然后放在第二层派发来统一处理。
这样从阅读的角度来说大概更容易理解一些。

例如:JuliaImages 中有一个非常有趣的 imfilter 分层派发的实现

# Step 1: if necessary, determine the output's element type
@inline function imfilter(img::AbstractArray, kernel, args...)
    imfilter(filter_type(img, kernel), img, kernel, args...)
end

# Step 2: if necessary, put the kernel into cannonical (factored) form
@inline function imfilter(::Type{T}, img::AbstractArray, kernel::Union{ArrayLike,Laplacian}, args...) where {T}
    imfilter(T, img, factorkernel(kernel), args...)
end
@inline function imfilter(::Type{T}, img::AbstractArray{TI}, kernel::AbstractArray{TK}, args...) where {T<:Integer,TI<:Integer,TK<:Integer}
    imfilter(T, img, (kernel,), args...)
end

...
  1. Julia 有一些复杂的语法写法,并不代表我们需要使用它。类型体操不是什么好玩的东西。
3 个赞

非常感谢,用举例说明,受益匪浅。 :fu:

感谢回复,我去学习学习。 :hugs: