紙箱

覚えたことをため込んでいく

Clojureのprotocol実践

この記事はもともとTumblrに書いていた自分のブログ記事を転載したものです。投稿日時も当時の投稿日時を再現してあります。

自社開発ではClojureを中心に使っていて、ユーザーが増えて欲しい気持ちもあって、Clojureのテキスト的なものを書いていては挫折、などを繰り返してました。キリがないので、テキストのことは気長に構えて、開発中に気がついたことはどんどんブログに書いて行こうと思います。

手始めに、最近やっと「実用的」という意味で使えるようになったかなーと思えた、「プロトコル」について。

Clojureのプロトコルは、JavaでいうところのInterfaceのようなものです。型名称と関数定義だけを束ねたものです。defprotocolによってプロトコルを定義することができます。
Javaプログラマ歴が長いので「まあInterfaceなら余裕だな」と構えていましたが、意外と使い方が難しい。以下、Javaプログラマ的感覚でプロトコルを使うと使いにくくなりかねない点について書いてみます。

「標準実装」という概念はない

Javaの場合、すべてのプログラムはなんらかのクラスの中に書きますし、さらにクラスの継承関係、特に実装継承というものが当たり前に存在します。もう、空気のようなもんですからInterfaceを用意する場合も、そのInterfaceの標準実装を、クラスとして提供することを前提にしていたりします。DefaultXXXとかAbstractXXXとかそんなのですね。だから、次のようなInterfaceを定義しがちです。

public interface IComponent {
    boolean isVisible();
    boolean isEnabled();
    List<Node> makeNodes(Session session);
    List<String> getDependentCssFiles();
    List<String> getDependentJavaScriptFiles();
}

isVisibleとisEnabledはほとんどのケースでは常に真であり、getDependentCssFilesとgetDependentJavaScriptFilesとは、デフォルトでは空のリストを返す、なんてことを空気のように想定していたりします。「デフォルトでは」と。

「デフォルト」がない!

これは実際に私がやらかしたことです。
前提として、私はClojureのような関数型言語であっても、データとその操作をする関数を束ねておく、という考え方はコードの書き方として重要だと思っています。「データとその操作」というコード整理の仕方はオブジェクト指向が普及するよりも前にすでに実践されていたわけだし(クラシックMacのAPIなんかは勉強になります)、なにより、どの関数が何をやっているのかが分かりやすい。ClojureはJavaと比べるとクラスに縛られないので、もう少し緩くコードをかけますが(クラスに結びつけられないものを無理矢理クラスにする、ということはせずに済む)、大枠としてはそういう構造を守りたいな、と思ってました。

ところが、人間は弱いもので、安直な書き方ばかりしてきてしまい、実際のコードとなると、各関数が操作するデータはまちまち、ひとつひとつ思い出さないとコードが読めない、という事態になってしまいました。
これはやばいということで、本気でちゃんと整理しようと思ったのが発端です。

Clojureにはデータ構造と操作を束ねる機構として、関数の集合を作るdefprotocol、データの集合とプロトコルを結びつけるdeftypeとdefrecordというマクロが用意されていて、これらを使えば、Cのころのように、頭を振り絞ってライブラリ構造を整理することもなく、すんなりと似たような構造を実現できそうです。それらのデータタイプが実装している関数群を示すのが、プロトコルです。

プロトコルは別にrecordとtypeに限定されたものではなく、Clojure(とJava)のあらゆるデータ構造は、プロトコルを「実装」できますが、ここでは関係ない話題なので、興味ある人はextend-protocolとか調べればいいかと。

私はWeb向けのアプリケーションを書いているので、まさに上記のようなComponentの概念が必要になりまして、次のような感じのプロトコルを定義したのです。このプロトコルを、ダイアログとか、画面上の部品なんかで使えばいいだろう、という感覚です。Java脳です。

(defprotocol Component
  (css-files [this])
  (js-files [this])
  (visible? [this])
  (enabled? [this])
  (make-nodes [this session]))

ところが、うまく行きませんでした。前にも書いたように、css-files, js-files, visible?, enabled? には想定しているデフォルト動作があって、ほとんどのケースではそのデフォルト動作で済むのです。しかし、ClojureはMLなんかをみても、実装と型は明確に分離されているべき、実装は継承すべきではない、という考え方が強い言語でして、このプロトコルに「標準実装」を提供する方法がありません。毎回、同じような実装を書かないといけない。

(defrecord LoginDialog []
  Component
  (visible? [this] true)
  (enabled? [this] true)
  (css-files [this] nil)
  (js-files [this] nil)
  (make-nodes [this session] (create-login-dialog-nodes session))

この例であれば、visible?からjs-filesの4行はほとんどのケースで同じになります。毎回同じです。

実際には、「Clojure Programming」なんかを読むと「extend使って、実装を、キーが関数名、値が関数であるようなマップで提供すれば、『デフォルト実装関数マップ』を用意しておくだけで、なんと!merge関数一発でデフォルト実装を『継承』できてしまう!すごい」って誇らしげに書いてるのですが、正直コードがやぼったくなるし遅くもなるので、あんまりやりたくない。

プロトコルは小さくする

でいろいろどうすればプロトコルをいい感じに使えるのかなーと、いろんなClojureライブラリを読んでいたのですが、ふと、どのプロトコルもとても小さいことに気がつきました。プロトコルの関数は2個とか1個だったりするケースが多い。

ああ、そうか。プロトコルは小さくていいんだ。と当たり前のことに気がつきました。

小さなプロトコルをたくさん定義して、そのプロトコルについて、デフォルトとして想定している動作でない動作を提供したい時のみ、プロトコルを実装すればいい。あるデータ型がプロトコルを実装してないなら、デフォルトの動作をすればいい。

というわけで、次のような細かいプロトコルに分割しました。

(defprotocol Visible (visible? [this]))
(defprotocol Enabled (enabled? [this]))
(defprotocol CssFileProvider (css-files [this]))
(defprotocol JavaScriptFileProvider (js-files [this]))
(defprotocol NodeProvider (make-nodes [this session]))

常に実装すべき関数はmake-nodesだけなので、それについてだけは、汎用関数を用意しておきます。

(defn make-base-nodes 
  [c session]
  (when (instance? Visible c)
      (when-not (visible? c) [])
  (when (instance? Enabled c)
       ...
  (when ...
  (when ...

みたいな感じで、ここで「もしプロトコルAを実装してれば…」みたいな判定を行い、実装してなければデフォルトの動作をすればよい。

こうすれば、実際に型定義をするときは、必要なプロトコルだけを実装すればいい。

(defrecord LoginDialog []
  NodeProvider
  (make-nodes [this session] (create-login-dialog-nodes session)))

シンプル。
同じ方式で、プログラムの大部分を改修したら、かなりプログラムの見通しがよくなりました。

まとめ

実装継承のような仕組みがない世界では、大きなプロトコルは避けないと、同じコードを毎回書くことになってしまいます。プロトコルは小さくまとめて、必要な時だけ実装するのが良い、というのは、ちょっとした発想の転換でした。

追記

コメントで、マルチメソッドを使えばいいって話が出ました。たしかに、マルチメソッドはデフォルト実装も持てますし、形象関係の定義すらできるので、よさそうですね。どうも私はマルチメソッドは「便利なcond」としてしか使ってないみたい…

プロトコルには、「関数を束ねて管理する」という利点があると思いますが、今回紹介したケースでは、すべてを関数ひとつだけのプロトコルに分割してしまいましたから、マルチメソッドでやればいいように感じます。

マルチメソッドは仕組み上ちょっと遅いようですが、そこまで速度が要求されるところでもないですし。