Super Technique 講座

longjmpと例外

プログラムの流れを制御する手段として、いわゆる「構造化構文(for とか if とか)」、悪名高い「goto 文」などがあるのは周知のことだが、かなり特殊なものとして、「setjmp, longjmp」による「非ローカル分岐」と呼ばれるものがある。この「非ローカル分岐」は「非ローカル」と言うだけあって、ある関数の中から、別な関数に制御を移すことができたりする。まあ、そのために乱用すべきではなく、注意して使わなくてはならないライブラリ関数なのだが、実はこれは Java や C++ で言語の上で実装されている「例外」の基礎となるものである。だから、まずこの標準ライブラリ関数「setjmp, longjmp」について解説しよう。 → Java 講座の「例外」




setjmp, longjmp とは

setjmp, lognjmp は古典的に標準ライブラリ関数として実装されているCライブラリ関数である。setjmp, longjmp はペアになって、関数の外にジャンプする機構を実現する。よろしいか、goto 文がたかが関数内部での制御の移動を実現するのに引き換え、この setjmp, longjmp は関数の間でのジャンプができるのである。だから、濫用すると、すぐに収拾がつかなくなるので、goto 文同様に禁止現場は多い。

setjmp, longjmp それぞれの担当は次の通り。これらは setjmp.h でプロトタイプ定義がなされている。

int setjmp( jmp_buf jmp );
まず、グローバル変数として jmp_buf 型のコンテキストバッファを定義しておく。これを引数にして setjmp() を呼び出す。そうすると、setjmp() はすぐに戻り値 0 で戻って来る。これは現在の「実行コンテキスト」を jmp_buf 型のコンテキストバッファに保存したのである。
void longjmp( jmp_buf jmp, int ret );
その後、何かの処理を呼び出す。この時、setjmp() を呼び出した関数から抜けても、あるいは setjmp() の戻り値処理の if文からも、抜けてはならない。そして、この setjmp が保存したコンテキストバッファが有効である限り、longjmp() を呼び出すと、制御が setjmp() の先程は戻り値 0 で戻ったのだが、戻り値 0 以外で setjmp() 関数から戻ったような動作をする。この時、第二引数 ret は、setjmp() の戻り値として振舞うべき値を指定する(もし 0 だと setjmp() が混乱するので、1 になる)。つまり、次の通りである。
jmp_buf jbuf;

void SubFunctions( ) {
   if( .... ) {
       longjmp( jbuf, 1 );
       /* NOTREACHED */
   }
}

void SomeFunc( ) 
{
    if( setjmp( jbuf ) == 0 ) {
        SubFuntions();  /* このブロック内部の関数では、自由に longjmp を呼べる */
    } else {
        /* longjmp によって、戻って来た */
    }
    /* もう longjmp を呼ぶ関数を呼んではいけない */
}

では、「実行コンテキスト」とは何だろう? 何を保存しているんだろう? とりあえず、jmp_buf の定義を見てみよう。(in setjmp.h)

/* 呼び出した環境。それに出来るならばシグナルマスクも保存する */
typedef struct __jmp_buf_tag  /* C++ ではこのようなタグのない構造体は好まれない */
  {
    /* NOTE: マシン依存の __sigsetjmp の定義は、jmp_buf が __jmp_buf で始まること
       を仮定している。だから、__jmp_buf を移動したり、この前にいかなるメンバも
       追加してはならない。*/
    __jmp_buf __jmpbuf;         /* 呼ばれた環境 */
    int __mask_was_saved;       /* シグナルマスクが保存されているか? */
    __sigset_t __saved_mask;    /* 保存されたシグナルマスク */
  } jmp_buf[1];

__jmp_buf の定義は、Linux だと bits/setjmp.h にある。

#if defined __USE_MISC || defined _ASM
# define JB_BX  0   /* それぞれ、レジスタの名前 */
# define JB_SI  1
# define JB_DI  2
# define JB_BP  3   /* ベースポインタ */
# define JB_SP  4   /* スタックポインタ */
# define JB_PC  5   /* プログラムカウンタ */
#endif

#ifndef _ASM
typedef int __jmp_buf[6];  /* 要するに __jmp_buf は int 6個の配列 */
#endif

とりあえず、シグナルマスクについては触れない。これについて知りたい人は「シグナルとコールバック〜POSIXシグナルシステムコール」を参照されたい。この構造を単純化すると、jmp_buf の定義はこんなところである。

struct jmp_buf_tag {
    int register[6];
};
typedef struct jmp_buf_tag jmp_buf;

