メソッド
関数の章で説明したように、関数とは引数のタプルを返り値に関連付ける (あるいは適切な返り値がなければ例外を送出する) オブジェクトです。概念としては同じ関数や演算であっても、実装が引数の型によって大きく異なることは少なくありません。例えば二つの整数を足す演算と二つの浮動小数点数を足す演算は全くの別物であり、さらに整数と浮動小数点数を足す演算はこのどちらとも異なります。しかし実装は異なるものの、こういった演算は「加算」という単一の一般的な概念で括られます。これに対応して、Julia でこれらの処理は +
関数という単一のオブジェクトで表されます。
単一の概念に対する複数の実装を円滑に実現するために、Julia では関数を一度に全て定義する必要はなく、特定の引数の型と個数ごとに処理を分けて書けるようになっています。関数が行う可能性のある処理の定義をメソッドと呼びます。例えば、このマニュアルでこれまで示してきた関数の例 (の多く) は一つのメソッドが定義された関数であり、どれも任意の型に対して適用できます。しかしメソッド定義のシグネチャには引数の個数の指定だけではなく引数に対する型注釈も付けることができ、さらに一つの関数に対して複数のメソッドを定義できます。関数が特定の引数のタプルに適用されると、その引数を適用できるメソッドの中で最も特定的なものが呼び出されます。
つまり関数の振る舞いとは、様々なメソッドで定義される処理を組み合わせたパッチワークと言えます。このパッチワークが整然と設計されていれば、それぞれのメソッドの実装が大きく異なっていたとしても、外から見たときの関数の振る舞いはシームレスで一貫して見えます。
関数が適用されたときに実行するメソッドを選択する処理をディスパッチ (dispatch) と呼びます。Julia のディスパッチ処理では与えられた引数の個数と型を使って呼び出すメソッドが選択されます。これは伝統的なオブジェクト指向のディスパッチと異なります: そういった言語では最初の引数だけを使ってメソッドが選択され、そのときは特殊な記法があったり第一引数を省略できたりします1。起動するメソッドを選ぶときに最初の引数だけではなく全ての引数を使う方法を多重ディスパッチと呼びます。
多重ディスパッチは数学的なコードで特に有用になります。というのも、数学的な演算をいずれかの引数に "属させる" のはいかにも人工的で、ほとんど意味をなさないのです: 加算演算 x + y
は x
に属するのでしょうか? それとも y
でしょうか? 数学的な演算子の実装は通常引数全ての型に依存します。さらに数学的でない演算であっても、多重ディスパッチはプログラムを構成・整理するための強力で便利なパラダイムであることが判明しています。
メソッドの定義
これまで例に示した関数 (の多く) には、引数の型に制約のないメソッドが一つだけ定義されていました。そういった関数の振る舞いは伝統的な動的型付け言語におけるものと同様です。もちろん、多重ディスパッチとメソッドは気付かないうちにほぼ絶え間なく使われていました: 上述の +
を含む 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) at none:1
julia> f(Float32(2.0), 3.0)
ERROR: MethodError: no method matching f(::Float32, ::Float64)
Closest candidates are:
f(!Matched::Float64, ::Float64) at none:1
julia> f(2.0, "3.0")
ERROR: MethodError: no method matching f(::Float64, ::String)
Closest candidates are:
f(::Float64, !Matched::Float64) at none:1
julia> f("2.0", "3.0")
ERROR: MethodError: no method matching f(::String, ::String)
この例から分かるように、引数は正確に Float64
でなくてはなりません。32 ビットの浮動小数点数のような他の数値型 (あるいは数値としてパースできる文字列) を渡しても、Float64
への自動的な変換は行われません。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
の定義となります。ただし f
は Float64
の組に対しては異なる処理を行います。もし引数の片方が Float64
でもう一方がそうでなかった場合には、メソッド 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
とした定義は最初の f(2.0, 3.0)
でだけ使われ、それ以外では 2x - y
とした定義が使われています。関数の引数に対するキャストや変換は一切行われていません: Julia における型変換はいつの間にか起こる魔法ではなく、明示的に行われる操作です。ただし型の変換と昇格の章では十分に進歩した技術が魔法と区別できないことを見ます2。
f
は数値でない型を持つ引数や、個数が二つでない引数に対しては定義されません。こういった引数を適用すると MethodError
が発生します:
julia> f("foo", 3)
ERROR: MethodError: no method matching f(::String, ::Int64)
Closest candidates are:
f(!Matched::Number, ::Number) at none:1
julia> f()
ERROR: MethodError: no method matching f()
Closest candidates are:
f(!Matched::Float64, !Matched::Float64) at none:1
f(!Matched::Number, !Matched::Number) at none:1
対話セッションに関数オブジェクトを入力すれば、ある関数に対して定義されたメソッドを確認できます:
julia> f
f (generic function with 2 methods)
この出力は f
が二つのメソッドを持つ関数オブジェクトであることを示しています。これらのメソッドのシグネチャを見るには methods
関数を使います:
julia> methods(f)
# 2 methods for generic function "f":
[1] f(x::Float64, y::Float64) in Main at none:1
[2] f(x::Number, y::Number) in Main at 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> f("foo", 1)
Whoa there, Nelly.
二つの引数を受け取るこの他のメソッドをどう定義してもこのメソッドより特定的になるので、このメソッドは二つの引数に対して適用できるメソッドの定義が他にないときにだけ呼ばれます。
単純な概念に思えるかもしれませんが、引数の型に対する多重ディスパッチはおそらく 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 では多重ディスパッチが柔軟なパラメトリック型システムと組み合わさることで、高水準のアルゴリズムを実装の詳細を切り離した形で抽象的に表現しつつも、個別のケースに対しては特殊化された高性能なコードを実行時に生成できるようになっています。
メソッドの曖昧性
特定の引数の組に対して最も特定的なメソッドが一つでなくなるようにメソッドを定義することが可能です:
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::Float64, y) in Main at none:1
g(x, y::Float64) 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> 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 Array{Int64,1}:
1
2
3
4
julia> myappend([1,2,3],2.5)
ERROR: MethodError: no method matching myappend(::Array{Int64,1}, ::Float64)
Closest candidates are:
myappend(::Array{T,1}, !Matched::T) where T at none:1
julia> myappend([1.0,2.0,3.0],4.0)
4-element Array{Float64,1}:
1.0
2.0
3.0
4.0
julia> myappend([1.0,2.0,3.0],4)
ERROR: MethodError: no method matching myappend(::Array{Float64,1}, ::Int64)
Closest candidates are:
myappend(::Array{T,1}, !Matched::T) where T at none:1
この例から分かるように、myappend
で配列に追加される要素の型は配列の要素型と一致する必要があり、もし一致しなければ MethodError
が発生します。
次の例では、メソッドの型パラメータが返り値に使われています:
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 at none:1
same_type_numeric(!Matched::Number, ::Number) at none:1
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 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
を新しく定義してすぐには呼び出せないことが分かります。新しいグローバル変数 newfun
は tryeval
から参照でき、return newfun
として返すこともできます。しかし tryeval
も、 tryeval
を呼び出した関数も、tryeval
が呼び出す関数も、この新しいメソッド定義 newfun
を呼び出すことはできないのです!
ただし例外があります: REPL から newfun
をもう一度呼び出すと、期待通りに動作します。つまり newfun
の新しい定義を参照でき、呼び出すこともできます。
ただしもう一度 tryeval
を呼び出すと、newfun
の定義が 直前に REPL に入力された文であるかのように、つまり newfun
が tryeval
を呼び出す前に定義されたように振る舞います。
どのように動作するかを自分で試してみてください。
この振る舞いの実装には "世界時カウンター" (world age counter) が使われます。この単調に増加するカウンターはメソッド定義演算を追跡します。世界時カウンターにより「ある時点におけるランタイム環境から参照できるメソッド定義の集合」を一つの数値 "世界時" (world age) で表せるようになり、さらに二つの世界で利用可能なメソッドの比較が数値の比較で可能になります。上の例であれば、"現在の世界" (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(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"
パラメトリックメソッドを使ったデザインパターン
性能や使いやすさだけを考えるなら複雑なディスパッチロジックは必要ではありませんが、多重ディスパッチはアルゴリズムを表現する最良の手段である場合もあります。ディスパッチを使ってアルゴリズムを表現するときに使われることの多い一般的なデザインパターンをいくつかここに示します。
上位型から型パラメータを取り出す
次に示すのは、任意の AbstractArry
の部分型から要素の型 T
を取り出して返す正しいコードテンプレートです:
abstract type AbstractArray{T, N} end
eltype(::Type{<:AbstractArray{T}}) where {T} = T
これは三角ディスパッチ (triangular dispatch) と呼ばれます。eltype(Array{T} where T <: Integer)
のように T
が UnionAll
型だと Any
が返ります (Base
の eltype
と同様の振る舞いです)。
次の方法もあります。三角ディスパッチが行えなかったバージョン 0.6 以前の Julia ではこれが唯一の正しい方法でした:
abstract type AbstractArray{T, N} end
eltype(::Type{AbstractArray}) = Any
eltype(::Type{AbstractArray{T}}) where {T} = T
eltype(::Type{AbstractArray{T, N}}) where {T, N} = T
eltype(::Type{A}) where {A<:AbstractArray} = eltype(supertype(A))
パラメータ T
をより狭くマッチさせる必要がある場合には、次のようにもできます:
eltype(::Type{AbstractArray{T, N} where {T<:S, N<:M}}) where {M, S} = Any
eltype(::Type{AbstractArray{T, N} where {T<:S}}) where {N, S} = Any
eltype(::Type{AbstractArray{T, N} where {N<:M}}) where {M, T} = T
eltype(::Type{AbstractArray{T, N}}) where {T, N} = T
eltype(::Type{A}) where {A <: AbstractArray} = eltype(supertype(A))
よくある間違いが、要素の型をイントロスぺクションで取得するものです:
eltype_wrong(::Type{A}) where {A<:AbstractArray} = A.parameters[1]
この定義が正しくない動作をする型を構築するのは簡単です:
struct BitVector <: AbstractArray{Bool, 1}; end
BitVector
という型はパラメータを持ちませんが、その要素型は完全に指定されており、T
は Bool
に等しいのです!
異なる型パラメータを持つ似た型を構築する
一般的なコードを書くときは、型のレイアウトを変更した似たオブジェクトの構築が必要になることがあり、そのときは型パラメータの変更が要求されます。例えば要素型が任意の AbstractArray
を持っていて、計算結果として定まった要素型を持つ配列を返さなければならないような状況です。この場合 AbstractArray{T}
の部分型全てに対してその特定の型への変換処理を書く必要があります。一つの部分型から異なるパラメータを持つ他の部分型への一般的な変換は存在しません。 (理解の確認: なぜ存在しないのか分かりますか?)
この問題を解決するために、AbstractArray
の部分型は通常二つのメソッドを実装します。入力の配列を指定された AbstractArray{T, N}
の部分型に変換する convert
と、指定された要素型を持つ初期化されていない新しい配列を作る similar
です。これらのメソッドのサンプル実装が Julia Base にあります。次に示すのは convert
と similar
の使用例であり、input
と output
が同じ型であることがこれで保証されます:
input = convert(AbstractArray{Eltype}, input)
output = similar(input, Eltype)
なお convert
は入力の別名を返すことがあるので、アルゴリズムが入力配列のコピーを必要とする場合には使ってはいけません。similar
(出力配列の作成) と copyto!
(入力配列のコピー) を使えば引数として渡された配列の改変可能なコピーを一般的に作成できます:
copy_with_eltype(input, Eltype) = copyto!(similar(input, Eltype), input)
反復ディスパッチ
引数のパラメトリック型に対するディスパッチが複数の段階からなる場合は、ディスパッチの各段階を一つの関数に切り分けると上手く行くことがよくあります。単一ディスパッチで使われるアプローチと同じに思えるかもしれませんが、次に示すように、多重ディスパッチはここでもより柔軟になります。
例えば、配列の要素型に対するディスパッチでは呼び出すべきメソッドが曖昧になりがちです。そのような状況では、最初にコンテナ型でディスパッチして、それから要素型を使って特定的なメソッドを呼び出すという方法がよく使われます。この階層的なアプローチは多くの場合で利用できますが、ときには絡み合った型の組み合わせを手動で解きほぐさなければならない場合もあるでしょう。このディスパッチの分岐を使った手法は例えば二つの行列を足す処理で見られます:
# まずディスパッチが map アルゴリズムを使った要素ごとの加算を選択する。
+(a::Matrix, b::Matrix) = map(+, a, b)
# それから要素型に対するディスパッチが行われ、
# 要素型の共通上位型に対する適切な演算が呼ばれる。
+(a, b) = +(promote(a, b)...)
# 要素が同じ型を持っているなら加算できる。
# 例えば、プロセッサが公開する基礎演算を使えばよい。
+(a::Float64, b::Float64) = Core.add(a, b)
トレイトベースのディスパッチ
上記の反復ディスパッチを自然に拡張すると、型の階層から定義される集合から独立した型の集合についてディスパッチを行うレイヤーをメソッド選択に加えるアプローチが導かれます。そういった型階層と関係を持たない型の集合は Union
を使えば定義できますが、Union
は作成後に変更できないので、こうして定義される集合は拡張できなくなります。しかし "Holy-trait" と呼ばれるデザインパターンを使うと、このアプローチを拡張可能な形で実装できます。
このパターンでは、引数が属するトレイト集合を表すシングルトン値 (または型) を返す総称関数が定義されます。この関数が純粋なら、通常のディスパッチと比べたときの性能の低下はありません。
前項の例で map
と promote
の実装の詳細を簡単に説明しましたが、この二つの関数はどちらもこういったトレイトに関して処理を行います。map
の実装などで行列を反復するときに生じる疑問の一つがデータの走査順序です。AbstractArray
の部分型が Base.IndexStyle
トレイトを実装しているなら、map
といった他の関数はこの情報を使って最も適したアルゴリズムへディスパッチを行います (参照: AbstractArray
インターフェース)。これは AbstractArray
の部分型が独自のバージョンの map
を実装する必要がないことを意味します。なぜなら一般的な関数の定義とトレイトクラスのおかげで、システムが最も高速なバージョンの関数を自動的に選択できるためです。次に示すのはトレイトベースのディスパッチを行う map
の簡単な実装例です:
map(f, a::AbstractArray, b::AbstractArray) = map(Base.IndexStyle(a, b), f, a, b)
# 一般的な実装
map(::Base.IndexCartesian, f, a::AbstractArray, b::AbstractArray) = ...
# 線形にアクセスする (高速な) 実装
map(::Base.IndexLinear, f, a::AbstractArray, b::AbstractArray) = ...
前項で示したスカラーの +
で使われている promote
もトレイトベースのアプローチと言えます。+
は最適な共通型を返す promote_type
を使って二つのオペランドに適用すべき演算を計算します。こうすると生じうる全ての引数型の組に対して関数を実装するという問題が、それぞれの型から共通の型への変換と組ごとの昇格規則の実装というずっと小さな二つの問題に帰着されます。
出力型の計算
トレイトベースの昇格の議論をさらに進めれば、行列演算に対する出力の要素型を計算する次のデザインパターンが得られます。
加算などの基礎演算の実装では promote_type
関数を使って適切な出力型が計算されます。例えば、上で示した +
では promote
が使われています
より複雑な行列に対する関数では、さらに複雑な演算の列が出力する型を計算する必要が生じる可能性があります。普通この計算は次のステップで行われます:
- アルゴリズムのカーネルが実行する演算の集合を表現する小さな関数
op
を書く。 - 入力配列それぞれに対して
eltype
を適用することで得られる型argument_types
に対して、結果の行列の要素型R
をpromote_op(op, argument_types...)
で計算する。 - 出力の行列を
similar(R, dims)
として構築する。dims
はその次元を表す。
具体的な例として、一般的な正方行列の乗算を行う疑似コードを示します:
function matmul(a::AbstractMatrix, b::AbstractMatrix)
op = (ai, bi) -> ai * bi + ai * bi
## こうはできない。
## one(eltype(a)) が構築可能であると仮定しているため。
# R = typeof(op(one(eltype(a)), one(eltype(b))))
## これも上手く行かない。
## a[1] が存在し、配列全ての要素の代表であることを仮定しているため。
# R = typeof(op(a[1], b[1]))
## これも正しくない。
## + が promote_type を呼ぶことを仮定しているため。
## Bool など一部の型では promote_type は呼ばれない。
# R = promote_type(a[1], b[1])
## これも間違い。
## 型推論の返り値に依存するのは非常に脆弱であるため。
## (さらに最適化も不可能となる)
# R = Base.return_types(op, (eltype(a), eltype(b)))
## しかし、これなら正しく動く:
R = promote_op(op, eltype(a), eltype(b))
## 必要より大きな型が返ることもあるが、
## 必ず正しい型が返る。
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)
## ここでは ab = zero(R) とできない。
## R が Any である可能性があり、zero(Any) は定義されないため。
## また ab の型をループ内で定数とするために ab::R としている。
## 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) at none:1
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) at none:1
さらに、メソッドのパラメータを使った可変長引数の個数の制限も可能です:
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)
という呼び出しと等価です。今の例では f(1,2)
で一番上の f(a,b)
が呼び出されるので 5
が返ります。ただし、いつもこうなるわけではありません。整数に対して特殊化した第四のメソッドを定義したとします:
f(a::Int,b::Int) = a-2b
すると f()
と f(1,2)
は両方とも -3
を返すようになります。言い換えると、省略可能引数は関数に結び付くのであって、特定のメソッドに結び付くのではありません。起動されるメソッドは省略可能引数の型に依存します。もしグローバル変数を使って定義されていれば、省略可能引数の型は実行中にさえ変動します。
キーワード引数は通常の位置引数とは大きく異なる動作をします。具体的に言うと、キーワード引数はメソッドのディスパッチに関与しません。メソッドは位置引数だけを使ってディスパッチされ、キーワード引数はメソッドが決定した後に処理されます。
関数様オブジェクト
メソッドは型に結び付きます。そのため、任意の Julia オブジェクトはその型にメソッドを追加することで呼び出せるようにできます (こういった "呼び出せる" オブジェクトを「ファンクタ (functor)」と呼ぶことがあります)。
例えば多項式の係数を保持し、多項式を評価する関数のように振る舞う型は次のように定義できます:
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
多くの場合でこれが正しい解決法ですが、この方法だと手間がかかり過ぎる状況もあり得ます。例えば総称関数が持つメソッドが増えればそれだけ、メソッドが曖昧になる型の組み合わせが増えます。メソッド階層がこの簡単な例より複雑になったときは、他の手段について注意深く考えてみるのも無駄ではないでしょう。
以下ではいくつかの具体的な問題とその解決法について議論します。
タプルの引数
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 # フォールバック
f(x::Tuple{Float64, Vararg{Float64}}) = 2 # 少なくとも一つの 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
の処理を変更することはできません。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) # 処理本体を行う
end
こうした上でデフォルトのパディングを提供する次のメソッドを定義すると、衝突が起きます:
# デフォルトでは端を複製する
myfilter(A, kernel) = myfilter(A, kernel, Replicate())
この二つのメソッドは A
を一列ずつ大きくしながら無限にお互いを再帰的に呼び出します。
次のように呼び出し階層を定義すると正しく動作します:
# パディングが必要ない、もしくは既にパディングされていることを示す。
struct NoPad end
# デフォルトの境界条件
myfilter(A, kernel) = myfilter(A, kernel, Replicate())
function myfilter(A, kernel, ::Replicate)
Apadded = replicate_edges(A, size(kernel))
# これ以上境界条件は必要ない。
myfilter(Apadded, kernel, NoPad())
end
# 他のパディングメソッドはここに書く。
function myfilter(A, kernel, ::NoPad)
# コア演算をここに書く。
end
NoPad
が他のパディングを示す引数と同じ位置に提供されることでディスパッチの階層が整理され、曖昧性も起きにくくなります。さらにコアの関数はパブリックな myfilter
インターフェースを持っているので、ユーザーが自分でパディングを制御したい場合には直接 NoPad
を渡すこともできます。