Lambdaカクテル

Common Lispと自宅サーバにWebエンジニアリングの香りを載せて

PerlのScope::GuardをCommon Lispで実装するぞ!!!

tl;dr: 実装したけど限定的にしかうまく動かなかった。PerlのScope::Guardについて既に良く知っている人は、最後の節まで飛ばしてもよい。

Server::Starter読んでた

サーバプロセスを起動するツールであるServer::Starterの内部がどうなっているのか知りたくなってきたので、コードリーディングをやることにした。プロセスを扱うツールなだけあって、システムコールやシグナルを活用する本場のUNIXプログラミングを目の当たりにし、普段は見ない語彙がたくさん出てきてたじろいでしまった。たじろぎつつもコードを読み進めていると、ちょっと不思議なコードを見付けた。それは[Guard](https://github.com/kazuho/p5-Server-Starter/blob/master/lib/Server/Starter/Guard.pmというクラスで、ちょっと不思議な見た目をしている。以下コードを引用する。

package Server::Starter::Guard;

use strict;
use warnings;

sub new {
    my ($klass, $handler) = @_;
    return bless {
       handler => $handler,
       active  => 1,
   }, $klass;
}

sub dismiss { shift->{active} = 0 }

sub DESTROY {
    my $self = shift;
    $self->{active} && $self->{handler}->();
}

1;

このGuard本体ファイルの中で、ファイルを削除したりするサブルーチンレファレンスを引数として呼び出されていた。 別ファイルに分離されているのだから複雑なことをやっているのだろうと身構えていたが、あまりに簡素に定義されているので拍子抜けしてしまった。一体これは何を行うクラスだろう?

不思議なコード

コードをよく見てほしい。大文字でDESTROYというメソッドが定義されているのが見える。一般的なコーディング規約では大文字でサブルーチンを書くことはないように思われる。これは何らかの特殊な操作を行うメソッドではないか?という勘が働き、perl DESTROYで検索したところ、perldocに以下のような記述を見出すことができた。

デストラクタ

あるオブジェクトに対する最後のリファレンスが消滅したとき、そのオブジェクトは 自動的に破棄されます(これはあなたがリファレンスを大域変数に格納していて、 プログラムを終了するときでもそうです)。 もしオブジェクトが解放される直前に制御を横取りしたいのであれば、クラスの 中で DESTROY メソッドを定義することができます。

つまりGuardはデストラクタに何か細工をしているクラスだということがわかる。しかも、newされるときにサブルーチンへのリファレンスを受け取り、デストラクタでそれを呼び出している!最後のリファレンスが消滅したときにコードを呼び出すようなクラスにどういった使い道があるというのか?

これはGuardが定義されたスコープを抜けるときに特定のコードを呼び出すためのコードだ。何を言っているかわからない?では同じようなコードで構成されている別のライブラリのドキュメントを見てみよう。名前はまさしくScope::Guardだ。

Scope::Guard - レキシカルスコープにおけるリソース管理

このモジュールは, スコープ終了地点でのクリーンアップまたはリソース管理などを行う 使い勝手のよい手段を提供します. これは例外を扱う場合に特に便利です: Scope::Guardのコンストラクタはサブルーチンリファレンスを受け取り, スレッドの実行が早い段階でアボートしたときでもそれを呼び出すことを保証します. この機能は, perlのガーベッジコレクタが, レキシカルスコープの処理を自動的に引き受けることを保証するので, 実現できます.

つまりこういうふうに使うライブラリだ。

  1. サブルーチンなどのブロックでスコープをつくる。
  2. スコープの中で、後から必ず開放しなければならないリソース、例えばファイルハンドルをオープンする。
  3. その直後にScope::GuardBless(インスタンス化)し、リソース(ここではファイルハンドル)をクローズするためのサブルーチンを与える。
  4. スコープから抜ける時に、自動的にScope::Guardに渡したサブルーチンが呼び出される。

コードを見たほうがわかりやすいかもしれない。

use Scope::Guard;
sub foo {
  print "opening resource...\n";
  # ここでファイルハンドルなどのリソースを開く
  my $g = Scope::Guard->new(sub { print "eject!!!\n" }); # ← リソースを閉じる処理をsubに渡しておく
  print "using resource...\n";
  # ここでリソースを使う
}
foo();
print "resource should be closed\n";

これの実行結果は以下の通りだ。

$ perl tmp.pl
opening resource...
using resource...
eject!!!
resource should be closed

foo脱出時に自動的にScope::Guardに渡したコードが動作した。これでファイルハンドルの開放忘れは起きなくなるだろう。 ちなみにfoo内でエラーが発生してもScope::Guardに渡したコードは必ず呼び出されるという優れ物だ。

use Scope::Guard;
sub foo {
  print "opening resource...\n";
  # ここでファイルハンドルなどのリソースを開く
  my $g = Scope::Guard->new(sub { print "eject!!!\n" }); # ← リソースを閉じる処理をsubに渡しておく
  print "using resource...\n";
  # おおっと、ひどいことが発生したようだ
  die "oops";
}
foo();
print "resource should be closed\n";
$ perl tmp.pl
opening resource...
using resource...
oops at tmp.pl line 8.
eject!!!

どうしてこういったことができるのだろう?

ガーベジコレクション

これは以下のようなメカニズムで実現されている。

  1. fooが呼び出される。
  2. $gblessされ、メモリに$gの領域が確保される。
  3. リソースが使われた後、fooから処理が戻ろうとする。
  4. $gへの参照がどこにも存在しなくなる。Perlのガーベジコレクションは参照カウント方式なので、$gを破棄すべきことが検知される。
  5. $gにはDESTROYが定義されているので、$gが破棄される前にDESTROYが呼び出される。
  6. DESTROYでリソースの開放が行われる。
  7. $gが破棄される。
  8. fooから処理が戻る。

ガーベジコレクションをうまく利用して、スコープ脱出時に処理を行わせることに成功している。とてもうまくできたやり方だ。 Server::Starterで定義されているGuardもこれとほぼ同じもので、ファイルハンドルやプロセスファイルの削除を保証する目的などに使われている。

Common Lispでやってみる

ここからが本題。Common Lispの勉強もかねて、Scope::Guard同様のものを・・・つまりスコープを抜けるときに必ず一定の処理を行うようなコードを書いてみることにした。 とはいえガーベジコレクションに頼る必要はない。Common Lispには、与えられたフォームから脱出する際に必ず一定の処理を行うことができる特殊オペレータUNWIND-PROTECTが用意されている。

Special Operator UNWIND-PROTECT

unwind-protect evaluates protected-form and guarantees that cleanup-forms are executed before unwind-protect exits, whether it terminates normally or is aborted by a control transfer of some kind. unwind-protect is intended to be used to make sure that certain side effects take place after the evaluation of protected-form.

UNWIND-PROTECTで先ほどのコードと同じことを実現するには、次のように書く。

(defun foo ()
  (unwind-protect
      (progn ;; 第一引数: 本体処理
        (format t "opening resource...~%") ;; ここでリソースを開き、使う
        (format t "using resource...~%"))
    (format t "eject!!!~%"))) ;; 第二引数: クリーンアップ処理
(foo)
(format t "resource should be closed")

UNWIND-PROTECTは、第一引数として処理本体となるフォーム(protected-form)を、第二引数として後始末に用いるフォーム(cleanup-form)を受け取る。 protected-formが実行されてUNWIND-PROTECTから処理が抜ける際、必ずcleanup-formが実行されるようになる。エラー(コンディション)の発生などでスタックが巻き戻されるときにも、cleanup-formは必ず実行される。

これを実行する*1と、Perl版と同様の結果が得られる。

$ ros tmp.ros
opening resource...
using resource...
eject!!!
resource should be closed

前回同様に、エラーを起こしてみよう。

(defun foo ()
  (unwind-protect
      (progn
        (format t "opening resource...~%")
        (format t "using resource...~%")
        (error "oops")) ;; うわわ!ゼロ除算だ!
    (format t "eject!!!~%")))
(foo)
(format t "resource should be closed")
$ ros tmp.ros
opening resource...
using resource...
Unhandled SIMPLE-ERROR in thread #<SB-THREAD:THREAD "main thread" RUNNING
                                    {10019876A3}>:
  oops

Backtrace for: #<SB-THREAD:THREAD "main thread" RUNNING {10019876A3}>
0: (SB-DEBUG::DEBUGGER-DISABLED-HOOK #<SIMPLE-ERROR "oops" {10037CB703}> #<unused argument>)
1: (SB-DEBUG::RUN-HOOK SB-EXT:*INVOKE-DEBUGGER-HOOK* #<SIMPLE-ERROR "oops" {10037CB703}>)
2: (INVOKE-DEBUGGER #<SIMPLE-ERROR "oops" {10037CB703}>)
3: (ERROR "oops")
4: (FOO)
...長いスタックトレース...

unhandled condition in --disable-debugger mode, quitting
eject!!!

処理系が発するエラーメッセージがたくさん表示されるが、クリーンアップ処理がきちんと動作していることがわかる。これで気兼ねなくリソースが開ける!やったね!

ちょっと待って!たくさんリソースを開く場合

一つリソースを開いたら、複数開きたくなるものだ。早速たくさんのリソースをガード付きで開いてみよう。Perlでは・・・

sub foo {
  # ここでファイルを開いた
  my $g1 = Scope::Guard->new(sub { print "file closed!!!\n" });
  # パイプも開こう!
  my $g2 = Scope::Guard->new(sub { print "pipe closed!!!\n" });
  # ソケットも開いた!!
  my $g3 = Scope::Guard->new(sub { print "socket closed!!!\n" });
}

foo();

実に良い。fooを抜けるタイミングで、3つのリソースを閉じることができるし、万一どこかでエラーが発生しても、その時点までに定義されたガードは、全て必ず実行される。 ではCommon Lispで同じことをやろうとするとどうなる?

(defun foo ()
  (unwind-protect
      (progn
        ;; open file ここで開いたファイルが・・・
        ;; use file
        (unwind-protect
           (progn
              ;; open pipe
              ;; use pipe
              (unwind-protect
                  (progn
                    ;; open sock
                    ;; use sock
                    )
                (format t "sock closed!!!~%")))
          (format t "pipe closed!!!~%")))
    (format t "file closed!!!~%"))) ;; ここで閉じられる
(foo)

う~ん・・・いたずらにインデントが深くなってしまっているのが否めない。もちろんUNWIND-PROTECTによる「保護膜」が三重になっているのはわかる。 でもScope::Guardに倣えば、「保護膜」はそのスコープが閉じられるまでをカバーすれば良いはずだ。同じスコープで複数のガードを定義したとしても、それらが有効であるのはどれも同じ、そのスコープの終わりまでだ。それなのにUNWIND-PROTECTは、どこまでが守られるべきフォームなのかを明示しなければならないから、Scope::Guardとまったく同じようには使えない。必ず明示的に守備範囲を教えてあげる必要があるのだ。それとは対照的に、Scope::Guardはその守備範囲を自動的に決定する。このメリットを活かせないのは残念だ。 しかも、最初にリソースをオープンした箇所(ここではファイル)と、それを閉じる箇所がかけ離れてしまっている。あまり読みやすいコードではない。 これを読みやすくする方法を考えようというのが今回のお話。

マクロを使ってみる

そもそも、クリーンアップ処理が最後に来てしまうのが見難さの原因になっている。マクロを使って順序を入れ替えてみよう。

(defmacro guard (cleanup protect)
  `(unwind-protect ,protect ,cleanup))

(defun bar ()
  (guard (format t "file closed!~%")
     ;; open file
     (guard (format t "pipe closed!~%")
       ;; open pipe
       (guard (format t "sock closed!~%")
         ;; open sock
     ;; use file, pipe and sock
        (format t "using...~%")
     ))))
(bar)

さっきより随分と見やすくなった。これを実行してみる。

$ ros tmp.ros
using...
sock closed!
pipe closed!
file closed!

マクロをつかって、UNWIND-PROTECTを使ったガードを綺麗に書けるようになった。でも相変わらずインデントが掘られているし、UNWIND-PROTECTに守備範囲を教えてあげる必要がある。 カッコの数をなんとか減らせないだろうか?なんとかしてScope::Guardを再現したい!

cl-annotでインデントを減らす

ここで満を持してcl-annotというライブラリが登場する。このライブラリの詳細な解説はREADMEを見てもらうとして、簡単にこのライブラリの機能を説明すると、おおむね以下の通りになる。

  • (foo bar)というフォームを@foo barとして表現できるようになる。(Pythonのアノテーションに影響を受けたらしい)
  • デフォルトで、クラスや関数のエクスポートや型宣言等を実現する各種のアノテーションを提供する。
  • 自分で複雑なアノテーションを定義するためのdefannotationマクロを提供する。

ここでdefannotationを使って先ほどのguardを定義し直してみよう。

(ql:quickload :cl-annot) ;; ライブラリ読み込み

;; アノテーションを定義する
;; アリティは2つ、インライン展開を有効化する
(annot:defannotation guard (cleanup protect) (:arity 2 :inline t)
  `(unwind-protect ,protect ,cleanup))

(annot:enable-annot-syntax) ;; アノテーション記法を有効化する

(defun qux ()
  @guard (format t "file closed!~%")
  @guard (format t "pipe closed!~%")
  @guard (format t "sock closed!~%")
  (format t "using...~%"))

ここで定義したquxは、前掲のfooと全く同じ形に展開される。これでインデントを深くせずにガードを定義することができた。ほとんど見た目もScope::Guardと同じだ。 cl-annotをうまく活用して、自然な形でガードを定義できるようになった。これをうまく使ってServer::Starterの写経を行ってみようと思う。

だめでした

実は先ほど定義したアノテーションは、@guardの間に何らかの処理が挟まるとうまくスコープ全体を守備範囲にできなくなってしまう。試しにガードの間に幾つかの処理を挟んでみた。マクロを展開して見るために、macroexpand-1を使っている。

(defun hoge ()
  (format t "~S~%" (macroexpand-1 '(progn
    (format t "file opened!~%")
    @guard (format t "file closed!~%")
    (format t "pipe opened!~%")
    @guard (format t "pipe closed!~%")
    (format t "file and pipe using~%")
    @guard (format t "sock closed!~%")
    (format t "using...~%")))))

このコードを実行すると、以下のように展開されたマクロが表示される。

(PROGN
 (FORMAT T "file opened!~%")
 (UNWIND-PROTECT (FORMAT T "pipe opened!~%")
   (FORMAT T "file closed!~%"))
 (UNWIND-PROTECT (FORMAT T "file and pipe using~%")
   (FORMAT T "pipe closed!~%"))
 (UNWIND-PROTECT (FORMAT T "using...~%")
   (FORMAT T "sock closed!~%")))

パイプを開いたとおもったら、ファイルが閉じられてしまった。このコードを実行すると、以下のような表示になる。

$ ros  tmp.ros
file opened!
pipe opened!
file closed!
file and pipe using
pipe closed!
using...
sock closed!

これではファイルを使うべき箇所でファイルを使えない。これは、cl-annotのアノテーションが:arityとして渡しただけのシンボルしか引数として読み込まないために起きる。:arityに2を渡せば、シンボルは2つしか読み込まれないので、それ以降の呼び出しはUNWIND-PROETCTprotected-formには入らない。quxのときにはうまくいったのは、マクロ展開の過程で連鎖的にアノテーションが展開され、結果的に@guard以降のコードがそれぞれ1つのフォームになり、これをそれぞれのアノテーションが取り込んだからだ。つまり、一番最後の@guardが展開された結果UNWIND-PROTECTという1つのフォームが形成され、これを後ろから二番目の@guardprotected-formとして取り込み、そうしてできた1つのフォームを最初の@guardprotected-formとして取り込んだのだ。

(:aritytを渡すなどして)スコープが終わるまで読み込めるだけのシンボルを読み込む動作があればいいのだが、今のところ存在していないようだ。わりと便利な挙動だと思うので、がんばってcl-annotのコードを読んで、あわよくば機能を増やしてみたい。今のままでも充分役立つと思うけどね。

もしcl-annotに手を入れずにうまくやる方法を見付けたら教えてください。

Common Lisp知見

実践Common Lisp

実践Common Lisp

  • 作者: Peter Seibel,佐野匡俊,水丸淳,園城雅之,金子祐介
  • 出版社/メーカー: オーム社
  • 発売日: 2008/07/26
  • メディア: 単行本(ソフトカバー)
  • 購入: 8人 クリック: 192回
  • この商品を含むブログ (69件) を見る

これを見ればCommon Lispがだいたい書けるようになる。

github.com

処理系はこれで動かす。ようするにperlのplenvとcpanmが合体したみたいなやつ。

github.com

これはようするにperlのcarton。

*1:ここでは処理系を直接呼び出す代わりにroswellを使った。