Friday, June 29, 2012

実践(?)Clojure「マルチメソッド」

こんにちはこんにちは!!

清水です。今回は入門Clojureじゃなくて、Clojureのちょっと実践的な機能を紹介します。飽きたわけじゃないんだからねっ!

ClojureはJVMで動いているので、Javaのクラスが使えます。実際、ClojureのデータはJavaのオブジェクトになっています。Javaではクラスを継承したりインタフェースを実装して、メソッドをオーバーライドすることにより、値によって異なる処理を実装します。

Clojureで値によって異なる処理を実装したい場合、同じようにクラスを作ることもできますが、その他にも「プロトコル」や「マルチメソッド」等いくつかの手段が用意されています。今回はその「マルチメソッド」を紹介します。

マルチメソッドとは

マルチメソッドは複数の関数をまとめたような特殊な関数で、引数の型などによって実行時に処理を変えることができます。また、後から簡単に処理を追加することもできます。

Javaのオーバーロードに似ていますが、Javaの場合はコンパイル時の型によって呼び出されるメソッドが決まるのに対して、マルチメソッドは実行時に実際の値によって呼び出されるメソッドが決まります。この特性はどちらかというと通常のメソッドに近いものですが、マルチメソッドは複数の引数を基にメソッドを切り替えることができ、さらにClojureのマルチメソッドは型以外の情報を使うことができます。(パターンマッチにも似ているかも)

Clojureの他に、Common Lispでもマルチメソッドが採用されています。Common Lispの場合は型(か値そのもの)でしかディスパッチできませんが、その分高速な気がします。あとだいぶ前にRubyで実装してみたりしました。Pythonにもライブラリがあるみたいです。

使ってみる

とりあえずどういうものか使ってみましょう。

(defmulti format-item (fn [item] (:type item)))

(defmethod format-item :book [book]
  (str (:title book) "/" (:author book)))

(defmethod format-item :shoe [shoe]
  (str (:manufacturer shoe) "/" (:name shoe) "(" (:color shoe) ")"))

上記のコードはformat-itemというマルチメソッドを定義しています。商品の情報をひとつのマップとして受け取り、種別(:type)に合わせた文字列を構築して返すようになっています。マルチメソッドは通常の関数と同じように呼び出すことができます。

(format-item {:type :book :title "Land of LISP" :author "Conrad, M.D. Barski"})
; => "Land of LISP/Conrad, M.D.Barski"

(format-item {:type :shoe :manufacturer "Converse" :name "ALL STAR MULTI-PIPES OX" :color "Black"})
; => "Converse/ALL STAR MULTI_PIPES OX (Black)"

このような結果になります。

defmultiはマルチメソッドを定義するマクロです。以下のような形式になっています。

(defmulti 関数名 ディスパッチ関数)

「関数名」はdefnと同じで定義する関数の名前です。

「ディスパッチ関数」は上記では

(fn [item] (:type item))

です。

これは呼び出すメソッドを決める際に、引数のどの情報を使うかを決める関数です。format-itemはマップをひとつ受け取ることを想定しているので、その中の:typeのキーに対応する値によって呼び出されるメソッドが決まるということになります。

defmethodはdefmultiで定義したマルチメソッドに対してメソッドを追加するマクロです。

(defmethod マルチメソッド ディスパッチ値 [引数] 本体)

ひとつ目の引数として、処理を追加するマルチメソッドを指定します。

「ディスパッチ値」はdefmultiのディスパッチ関数の戻り値に対応する値を指定します。ディスパッチ関数の戻り値がこの値と一致している場合にこのメソッドが呼び出されます。

defmethodの引数はディスパッチ関数の引数と対応している必要があります。「本体」は通常の関数と同じように書きます。

先ほどのformat-itemのディスパッチ関数は受け取ったマップの:typeの値を返すので、

{:type :book ...}

というマップを渡した場合には

(defmethod format-item :book ...)

で定義したものが呼び出されます。

ちなみにキーワードは関数として呼び出すことができるので、先ほどのdefmultiは以下のように書くこともできます。

(defmulti format-item :type)


複数の引数によるディスパッチ

ディスパッチ関数は複数の引数を受け取ることができます。これによりいくつかの値の組み合わせによって柔軟に処理を切り替えることができたりするかもしれません。

