方法
從 函數 回想,函數是一個物件,它將一個元組的引數對應到一個回傳值,或者如果沒有適當的值可以回傳,則會擲回一個例外。對於不同類型的引數,同一個概念函數或運算通常會以非常不同的方式實作:將兩個整數相加與將兩個浮點數相加非常不同,而這兩個都與將一個整數加到一個浮點數不同。儘管它們的實作不同,這些運算都屬於「加法」這個一般概念。因此,在 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
中的算術運算。
若要定義一個有多個方法的函數,只需多次定義該函數,並使用不同的數字和類型的參數。函數的第一個方法定義會建立函數物件,而後續的方法定義會將新方法新增到現有的函數物件。當套用函數時,將執行與參數數量和類型最相符的方法定義。因此,上述兩個方法定義共同定義了抽象類型 Number
的所有實例對的 f
行為,但對於 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
函數非常相似,但僅定義為數字對。
參數方法允許與用於撰寫類型(請參閱 UnionAll 類型)的 where
表達式相同的語法。如果只有一個參數,則可以省略括在大括號中的花括號(在 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
)比在執行 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)...)
參數約束的 Varargs 方法
函數參數也可以用來限制可提供給「變參」函數的參數數量(變參函數)。表示法 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
这通常是正确的策略;但是,在某些情况下,不假思索地遵循此建议可能会适得其反。特别是,泛型函数拥有的方法越多,出现歧义的可能性就越大。当你的方法层次结构变得比这个简单的示例更复杂时,仔细考虑替代策略是值得的。
下面我们将讨论具体挑战以及解决此类问题的一些替代方法。
元组和 N 元组参数
元组
(和 N 元组
)参数提出了特殊的挑战。例如,
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