単に int 値6個の配列に過ぎない。しかし、実はこの jmp_buf はアセンブリレベルで、現在の実行状態である特殊なレジスタをすべて配列の中に保存するのである。

コンピュータというものは、形式的に言えば「状態機械」である。つまり、複数の「状態」を持った機械であり、「状態」に応じてその動作が違うだけではなく、「状態」を書き換えて動作して行くものである。もし、このマシンの状態をどこかに保存しておいて、戻りたくなったら現在の「状態」を保存しておいた「状態」に書き換えれば、結果としてプログラム上は、ある関数からそれを呼び出した関数に、通常の return の構造とは無関係に戻って来るように見える。これが setjmp/longjmp の基礎である。

どうやって実現してるの?

この setjmp/longjmp 機構の関数は、だからアセンブリでしか定義できない。C言語のレベルではレジスタを直接いじることはできない(はず..)である。ここで、「状態」にはどんな種類があるのかを考えよう。

演算レジスタの状態
CPUのレジスタは、演算のために使われる特別なメモリであり、演算の中心的な役割を果たす。これは setjmp によって保存される。
現在の実行行
これはCPUのレベルでは PC レジスタの値である。要するに PC レジスタの値(次に読んで実行すべきメモリアドレス)は、他のレジスタと違いがあるわけでも何でもない。つまり、JMP 命令とは「PCレジスタに値をセットする」命令に過ぎないのである。だから、これも演算レジスタと同じく保存できる。
スタックの状態
関数の呼び出しネストや自動変数は、スタックに取られている(ここらへんが怪しい人は「ポインタ虎の巻」を参照のこと)。しかし、スタックは関数の呼び出しネストに従って、「上に積み重なっていく」。だから、関数から別な関数を呼び出したとしても、呼び出し元関数のスタックの状態はそのままなのである。だから戻っても処理を継続できる。CPUのレベルでは、スタックのトップを示す特殊なレジスタ SP レジスタがあり、スタックの状態を「巻き戻す」ためには、単に SP レジスタの値を「より古い」状態に書き換えるだけで済む。

通常の通りに関数から戻って SP が「巻き戻される」場合を例にとって、このようなレジスタがどう動いているのかを理解しよう。

  1. PC レジスタが CALL 命令によって、そのサブルーチンの実行行の値になる(要するに、関数に入る)。この時、CALL 命令の次の命令のアドレスが、同時にスタックに積まれる。
  2. まず、入ったばかりの時の SP レジスタの値を、特殊な役割に固定されているレジスタである BP レジスタに保存する。だから、BP の値は戻りアドレスの位置を示している。
  3. 自動変数を確保する。これは単にスタックにそれ用の領域が確保できれば良いだけである。PUSH した気になって、SP だけをそれ用に必要な分だけ更新すれば良い。
  4. それから、現在の他のレジスタの値をスタックに積んで保存する。これは戻る時に状態を回復するためである。昔はすべてのレジスタをいちいち PUSH で積んでいたが、Intel 80186 で PUSHA という、保存すべきレジスタを一括してスタックに PUSH する命令が追加された。まあ、どんなCPUでも便利なので、こういう一括レジスタ PUSH 命令があることが多かろう。
  5. そして、さまざまな処理をする。では関数から戻る時にはどうか。
  6. まず、保存しておいたレジスタの値を回復する。これも POPA 命令があるので、これで一括してスタックからレジスタの値を回復する。
  7. 自動変数を破棄する。これも単に SP の値を BP に保存しておいた値に戻すだけである。
  8. そうすれば、SP の値はスタック上の戻りアドレスを指していることになる。ここで RET 命令を発行する。そうすると、RET 命令は SP で示されるスタック上のアドレスから、戻りアドレスを取得し、これを PC レジスタにセットする。平たく言えば、保存しておいた戻りアドレスにジャンプする。
  9. これで、サブルーチンを実行し、呼び元(CALL命令の次の行)に戻ったのである。


だから、setjmp/longjmp 機構が、このような通常の関数呼び出しに介入できる枠組が何となく理解できたのではなかろうか。
その他特殊なレジスタ
たとえばフラグレジスタや、割り込み状態をマークするレジスタがあることだろうが、これらは特にいじらなくても問題がないだろう。
グローバル変数領域(データセグメント)
これについては、setjmp/longjmp 機構の取り扱い範囲外である。特に何もしないことが、その機能である。

だから、setjmp/longjmp は、このような通常の関数呼び出しの枠組みを「ダマす」ことによって実現可能なのである。大体の setjmp/longjmp の動作を疑似コードで示そう。

