Gauche:スタックトレース

Gauche:スタックトレース

Shiro(2015/07/09 04:09:54 UTC):

コンディションとスタックトレース

Conditions and stack traceで、keiさんがスタックトレースの情報をどうやってコンディションに付加するのが良いか議論している。

Gaucheは今のところ、エラー時にスタックトレースをコンディションに付加していない。エラー時に表示されるスタックトレースは、デフォルトのエラーハンドラが出している。エラーハンドラが呼ばれた 時点ではスタックは巻き戻されていないので、そこでスタックを読んで表示しているだけだ。

コンディションにスタックトレースを付加すれば、投げられたコンディションを保存しておいて後から原因を調べたりとか何かと都合が良いのだけれど、設計上の迷いがあってまだどうすれば良いか決めかねている。

迷いの理由は二つある。

抽象度

例外処理をどのくらい抽象化すべきか。より抽象的なメカニズムでは、例外のメカニズムの実装はシステムに隠される。例外を表すオブジェクト(コンディション)が例外送出時に暗黙に作られるとか、例外ハンドラへの制御の以降の裏でシステムがいろいろ暗黙のうちに後始末をよしなにやってくれるとか。一方、抽象度を下げる場合は、コンディションの具体的な実装だとか、制御の移行とかが全て明示的に行われる。

Javaの例外機構は抽象度という点では若干混ざってる。例外オブジェクトはプログラマが陽に作成する。ただ、Throwableの中にスタックトレースみたいな暗黙的に作られる何かがひっついてくる。制御の移行はこれも言語実装の裏に隠されていて、 一番内側のマッチするcatch節までのスタックが自動的に巻き戻される。

Schemeの例外機構はもっと抽象度が低い。例外オブジェクトの構成は完全にユーザ任せで ブラックボックスは一切無い。raiseはその時点の例外ハンドラを呼び出すだけで、 そのメカニズムはcall/ccと手続き呼び出しで説明できる。guardも 単なるライブラリ構文にすぎない。

つまり、コンディションへのスタックトレースの付加、みたいなことをunder the hoodでやるのは、 Schemeの提供する抽象レベルとマッチしない感じになる。

抽象レベルを合わせるとしたら、コンディションを作る時に陽にスタックトレース用の コンディションをcompoundしてやる、といった方向になるだろう。

(raise (condition (&message (message "Error!"))
                  (&runtime-context (stack-trace (get-current-stack-trace)))))

いかにも部品から積み上げてゆくSchemeっぽい形だが、使い勝手は悪い。

raise/stack-trace みたいにラップしたライブラリ関数を提供すれば使い勝手は 良くなるけど、raiseを使ってるサードーパーティライブラリからはスタックトレースが 取れないことになる。

効率

もうひとつの問題は、スタックトレースを第一級のオブジェクトとして取り出すのが 現在のGaucheでは重いということだ。VM上のスタック情報はすぐ上書きされてしまうので、 スタックフレームの情報のチェインをヒープにコピーしないとならない。

デフォルトのエラーハンドラに制御が渡ってスタックトレースを表示する場合はそんなに 気にする必要はないんだけど、エラーを捕まえて処理を続行する場合、 取り出したスタック情報は結局使われないので無駄になってしまう。

なお、これが重いかどうかは処理系の実装による。継続の捕捉が軽いChickenやChez Scheme みたいな処理系では問題にならない。継続を捕まえとけばスタックトレースに必要な 情報は全部入っているわけだから。

そもそもコンディションにスタックトレースをつけるべきか

コンディション(例外オブジェクト)は、本来は例外発生とそれを処理するコード間の コミュニケーションという役割であった。その意味では、スタックトレースも 発生した例外の情報の一部だから、コンディションにくっつけるのは理にかなっている。

しかし、コンディションの作成をユーザコード任せにしたことで、

ということになっている。この本来の目的と現実の齟齬が問題の根本だ。

