重箱の隅的Clojureのイケてないところ

この記事はClojure Advent Calendar 2011 4日目の記事です。


Clojureには,シーケンスや組み込みのイミュータブルなコレクション型,STMなどエレガントだといわれる様々な特徴がある。一方で,ときにプログラマを悩ませるような厄介な挙動や不自由さを感じる制約も結構あったりする。

今回は,僕がClojureを使い始めてから2年ちょっとの間に遭遇した,そういったClojureの"イケてない"部分のうち,現在の最新バージョン(v1.3.0)でも見られるものについてまとめてみた。

ネストしたloopをうまく扱えない

ClojureJVM上で実行されるため,(今のところ)末尾呼び出しが最適化されない。末尾再帰(ループ)を使いたい場合にはloop - recurを使う。たとえば,階乗を計算する関数factはloopを使って下のように定義できる。

(defn fact [n]
  (loop [n n, ret 1]
    (if (= n 0)
      ret
      (recur (- n 1) (* n ret)))))

関数の最初からループをする場合には,loopを使わずに単にrecurを呼べばいい。

(defn fact [n ret]
  (if (= n 0)
    ret
    (recur (- n 1) (* n ret))))


さて,末尾再帰が最適化されないのは残念だが,百歩譲ってそれを認めたとしても,recurというキーワードを導入したことは間違いだと個人的には思う。それは,ひとつにはloopをネストして使うことができないためだ。

(loop [m m]
  ...
  (loop [n n]
    ...
    (recur ...) ...) ...)

というコードを書くと,内側のループ内で使われているrecurでは内側のループの先頭まで戻ることはできても,外側のループの先頭まで戻ることはできない。これではラベル指定付きcontinue文をもつJavaよりも制御をうまくできない。

これを解決するClojure的なやり方は,おそらくtrampolineを使う方法だろう。

(trampoline
  (letfn [(loop1 [m]
            ...
            (letfn [(loop2 [n]
                      ...
                      #(loop1 ...) ...)]
              (loop2 n)) ...)]
    (loop1 m))

しかし,この方法にはいくつか問題がある。1つは,コードが読みにくくなること。もう1つは,効率が悪くなることだ。Clojureのloopは,Javaでループを書くのと同じくらい効率的なコードにコンパイルされる,Clojureのループ構文中ではもっとも効率のよいループ構文だ。trampolineを使うと,ループのたびにクロージャが生成されるためどうしても効率が落ちる。


この問題を効率を落とさずに解決する方法のひとつとして個人的に提案したいのは,Schemeのnamed letのようにloopごとに名前をつけられるようにし,

(loop recur1 [m m]
  ...
  (loop recur2 [n n]
    ...
    (recur1 ...) ...) ...)

と書けるようにloop構文を変更することだ。そのうえで,loopの名前を省略した場合のデフォルトの名前を"recur"にしておけば今のloopの完全な上位互換となり,効率を落とさずより柔軟な制御を記述できるようになると思う。

(let [x v] ...) != ((fn [x] ...) v)

これは,Clojureがrecurというキーワードを導入したことが間違いだと思うもう1つの理由だ。

なんかを読むと,Schemerはletとlambdaを自然に読み替えるといったことが書かれている。Clojureでも,状況はだいたい同じようなもので,基本的には

(let [x v] ...)

という式は

((fn [x] ...) v)

と等価だと考えられていると思う。

ところが,Clojureではこの2つの式は等価ではない。違いはrecurの扱いにある。上でも書いたとおり,関数中に現れるrecurは関数の最初からのループを表す。ところが,let中のrecurはletの外にあるloopや関数へのループとなる。

この違いは,特にマクロを定義するときに問題になる場合がある。たとえば,

(defmacro with-open [[name expr] & body]
  `(let [~name ~expr]
     (try
       ~@body
       (finally (.close ~name)))))

と定義していたマクロを

(defmacro with-open [[name expr] & body]
  `(letfn [(f# [x#]
             (try
               ~@body
               (finally (.close x#)))]
     (f# ~expr)))

と定義したとする。このとき,(やや人工的な例だが)

(loop [files ["hoge.txt" "fuga.txt"]]
  (if (empty? files)
    'done
    (let [[file & files] files]
      (with-open [w (writer file)]
        (spit w "hihi")
        (recur files)))))

というようにwith-openの本体中でrecurを使うとエラーになる。recurが,with-open内部のローカル関数のループとして展開されてしまうためだ。これは,コードを見ても怪しげな部分がないにも関わらずエラーになるうえ,エラーメッセージを見ても何が原因なのか分からない(例の場合は,「clojure.lang.PersistentVector$ChunkedSeqクラスにcloseというフィールドはない」というエラーになる)ため,非常に厄介なバグになる可能性がある。

まとめ

以上で説明したのは,あくまで僕個人が遭遇したClojureのイケていない部分のうちの一部にすぎないが,特に「コードの見た目から予想される挙動と実際の挙動が異なる」というものが多い。コードの見た目と実際の挙動が異なるのは,見つけにくいバグを埋め込む原因になりやすい。それらの差異が,ぜひ今後小さくなっていくことを期待したい。