Julia Advent calendar 2019の 9日目です。
「一度書いた関数を、別の型にも利用させる」手法である trait based dispatch あるいは Holy trait パターンを軽く解説します。
この記事は、blog記事 The Emergent Features of JuliaLang: Part II - Traitsの翻案です。Julia 公式ドキュメントの本項目 trait-based dispatchよりも説明が分かりやすく、ぜひ紹介したいと思いました。
以下の実行例は、Julia 1.1 による実行結果を示します。
julia>versioninfo()JuliaVersion1.1.1Commit55e36cc(2019-05-1604:10UTC)PlatformInfo:OS:macOS(x86_64-apple-darwin15.6.0)CPU:Intel(R)Core(TM)i5-7267UCPU@3.10GHzWORD_SIZE:64LIBM:libopenlibmLLVM:libLLVM-6.0.1(ORCJIT,skylake)
「拡張可能」ではない関数
引数を一つとり、以下のように動作する関数を定義しましょう。
* 引数がタプルやベクトル(1次元配列)なら、それ自身を返します。
* 引数が数(スカラー)なら、1つの要素のベクトルを作って返します。
次のように、定義できます。
タプル型 Tuple
とベクトル型 AbstractVector
に対する挙動は同じなので、同じメソッド定義(=
の右側)を繰り返し記述します。
julia>aslist1(x::Tuple)=xaslist1(x::AbstractVector)=xaslist1(x::Number)=[x]
関数 methods(a)
を用いてメソッドを一覧しましょう。
julia>methods(aslist1)# 3 methods for generic function "aslist1":[1]aslist1(x::Tuple)inMainatREPL[2]:1[2]aslist1(x::AbstractArray{T,1}whereT)inMainatREPL[3]:1[3]aslist1(x::Number)inMainatREPL[4]:1
では、関数の動作を確かめましょう。
julia>aslist1(1)# スカラー1-elementArray{Int64,1}:1julia>aslist1([1])# ベクトル1-elementArray{Int64,1}:1julia>aslist1([1,2])# ベクトル2-elementArray{Int64,1}:12julia>aslist1((1.0,))# タプル(1.0,)
正しく動作していますね。
さて、文字列 String
に対しても、タプルまたはベクトルと同じように振る舞うようにしましょう。String
に対しても、Tuple
と同じ定義(=
の右側)を繰り返し記述することになります。
julia>aslist1(x::String)=xaslist1(genericfunction with4methods)julia>methods(aslist1)# 4 methods for generic function "aslist1":[1]aslist1(x::String)inMainatREPL[10]:1[2]aslist1(x::Tuple)inMainatREPL[2]:1[3]aslist1(x::AbstractArray{T,1}whereT)inMainatREPL[3]:1[4]aslist1(x::Number)inMainatREPL[4]:1julia>aslist1("abc")"abc"
なお、この例で、タプル引数とベクトル引数のメソッド定義を一つにまとめるためには、それらの共用型(型ユニオン Type Union) を使ってもよいでしょう。
julia>aslist2(x::Number)=[x]aslist2(x::Union{AbstractVector,Tuple})=x# <=julia>aslist2([1])1-elementArray{Int64,1}:1
しかし、別の型の引数に同じ機能を持たせるには、上の String
型の例のように、メソッド定義をやはり繰り返す必要があります。
すなわち、ここまで定義した関数 aslist1
、aslist2
はどちらも、「拡張可能(extensible)」ではありません。
この例では、簡単なメソッド定義を繰り返すのは容易ですが、定義が複雑になると、記述の誤りも起こりうるでしょう。
拡張可能な関数を書く 〜 Holy trait
では、trait パターンを用いて、「拡張可能な」関数を書いてみましょう。
まず、関数の挙動を整理し、それぞれの挙動に対応する型を定義します。トレイツ型 trait type と呼ばれます。
例題では、二通りの挙動があります。リストのような挙動と、そうでない挙動です。それぞれに List
, NoList
という型を与えます。
julia>structListendstructNonlistend
次に、引数の型から、トレイツ型のインスタンスを返す関数を定義します。トレイツ関数 trait function と呼ばれます。
例題のトレイツ関数は、こうなります。
実引数の型のみが必要なので、仮引数の並びは (::T)
と書かれています。
式 T()
は、型 T
のインスタンスを作ります。
julia>islist(::AbstractVector)=List()islist(::Tuple)=List()islist(::Number)=Nonlist()
julia>methods(islist)# 3 methods for generic function "islist":[1]islist(::AbstractArray{T,1}whereT)inMainatREPL[20]:1[2]islist(::Tuple)inMainatREPL[21]:1[3]islist(::Number)inMainatREPL[22]:1
最後に、各々のトレイツ型(のインスタンス)に対して、その挙動を定義します。
ユーザが呼び出す関数は、引数からトレイツ型を得て、そのトレイツ型に対応する挙動を、引数とともに呼び出すように定義します。 2つ目に呼び出すメソッドを trait を用いて決めるので、trait-based dispatch というわけです。
例題は、以下のように整理されました。
ユーザが呼び出すのは、引数1つのメソッドです。その引数をトレイツ関数 islist()
に渡してトレイツ型を得ます。トレイツ型と、元の引数で、引数2つのメソッドを呼出します。引数2つのメソッドが、各トレイツ型に対する挙動を定義します。
julia>aslist(x)=aslist(islist(x),x)aslist(::List,x)=xaslist(::Nonlist,x)=[x]
julia>methods(aslist)# 3 methods for generic function "aslist":[1]aslist(::List,x)inMainatREPL[26]:1[2]aslist(::Nonlist,x)inMainatREPL[27]:1[3]aslist(x)inMainatREPL[25]:1
動作を確認しましょう。
julia># スカラーislist(1)Nonlist()julia>aslist(1)1-elementArray{Int64,1}:1julia># 数の配列islist([1,2])List()julia>aslist([1,2])2-elementArray{Int64,1}:12
どちらも、題意の挙動が得られました。
さて、このようにしておけば、別の型に対応させることも簡単です。その型に対するトレイツ関数を用意するだけです。
上の例のように、String
型を、配列同様に振る舞わせるには、こうします。
julia># 文字列islist(::String)=List()julia>islist("abc")List()julia>aslist("abc")"abc"
ところで、「トレイツ関数」を追加すると、間接コスト(オーバーヘッド)を増やす恐れがあります。
関数 @code_typed
でコンパイル結果を表示させてみましょう。
ベクトルの引数を与えた場合のコンパイル結果は、実に、こうなります!!
julia>@code_typedaslist([1,2])Body::Array{Int64,1}1─returnx
トレイツ関数 islist(x)
は消化されて、なくなってしまいました。引数を、そのまま返します。
数のベクトルの引数を与えた場合のコンパイル結果は、こうなります。
julia>@code_typedaslist(1)Body::Array{Int64,1}1─%1=$(Expr(:foreigncall,:(:jl_alloc_array_1d),Array{Int64,1},svec(Any,Int64),:(:ccall),2,Array{Int64,1},1,1))::Array{Int64,1}│(Base.arraysize)(%1,1)└──goto#3 if not true2─(Base.arrayset)(false,%1,x,1)3┄goto#44─goto#55─return%1
少しわかりにくいですが、配列をメモリ確保して、引数の値を書き込んでいます。つまり、この場合も、トレイツ関数 islist(x)
は消化されて、消えてしまいました。
というわけで、「トレイツ関数」の追加は、間接コストとはならないでしょう。Julia の最適化コンパイラの威力ですね。
Julia の中のトレイツ
「拡張可能な関数」の発端は、StridedArray should be an abstract class, not a union「StridedArray(筆者注:飛び飛びにアクセスされる配列、例えば画像)を拡張可能にするには、共用体にしてはいけない」という issue です。これを指摘した Tim Holy 先生は、上で紹介した実装を提案しています。そこで、この手法は Holy Trait と呼ばれています。
上の例では、引数1つの場合を示しましたが、Julia 公式ドキュメントの項目 trait-based dispatchには、2引数の関数に対する例が紹介されています。
Julia の基本ライブラリで、Holy trait は用いられています。
拙記事([逆引きJulia] 配列要素の参照、色々)で紹介しましたが、多次元配列の要素の添字指定には、2種類の書き方があります。列毎の添字を書くデカルト添字記法 IndexCartesian
と、連番の添字を書く線形添字記法 IndexLinear
です。そこで、(多次元)配列の型を定義した場合には、どちらを使うかを指定することになっています。Base.IndexStyle
がトレイツ関数です。(既定は IndexCartesian
なので、実際に指定すべきは IndexLinear
とする場合のみですが。)
Base.IndexStyle(::Type{<:MyArray})=IndexLinear()
動的なトレイツ関数
上の例では、引数の型からトレイツ型を決めました。
値がリストとして振る舞うか否かを、「その値の型が iterate
メソッドを有するか否か」で判定してもよいでしょう。型 T
がメソッド iterate(::T)
を持つか否かは、 hasmethod(iterate, Tuple{T})
で判定できます。
これを用いて、例題のトレイツ関数は、以下のように書けます。
julia>islistD(T)=hasmethod(iterate,Tuple{T})?List():Nonlist()julia>aslistD(x::T)whereT=aslistD(islistD(T),x)aslistD(::List,x)=xaslist(::Nonlist,x)=[x]
AbstractArray
や String
は iterate
メソッドを持つので、上の例と同様に動作します。
julia> aslistD(1)
1
julia> aslistD([1])
1-element Array{Int64,1}:
1
julia> aslistD("abc")
"abc"
しかし、現行の Juliaでは、上のような「動的な」トレイツ関数は、最適なコンパイル結果を与えません (@code_warntype aslistD([1])
を試してください)。 しかし、hasmethod
を用いたトレイツ関数も静的にコンパイルできるようになると予告されています → WIP: Make hasmethod
able to be used for static dispatch
終わりに
他のオブジェクト言語をご存じの方は、trait が、(C++ などの)多重継承や、Ruby のミックスイン (mix-in)に相当することを見抜いたでしょう。
Julia では、柔軟な型と多重ディスパッチを用いて、特別な構文を用いずに記述できます。しかも、静的トレイツ関数ならオーバーヘッドもありません。もっと気軽に書いてみましょう。
この記事を投稿したのは、12月22日の夜です。
Julia ソースの中に隠れている Holy trait を数えながら、聖なる夜 Holy night を迎えましょう。