Common Lisp で unwind-protect 等のインデントを減らすことを考える。
背景
こちらの記事を読んだ。
Perl には Scope::Guard
という、スコープを抜けるときに一定の処理が行われることを保証する仕組みがあるらしい。
Common Lisp でそれに相当するものは unwind-protect
なのだけど、どうしてもネストとインデントが深くなってしまう、どうしよう・・という話という風に僕は読んだ。
僕も最近、雑に unwind-protect
がネストしたコードを書き、それを掃除するということをしていたので、個人的にタイムリーな話題だった。
そういうわけで、僕もどうにか整理できないのかなと遊んでいた結果を、適当に書いていきたい。
- 背景
- アプローチ1: unwind-protect の束を並べることだけを考える
- アプローチ2: with-系マクロを使う。
- ネストを扱う nest マクロ
- 変数を導入するマクロのネストを考える
- 適当なまとめ
- 追記 (2018-5-18)
- 追記 (2019-2-27)
アプローチ1: unwind-protect の束を並べることだけを考える
元記事の要件は、以下のようなものと考えられる:
- 確保したリソースを確実に解放する。
- リソースの解放処理は、リソースの確保処理の近所に置く。
- インデントを深くすることなく、たくさん確保と解放処理を書きたい。
これだけなら、もっと直接的に、 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)))))
上記は、以下のように読める:
- file1 を
open
して、戻り値をstream1
にsetf
。 抜けるときに(close stream1)
で閉じる。 - file2 を
open
して、戻り値をstream2
にsetf
。 抜けるときに(close stream2)
で閉じる。 - file3 を
open
して、戻り値をstream3
にsetf
。 抜けるときに(close stream3)
で閉じる。 (loop ...)
の処理をする。
まあとりあえず、ネストを浅くし、資源の確保と解放をまとめて書くということだけは、達成したように見える。
しかし、これを見ると、 open
で得た stream のための変数を let
したり、そこに setf
するくだりがいかにも冗長に見える。
どうせ let
と setf
をするのなら、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
, close
と unwind-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
に限らず、新しい変数を増やそうとするとネストが深くなるので、それによる見た目の長さに困ることもまあまあある。
別の人工的な例として、以下のようなことを考えてみる:
- 所定のURLに http get しにいく。
- http ステータスコードが200以外なら、なにもしない。
- 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-系マクロで何かの資源を確保したり、変数を let
や multiple-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
マクロはお気に入り だそうだ。