int setjmp( jmp_buf jb ) {
     必要なレジスタを取得し、それを jmp_buf(実は配列)のそれぞれの領域に保存
     return 0;
}

setjmp の動作はそれほど難しくない。では、longjmp はどんな動作をするのだろう?

void longjmp( jmp_buf jb, int ret ) {
     ●jmp_buf のそれぞれの領域から、レジスタに値を書き戻す
     割と順番などもあって、ホントは難しいが、通常の関数が POPA する領域を
     jmp_buf の値で書き換えて、POPA すると比較的やさしいかな?
     ●戻り値を示すレジスタに、引数 ret の値をセット(Intel だと EAX レジスタ)
     ●そして、保存された戻りアドレスに JMP、あるいはスタック上の戻りアドレスを
      jmp_buf に保存された値に書き換えて、単に RET を発行する。
}

そうすると、longjmp からの戻りは、あたかも setjmp から戻って来たかのように見えるのである。この時、いくつかの条件があることを確認しよう。

  1. 当然、jmp_buf で保存される実行コンテキストを表す配列は、スタック領域に置くことはほぼ不可能である。普通はグローバル変数領域に置くが、以下に見る「例外の模倣」では malloc して使っている。
  2. 戻るべきスタックがすでに無効化していたら(要するに setjmp の if 文によるブロックの外から呼ばれた関数の中で、longjmp が呼ばれたら)、setjmp で戻っても有効に動作を継続できない。
  3. 当然、jmp_buf に値がセットされていないのに(言い替えれば setjmp の呼び出し無しに)、longjmp を呼び出せば、結果として全てのレジスタがゼロでクリアされて、即刻セグメンテーションフォルトが起きる。

setjmp/longjmp の利用例〜インタプリタ

よろしいかな? 少し利用方法について触れておく。やはり goto 文と同様に、このsetjmp/longjmp 機構は、乱用すると著しくソースの可読性を損なう。だから、節度をもって使うことを心がけなくてはならない。一般に良く使われるのは、やはりエラー処理である。筆者はインタプリタが好きなので、大概次のようなコードを書く。

SyntaxTree st;
jmp_buf  top;
while( (st = getNextSyntaxTree()) ) {
    if( setjmp( top ) == 0 ) {
         exec( st );  /* エラーが起きれば、この中で longjmp が呼ばれる */
    } else {
         if( ErrorCode == STATUS_QUIT ) {
              break;
         } else { 
              /*  エラー回復処理 */
         }
    }
}

インタプリタというものは、ユーザの入力が頻繁にエラーを引き起こしてくれるものである(これは皆さんもよくご存じだな)。実際に実行を行う exec() では、実に複雑なプログラムが書かれることになるわけで、その多くは再帰関数である。だから、戻り値のチェックでやろうとすると、大変ミスりやすいし、エラー処理を統一的にするのが難しい。だから、こういう場合には setjmp/longjmp 機構を使った方のがソースの可読性を上げ、ミスを未然に回避できる。たとえば、このような longjmp のための関数を作る。

void warining( int errcode, char *message )
{
    message_out( mesasge );
    ErrorCode = errcode;
    longjmp( top, 1 );
}

void fatal( int errcode, char *message )
{
   /* この中身は warning とほぼ同様 */
}

warning と fatal で分けているのは、可読性のためである。これらは次のように使う。

/* exec() 内部から呼ばれ、割算の処理をする */
SyntaxTree do_divide( SyntaxTree st )
{
    int d0, d1;
    d0 = getFirstNodeAsNumber( st );  /* 第1オペランドを取得(被除数)*/
    d1 = getSecondNodeAsNumber( st ); /* 第2オペランドを取得(除数)*/
    if( d1 == 0 ) {  /* もし除数が0ならば実行を打ち切る */
        warning( ERROR_DIVISION_BY_0, "division by 0" );
        /* NOTREACHED */
    }
    d0 /= d1;  /* 割算ができる */
    return newNodeByNumber( d0 );  /* 新しい値をセットしたオブジェクトを返す */
}

こうすれば、戻り値には実際に処理で扱うオブジェクトを渡すことができるのである。エラーの値として NULL を戻すと、それぞれの引数、戻り値の NULL 処理に神経質にならざるを得なくなり、結果として場合分けが増えてソースも読みづらくなる。