(defmulti calc-attr (fn [attack-attr target-attr damage] [attack-attr target-attr]))

(defmethod calc-attr [:water :fire] [attack-attr target-attr damage]
  (* 2 damage))

(calc-attr :water :fire 100) ; => 200

RPGとかでよくある属性によるダメージ補正をするマルチメソッドです。みっつの引数attack-attr(攻撃属性)、target-attr(対象の属性)、damage(基本ダメージ)を受け取って、補正後のダメージを返します。

ディスパッチ関数はattack-attrとtarget-attrのふたつを要素に持つベクターを返します。defmethodのディスパッチ値も同じようなベクターにすることで、ふたつの値によってメソッドを変えることができます。

defmethodで定義したメソッドはディスパッチ値として

[:water :fire]

というベクターを持ちます。これにより、attack-attrが:water、target-attrが:fireで呼び出された場合にdamageが2倍になります。

こういう場合は属性テーブルとか作った方がわかりやすいかもしれません。複数の引数を使ったディスパッチ関数は個人的にあまり使わないため、良い例が浮かびませんでした。関数を返す関数を作ったり、関数を値に持つマップを作ったりした方が柔軟で分かりやすくなるような気がします。

デフォルトメソッド

先ほどのcalc-attrをどのメソッドにも該当しないように呼び出した場合

(calc-attr :normal :ice 60)
java.lang.IllegalArgumentException: No method in multimethod 'calc-attr' for dispatch value: [:normal :ice]

このようなエラーが発生します。ディスパッチ関数が[:normal :ice]を返したけど、それにマッチするメソッドがないというエラーです。

どれにもマッチしない場合に呼び出されるメソッドを作りたいことは頻繁にあります。そういう場合にはdefmethodのディスパッチ値に:defaultというキーワードを指定します。

(defmethod calc-attr :default [attack-attr target-attr damage]
  damage)

こうしておけば、該当するメソッドがない場合にこのメソッドが呼ばれるようになります。

(calc-attr :normal :ice 60) ; => 60

もし、ディスパッチ値として:defaultを使いたい場合は、defmultiのオプションでデフォルトメソッドのディスパッチ値を変えることができます。

(defmulti xyzzy (fn [x] (:type x))
  :default nil)

こうすると、ディスパッチ値としてnilを指定したメソッドがデフォルトメソッドになります。nilでもディスパッチしたいときには名前空間付きのキーワードを使ったり、一時的なオブジェクトを使ったりもできます。

継承みたいなもの

最初の例のformat-itemのようなマルチメソッドを作ったときに、comicのようなタイプをbookとして扱いたいことがあると思います。そういうときにはderiveという関数を使い、親子関係を構築します。

(defmulti format-item (fn [item] (:type item)))

(defmethod format-item ::book [book]
  (str (:title book) "/" (:author book)))

(derive ::comic ::book)

(format-item {:type ::comic :title "Kodoku no gurume" :author "Masayuki Qusumi and Jiro Taniguchi"})
; => "Kodoku no gurume/Masayuki Qusumi and Jiro Taniguchi"

deriveに子となるキーワードと親となるキーワードを指定すると、メソッドの決定時に自動的に子を親として扱ってくれます。deriveに渡すキーワードは名前空間を持つ必要があります。

子にはキーワードの他に、名前空間付きのシンボルと、Classオブジェクトを指定することができます。親には名前空間付きのキーワードとシンボルしか指定できません。

特定のマルチメソッドでのみの親子関係を使いたいときには、make-hierarchyという関数で階層オブジェクトを作り、そのRefをdefmultiに渡します。

(def hierarchy (ref (make-hierarchy)))

(defmulti xyzzy (fn [item] (:type item))
  :hierarchy hierarchy)

(dosync (alter hierarchy derive ::comic ::book))

階層に親子関係を追加するときにはderiveに引数として渡します。この場合のderiveは親子関係を追加した新しい階層オブジェクトを返します。それを再度Refの値にしなければマルチメソッドに反映されません。そのため上記のコードではdosyncとalterを使ってRefを更新しています。


マルチメソッドの説明は大体以上です。マルチメソッドだけでも便利ですが、メタデータと組み合わせたり、マクロの展開時にマルチメソッドを呼び出したりすると面白いことができそうな気がします。

No comments:

Post a Comment