y2q_actionman’s ゴミクズチラ裏

内向きのメモ書きを置いてます

Common Lisp で unwind-protect 等のインデントを減らすことを考える。

背景

こちらの記事を読んだ。

windymelt.hatenablog.com

Perl には Scope::Guard という、スコープを抜けるときに一定の処理が行われることを保証する仕組みがあるらしい。 Common Lisp でそれに相当するものは unwind-protect なのだけど、どうしてもネストとインデントが深くなってしまう、どうしよう・・という話という風に僕は読んだ。

僕も最近、雑に unwind-protect がネストしたコードを書き、それを掃除するということをしていたので、個人的にタイムリーな話題だった。 そういうわけで、僕もどうにか整理できないのかなと遊んでいた結果を、適当に書いていきたい。

アプローチ1: unwind-protect の束を並べることだけを考える

元記事の要件は、以下のようなものと考えられる:

  1. 確保したリソースを確実に解放する。
  2. リソースの解放処理は、リソースの確保処理の近所に置く。
  3. インデントを深くすることなく、たくさん確保と解放処理を書きたい。

これだけなら、もっと直接的に、 unwind-protect をたくさん並べるマクロを定義すればよいと思った。

雰囲気はこんな感じ:

(in-many-unwind-protect ((<Aの確保処理> <Aの解放処理>)
                         (<Bの確保処理> <Bの解放処理>)
                         (<Cの確保処理> <Cの解放処理>))
  処理本体)

というわけで、 in-many-unwind-protect という激ダサな名前のマクロを適当に定義してみる:

(defmacro in-many-unwind-protect (windings &body body)
  (if (null windings)
      `(progn ,@body)
      (destructuring-bind (before &rest after) (first windings)
        `(progn ,before
                (unwind-protect
                     (in-many-unwind-protect ,(rest windings) ,@body)
                  ,@after)))))

この in-many-unwind-protect で、ファイルを開いて unwind-protect で確実に閉じるという例を考えて見る。 普通、ファイルは with-open-file で開くけれど、それは一旦忘れて・・3つのファイルを open で開いて combine し、close で閉じるという人工的な例を作った:

(defun combine-3-files-v1 (file1 file2 file3)
  (let (stream1 stream2 stream3)
    (in-many-unwind-protect
        (((setf stream1 (open file1)) (close stream1))
         ((setf stream2 (open file2)) (close stream2))
         ((setf stream3 (open file3)) (close stream3)))
      (loop for l1 = (read-line stream1 nil)
         for l2 = (read-line stream2 nil)
         for l3 = (read-line stream3 nil)
         while (or l1 l2 l3)
         if l1 do (format t "~A~%" l1)
         if l2 do (format t "~A~%" l2)
         if l3 do (format t "~A~%" l3)))))

上記は、以下のように読める:

  1. file1 を open して、戻り値を stream1setf。 抜けるときに (close stream1) で閉じる。
  2. file2 を open して、戻り値を stream2setf。 抜けるときに (close stream2) で閉じる。
  3. file3 を open して、戻り値を stream3setf。 抜けるときに (close stream3) で閉じる。
  4. (loop ...) の処理をする。

まあとりあえず、ネストを浅くし、資源の確保と解放をまとめて書くということだけは、達成したように見える。

しかし、これを見ると、 open で得た stream のための変数を let したり、そこに setf するくだりがいかにも冗長に見える。 どうせ letsetf をするのなら、let も含めた以下のようなマクロを定義したくなってくる:

(let-and-unwind-protect ((<変数A> <変数Aへの確保処理> <変数Aの解放処理>)
             (<変数B> <変数Bへの確保処理> <変数Bの解放処理>)
             (<変数C> <変数Cへの確保処理> <変数Cの解放処理>))
  処理本体)
(defmacro let-and-unwind-protect (bindings &body body)
  (if (null bindings)
      `(progn ,@body)
      (destructuring-bind (var get-form &rest release-forms) (first bindings)
        `(let ((,var ,get-form))
           (unwind-protect
                (let-and-unwind-protect ,(rest bindings) ,@body)
             ,@release-forms)))))

let-and-unwind-protect という名前のかっこ悪さを除けば、上の関数は以下のように書ける:

(defun combine-3-files-v2 (file1 file2 file3)
  (let-and-unwind-protect
   ((stream1 (open file1) (close stream1))
    (stream2 (open file2) (close stream2))
    (stream3 (open file3) (close stream3)))
   (loop for l1 = (read-line stream1 nil)
      for l2 = (read-line stream2 nil)
      for l3 = (read-line stream3 nil)
      while (or l1 l2 l3)
      if l1 do (format t "~A~%" l1)
      if l2 do (format t "~A~%" l2)
      if l3 do (format t "~A~%" l3))))

これなら、まだ見た目がましかもしれない。

アプローチ2: with-系マクロを使う。

上ではファイルを手動で開いていたが、普通は with-open-file で開く。 with-open-file は、上のように open, closeunwind-protect を適切に使ったコードに展開される。

また、一般的な Lisp ライブラリなら、確保したいリソースに合わせ unwind-protect などを適切に使ったコードに展開される with-系マクロがあるのが普通。 unwind-protect を毎回書くということは少ない。

というわけで、上の let-and-unwind-protect で書いたものと同じ挙動を、 with-open-file で書いてみると・・

(defun combine-3-files-v3 (file1 file2 file3)
  (with-open-file (stream1 file1)
    (with-open-file (stream2 file2)
      (with-open-file (stream3 file3)
        (loop for l1 = (read-line stream1 nil)
           for l2 = (read-line stream2 nil)
           for l3 = (read-line stream3 nil)
           while (or l1 l2 l3)
           if l1 do (format t "~A~%" l1)
           if l2 do (format t "~A~%" l2)
           if l3 do (format t "~A~%" l3))))))

