Last update 1999/08/07

Scheme処理系の制作 第7回

(C)平山直之
無断転載は禁止、リンクはフリー
誤字脱字の指摘は歓迎


異なるプログラム方式の連携(理論編その2)

前回は割と一般的な話に終始してしまいましたが、今回はVismに特化しした話を進めたいと思います。

今回もまた理論の続き、実践は次回、ということになってしまいましたが、もう少々お付き合いください。

今回、C++とschemeが入り組んでいてややこしいので、C++ソースはバックが、schemeはバックがと色分けしてあります。

Vismのコールバック関数の定義

最新のVismでは、コールバック関数を定義できるようになりました。

Vismのコールバック関数は、クライアント側で定義した関数をあたかもschemeの組み込み関数であるかのごとく使うことのできるものです。

Vismとそのクライアントアプリケーションとの連携には必要不可欠なものなので、これをざっと解説しておきます。

手順

1.コールバック関数の定義

まず、コールバック関数を定義します。Vismのコールバック関数に用いる関数は、以下のようなプロトタイプで定義することが義務づけられています。

VISMOBJ 関数名(void* パラメータ,VCONTEXT コンテキスト,VISMOBJ 引数)
{
}

パラメータは、ユーザが好きに使えるデータです(詳しくは後程)。コンテキストは、言うまでもなくこの関数が呼ばれるschemeコンテキストです。引数は引数のリストです。普通のschemeリストですので、VIGetCar/VIGetCdrなどを用いて操作します。破壊的な操作は禁じられています。

最後に、この関数の戻り値がscheme関数としての戻り値になります。

2.コールバック関数オブジェクトの生成

次に、この関数へのポインタを使って、Vismの「コールバック関数オブジェクト」を生成します。このオブジェクトは、schemeの組み込み関数と全く同じように使うことができます。

実際に生成するには、以下のようにします。

VIMakeCallBackPrimitive(コンテキスト,関数へのポインタ,パラメータ);

ここで、パラメータで何か値(void*型)を指定しておくと、定義したコールバック関数が呼ばれるたびに、パラメータとしてその値が渡されることになります。うまく使ってください(うまくやればthisをパラメータにしてクラスのメンバ関数をコールバック関数にできるかも?)

3.シンボルへのバインド

コールバック関数を作っても、schemeスクリプトからそのオブジェクトに触る方法がなければ意味がありません。そこで、このオブジェクトをシンボルに束縛しましょう。

上で説明したコールバック関数オブジェクトの生成とあわせて、以下のようにします。

VIDefine(context,
    VIReadFromString(context,"関数名"),
    VIMakeCallBackPrimitive(context,関数へのポインタ));

これも特に問題はないと思います。

サンプル

ここまでの手順を踏まえて、試しに引数をすべて文字列とみなして標準出力に出力するコールバック関数を定義してみましょう。

呼び出し側(schemeスクリプト)ではこのように呼び出します。

(callback-message "こぶた\n" "たぬき\n" "きつね\n" "ねこ\n")

このスクリプトがt.vscというファイル名でセーブしてあるものとすると、これを実行するプログラムは以下のようになります。

VISMOBJ callback_message(void* /*無視*/,VCONTEXT context,VISMOBJ args)
{
    while(VIIsPair(context,args)){
        cout << VIGetString(context,VIGetCar(context,args));    
        args=VIGetCdr(context,args);
    }
}

int main(int,char**)
{
    // 初期化
    VCONTEXT context=VICreateContext();

    // コールバック関数の定義
    VIDefine(context,
        VIReadFromString(context,"callback-message"),
        VIMakeCallBackPrimitive(context,callback_message,NULL));

    // スクリプトファイルの読み込み・実行
    VILoad(context,"t.vsc");
    
    // 後始末
    VIDestroyContext(context);

    return 0;
}

お分かりいただけたでしょうか。

コールバックと継続

コールバック関数の仕組みが理解できたものとして、次にどのようにしてコールバックと継続を用いたVAFXとVismの連携が行えるのかを解説しましょう。

コールバックとマルチスレッド

前回書いたように、VAFXは時間をイベントとして扱っており、そのイベントの処理はごく短時間で終わらなければならない仕組みになっています。

一方、スクリプトのほうは必ずしも短時間で終われるとは限りません。たとえば、典型的なRPGで、主人公の移動を以下のようにスクリプト化するとしましょう。

(define (event118)
    (move -3 0)
    (move 0 3)
    (move -7 0))

この場合、主人公の移動は暗黙のうちに画面の更新を伴うわけですから、スクリプトの実行中も必要に応じて画面の更新のチャンスをVAFXシステムに与えることが必要になります。

ここで、仮にmoveをコールバック関数にすることを考えてみましょう。すると、マルチスレッド無しでは実現が不可能なことがわかります。画面・ゲームオブジェクトの更新とスクリプト実行が同じである場合、moveの実行中は画面・ゲームオブジェクトの更新を行うことができないからです。

