Methods
Functionsから思い出してください。関数は、引数のタプルを戻り値にマッピングするオブジェクトであり、適切な値を返せない場合は例外をスローします。同じ概念的な関数や操作が、異なるタイプの引数に対してかなり異なる方法で実装されることは一般的です。2つの整数を加算することは、2つの浮動小数点数を加算することとは非常に異なり、整数と浮動小数点数を加算することとも異なります。これらの実装の違いにもかかわらず、これらの操作はすべて「加算」という一般的な概念に属します。それに応じて、Juliaでは、これらの動作はすべて単一のオブジェクトに属します:+
関数です。
異なる実装をスムーズに使用するためには、関数は一度にすべて定義する必要はなく、特定の引数の型や数の組み合わせに対して特定の動作を提供することで、部分的に定義することができます。関数の可能な動作の定義はメソッドと呼ばれます。これまで、すべての引数の型に適用可能な単一のメソッドで定義された関数の例のみを提示してきました。しかし、メソッド定義のシグネチャには、数に加えて引数の型を示す注釈を付けることができ、単一のメソッド定義だけでなく、複数のメソッド定義を提供することもできます。関数が特定の引数のタプルに適用されると、その引数に適用可能な最も特定的なメソッドが適用されます。したがって、関数の全体的な動作は、そのさまざまなメソッド定義の動作のパッチワークとなります。このパッチワークがうまく設計されていれば、メソッドの実装がかなり異なっていても、関数の外部の動作はシームレスで一貫しているように見えるでしょう。
関数が適用されるときにどのメソッドを実行するかの選択をディスパッチと呼びます。Juliaは、ディスパッチプロセスが与えられた引数の数や、関数のすべての引数の型に基づいて、どの関数のメソッドを呼び出すかを選択できるようにします。これは、ディスパッチが最初の引数のみに基づいて行われる従来のオブジェクト指向言語とは異なります。従来の言語では、最初の引数には特別な引数構文があることが多く、時には明示的に引数として書かれるのではなく、暗黙的に示されることがあります。[1] 関数のすべての引数を使用して、どのメソッドを呼び出すべきかを選択することは、最初の引数だけを使用することとは異なり、multiple dispatchとして知られています。複数のディスパッチは、数学的なコードに特に便利であり、操作が「どの引数に属する」と人工的に決定することはあまり意味がありません。x + y
の加算操作は、x
に属するのか、y
に属するのか、どちらでしょうか?数学的演算子の実装は、一般的にそのすべての引数の型に依存します。しかし、数学的な操作を超えて、複数のディスパッチはプログラムを構造化し整理するための強力で便利なパラダイムとなります。
この章のすべての例は、同じ モジュール内の関数にメソッドを定義していると仮定しています。別の モジュールの関数にメソッドを追加したい場合は、それを import
するか、モジュール名で修飾された名前を使用する必要があります。namespace management のセクションを参照してください。
Defining Methods
これまでの例では、制約のない引数型を持つ単一のメソッドを持つ関数のみを定義してきました。このような関数は、従来の動的型付け言語と同様に動作します。それにもかかわらず、私たちはほとんど意識せずに複数のディスパッチとメソッドを使用してきました。前述の +
関数のように、Juliaの標準関数や演算子はすべて、さまざまな引数の型と数の組み合わせに対する動作を定義する多くのメソッドを持っています。
関数を定義する際、::
型アサーション演算子を使用して、適用可能なパラメータの型をオプションで制約することができます。この演算子は、Composite Types のセクションで導入されました。
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)
The function `f` exists, but no method is defined for this combination of argument types.
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)
The function `f` exists, but no method is defined for this combination of argument types.
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)
The function `f` exists, but no method is defined for this combination of argument types.
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)
The function `f` exists, but no method is defined for this combination of argument types.
ご覧のとおり、引数は正確に型 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
の算術演算に委ねられています。
複数のメソッドを持つ関数を定義するには、異なる数とタイプの引数を持つ関数を複数回定義するだけです。関数の最初のメソッド定義は関数オブジェクトを作成し、その後のメソッド定義は既存の関数オブジェクトに新しいメソッドを追加します。引数の数とタイプに最も特化したメソッド定義が、関数が適用されるときに実行されます。したがって、上記の2つのメソッド定義は、抽象型Number
のすべてのインスタンスのペアに対するf
の動作を定義しますが、Float64
値のペアに特有の異なる動作を持っています。引数の1つが64ビット浮動小数点数であり、もう1つがそうでない場合、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におけるすべての変換は非魔法的で完全に明示的です。Conversion and Promotionは、十分に高度な技術の巧妙な応用が魔法と区別がつかないことを示しています。[^\Clarke61]
非数値の値や、引数が2つ未満または2つ以上の場合、関数 f
は未定義のままであり、それを適用すると依然として MethodError
になります:
julia> f("foo", 3)
ERROR: MethodError: no method matching f(::String, ::Int64)
The function `f` exists, but no method is defined for this combination of argument types.
Closest candidates are:
f(!Matched::Number, ::Number)
@ Main none:1
f(!Matched::Float64, !Matched::Float64)
@ Main none:1
Stacktrace:
[...]
julia> f()
ERROR: MethodError: no method matching f()
The function `f` exists, but no method is defined for this combination of argument types.
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
が2つのメソッドを持つ関数オブジェクトであることを示しています。それらのメソッドのシグネチャを見つけるには、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
には2つのメソッドがあり、1つは2つのFloat64
引数を取り、もう1つは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> 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に実装の詳細から切り離された高レベルのアルゴリズムを抽象的に表現する能力を与えます。
Method specializations
複数の同じ関数のメソッドを作成する際、これを「特化」と呼ぶことがあります。この場合、追加のメソッドを加えることで関数を特化しています:各新しいメソッドは関数の新しい特化です。上記のように、これらの特化は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
を2回コンパイルします。1回目はx::Int, y::Int
用、2回目はx::Float64, y::Float64
用です。2回コンパイルする目的はパフォーマンスです。mysum
が使用する+
の呼び出されるメソッドは、x
とy
の具体的な型によって異なり、異なる特殊化をコンパイルすることで、Juliaは事前にすべてのメソッド検索を行うことができます。これにより、プログラムは実行中にメソッド検索を行う必要がなくなり、はるかに迅速に実行されます。Juliaの自動特殊化により、汎用アルゴリズムを書くことができ、コンパイラが必要な各ケースを処理するための効率的で特殊化されたコードを生成することを期待できます。
潜在的な専門分野の数が事実上無限である場合、Juliaはこのデフォルトの専門化を回避することがあります。詳細については Be aware of when Julia avoids specializing を参照してください。
Method Ambiguities
特定の引数の組み合わせに対して、適用可能な最も特定的なメソッドが一意に定義されないような関数メソッドのセットを定義することは可能です:
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
まず、曖昧さを解消する方法を定義することが推奨されます。そうしないと、より具体的な方法が定義されるまで、曖昧さが一時的に存在するからです。
より複雑なケースでは、メソッドの曖昧さを解決することには一定のデザイン要素が関与します。このトピックはさらに探求されています below。
Parametric Methods
メソッド定義は、署名を修飾する型パラメータをオプションで持つことができます:
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)
最初のメソッドは、両方の引数が同じ具体的な型である場合に適用され、型が何であれ関係ありません。一方、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
型の型シグネチャを持つメソッドに対応します(UnionAll Types を参照)。
このようなディスパッチによる関数の振る舞いの定義は、Juliaでは非常に一般的で、慣用的です。メソッドの型パラメータは、引数の型として使用されることに制限されず、関数のシグネチャや関数の本体で値が必要な場所であればどこでも使用できます。以下は、メソッドの型パラメータ T
がメソッドシグネチャのパラメトリック型 Vector{T}
の型パラメータとして使用されている例です:
julia> function myappend(v::Vector{T}, x::T) where {T}
return [v..., x]
end
myappend (generic function with 1 method)
型パラメータ T
は、この例において追加される要素 x
がベクター v
の既存の要素型のサブタイプであることを保証します。where
キーワードは、メソッドシグネチャの定義の後にこれらの制約のリストを導入します。これは、上記のように1行の定義でも同様に機能し、もし存在する場合は、以下に示すように return type declaration の 前 に現れなければなりません。
julia> (myappend(v::Vector{T}, x::T)::Vector) 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)
The function `myappend` exists, but no method is defined for this combination of argument types.
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)
The function `myappend` exists, but no method is defined for this combination of argument types.
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
型宣言において型パラメータにサブタイプ制約を付けることができるのと同様に(Parametric Typesを参照)、メソッドの型パラメータにも制約を付けることができます:
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)
The function `same_type_numeric` exists, but no method is defined for this combination of argument types.
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)
The function `same_type_numeric` exists, but no method is defined for this combination of argument types.
julia> same_type_numeric(Int32(1), Int64(2))
false
same_type_numeric
関数は、上で定義された same_type
関数と非常に似た動作をしますが、数のペアに対してのみ定義されています。
パラメトリックメソッドは、型を書くために使用される where
式と同じ構文を許可します(UnionAll Types を参照)。パラメータが1つだけの場合、囲む波括弧(where {T}
の中の)が省略できますが、明確さのために好まれることが多いです。複数のパラメータはカンマで区切ることができ、例えば where {T, S<:Real}
のように書くことができます。また、ネストされた where
を使用して書くこともでき、例えば where S<:Real where T
のようになります。
Redefining Methods
メソッドを再定義したり新しいメソッドを追加したりする際には、これらの変更がすぐには反映されないことを理解することが重要です。これは、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
(括弧なし)と書くことができます。しかし、あなたも、あなたの呼び出し元も、また彼らが呼び出す関数も、この新しいメソッド定義を呼び出すことはできません!
しかし、例外があります:newfun
への将来の呼び出しはREPLから期待通りに動作し、新しい定義のnewfun
を見て呼び出すことができます。
しかし、将来のtryeval
への呼び出しは、newfun
の定義をREPLの前のステートメントでの状態として見ることになり、そのため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
最後に、このルールが適用されるもう少し複雑な例を見てみましょう。関数 f(x)
を定義します。これは最初に1つのメソッドを持っています:
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"
Design Patterns with Parametric Methods
複雑なディスパッチロジックは、パフォーマンスや使いやすさのために必ずしも必要ではありませんが、時には特定のアルゴリズムを表現する最良の方法となることがあります。以下は、このようにディスパッチを使用する際に時折見られる一般的なデザインパターンのいくつかです。
Extracting the type parameter from a super-type
ここに、要素型 T
を返すための正しいコードテンプレートがあります。これは、明確に定義された要素型を持つ任意の AbstractArray
のサブタイプに適用できます。
abstract type AbstractArray{T, N} end
eltype(::Type{<:AbstractArray{T}}) where {T} = T
いわゆる三角ディスパッチを使用しています。例えば、eltype(AbstractArray{T} where T <: Integer)
のようなUnionAll
型は、上記のメソッドには一致しません。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
Building a similar type with a different type parameter
汎用コードを構築する際には、型のレイアウトに変更を加えた類似のオブジェクトを構築する必要があることがよくあります。これには型パラメータの変更も必要です。たとえば、任意の要素型を持つ抽象配列があり、特定の要素型でその計算を行いたい場合があります。この型変換をどのように計算するかを説明するメソッドを各 AbstractArray{T}
サブタイプに実装する必要があります。異なるパラメータを持つ別のサブタイプへの一般的な変換は存在しません。
AbstractArray
のサブタイプは、通常、これを達成するために2つのメソッドを実装します。1つは、入力配列を特定のAbstractArray{T, N}
抽象型のサブタイプに変換するメソッドであり、もう1つは、特定の要素型を持つ新しい未初期化配列を作成するメソッドです。これらのサンプル実装は、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)
Iterated dispatch
マルチレベルのパラメトリック引数リストをディスパッチするためには、各ディスパッチレベルを異なる関数に分けるのが最適な場合が多いです。これはシングルディスパッチのアプローチに似ているように聞こえるかもしれませんが、以下で見ていくように、依然としてより柔軟です。
例えば、配列の要素タイプに基づいてディスパッチしようとすると、しばしば曖昧な状況に直面します。その代わりに、一般的には最初にコンテナタイプに基づいてディスパッチし、次にeltypeに基づいてより具体的なメソッドに再帰します。ほとんどの場合、アルゴリズムはこの階層的アプローチに便利に適合しますが、他の場合にはこの厳密さを手動で解決する必要があります。このディスパッチの分岐は、例えば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)
Trait-based dispatch
自然な拡張として、上記の反復ディスパッチにメソッド選択のレイヤーを追加し、型階層によって定義されたセットとは独立した型のセットに基づいてディスパッチを行うことができます。このようなセットは、問題の型の Union
を書き出すことで構築できますが、その場合、このセットは拡張可能ではなく、Union
型は作成後に変更できないためです。しかし、そのような拡張可能なセットは、しばしば "Holy-trait" と呼ばれるデザインパターンを使用してプログラムすることができます。
このパターンは、関数引数が属する可能性のある各トレイトセットに対して異なるシングルトン値(または型)を計算する汎用関数を定義することによって実装されます。この関数が純粋であれば、通常のディスパッチと比較してパフォーマンスに影響はありません。
前のセクションの例では、map
と promote
の実装の詳細が省略されており、これらはこれらの特性に基づいて動作します。行列を反復処理する際、例えば map
の実装において、データをトラバースするためにどの順序を使用するかは重要な質問です。AbstractArray
のサブタイプが Base.IndexStyle
特性を実装すると、map
のような他の関数はこの情報に基づいて最適なアルゴリズムを選択することができます(Abstract Array Interface を参照)。これにより、各サブタイプは 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つの型に基づいて操作を計算するための最適な共通型を返します。これにより、すべての可能な型引数のペアに対してすべての関数を実装するという問題を、各型から共通型への変換操作を実装するというはるかに小さな問題と、好ましいペアワイズ昇格ルールのテーブルに還元することが可能になります。
Output-type computation
特性に基づく昇進の議論は、次のデザインパターンへの移行を提供します:行列演算の出力要素タイプを計算すること。
原始操作を実装するために、加算などの操作には、promote_type
関数を使用して、望ましい出力タイプを計算します。(以前と同様に、+
への呼び出しの中で promote
呼び出しでこれを見ました)。
より複雑な行列の関数に対しては、より複雑な操作のシーケンスに対する期待される戻り値の型を計算する必要がある場合があります。これは通常、以下のステップによって実行されます:
- Write a small function
op
that expresses the set of operations performed by the kernel of the algorithm. - 結果行列の要素型
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
Separate convert and kernel logic
コンパイル時間とテストの複雑さを大幅に削減する方法の一つは、希望する型への変換と計算のロジックを分離することです。これにより、コンパイラは大きなカーネルの本体とは独立して変換ロジックを特化させ、インライン化することができます。
これは、アルゴリズムによって実際にサポートされている特定の引数型に、より大きな型のクラスから変換する際に見られる一般的なパターンです。
complexfunction(arg::Int) = ...
complexfunction(arg::Any) = complexfunction(convert(Int, arg))
matmul(a::T, b::T) = ...
matmul(a, b) = matmul(promote(a, b)...)
Parametrically-constrained Varargs methods
関数のパラメータは、「varargs」関数(Varargs Functions)に供給される引数の数を制約するためにも使用できます。このような制約を示すために、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)
The function `bar` exists, but no method is defined for this combination of argument types.
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)
The function `bar` exists, but no method is defined for this combination of argument types.
Closest candidates are:
bar(::Any, ::Any, ::Any, ::Any)
@ Main none:1
Stacktrace:
[...]
より有用なことに、パラメータによって varargs メソッドを制約することが可能です。例えば:
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
の省略形です。
Note on Optional and keyword Arguments
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
の最初のメソッドを呼び出すからです。しかし、これは常にそうである必要はありません。整数に特化した第四のメソッドを定義した場合:
f(a::Int,b::Int) = a-2b
そのため、f()
と f(1,2)
の両方の結果は -3
です。言い換えれば、オプション引数は特定のメソッドではなく、関数に結び付けられています。どのメソッドが呼び出されるかは、オプション引数の型によって決まります。オプション引数がグローバル変数に基づいて定義されている場合、オプション引数の型は実行時に変更されることさえあります。
キーワード引数は、通常の位置引数とはかなり異なる動作をします。特に、メソッドのディスパッチには参加しません。メソッドは位置引数のみに基づいてディスパッチされ、キーワード引数は一致するメソッドが特定された後に処理されます。
Function-like objects
メソッドは型に関連付けられているため、型にメソッドを追加することで任意の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における型コンストラクタとクロージャ(周囲の環境を参照する内部関数)がどのように機能するかの鍵でもあります。
Empty generic functions
時折、メソッドを追加せずに汎用関数を導入することが有用です。これは、インターフェース定義を実装から分離するために使用できます。また、ドキュメントやコードの可読性のために行うこともあります。これに対する構文は、引数のタプルなしの空の function
ブロックです:
function emptyfunc end
Method design and the avoidance of ambiguities
ジュリアのメソッドの多態性は、その最も強力な機能の一つですが、この力を活用することは設計上の課題を引き起こすことがあります。特に、より複雑なメソッド階層では、ambiguities が発生することは珍しくありません。
上記では、曖昧さを解消する方法が指摘されました。
f(x, y::Int) = 1
f(x::Int, y) = 2
メソッドを定義することによって
f(x::Int, y::Int) = 3
これはしばしば正しい戦略ですが、このアドバイスを無思考で従うことが逆効果になる場合もあります。特に、一般的な関数が持つメソッドが多ければ多いほど、あいまいさの可能性が増えます。この単純な例よりもメソッドの階層が複雑になると、代替戦略について慎重に考える価値があるかもしれません。
以下では、特定の課題とそれらの問題を解決するためのいくつかの代替方法について議論します。
Tuple and NTuple arguments
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
Orthogonalize your design
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
に変換します。これは、より一般的な原則 orthogonal design の非常に特定の例であり、別々の概念が別々のメソッドに割り当てられます。ここで、g
はおそらくフォールバック定義が必要になります。
g(x::A) = x
関連する戦略は、promote
を利用してx
とy
を共通の型に持ってくることです:
f(x::T, y::T) where {T} = ...
f(x, y) = f(promote(x, y)...)
このデザインのリスクの一つは、x
と y
を同じ型に変換する適切なプロモーション方法がない場合、2 番目のメソッドが自分自身を無限に再帰し、スタックオーバーフローを引き起こす可能性があることです。
Dispatch on one argument at a time
複数の引数でディスパッチする必要があり、あまりにも多くの組み合わせがあるため、すべての可能なバリアントを定義するのが実用的でない場合は、「名前のカスケード」を導入することを検討してください。たとえば、最初の引数でディスパッチし、その後内部メソッドを呼び出すという方法です。
f(x::A, y) = _fA(x, y)
f(x::B, y) = _fB(x, y)
その後、内部メソッド _fA
と _fB
は、x
に関する互いの曖昧さを気にせずに y
に基づいてディスパッチできます。
この戦略には少なくとも1つの大きな欠点があることに注意してください。多くの場合、ユーザーはエクスポートされた関数 f
の動作をさらにカスタマイズするために、追加の特化を定義することができません。代わりに、ユーザーは内部メソッド _fA
と _fB
の特化を定義しなければならず、これによりエクスポートされたメソッドと内部メソッドの境界が曖昧になります。
Abstract containers and element types
可能な限り、抽象コンテナの特定の要素タイプに基づいてディスパッチするメソッドの定義を避けるようにしてください。例えば、
-(A::AbstractArray{T}, b::Date) where {T<:Date}
メソッドを定義する人にとってあいまいさを生み出します。
-(A::MyArrayType{T}, b::T) where {T}
最良いアプローチは、これらのメソッドのいずれも定義しないことです。代わりに、汎用メソッド -(A::AbstractArray, b)
に依存し、このメソッドが各コンテナタイプと要素タイプに対して別々に正しい動作をするように、汎用呼び出し(similar
や -
など)で実装されていることを確認してください。これは、あなたのメソッドを orthogonalize するというアドバイスのより複雑なバリアントに過ぎません。
このアプローチが不可能な場合、他の開発者と曖昧さを解決するための議論を始める価値があるかもしれません。最初に定義されたメソッドがあるからといって、それを修正したり排除したりできないわけではありません。最終手段として、1人の開発者が「バンドエイド」メソッドを定義することができます。
-(A::MyArrayType{T}, b::Date) where {T<:Date} = ...
それは力任せで曖昧さを解消します。
Complex method "cascades" with default arguments
デフォルトを提供するメソッド「cascade」を定義する場合、潜在的なデフォルトに対応する引数を省略しないように注意してください。たとえば、デジタルフィルタリングアルゴリズムを作成していて、信号のエッジを処理するためにパディングを適用するメソッドがあるとします:
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
は、他の種類のパディングと同じ引数の位置に供給されるため、ディスパッチ階層を適切に整理し、あいまいさの可能性を減らします。さらに、"public" myfilter
インターフェースを拡張します。パディングを明示的に制御したいユーザーは、NoPad
バリアントを直接呼び出すことができます。
Defining methods in local scope
local scope 内でメソッドを定義できます。例えば、
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
- 1In C++ or Java, for example, in a method call like
obj.meth(arg1,arg2)
, the object obj "receives" the method call and is implicitly passed to the method via thethis
keyword, rather than as an explicit method argument. When the currentthis
object is the receiver of a method call, it can be omitted altogether, writing justmeth(arg1,arg2)
, withthis
implied as the receiving object. - Clarke61Arthur C. Clarke, Profiles of the Future (1961): Clarke's Third Law.