このように、どうしてもネストしたコードになってしまう。

この場合のネストを減らすだけなら、ネストした with-open-file に展開される専用のマクロを書けば解決するけれど、そんないかにも潰しがきかないものを定義するのは憚られる。

なので、僕は普段は愚直に with-open-file をネストさせたコードを書くことが多い。

ネストを扱う nest マクロ

ここまで考えながら、既存のツール群に何かないのかな? と探していると、 serapeum:nest なんてものが見つかった。

これを使うと、以下のように書けてしまう:

(defun combine-3-files-v4 (file1 file2 file3)
  (serapeum:nest
   (with-open-file (stream1 file1))
   (with-open-file (stream2 file2))
   (with-open-file (stream3 file3))
   (loop for l1 = (read-line stream1 nil)
      for l2 = (read-line stream2 nil)
      for l3 = (read-line stream3 nil)
      while (or l1 l2 l3)
      if l1 do (format t "~A~%" l1)
      if l2 do (format t "~A~%" l2)
      if l3 do (format t "~A~%" l3))))

そしてこれは、上の節で書いたような with-open-file をネストしたものに展開される! これなら、既存のwith-系マクロもそのまま使えるので、割と便利かもしれない。

変数を導入するマクロのネストを考える

Common Lisp を書いていると、 with-open-file に限らず、新しい変数を増やそうとするとネストが深くなるので、それによる見た目の長さに困ることもまあまあある。

別の人工的な例として、以下のようなことを考えてみる:

  1. 所定のURLに http get しにいく。
  2. http ステータスコードが200以外なら、なにもしない。
  3. http ステータスコードが200なら、受け取ったhttpレスポンスの body を保存する。

これを、適当に書いてみると、以下のようになる:

(defun get-and-save-example ()
  (multiple-value-bind (body status-code)
      (net.aserve.client:do-http-request "http://example.com")
    (unless (= status-code 200)
      (return-from get-and-save-example nil))
    (with-open-file (out "/tmp/foo.txt" :direction :output
                         :if-does-not-exist :create :if-exists :rename)
      (write-string body out))))

(僕は普段 Allegro Common Lisp を使っており、Allegro Common Lisp 特有の関数が出てくることについてはご容赦いただきたい。)

net.aserve.client:do-http-requestは、戻り値の一つ目に http レスポンスの body, 戻り値の二つ目に http レスポンスのステータスコードを返す。今回はその二つを受け取りたいので、多値を受け取るために multiple-value-bind を使っている。 その後、ステータスコードを調べ、 200 でなければさっさと return する。 そして、 with-open-file でファイルを開き、そこに書き込んでいる。

こういうちょっとした処理でも、変数を増やす度にネストが深くなるので、 write-string に至るころにはネストが3段目になっていることが分かる。 Common Lisp だと、 with-系マクロで何かの資源を確保したり、変数を letmultiple-value-bind する度に、どうしてもネストが深くなってしまうためだろう。 他の言語だと、変数を作るだけでネストが深くなることはあまりないので、そのノリで気ままに変数を作っていると、どんどんネストが深くなっていくことが多い。

さて、一応この例も、 serapeum:nest で均すことはできる:

(defun get-and-save-example-1 ()
  (serapeum:nest
   (multiple-value-bind (body status-code)
       (net.aserve.client:do-http-request "http://example.com")
     (unless (= status-code 200)
       (return-from get-and-save-example nil)))
   (with-open-file (out "/tmp/foo.txt" :direction :output
                        :if-does-not-exist :create :if-exists :rename))
   (write-string body out)))

ネストを浅くすることはできたけれど、置き場所を間違えると大惨事になってしまう。 例えば、 上で multiple-value-bind の中に置いていた unless を外に並べてしまった場合:

(defun get-and-save-example-bad ()
  (serapeum:nest
   (multiple-value-bind (body status-code)
       (do-http-request "http://example.com"))
   (unless (= response-code 200)                        ; ここが外に出ている!
     (return-from get-and-save-example nil))
   (with-open-file (out "/tmp/foo.txt" :direction :output
                        :if-does-not-exist :create :if-exists :rename))
   (write-string body out)))

with-open-file 以降が unless の中に展開されてしまい、決して実行されなくなってしまう。

以下が展開結果:

(MULTIPLE-VALUE-BIND (BODY RESPONSE-CODE)
    (DO-HTTP-REQUEST "http://example.com")
  (UNLESS (= RESPONSE-CODE 200)
    (RETURN-FROM GET-AND-SAVE-EXAMPLE NIL)
    ;; ここから、絶対に実行されない
    (WITH-OPEN-FILE
        (OUT "/tmp/foo.txt" :DIRECTION :OUTPUT :IF-DOES-NOT-EXIST :CREATE
         :IF-EXISTS :RENAME)
      (WRITE-STRING BODY OUT))))

こういう間違いはいつか絶対に踏んでしまうので、まああまり書きたくないなと思ってしまった。

適当なまとめ

色々考えたけれど、結局僕は、適切な unwind-protect を使うwith-系マクロを用意し、それをネストさせるのがいいと思った。

また、ネストを浅くすること自体が目的なら、上で適当に定義した in-many-unwind-protect みたいなのを書いたり、 serapeum:nest を使うなど、専用のマクロを書いたりするのがいいと思った。

追記 (2018-5-18)

reddit コメントも参照のこと

追記 (2019-2-27)

id:privet-kitty 氏にコメントで頂いたように、この nest マクロは uiop にも含まれている。 uiop 作者の fare 氏にとっても、nest マクロはお気に入り だそうだ。