この齟齬を解消するには、「例外発生とコンディションオブジェクトを必ず1対1対応させる」ことをイディオムにするか、コンディションオブジェクトをraiseが暗黙に生成するようにするしかない。

それができないのなら、いっそコンディションオブジェクトは参考情報と割り切って、スタックトレースをシステムが自動生成する文脈情報として別に渡してやるのはどうだろう。概念的には、例外ハンドラがconditionとcontextを受け取る。contextは実行時の文脈情報にアクセスするためのハンドルで、文脈情報自体はシステム側の管理下にある。

(with-exception-handler
  (lambda (exc ctx)
    (print (get-stack-trace ctx)) 
    ...)
  (lambda () ... (raise exc) ...))  ; この時点の文脈情報へアクセスするハンドルが暗黙のうちに生成されてctx引数に渡される

エラーハンドラ中で例外が投げられる場合、大元の文脈情報が必要な場合も、中間の文脈情報が必要な場合もある。つまりシステムはraise時点でctxを作ってお終い、ではなく実行環境の中でそれを追跡している。

(with-exception-handler
  (lambda (exc ctx1)
    ;;; ここで、ctx1を通して直近のraiseの文脈情報 (*1) だけでなく大元のraiseの文脈情報 (*2) へもアクセス可能である必要がある。
    )
  (lambda ()
    ...
    (with-exception-handler
      (lambda (exc ctx2)
        (raise exc)) ; *1
      (lambda ()
         ...
         (raise exc) ; *2
         ...))))

システムは現在実行中の地点での文脈情報だけでなく、「例外ハンドラを実行している場合は、そのハンドラが呼ばれる原因となった地点での文脈情報」も持っていて取り出せるようになっているということ。

ところで、システムが暗黙のうちに保存している実行環境の文脈を取り出す、という操作をSchemeに既に持っている。call/ccだ。意味的には、call/ccが作る継続オブジェクトの中に、その時点のスタックトレースの情報は含まれている(でなければ呼び出し元に戻って実行を続けることができないわけだから)。ただし、その情報が簡単に取り出せるようになっているとは限らないし、ネストしたraiseで必要になる複数の文脈情報の管理もできないから、文脈情報を取り出すcall-with-current-trace, call/ctというのを考えてみよう。call/ctはその時点で「生きている」文脈情報をまとめて、それにアクセスできるハンドルを伴って引数となる手続きを呼び出す。すると、raiseはこんな感じの動作と考えられるだろう。

(define (raise exc)
  (call/ct (cut (current-exception-handler) exc <>)))

でも、call/ctでいつでも文脈情報を取り出せるなら、いちいちraise側で取り出してやらなくても、必要になったところで取り出してやってもいいわけじゃん?

(with-exception-handler
  (lambda (exc)
    (call/ct (lambda (ctx)
                ;; ctxからは、今ここでのトレースの他に、このハンドラを呼んだ箇所(*1)のトレースなどへも数珠つなぎにアクセス可能
                ))
  (lambda ()
    ... 
    (raise exc) ;; *1
    ...))

これなら、文脈情報をファーストクラスオブジェクトにするのが重くても、必要になるまでその生成を遅らせるという戦略も取れる。

文脈情報の自然な寿命(取り出されなかった場合にいつ破棄するか)は例外発生時の動的環境の寿命と同じで良いだろう。raiseは、例外ハンドラがひとつポップされること以外は、その時点での動的環境でハンドラを呼び出すから、ハンドラの中は依然としてraiseの動的環境の中にある。ネストしてても同じ。

(JavaやC++のcatchはスタックをrewindした後で実行されるので、ここの事情は異なる。なおSchemeでもguardはハンドル節実行前にスタックを巻き戻してしまうので、call/ctで元の文脈情報を得るにはwith-exception-handlerを使う必要がある)

と、このへんまでぼんやり考えてるところ。


Last modified : 2015/07/17 00:01:48 UTC