メソッド
関数 の章を思い出してください。 関数とは、引数のタプルを受け取り、戻り値を返す、または適切な値を返せない場合は例外を投げる、オブジェクトです。 概念的には等しい関数や演算が、引数の型によって実装がまったく異なる、ということはよくあります。 2つの整数を足すことと、2つの浮動小数点数を足すことは、全く異なるし、整数に浮動小数点数を足すこととも異なります。 実装が違っていても、これらの操作はすべて、一般的な概念の「足し算」に当てはまります。 したがって、Juliaでは、これらの挙動はすべて1つのオブジェクト「+
関数」に属します。
同じ概念の異なる多くの実装を、円滑に利用するためには、関数を一度にすべて定義する必要はなく、引数の型と個数の組み合わせごとに、挙動を指定して、区分的に定義したほうがいいでしょう。 こうした関数の、とりうる挙動の定義のひとつひとつは、メソッド と呼ばれます。 今までに例示した関数は、単一のメソッドで定義された、引数に対してすべての型を適用可能な関数だけです。 しかし、メソッド定義のシグネチャに、引数の型と数を指定するために型注釈をつけることも可能で、 メソッドの定義も複数あっても構いません。 関数を具体的な引数の組に適用する場合、その引数に適用可能なものから最も特化したメソッドが適用されます。 つまり、関数全体の挙動は、さまざまなメソッドで定義された挙動のパッチワークです。 パッチワークがうまく設計されていれば、メソッドの実装が全く異なっていても、外側からの関数の挙動は継ぎ目なく一貫しているように見えます。
関数を適用する際に、実行するメソッドを選択することを、 ディスパッチ と呼びます。 Juliaでは、ディスパッチの過程で、関数のメソッドのどれを呼び出すか、関数のすべての引数の型と個数に基づいて、選択することができます。 これは、従来のオブジェクト指向言語とは異なります。従来のオブジェクト指向言語では、通常は最初の引数のみに基づいてディスパッチが行われ、最初の引数を特別視して引数に見えないような構文を持つものもあります。 [^ 1] 関数に対して、最初の引数だけではなく、すべての引数を利用して、呼び出すメソッドを選択することは、多重ディスパッチとして知られています。 多重ディスパッチは特に数学的なコードで有用です。 演算が、他の引数よりもどの引数に、より「属している」かと不自然なことを考えても、意味がないからです。 x + y
の式の中の足し算は、 x
に y
よりも属してると思いますか。 数学的な演算子の実装は、通常は、すべての引数の型に依存しています。 しかし、数学的な操作以外でも、多重ディスパッチは、プログラムを構築し組織化する強力で便利なパラダイムです。
[^ 1]: 例えば、C ++やJavaでは、obj.meth(arg1,arg2)
のようなメソッド呼びだしの時は、オブジェクトobjはメソッド呼び出しを「受け取 り」、暗黙のうちにthis
キーワードを介して、引数だと明示せずに、メソッドに引き渡します。 this
オブジェクトがメソッド呼び出しのレシーバである場合は省略できます。meth(arg1,arg2)
と書くだけで、this
は受け取るオ ブジェクトを暗に示します。
メソッドの定義
今までの例では、引数に型の制約がない、単一のメソッドしかない関数しか定義していませんでした。 そのような関数は、従来の動的型付け言語と同じように動作します。 にもかかわらず、私たちは知らない間に、ずっと多重ディスパッチとメソッドを使っていました。 前述の+
関数のような、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ビット浮動小数点数などの数値型では、自動的に64ビット浮動小数点数に変換されず、文字列も数字として解析されません。 Float64
は具象型で、Juliaでは、7具象型はサブクラス化できないので、このような定義は、型が正確に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
の算術演算に委譲されます。
複数のメソッドを持つ関数を定義するには、単に関数の定義を、数と型の異なる引数に対して、複数回行うだけです。 最初のメソッド定義では、関数オブジェクトが生成し、次回以降のメソッド定義では、既存の関数オブジェクトに新しいメソッドが追加されます。 関数の適用時には、引数の数と型が一致している最も特化したメソッド定義が実行されます。 このように、上記の2つのメソッド定義が一緒になって、抽象型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では、すべての変換は魔法ではなく完全に明示的です。 しかし、[変換と昇格](@ ref conversion-and-promotion)では、十分に高度な技術をうまく使うと、魔法と区別できなくなることを示しています。[^ Clarke61]
数値以外の値や引数の数が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
が2つのメソッドを持つ関数オブジェクトであることを示しています。 これらのメソッドのシグネチャを調べるには、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
には二つのメソッドがあり、一方は2つの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, 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
曖昧さのないメソッドは最初に定義することをお勧めします。でなければ、一時的にせよ、より特化したメソッドが定義されるまで、曖昧さが残るからです。
より複雑なケースでは、メソッドの曖昧さを解決するにはある種の設計要素が必要になります。この話題は[以下で](@ ref man-method-design-ambiguities)さらに詳しく解説しています。
パラメータメソッド
メソッド定義では、必要に応じて型パラメータを使い、シグネチャを細かく指定することができます。
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)
第1のメソッドは、両方の引数が同じ具象型であれば、どんな型であっても適用できます。 第2のメソッドは他のすべての場合に対する全捕捉として作用します。 結局これは、2つの引数が同じ型であるかどうかを検査するブール関数を定義しています。
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
型であるメソッドに対応します(全合併型 参照)。
このようにディスパッチを使って関数の動作を定義することは、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
ご覧のように、追加する要素の型は、追加されるベクトルの要素の型と一致しなければなりません。でなければ、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<:Number, ::T<:Number) 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
式と同じ構文を使って型を書くことが可能です(全合併型を参照)。 パラメータが1つしかない場合は、中括弧(where {T}
)を省略することができますが、わかりやくするために、好んでよく使われます。 複数のパラメータは、カンマで区切ったり(例where {T, S<:Real}
)、where
をネストしたり(例where S<:Real where T
)して記述します。
メソッドの再定義
メソッドを再定義したり、新しく追加したりする時は、変更がすぐには反映されない、ということを理解しておくことが重要です。 これは、静的にコードを推論・コンパイルし、よくあるJITトリックやオーバーヘッドなしで実行速度が速い、というJuliaの能力の鍵となります。 実際、新しいメソッド定義は、実行時環境からは、タスクおよびスレッド(そして以前に定義された@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
への今後行う呼び出しは、REPLで前回行った newfun
の定義を参照し続けます。これは前回のtryeval
の呼び出しも同じです。
これを自分で試して、どのように動作するかを見てみたいと思うかもしれません。
この挙動の実装は「世界の年齢のカウンター」です。 この単調に増加する値は、各メソッド定義の操作を追跡します。 これにより、「実行時環境から見えるメソッド定義の集合」を、単一の数値「世界の年齢」として記述することができます。 また、年齢を比較するだけで、2つの世界で利用できるメソッドを比較することもできます。 上記の例では、メソッドnewfun()
が存在する「現在の世界」が、tryeval
開始時に設定されたタスクローカルの「実行時の世界」よりも1つ大きいことがわかります。
場合によってはこれを回避する必要があります(たとえば、上記のREPLを実装している場合など)。 幸いにも、簡単な解決策があって、Base.invokelatest
を使って関数を呼び出せば、いいのです。
julia> function tryeval2()
@eval newfun2() = 2
Base.invokelatest(newfun2)
end
tryeval2 (generic function with 1 method)
julia> tryeval2()
2
最後に、このルールが適用されるより複雑な例を、いくつか見てみましょう。最初は1つのメソッドを持つ関数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
いわゆる三角ディスパッチを使います。 T
が例えばeltype(Array{T} where T <: Integer)
のような全合併型
のときには、 Any
が返される点に注意してください。(Base
の中のeltype
の変形版のような働きをします)
別の方法で、Julia v0.6で三角ディスパッチが使えるようになる以前の唯一正しい方法は、
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{T}
のサブタイプに対するメソッドには、型の変換をどのように計算するか記述しなければなりません。 パラメータの違うサブタイプどうしに対する一般的な変換はありません。 (おさらい:どうしてだかわかりますか)
これを達成するには、通常AbstractArray
のサブタイプに2つのメソッドを実装します。 入力の配列を特定の抽象型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)
ディスパッチの反復
様々なレベルのパラメータ引数のリストをディスパッチするには、 それぞれのディスパッチのレベルを別の関数に分離するのが最善策で有ることがよくあります。 これは、単一のディスパッチ方式と似ているように聞こえるかもしれませんが、以下に示すように、より柔軟です。
例えば、配列の要素の型によるディスパッチを行なおうとすると、曖昧な状態に陥ることがよくあります。 この代わりに、まずコンテナの型によるディスパッチを行い、次に再帰的に要素型に対してメソッドを特化させます。 大抵の場合、アルゴリズムはこの階層的な方式を便利に利用するでしょうが、手動で厳密に解決しなければならないこともあるでしょう。 こうしたディスパッチの分岐は、2つの行列の和をおこなう場合などで見られます。
# 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)
トレイトに基づくディスパッチ
上記の反復するディスパッチの自然な拡張として、型の階層が定義された集合とは無関係に型の集合に対するディスパッチを行う層を追加することがあります。 そのような集合を構築するには、問題としている型に対する合併型
を書けばいいですが、 合併型
は生成後に変更できないので、この集合は拡張できません。 しかし、拡張可能な集合は、"Holy-trait"とよくよばれるデザインパターンを使ってプログラム可能です。
このパターンでは、汎化関数の定義を実装します。 この汎化関数は、各引数が所属可能なトレイトの集合ごとに異なるシングルトンの値(または型)を算出するものです。 この関数が純粋である場合、通常のディスパッチと比較してパフォーマンス上全く影響はありません。
前のセクションでは、実装の詳細をごまかしていた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
は、2種類の被演算子最適な共通の型を、の与えられた演算から算出して返します。 これによって、すべての関数に対して、引数の取りうる型の組み合わせすべてに対する実装を行うという問題を、 それぞれの型から共通の型への変換を実装し、組み合わせごとの望ましい昇格規則の表を作るという、より小さな問題に縮小できます。
出力型の算出
トレイトに基づく昇格の議論から次のデザインパターンに移行します。 行列演算に対する出力要素の型の算出です。
足し算のような原始的な演算に対しては、promote_type
関数を使って所望の出力の型を算出します。 (以前に、+
を呼び出した時に起こるpromote
の呼び出しでこの動作を見ました)
もっと行列に対する関数が複雑な場合は、期待する型の戻り値を算出するために、もっと複雑ない一連の操作が必要かもしれません。 これは次のような段階を経てよく実行されます。
1.アルゴリズムの中核で行われる操作の集合を表す小さな関数op
を書く。 2.計算結果の行列の要素の型R
を、promote_op(op, argument_types...)
を使って計算する。 ここでargument_types
はeltype
を入力行列それぞれに適用して算出する。 3.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(b, 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) 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}
indexes
の数が配列の次元の数と一致する場合にのみ呼び出されます。
受け取る引数の型をVararg{T}
に制限したいだけなら、同等なT...
という書き方も可能です。 例えば、f(x::Int...) = x
はf(x::Vararg{Int}) = x
の略記法です。
オプション引数・キーワード引数に関する注記
[関数](@ ref man-functions)で簡単に述べたように、オプション引数は複数のメソッドを定義するための構文として実装されています。 例として、この定義を見てみましょう。
f(a=1,b=2) = a+2b
これは以下の3つのメソッドに変換されます。
f(a,b) = a+2b
f(a) = f(a,2)
f() = f(1,2)
これは、f()
の呼び出しとf(1,2)
の呼び出しは同等なことを意味します。 この場合、計算結果が5
となるのは、f(1,2)
が上記のf
の1番目のメソッドを呼び出すからです。 しかし、必ずこうする必要はなく、整数に特化した4番目のメソッドを定義した場合は、次のようになります。
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 emptyfunc
end
メソッドの設計と曖昧さの回避
Juliaのメソッドの多相性は、最も強力な機能の1つですが、この力を利用した時に、設計上の課題が顕在化する可能性があります。 特に、メソッドの階層がより複雑なときは、曖昧さ が生じることは珍しくありません。
以前、以下のように曖昧さを解決できると指摘しましたが
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
また、1つのメソッドを除くすべてのメソッドが、少なくとも1つの要素がタプルにあることを前提とする定義にするという方法もあります。
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
設計の直交化
ディスパッチに2つ以上の引数を使いたい場合は、「ラッパー」関数を使うと、設計が単純になるかどうかを検討してください。 たとえば、複数のメソッドを記述する代わりに
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
関連する戦略として、昇格
を利用してx
とy
を共通の型に変換する、というのもあります。
f(x::T, y::T) where {T} = ...
f(x, y) = f(promote(x, y)...)
この設計のリスクの1つは、x
とy
を同じ型に変換する適切な昇格メソッドがないために、 2番目のメソッドが再帰的に無限に繰り返し実行され、スタックオーバーフローが発生する可能性です。 公開されていない関数Base.promote_noncircular
を代わりに利用できます。 昇格に失敗した場合でもエラーを投げますが、もっと早く失敗してより具体的なエラーメッセージが表示されます。
一度に1引数のディスパッチ
複数の引数に対してディスパッチを行う必要があっても、メソッドの組み合わせが多すぎて、フォールバックを現実的には定義しきれない場合は、(たとえば)最初の引数に対するディスパッチを行ってから内部のメソッドを呼び出すという「名前のカスケード」の導入を検討してください。
f(x::A, y) = _fA(x, y)
f(x::B, y) = _fB(x, y)
そうすれば、内部メソッドの _fA
と_fB
は、x
に関しては曖昧さを気にせずに、y
に対してディスパッチできます。
この戦略には、少なくとも1つの重要な欠点があることに注意してください。 多くの場合、公開された関数f
に対してさらに特化した定義を行うようなカスタマイズをユーザーがすることはできません。 代わりに、自作の内部メソッド_fA
と_fB
の特化を定義する必要があり、これによって、公開した関数と内部メソッドの境界がぼやけます。
抽象コンテナと要素の型
可能であれば、抽象型コンテナに特定の要素型に対してディスパッチするようなメソッドの定義は避けてください。例えば、
-(A::AbstractArray{T}, b::Date) where {T<:Date}
-(A::MyArrayType{T}, b::T) where {T}
最良の方法は、これらのメソッドの いずれかを 定義することを避けることです。 代わりに、汎化メソッドの-(A::AbstractArray, b)
を使い、このメソッドが各コンテナ型や要素の型ごとに 別々に 汎化呼び出し(similar
や -
など)を適切に行うような実装であることを確認します。 これはメソッドを[直交化](@ ref man-methods-orthogonalize)するという助言のちょっと複雑な変形版です。
この手法が不可能な場合、曖昧さを解決する議論を他の開発者と始めることは、十分価値があります。 始めにメソッドが1つ定義されていても、必ずしもそのメソッドを変更や削除できないわけではないからです。 最後の手段として、1人の開発者が「救済」メソッドを定義するという手があります。
-(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
これらの2つのメソッドは、一緒になって、常に大きくなる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
のメソッドを直接呼び出すことができます。
Arthur C. Clarke, Profiles of the Future (1961): Clarke's Third Law.