ここでコメント /* NOTREACHED */ は、知らない人もいるだろうからちょっと注釈しておこう。gcc では、正確なフロー解析を行い、初期値がセットされていない自動変数を指摘することは皆さんも御承知のことだろう。つまり、プログラムの流れが複雑に分岐していても、それの流れを辿って、すべての場合に変数が正しくセットされるかどうかをチェックしているのである。この時、基本的に関数は戻って来るものとして処理される。呼び出したらそこに戻らずに、どこかに行ってしまう関数は変態であり、そういうライブラリ関数は数少ないが、しかし、そのようなライブラリ関数をラップしたユーザ関数は、任意に作れるからである(warning() なんて良い例だが...)。この時、「ユーザ関数 warning() は戻って来ない」ことをコンパイラに伝えてやれないと、フロー解析では戻って来ることを前提にするので、無意味に「初期値がセットされていない」という警告が発生することがある。だから、コメントのかたちで /* NOTREACHED */ と書いてやると、コンパイラはこのコメントを認識し、このコメントには制御が移らないことを理解する。だからlongjmp 呼び出しをするユーザ関数の呼び出しの後には、このコメントを入れておくべきであるし、また、同様な戻らない関数 exec() などにもこれは有用なわけである。

ちなみにこのようにコンパイラが認識する特殊なコメントとしては、switch 文の FALLTHROUGH を「判って書いている」ことを伝える /* FALLTHROUGH */ があることはご存知かな?

補追1:setjmp() if判定文ブロック外から longjmp を呼んでいいの?

と、最近のことだが、筆者のページをお読みになられた読者のブログで、この問題が取り上げられていた。どうやらその読者は、こういうコードを書いているようだ。

#include <stdio.h>
#include <setjmp.h>
#include <unistd.h>

jmp_buf jb;

......
	if( setjmp( jb ) == 0 ) {
           ..........
	} else {
	    printf( "戻って来たよ!\n" );
	    ......
	}
    }
    ......
    longjmp( jb, 1 );

で、この問題は実は凄く奥が深い。ちょっと冒険の旅にでかけようか。筆者がこの問題の検証のために書いたコードはこうだ。

#include <stdio.h>
#include <setjmp.h>
#include <unistd.h>

jmp_buf jb;

int main( int argc, char **argv ) {
    {
	int x = 777;
	if( setjmp( jb ) == 0 ) {
	    printf( "before: %p=%d\n", &argc, x );
	} else {
	    printf( "after : %p=%d\n", &argc, x );
	    exit(0);
	}
    }
    {
	int y = 666;
	longjmp( jb, 1 );
    }
}

