さて、ここまでの話は、「クロージャの使用を追跡して、しっかり最適化してくれる賢い処理系が理論上作成可能である」という話でした。では、現実の処理系はどこまで賢いのでしょうか。
先に述べたように、どこまで最適化を追究するかはほかの要素とのトレードオフになり、処理系の設計方針に依存します。ソースを直接読んで実行するタイプのスクリプト処理系では、最適化時間も実行時に影響を与えるため、あまりアグレッシブな最適化は行えないでしょう。一方バッチコンパイルして実行する処理系ならば、かなり突っ込んだ解析を行えます。
例えばSchemeスクリプト処理系Gaucheは、本稿執筆時のバージョン0.8.7では1つのトップレベル関数内における簡単な依存性解析と最適化を行っています。どのような最適化が行われたかを簡潔に知る方法は残念ながらないのですが、Gaucheはソースを内部VM用にコンパイルするので、その結果をのぞくことで見当をつけることができます。
リスト4で定義したfoldのコンパイル結果を、Gaucheの組み込み関数disasmで逆アセンブルしてみましょう*(実行例1)。ここではVMのインストラクションの詳細な説明を省きますが、foldの中でローカルに定義していたクロージャloopが展開され、ジャンプ命令(LOCAL-ENV-JUMP)を使った単なるループになっている雰囲気は分かると思います。
gosh> (disasm fold)
main_code (name=fold, code=0x886aea0, size=22, const=0, stack=23):
args: #f
0 LREF1-PUSH ; seed
1 LREF0-PUSH ; lis
2 LOCAL-ENV(2) ; (loop seed lis)
3 LREF0 ; lis
4 BNNULL 8 ; (null? lis)
6 LREF1 ; seed
7 RET
8 PRE-CALL(2) 15
10 LREF1-PUSH ; seed
11 LREF0 ; lis
12 CAR-PUSH ; (car lis)
13 LREF12 ; proc
14 CALL(2) ; (proc seed (car lis))
15 PUSH
16 LREF0 ; lis
17 CDR-PUSH ; (cdr lis)
18 LOCAL-ENV-JUMP(1) 3 ; (loop (proc seed (car lis)) (cdr lis))
20 RET
21 RET
一方、リスト5のmore-thanを逆アセンブルしてみると、関数が2つ(main_codeとinternal_closure_0)に分けられているのが見て取れます(実行例2)。main_code内にあるCLOSURE命令が、コード列internal_closure_0とその時点での環境を一緒にしてクロージャを作成する命令です。Gaucheはグローバルな関数をまたいだ最適化を(まだ)行わないため、このケースではクロージャの実体がアロケートされてしまいます。
gosh> (disasm more-than)
main_code (name=more-than, code=0x9e79ca0, size=7, const=2, stack=6):
args: #f
0 CLOSURE #<lambda 0> ; (lambda (out elt) (if (> elt n) (cons el ...
2 PUSH
3 CONSTN-PUSH
4 LREF0-PUSH-GREF-TAIL-CALL(3) #<identifier user#fold>; (fold (lambda (out elt) (if (> elt n) (c ...
6 RET
internal_closure_0 (name=#f, code=0x9e7be60, size=10, const=0 stack=1):
args: #f
0 LREF0-PUSH ; elt
1 LREF11 ; n
2 BNGT 8 ; (> elt n)
4 LREF0-PUSH ; elt
5 LREF1 ; out
6 CONS ; (cons elt out)
7 RET
8 LREF1 ; out
9 RET
ではもっと賢い処理系を見てみましょう。2006年時点では、Scheme界の最適化処理系で名高いのはジェフリー・シスカインド(Jeffrey Mark Siskind)によるStalin*です。Stalinは、シスカインド氏が本業の傍ら、もっぱら個人で使うために開発している処理系のため開発ペースが速いとはいえませんが、実行時性能の高さには定評があります(ただし、その突っ込んだ最適化のために、コンパイル時間が数十分におよぶこともあるのですが)。
Stalinは、標準関数もひっくるめたプログラム全体の依存性解析と型推論を行い、徹底的に最適化されたコードを出力するコンパイラです。Stalinによってどこまでの最適化が可能なのか、実際に処理系を入手して試してみました。なおStalinはCを介してネイティブコードを出力するのですが、gcc-4系列ではgcc自身の最適化ルーチンが終了しなくなるので、gcc-3系列を用いて処理系をビルドする必要があります。
例えばFedora Coreでは、compat-gcc-32パッケージをインストールすることによって、gcc32という名前でgcc-3.2.3が起動できます。stalinをビルドするには、makefileの冒頭にあるCCの定義を、
CC=gcc32
のように書き換えた上で、シェルスクリプトbuildを走らせてください。
なお、テストのためにクロージャベースのオブジェクトを使った、簡単なプログラムを用意しました(リスト8)。Stalinはまだdefine-syntax構文*をサポートしていないので、本稿の最初に示したdispatchルーチンを手で書いてやる必要があります。また、大括弧[]も認識しないようです。
(define (make-point x y)
(define (dispatch msg)
(case msg
((get-x) (lambda () x))
((get-y) (lambda () y))
((set-x) (lambda (nx) (set! x nx)))
((set-y) (lambda (ny) (set! y ny)))))
dispatch)
(let ((the-point (make-point 111 222)))
((the-point'set-x) 333)
(display ((the-point 'get-x)))
(display ((the-point 'get-y)))
)
さて、リスト8の内容をpoint.scmというファイルに保存して、stalinを起動します。「-k」オプションで中間コードとなるCファイルを消さないように指示しました。また、そのCファイルを調べやすいように、幾つかの「-O」オプションを指定し、ランタイムチェックをoffにしました。
$ stalin -k -On -Ob -Om -Or -Ot point.scm
これで中間コードであるpoint.cと実行バイナリであるpointが作られます。point.cは人間が読むことは想定していない上、displayなどの標準関数までインライン展開されているため極めて読みづらいですが、初期値として与えている定数111、222、333を手がかりとして追っていきます。すると、プログラム本体はリスト9のようにコンパイルされていることが分かりました(コメントは適宜補いました)。
int a657; /* X */
int a658; /* Y */
FILE *a1728; /* THE-CURRENT-OUTPUT-PORT */
struct headed_vector_type591 *a1730; /* BUFFER */
void f0(void)
{int a2078; /* OBJ */
int a2108; /* NX */
int a2114; /* OBJ */
int t72;
int t73;
FILE *t74;
int t75;
int t76;
int t77;
FILE *t78;
int t79;
int t80;
FILE *t95;
struct headed_vector_type591 *t97;
int t98;
t95 = stdout;
/* 出力用バッファ */
t98 = 20;
t97 = (struct headed_vector_type591 *)GC_malloc_atomic(sizeof(struct
headed_vector_type591)+((t98-1)*sizeof(int)));
t97->length = t98;
a1728 = t95;
a1730 = t97;
/* 初期化 (make-point 111 222) */
t79 = 111;
t80 = 222;
a657 = t79;
a658 = t80;
/* ((the-point'set-x) 333) */
t75 = 333;
a2108 = t75;
a657 = a2108;
/* ((the-point'get-x)) */
t76 = a657;
a2114 = t76;
t77 = a2114;
t78 = a1728; /* stdout */
/* (display ...) */
f1438(t77, t78);
/* ((the-point'get-y)) */
t72 = a658;
a2078 = t72;
t73 = a2078;
t74 = a1728; /* stdout */
/* (display ...) */
f1438(t73, t74);
return;}
このうち、出力バッファはdisplay内部で用いるため、displayを使う限りはアロケートされるようです。注目すべきは、make-point関係のクロージャがすべて消去されていることです。pointのインスタンスは、何と単なるグローバル変数「a657」、「a658」として表現され、「get-x」、「set-x」などのメッセージの処理が、すべてインライン展開されています。
このように、プログラムの字面でのクロージャは、必ずしも実装上での関数とは対応しません。むしろそれは、引数によってパラメータライズされた論理的なブロックと見るべきなのです。
クロージャを「C/C++的な関数に環境がくっついたもの」と考えていると、それとオブジェクトとが等価であるとはどうしても思えないかもしれません。しかし、クロージャをプログラムの意味、あるいは仕様の記述と見れば、両者は同じものを別の言葉で記述しているにすぎないということが分かります。「それが実際にマシン上でどのように実装されるか」という問題は、処理系の実装次第なのです。
Copyright © ITmedia, Inc. All Rights Reserved.