Island Life

< Google翻訳と下訳 | parameterizeの面倒くさい話 >

2016/12/08

Schemeとunwind-protect

(この記事はLisp Advent Calender 2016の 9日目の記事です。)


Schemeでファイルを開いて何かするには、with-系やcall-with-系の 手続きを使うのが定番だ。次のコードはファイルfooを開いて最初の行を呼んで返す。 ファイルは、lambdaフォームによるクロージャが戻った後で閉じられる。

(call-with-input-file "foo"
  (lambda (port) (read-line port)))

ここで、ファイルを読んでいる最中にエラーが起きて、call-with-input-fileから 抜けてしまった場合、オープンされていたファイルはどうなるだろうか。

(call-with-input-file "foo"
  (lambda (port) (error "oops!")))

スコープを抜ける時にリソースを解放するイディオムに慣れた人なら、 call-with-input-fileを抜けた時点でファイルが閉じられることを 期待するかもしれない。

実は、Schemeの規格では、そこでファイルが閉じられることは保証されていない。 実際Guileでは閉じられないようだ (Cf. Guile では call-with-input-file の proc 内でエラーが起きるとポートが閉じられない件)。 Gaucheでは閉じるのだが、その理由については後述する。


規格の該当部分を見てみよう。R7RSではcall-with-input-filecall-with-portで実装されるとあり、call-with-portの 仕様には次のくだりがある。

If proc does not return, then the port must not be closed automatically unless it is possible to prove that the port will never again be used for a read or write operation.

つまり、procが戻らなかった場合、portが二度と読み書きに使われないことを 証明できない限り、portを勝手に閉じてはいけない、というのが仕様だ。

多くのプログラミング言語では、スコープを一度抜けてしまったら、 スコープ内の変数にアクセスする手段がない。従って、スコープの寿命と リソースの確保を結びつけてある場合、正常終了だろうが異常終了だろうが スコープを抜けた時点でリソースを解放して構わない。

しかしSchemeには第一級継続があるので、スコープの処理の途中で一度抜けて、 後で戻ることが可能なのだ。 次の手続きfooは、「ファイルの2行目を読む」という継続[2]を変数cに 束縛して、1行目だけを読んで(call-with-input-fileを抜けて)戻ってくる。 次にcを呼び出すと、ファイルの2行目が読まれて帰ってくる。