マルチスレッドで実現するならば、moveの定義は

VISMOBJ move(void*,VCONTEXT context,VISMOBJ args)
{
    // 未確認コードゆえ概念のみ読み取られたし

    int x=VIGetInteger(context,VIGetCar(context,args));
    int y=VIGetInteger(context,VIGetCar(context,VIGetCdr(context,args)));

    // グローバル関数を変更するなどしてメインスレッドに指示を出す関数
    MovePlayerCharacter(x,y);
    
    // done_moveはグローバル通信オブジェクトとして、
    // メインスレッドがシグナルを発行してくれるものと仮定
    WaitForSingleObject(done_move,INFINITE);
}

などとすることができます。

コールバックと継続

Vismがscheme以外の言語をインプリメントしたものであったなら、VAFXとの連携には上で述べたようにマルチスレッドを使うか前回述べたように仮想マシンの制御を行うしかなかったでしょうが、Vismはschemeをインプリメントしたものであるので、VAFXとの連携はschemeの「継続」を使って行うことができます。

通常、こちらのほうが柔軟で安全ですが、「継続」の概念を理解していないと把握が困難という欠点があります。

いきなりですが、コードはだいたいこんな感じになります。

scheme(スクリプト)側

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; run-event
;; 正常終了した場合は#fを、
;; 脱出された場合は継続を返す
(define (run-event event)
  (call/cc (lambda (x)
            (set! *entry-point* x)
            (event)
            #f))
  )

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; continue-event
;; 正常終了した場合は#fを、
;; 脱出された場合は継続を返す
(define (continue-event cont ret)
  (call/cc (lambda (x)
            (set! *entry-point* x)
            (cont ret)))
  )

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; message
;; コールバック関数を呼び出す
(define (message messages)
  (apply client-message messages)
  (call/cc *entry-point*)
  )

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; query-yesno
;; コールバック関数を呼び出す
(define (query-yesno)
  (client-query-yesno)
  (call/cc *entry-point*)
  )

C++(VAFX)側

VISMOBJ RunEvent(const char* event)
{
    char buffer[256];
    sprintf(buffer,"(run-event %s)",event);

    return 
        VIEval(
            context,
            VIReadFromString(context,buffer));
}

VISMOBJ ContinueEvent(VISMOBJ continuation,VISMOBJ argument)
{
    return 
        VIApply2(
            context,
            VIEval(context,VIReadFromString(context,"continue-event")),
            continuation,
            argument);
}

VISMOBJ ClientMessage(void* param,VCONTEXT c,VISMBOJ a)
{
	std::string s;
	while(VIIsPair(c,a)){
		s+=VIGetString(c,VIGetCar(c,a));
		s+="\n";
		a=VIGetCdr(c,a);
	}
	((MessageWindow*)param)->Prompt(s);
	return VIMakeBoolean(c,false);
}

これを使う場合、まずClientMessage関数をコールバック関数として登録し、その後イベントを実行したくなったら

RunEvent("イベント(scheme関数)名");

とします。そしてRunEventの戻り値が#f(C++側ではVIMakeBoolean(context,false))と等しければそれはイベントが終了したということです。そうでなければユーザー入力待ちなどで一時的に制御を戻しただけでまだ続きがあるということなので、その値を保存しておきます。

RunEventの戻り値が#fでなかった場合、ユーザー入力待ちなどが終了してそのイベントの続きを実行したくなったら、その値とユーザー入力の結果など(必要なければ適当な値でよい)を引数としてContinueEventを呼び出します。もちろん、第1引数がRunEventの戻り値です。ContinueEventの戻り値もRunEventの戻り値と同じような意味を持っているので、イベントが終了するまで同じように扱います。

本連載の第3回目の継続の説明を読みながら上記のプログラムを死ぬ気で読むのが継続の習得のためにもベストなのですが、一応可能な限り説明してみようと思います。

仮に、ClientMessageがコールバック関数としてすでに登録されており、また事前に以下のようなイベントが定義されたファイルが読み込まれているものとします。

(define (event117)
    (message "私は恐怖の大王")
    (message "いけにえをよこせ")
    (if (query-yesno)
        (message "許してやる")
        (message "死ね"))
    )

C++(VAFX)側でイベントを実行したくなった場合、イベント名(scheme関数名)を引数としてRunEvent関数を呼び出します。

RunEvent("event117");

RunEvent関数の内部では、scheme関数のrun-eventが以下のような(schemeの)形式で呼び出されます。

(run-event event117)

このように、イベントの呼び出しは必ずrun-event経由で行うことを、このシステムの規約とします。今回は引数を与えない仕様になっていますが、可変個引数を取らせたりするのもschemeではまったく問題ないことですから、必要な場合は拡張するとよいでしょう。

run-eventの内部では、まずcall/cccall-with-current-continuationの略)が呼び出されます。上のソースでは無名関数を使っていますので、schemeに慣れない人のために名前付き関数を使って書き換えてみましょう。上のrun-event関数は、以下の関数とほぼ等価です。

(define *event* #f)
(define *entry-point* #f)

(define (run-event-aux continuation)
  (set! *entry-point* continuation)
  (*event*)
  #f)

(define (run-event event)
  (set! *event* event)
  (call/cc run-event-aux)
  )

このホームページに置かれている継続の説明を見ればわかると思いますが(わかるといいなあと思いますが(笑))、call/ccは、引数として与えられた関数を、現在の継続を引数として呼び出す関数です。ゆえに、上の書き換えた式におけrun-eventの3行目では、その時点での継続を引数としてrun-event-auxを呼び出すわけです。

ここで、run-event-auxに目を転じてみましょう。run-event-auxの引数continuationには、run-event-auxが呼び出されるときの継続が代入されています。そこで、これを大域脱出に使うために、*entry-point*という名のシンボルにバインドします(「シンボルにバインド(束縛)される」=「変数に代入される」と考えて問題ありません)。

そののち、run-event呼び出しの第1引数(run-eventの2行目でグローバルシンボル*event*に代入されています)を関数とみなして、その関数を引数無しで呼び出すのがrun-event-auxの3行目の

(*event*)

の部分です。この実行がつつがなく終わると、run-event-aux#fを戻り値として呼び出し元、すなわちcall/ccを呼び出した場所に戻ります。run-event関数ではcall/ccの後には何もすることが残っていないので、このcall/ccの戻り値(つまりrun-event-auxの戻り値)をrun-eventの戻り値として呼び出し元(C++クライアント)に戻ります。

さて、難しいのはここからです。以上の手順で終わるのは、あくまで前述の

(*event*)

の部分がつつがなく終わった場合に限るからです。つつがなく終わらなかった場合とはすなわち、実行中に継続が呼び出されて大域脱出が起こったときのことです。

たとえば、上のスクリプトの一行目では、

(message "私は恐怖の大王")

message関数が呼び出されていますね。そのmessage関数は

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; message
;; コールバック関数を呼び出す
(define (message messages)
  (apply client-message messages)
  (call/cc *entry-point*)
  )

という関数ですから、その実行は

  1. コールバック関数client-messageを呼び出す。
  2. 現在の継続を引数として*entry-point*に束縛された継続を呼び出す。

という手順で行われるわけです。

前述のように、call-with-current-continuation(call/cc)は、現在の継続を引数として、引数として与えられた関数を呼び出す関数ですから、上の手続き2.では、現在の継続を引数として*entry-point*に束縛された関数を呼び出す、すなわち現在の継続を引数として昔の継続を呼び出す式になるわけです。

さて、ここで、*entry-point*に束縛されている継続はいったいなんだったでしょうか。このシステムでは規約としてイベントを呼び出すときはrun-event関数経由で呼び出すことになっていますから、上の

(define *event* #f)
(define *entry-point* #f)

(define (run-event-aux continuation)
  (set! *entry-point* continuation)
  (*event*)
  #f)

(define (run-event event)
  (set! *event* event)
  (call/cc run-event-aux)
  )

からわかるように、*entry-point*には(call/ccの次の行から始まる継続、すなわちrun-eventを終了する継続が束縛されているのです。

また、

以上のschemeの仕様から、message関数がいままさに終わらんとするときの継続が、run-eventの値となってクライアントアプリケーションに復帰するという結果が得られるというわけです。

continue-eventもほとんど同じ仕様ですが、引数として関数ではなく継続と継続呼び出しの引数をとるところが異なっています。もちろん、呼び出しの第1引数は前回のrun-event(continue-event)で得た継続を用います。そして第2引数は、メニュー応答の選択データを渡すのに使えます。たとえば、query-yesno関数を、上のように

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; query-yesno
;; コールバック関数を呼び出す
(define (query-yesno)
  (client-query-yesno)
  (call/cc *entry-point*)
  )

と定義しておけば、continue-eventの呼び出しによって、continue-eventに第2引数として渡された値を引数としてquery-yesno内で作った継続が呼び出されますから、continue-eventの第2引数がquery-yesno内のcall/cc呼び出しの値であったかのごとく扱われるのです。そういうわけで、「はい」を選択したら#tを、「いいえ」を選択したら「#f」をcontinue-eventの第2引数として渡せば、それがquery-yesnoの値となるわけです。

わかりました?

ええ、もちろん、すぐにはわからないと思います。わたしも始めはさっぱりわかりませんでした。

でも、一所懸命考えればきっとわかると思います。そして一旦わかってしまえば、決してそれほど難しい概念ではないこと、そしてものすごく柔軟かつ強力な機能であることがわかることでしょう。

がんばってがんばって、それでもわからなかったときは、ウルトラ……もとい掲示板などで質問してください。なるべく理解の手助けをできるよう努力しますので。

ここから何か新しいものが生まれればいいな、と私は考えています。


(C) 1998 Naoyuki Hirayama. All rights reserved.