....あれ?何か「{」が多いぞ...と思われたのではないかと思う。要するに、ここでは「ブロック内変数」を使いたいのである。つまり、ブロック内変数として x と y とを用意して、それぞれ別な値を持っている。少なくとも高級言語の常識として、こういう書きかたをすれば、x と y とは「別な変数」に決まっている。コンパイルして実行してみるといい....

$ ./longjmpTest
before: 0xbffff0c0=777
after : 0xbffff0c0=666

何たることだ! ラッキー7(777)が獣の数字(666)になってしまった.....高級言語のセマンティクス(意味論)としては、x=777, y=666 以外の何者でもないはずなのだが、こういう怪奇現象が起きてしまうのである!

これのタネ明かし。こうしてごらん?

    {
	int y = 666;
	printf( "y     : %p=%d\n", &argc, y );
	longjmp( jb, 1 );
    }

実行結果は

$ ./longjmpTest
before: 0xbffff0c0=777
y     : 0xbffff0c0=666
after : 0xbffff0c0=666

なのである。言い替えると、x と y とはまったく同じメモリ上の位置を示しているのである。この x と y がある場所は、言うまでもなくスタックだ。スタックに着目して、一連の動作を追ってみよう。

たとえば、最初は SP の値が 1000 としようか。
{ int x = 777; スタック上に変数 x が確保を確保する。SP を996 にして、領域を確保する。ここで &x = 996 であり、996 〜 999 の4byteの領域に 777 の数値が格納される。
if( setjmp( jb ) == 0 ) {setjmp() で jb にこの位置の情報を保存する。この時、保存される SP の値は 996 だ。
}スタックが破棄され、ブロック前の位置の状態に SP の値が戻り、SP = 1000 となる。しかし、これは SP の値の変動だけのことで、 メモリがクリアされるとか、そういうことはない。
{ int y = 666; スタック上に変数 y が確保される。先ほどと同様に確保するなら、&y = 996 であり、996 〜 999 の4byteの領域に 666 の数値が格納される。これは x と同じだとしても、Cの文法上では問題はないはずだ。
longjmp( jb, 1 ); longjmp する。そうすると、setjmp() で保存されたスタック情報などが回復される。SP == 996 になる。これも SP の値が変わるだけで、特にスタックの内容が変わるわけではない。&x == &y を確保したスタックの内容(996〜999)は、y で上書きされたままである。
printf( "after : %p=%d\n", &argc, x );ここのコードでは、x のアドレスは 996 だと思っている。つまり、どんな経緯があったかは全然知らずに、単に「今の SP の値と同じ値」を「x のアドレス」と捉えてコンパイルされているのである。だから、コードの上では x にアクセスしているようでも、この変数 x は y = 666 によって上書きされたスタックを示しているので、y の値を出力してしまう。

要するに、「ブロック内変数」を使って見せたのは、これがスタック操作で確保される変数であり、同じ関数内であろうとも、その時どきの SP の値によって適当な位置に取られるものなのである。計算の都合でスタックを変数代わりに使う最適化もある(レジスタが足りないとそうする)ので、ブロック内変数のような明示的なスタック操作がなくても、同様の現象が起きる可能性があるのである。

これは言い替えるならば、

スタック状態から見て、高い(よく積まれた)位置から、低い(積まれていない)位置へ longjmp するのは安全だが、逆はすでに無効なスタックが復活し、ゴミを拾う可能性がある。

ということなのだ。筆者が「あるいは setjmp() の戻り値処理の if文からも、抜けてはならない」と書いたのは、

    if( setjmp( jbuf ) == 0 ) {
        SubFuntions();  /* このブロック内部の関数では、自由に longjmp を呼べる。
	なぜなら、ここでどんなスタック操作をしようとも、setjmp() を呼んだ
	スタック位置から下がることはないからである! */
    } else {
        /* longjmp によって、戻って来た */
    }

..ということなのである。

で更にこの現象を考えてみれば、こういうことになるのではないのだろうか?

高級言語というものは、変数を「メモリ上のどこかに確保される、値の単なる入れ物」として扱うことに最大のメリットがある。それが関数内では不変であることを保証された BP で参照される引数やローカル変数だろうと、固定したアドレスを付与されるグローバル変数だろうと、あるいは、スタックに相対的に確保されるブロック内変数だろうと、「別な名前で呼べば別なもの」という人間にとって判りやすいルールのもとで、効果的にプログラム開発をするための「考え方」なのである...が、longjmp を誤用すると、ここらへんの舞台裏が見えてしまい、「別な名前ならば相互に独立!」という大前提が崩壊してしまう...

わけだ。筆者に言わせれば、「高級言語を使いたいのならば、longjmp() を呼ぶのは、setjmp() if判定文の範囲内であるべきだ」というのが結論である。

とはいえ、批判をなさった方の意見によると、筆者の書いた怪奇現象は、

対応するsetjmpマクロの呼出しを含む関数中の、volatile修飾型でない自動記憶域期間をもつオブジェクトの値が、setjmp呼出しとlongjmp呼出しの間に変更された場合に不定となることを除いて、すべてのアクセス可能なオブジェクトはlongjmpが呼び出された時の値を保持する。

の結果である...とご指摘頂いたが、こりゃまあその通りである。がまあ、こういう問題が出る...というのは、やはり特殊体質過ぎるようにも思う。そういう「特殊体質」を避けるための、モデルがやはり「例外のように使おう」という筆者の提言(みたいなもの)の趣旨なんだけどね。

補追2: 伝説の COME FROM

じゃあ、まあ思い切って、「筆者が良いなんて全然思わない setjmp/longjmp の使い方」の解説でもしちゃおうか。ただし、面白くないと書いてて詰まらないので、「伝説の COME FROM」という話だ。「COME FROM」って聞いたことがあるかな? GO TO じゃないよ、COME FROM だ。

Jargon File によると、COME FROM ってこういうものだ。

`go to' と両輪をなす半分架空の言語構文。COME FROM <ラベル> とすると参照されたラベルが一種のトラップドアとして働くようになり、プログラムがそのトラップドアに到達すると、制御が黙ってオートマジカルに COME FROM の次の文に移る。COME FROM が初めて提唱されたのは、1973年の「Datamation」誌に掲載された R.L.Clark の「A Linguistic Contribution to GOTO-less programming」の中でだった。この論文は当時吹き荒れていた「構造化プログラミング」をめぐる聖戦を茶化したものだ。代入型 COME FROM とか計算型 COME FROM という架空のバリエーションもある。同じラベルに基づく複数の COME FROM 文を用意すれば、当然マルチタスキング(あるいは非決定性)を実現できるはずだ。(中略)COME FROM は15年たってやっと本来の名前でサポートされたが、それは C-INTERCAL でだった。見識ある人々は、いまだにショックから立ち直れないでいる。(引用は「ハッカーズ大辞典」(Raymond編:福崎俊博訳)による)

ふーん、やってみようじゃないの。とはいえ、まずはこういうコードから始めよう。

#include <stdio.h>
#include <setjmp.h>
#include <unistd.h>

jmp_buf jb[3];

int main( int argc, char **argv ) {
    if( setjmp( jb[0] ) > 0 ) {
	printf( "return from longjmp #1\n" );
	return 1;
    }
    if( setjmp( jb[1] ) > 0 ) {
	printf( "return from longjmp #2\n" );
	return 1;
    }
    if( setjmp( jb[2] ) > 0 ) {
	printf( "return from longjmp #3\n" );
	return 1;
    }

    longjmp( jb[1], 1 );
    return 0;
}

筆者は良いとは思わないが、if( setjmp() ) { } から外れたところで longjmp() を呼び出すタイプの使い方だ。これだと、longjmp() に渡される jmp_buf によって、戻って来る場所が違う...というのはご理解頂けると思う。これにちょっくらマクロをまぶして、セマンティックスを明確にしてやろう。

#include <stdio.h>
#include <setjmp.h>
#include <unistd.h>

#define level_No1 0
#define level_No2 1
#define level_No3 2

jmp_buf jb[3];
#define come_from(lab)  if( setjmp(jb[level_##lab]) > 0 ) 
/* こんな風に書けたら面白いが出来ない(再帰も気をつけないといけないし...)
#define come_from(lab) if( setjmp(jb[level_##lab]) > 0 ) #define lab lab:longjmp(jb[level_##lab],1);
*/


void sub(int n);

int main( int argc, char **argv ) {
    come_from(No1) {  /* No1 タグから「来い!」 */
	printf( "return from longjmp No1\n" );
	return 1;
    }
    come_from(No2) {  /* No2 タグから「来い!」 */
	printf( "return from longjmp No2\n" );
	return 1;
    }
    come_from(No3) {  /* No3 タグから「来い!」 */
	printf( "return from longjmp No3\n" );
	return 1;
    }
    
    sub( 2 );
    return 0;
}

/* タグが「黙ってトラップドアになる」 */
#define No1 No1:longjmp(jb[level_No1],1);No1_dummy
#define No2 No2:longjmp(jb[level_No2],1);No2_dummy
#define No3 No3:longjmp(jb[level_No3],1);No3_dummy

void sub( int n ) {
    switch( n ) {
    case 1:
    No1:   /* これが化けるので、 */
       printf( "not reach No.1" );  /* 到達しない */
       break;
    case 2:
    No2:
       printf( "not reach No.2" );
       break;
    case 3:
    No3:
       printf( "not reach No.3" );
       break;
    }
}

残念なことに、C プリプロセッサでは不定のマクロを定義する手段(define文の中でddefine を定義する)がないので、「COME FROM <ラベル> とすると参照されたラベルが一種のトラップドアとして働く」というのが素直に実現しにくいのだが、大体こんなもので COME FROM の雰囲気は味わうことができよう(m4 あたりのプロセッサを噛ませれば出来るが...)。

まあ、そうしてみると、こういう風に複数の jmp_buf を使って、「それぞれの場合で出口が違う....」というのも書いて書けないわけではないのだが、これってやっぱり「伝説の COME FROM」みたいな感覚である。要するに「こうしないために、longjmp() の第2引数がある」というあたりが実装の意図なんではなかろうか。言い替えると、longjmp() の第2引数の値が、戻った setjmp() の戻り値になる、という仕様は、このような「複数のjmp_bufは使わないように...」という言外の意味を込めているように思う。

あ、勿論これは「書きゃ書けるけど...」という悪ノリで書いたものだ、ということは、ちゃんと認識しておいてくれたかな? 勿論こんなコードは書くべきではない。

「例外」との関係

C++ や Java では、このような非ローカル分岐は try〜catch 構造による「例外」として、文法機能に組み込まれている。そのため、setjmp/longjmp で気をつけなくてはならなかったような、さまざまな制限事項がコンパイル時に静的にチェックできたり、あるいは動的にチェックできたりしている。

一般に例外は次のかたちを取る。これが setjmp/longjmp 構造を焼き直したものであることは、もはや皆さんには明らかであろう。

void sub( ) {
    ............
    throw( new Exception() );   /* 要するに longjmp() */
}

int main( ) {
     ................
     try {                     /* if( setjmp() == 0 ) { */
         sub();
     } catch( Exception e ) {  /* } else { */
         /* エラー処理 */
     }                         /* } */
}

しかし、setjmp/longjmp の場合には longjmp() の第二引数で区別してエラー種別を判定することができるに過ぎなかったが、例外の場合には複数の例外オブジェクトがあり、その「投げられた」例外オブジェクトの種類によって、try〜catch 構造が入れ子になっている階層を「上に辿って」、自分を処理する try〜catch 構造を見つけ出して、そこにまで「戻る」のである。つまり、実行環境が jmp_buf を統一的に管理し(だから jmp_buf は宣言しない)、try〜catch 構造の階層に合わせた jmp_buf の層を陰で持っているのである。

この jmp_buf の層はどういう風に実現されているのか、と考えれば答えはすぐに出る。「スタック」である。つまり、try, catch, throw が実際に何をしているのか、は次のようなものである。

try
これは、そのブロックがキャッチする例外の種類と jmp_buf をセットにしたデータを作り、「例外スタック」に積む。また、この try ブロックから出る時には、積んだデータを「例外スタック」から POP して破棄する。
catch
setjmp() の例外時の戻り値(オブジェクト指向では例外オブジェクト)が、自分が扱う例外の種類かどうかをチェックして、一致すれば処理する。一種の if 文である。
throw
例外スタックの上から順に POP していき、それが投げる例外種別と一致するものを探す。それが見つかれば、その jmp_buf を使って longjmp() する。

ちょっとしたトリックとして、以下のプログラムでは「例外スタック」に積まれるオブジェクト StackEntry は、次のような定義をする。

int kind メンバ
例外種別を表す。しかし、kind == NoException(0) の時にだけ、次の jmp_buf jmp がセットされている。つまり、その他の例外種別は、単にキャッチする例外種別を表すのみとする。これは、try〜catch ブロック毎に一括して複数の例外ハンドラを廃棄しなければならないし、そのブロックで catch する例外がいくつあるか判らないからである。

また、kind == Exception(-1) の時は、ワイルドカードとしてすべての例外種別と一致することにする。
jmp_buf jmp メンバ
上記のように、kind==NoException(0) の時にだけセットされている。

try〜catch風動作の実現

では、このような例外をC言語で模倣してみよう。まず、例外スタックの定義から。これを estack.h というヘッダにしておく。

#include <setjmp.h>
/* 念のため include ... */

struct StackEntry {
    jmp_buf jmp;   /* 実行コンテキスト */
    int     kind;  /* 例外種別 */
};

/* 例外種別 */
#define Exception  -1    /* 総称型例外。すべての例外とマッチする */
#define NoException 0    /* マーク用に使い、この時にだけ jmp がセットされている */
#define IOException 1    /* ファイル入出力などのエラーに使おう */
#define ArithmetricException 2  /* Division by 0 に使おう */
#define MyException  3   /* 自分で定義したもの */
/* などなど、投げたい例外種別を定義する */

/* プロトタイプ */
struct StackEntry *start_tryBlock( void );
void catch_exception( int kind );
void end_tryBlock( void );
void throw( int kind );

では例外スタックを定義し、スタックとして操作する下請け関数は次の通り。常識的なスタックのプログラムである。これは estack.c としておこう。また、StackEntry のオブジェクトを作成して返すルーチンも必要だ。ホントは分けた方がいいが、これも突っ込んでおく。

#include <stdio.h>
#include <unistd.h>
#include <malloc.h>
#include "estack.h"
#include "estackproto.h" /* このファイルの関数のプロトタイプ(省略) */

#define EXCEPTIONSTACKSIZE   128
static struct StackEntry *ExceptionStack[EXCEPTIONSTACKSIZE];
/* 勿論、ホントは可変長配列であるべきである */
static int StackEntrySP = 0;

int isEStackEmpty( void )   /* isEmpty() 操作 */
{
   if( StackEntrySP > 0 ) {
       return 0;
   } else {
       return 1;
   }
}

void EStackPUSH( struct StackEntry *se )  /* PUSH 操作 */
{
    ExceptionStack[StackEntrySP++] = se;
    /* ホントは StackOverFlow を処理して、動的に配列を拡大する */
}

struct StackEntry *EStackPOP( void )  /* POP 操作 */
{
    if( isEStackEmpty( ) ) {
        fprintf( stderr, "Stack UnderFlow!!!\n" );
        exit( 1 );  /* 処理に困る... */
    }
    return ExceptionStack[--StackEntrySP];
}

struct StackEntry *newStackEntry()  /* 新しい StackEntry オブジェクトを作成 */
{
    void *ret;
    ret = malloc( sizeof(struct StackEntry) );
    if( ret == NULL ) {
        fprintf( stderr, "cannot malloc StackEntry!!!" );
        exit( 1 );  /* 処理に困る... */
    }
    return (struct StackEntry *)ret;
}

では、実際に try, catch, throw を模倣するプログラムは次の通り。

#include <stdio.h>
#include <unistd.h>
#include <malloc.h>
#include <setjmp.h>
#include "estack.h"
#include "estackproto.h"

struct StackEntry *start_tryBlock( void )  /* ブロックの開始をマーク */
{
    struct StackEntry *at = newStackEntry();
    at->kind = NoException;            /* jmp_buf のセットは後で */
    EStackPUSH(at);
    return at;                         /* 呼び元でやるから、そのために返す */
}

void catch_exception( int kind )       /* catch する例外を登録 */
{
    struct StackEntry *at = newStackEntry();
    at->kind = kind;
    EStackPUSH(at);
}

void end_tryBlock( void )             /* 結局使わなかった例外スタックを破棄 */
{
    struct StackEntry *at;
    while( ! isEStackEmpty() ) {
       at = EStackPOP();
       if( at->kind == NoException ) {
            free( at );
            break;
       }
       free( at );
    }
}

void throw( int kind ) {             /* 投げる!!! */
    struct StackEntry *at;

    /* まず引数の例外種別を扱う例外スタックエントリを探す */
    while( ! isEStackEmpty() ) {
       at = EStackPOP();
       if( at->kind == kind || at->kind == Exception ) {
            free( at );        /* 総称例外は何でも一致 */
            break;
       }
       free( at );
    }  /* POP しながら free していることに注意 */

    /* 扱う例外スタックエントリが見つからない... トップレベルの処理 */
    if( isEStackEmpty() ) {
       fprintf( stderr, "Exception caught by Top Level\n" );
       exit( 1 );  /* 終ってしまえ!! */
    }

    /* さて、そこから実際の jmp_buf の入った NoException を探す */
    while( ! isEStackEmpty() ) {
       at = EStackPOP();
       if( at->kind == NoException ) {
            break;
       }
    }
    /* jmp_buf の入った NoException が見当たらない... 翻訳のバグである */
    if( at->kind != NoException ) {
       fprintf( stderr, "Illegal StackEntry!!! cannot longjmp\n" );
       exit( 1 );
    }
    longjmp( at->jmp, kind );  /* longjmp できる!! */
}

で、使う場面では次のように翻訳していく。

#include <stdio.h>
#include <unistd.h>
#include <setjmp.h>
#include "estack.h"

int sub( int n ) {
    if( n == 0 ) {
        throw( MyException );  /* ここで投げる */
    } else {
        printf( "sub: n=%d\n", n );
    }
}

int main( ) {
    struct StackEntry *se;
    int ret;

    se = start_tryBlock();                  /* try -- まず最初のオブジェクト*/
    catch_exception( IOException );         /*     -- 例外の登録 */
    catch_exception( MyException );
    if( (ret = setjmp( se->jmp )) == 0 ) { /* {    -- setjmp をここでする */
        int i;  /* setjmp() からの戻り */
        for( i = 10; ; i-- ) {
            sub( i );
        }
        end_tryBlock();                    /* }    -- 例外スタックの破棄 */
    } else {  /* longjmp() からの戻り */
        free( se ); /* オブジェクトの破棄.. ちょっと変な位置でせざるを得ない */
        if( ret == IOException ) {         /* catch( IOExceptoin e ) { */
            printf( "IOException occured\n" );
        } else if( ret == MyException ) {  /* } catch( MyExcepiont e ) { */
            printf( "MyException occured\n" );
        }                                  /* } */
    }
    /* この時点では、例外スタックにはもう何も積まれていない */
    return 0;
}

要するに、「例外」とは setjmp/longjmp 機構を形式化したものに過ぎないことがおわかりだろう。つまり、goto文を構造化する手段として、if, for, while のような構造化構文が導入されたように、setjmp/longjmp を「構造化」するために、「例外」が導入されたと考えてもよいのではないか、と筆者は思っている。筆者は実は setjmp/longjmp 機構も、「例外」機構も大変好きである。皆さんの理解が深まり、一部にある偏見(あるんだよな、これ)が取り除かれんように祈る。(ただし、乱用しちゃいけないよ!)



copyright by K.Sugiura, 1996-2006