(define c #f)

(define (foo file)
  (call/cc (lambda (k0)   ; k0は[1]へジャンプする継続
             (call-with-input-file file
               (lambda (port)
                 (call/cc (lambda (k1) ; k1は[2]へジャンプする継続
                            (set! c k1)
                            (k0 (read-line port)))) ;; [1]へと脱出
                 ;; [2]
                 (read-line port))))
  ;; [1]
  ))

やってみよう。cに与える引数は内側のcall/ccの戻り値になるが、 今は使ってないので何でも良い。

gosh> (foo "README")
"Gauche is a Scheme scripting engine aiming at being a handy tool that helps"
gosh> (c #f)
"programmers and system administrators to write small to large scripts quickly."

(一度cを起動して[2]からの継続を実行すると、call-with-input-fileへと 正常に制御が戻るので、仕様どおりportは閉じられる。 従ってcをもう一度呼び出すとエラーになる。)

この例はあまり便利そうには見えないが、例えば継続を使ったコルーチンを実装して、 call-with-input-fileの中で別のコルーチンにスイッチする、といった場合にも 同じことが起きる。なので、単に「制御が外に飛び出す」だけでは、 ポートを閉じて良いかどうかはわからないのだ。

portが二度と読み書きに使われない」ことが確実になるタイミングのひとつは、 portがGCされる時である。なので、GCされる時点でポートを閉じるという実装は、 処理系としてはひとつの選択肢である。


時々、他の言語での「後始末」をする構文 (try〜finally、begin〜ensure等)に 相当するものをSchemeでやろうとして、dynamic-windをこんな感じで 使っているのを見ることがある。

(let ((port (open-input-file ...)))
  (dynamic-wind
    (lambda () #f)                   ; before thunk、特にやることなし
    (lambda () (read-line port))     ; body thunk、 処理本体
    (lambda () (close-port port))))  ; after thunk、必ずportを閉じる

制御がbody thunkから外に出る際にafter thunkは必ず呼ばれるので、 そこで後始末をするのは一見妥当に見える。 けれども、after thunkは「後で本体に戻るかもしれないけど一時的に抜ける」場合にも 呼ばれてしまう。だから、dynamic-windで後始末をやってはいけないのだ。

じゃあSchemeで後始末を確実にするにはどうすればいい? 実は、Schemeにそのための構文や手続きは備わっていない。 どうしてもやりたいのなら、エラーハンドリング構文のguardを使って 異常終了時の後始末を書くことになるが、 正常終了時の後始末は別に書かなければならないのがなんだか残念な感じである。

(let ((port (open-input-file ...)))
  (guard (e [else (close-port port)  ; エラー時。portを閉じてエラーを再度投げる
                  (raise e)])
    (let ((r (read-line port)))
      (close-port port)              ; 正常終了のパス
      r)))

なので、Gaucheではこのイディオムをunwind-protectというマクロにしている (名前は同様の機能を持つCommon Lispのマクロから拝借した)。 dynamic-windの例と違うのは、call/ccを使って出たり入ったりする際には 後始末部分は呼ばれない、という点である。あくまで、 「正常終了か、エラーによる異常終了」という特定の制御パスだけを扱っている。

(let ((port (open-input-file ...)))
  (unwind-protect
      (read-line port)    ; 本体
    (close-port port)))   ; 正常終了でも異常終了でもここを通る

dynamic-windは、guardなどのエラーハンドリングを実装するための、 もう一段下にあるメカニズムと考えるのが良いだろう。

第一級継続があると unwind-protectが実装できない、との意見もあるが、 継続による制御が低レベルのレイヤなのに対し、エラーによる大域脱出はその上に 乗った別レイヤであるので、上のレイヤで実装することは可能なのだ。

なお、エラーではない継続で本体を脱出した後、制御を戻さなければ、 後始末ルーチンは呼ばれないことになる。これは、マルチスレッドプログラムで 一つのスレッドがブロックしている場合と同等であると考えることができる。 再開のための継続をどこかに持っていれば、それが生きている限りはリソースが 保持されるが、その継続がGCされれば(スレッドがキャンセルされる場合に相当)、 掴んでいるリソースも解放される。

(追記(2016/12/10 02:02:35 UTC): Gaucheのunwind-protectは上に示したguard による後始末よりちょっと込み入ったことをやっている。詳しくはソース参照)


さて、実はGaucheではさらに一歩踏み込んで、call-with-input-fileの中で unwind-protectを使ってポートを閉じるようにしている。これは実際のコード:

(define-in-module scheme (call-with-input-file filename proc . flags)
  (let1 port (apply open-input-file filename flags)
    (unwind-protect (proc port)
      (when port (close-input-port port)))))

(whenportをチェックしているのは、flagsによっては port#fになる可能性があるため。)

その根拠はこうだ。エラーが投げられて制御が外側に抜けるということは、 もはや内側の状態が処理の継続が不可能なほどに一貫性を失っているということである。 従ってこの後再びprocの中に制御を戻すことは意味のあることではなく、 正常な動作は保証できないのだから、戻ることは考えなくて良い。

これはかなりアグレッシブな立場だ。 ポートと関係ないところでエラーが発生したが、それ以前に保存してあった継続を使って 一旦本体内に戻って何らかの処理をして、また(エラーではない)継続で抜ける、という 例を作ることは可能である。しかしエラーの発生箇所と影響範囲を厳密に知っていなければ そういったコードが確実に動く保証はできないし、それはあまり現実的な仮定ではない、 というのがGaucheの解釈である。

逆に、call-with-input-fileでエラー時にポートを閉じずGCに任せるとどうなるだろうか。 Schemeの世界だけで完結できるなら、ファイルをopenしようとしたところで ディスクリプタが足りなければそこでGCを走らせて、使っていないディスクリプタを回収できる。 しかしGaucheのように、他言語で書かれたコンポーネントと一緒に使うことを想定している場合、 Gaucheがファイルディスクリプタを全部抱え込んでしまっている状態で サードパーティのC言語のライブラリ内でopen(2)が呼ばれたら、 failするしかないわけで、 それは実用的ではない、という判断だ。 他の処理系には、それぞれのユースケースに応じた別の判断があって良いだろう。

Tags: Programming, Scheme, Gauche

Post a comment

Name: