環境の使い捨てをやめよう! 〜環境にやさしい環境リサイクル continuation with assembly 編〜

君は継続(continuation)を効率よく使える言語を知っているか!!
そんな、アレだったり、コレだったりではない!原点にかえってアセンブリ言語の話をしよう。なんと、アセンブリ言語は継続を効率良く扱うことができるのだ!!CPUは素晴らしい!!


アセンブリで継続を扱う方法は、

  • ebp保存は呼び出し側でやる
  • espは減らさない

たったこれだけ!!

void do_nothing( void ) { return ; }
void main( ) { const char *p = "hogehoge"; do_nothing(); puts(p); }

こーいうプログラムを考えよう。
ふつーにコンパイルすると、こんな感じ。

	.globl main
	.globl puts

	.section .rodata
str:	
	.string "hogehoge"

	.text
do_nothing:
	pushl	%ebp       # ebp 保存
	movl	%esp, %ebp # スタック先頭をフレームに

	movl	%ebp, %esp # スタック戻す
	popl	%ebp       # ebp戻す
	ret

main:
	pushl	%ebp
	movl	%esp, %ebp

	subl	$4, %esp
	movl	$str, -4(%ebp) # p = "hogehoge"
	
	call	do_nothing     # callで呼ぶだけ

	pushl	-4(%ebp)
	call	puts
	movl	%ebp, %esp
	popl	%ebp
	ret

こいつを、少し考えかたを変える。

struct cont { prog_addr *addr, frame_addr *env }; /* 継続はジャンプ先アドレスと環境をペアにしたの */

void do_nothing( cont c ) { 
	super_goto *c;  /* super_gotoは、フレームも戻すgoto */
}

void main( ) {
	const char *p = "hogehoge";
	cont c = { retaddr, get_curent_frame() }; /* get_current_frame() は今のフレームを拾ってくる */

	do_nothing( c );
retaddr:
	puts(p);
}

こんな感じにする。super_gotoとget_current_frame()はなんか拡張だと思って。
引数と一緒に、継続データ(続きの実行アドレスとその環境をあわせたもの)を渡しておくのだ。


アセンブリで書くとこんな感じ。

	# | local var |
	# | local var |
	# | cont addr | <- esp
	# | cont env  |
	# | argument  |
	# | argument  |
	# | ...       |

	# 何もしないでおわる関数
do_nothing:
	movl	%esp, %ebp      # フレーム構築
	
	movl	(%ebp), %ebx
	movl	4(%ebp), %ebp   # 引数で渡された環境に戻す
	jmp	*%ebx           # 引数で渡されたアドレスにジャンプ

	.globl	puts
	.globl	main
	
main:
	movl	%esp, %ebp      # フレーム構築
	addl	$-4, %esp	# 本題とは関係無いけど、addl -4と、subl 4はどのぐらい違うんだろうか

	movl	$message, -4(%ebp)

	pushl	%ebp		# 呼び出し側でebpを保存
	call	do_nothing

	pushl	-4(%ebp)	# ライブラリ関数を呼ぶ時は呼びかた変えないと…
	call	puts
	movl	%ebp, %esp
	ret			# mainはライブラリルーチンから呼ばれてるのでそれにあわせないと…

	.section .rodata
message:
	.string "hogehoge"
	

で、これで、何ができるのか。
いや、なんでもできるよ。

void do_nanika( int x, int y )
{
	int sum = x+y;    /* この処理は重い(?)ので、あんまりやりたくない */

	printf( "%d\n", sum );  /* ので、この部分だけを実行する処理を値として返したい */
}

どんな状況だ。
これを継続を使うと、こう書ける。

cont *do_nanika( ret_cont, int x, int y )
{
	int sum = x+y;    /* この処理は重い(?)ので、あんまりやりたくない */

	cont c = { next, get_current_frame() };
	super_goto *ret_cont(c);

next:
	printf( "%d\n", sum );  /* ので、この部分だけを実行する処理を値として返したい */
	super_goto *ret_cont(c);
}

void nanika_loop( int n )
{
	cont *c = do_nanika( next_cont(), 300, 500 ); /* next_contは継続を拾ってくる都合の良い関数 */

	if ( n >= 0 ) {
		n --;
		super_goto *c;
	}
}

嬉しいのか…は、ともかく、これはC言語では書けない。しかし、アセンブリなら書ける。

	.globl main
	.globl printf

	.section .rodata
format:
	.string	"%d\n"

	.text
	# c = -12,-8(ebp)   {jump_addr,frame_addr}
	# sum = -4(ebp)
	# cont = 0(ebp), 4(ebp)
	# x = 8(ebp)
	# y = 12(ebp)
do_nanika:
	movl	%esp, %ebp
	subl	$12, %esp
	
	movl	12(%ebp), %ebx
	movl	8(%ebp), %ecx
	addl	%ebx, %ecx
	movl	%ecx, -4(%ebp)  # sum = x+y

	movl	$next_addr, -12(%ebp)
	movl	%ebp, -8(%ebp)  # next_cont()

	leal	-12(%ebp), %eax # 戻り値(継続)は%eaxへ

	movl	(%ebp), %ebx
	movl	4(%ebp), %ebp
	jmp	*%ebx           # super_goto

next_addr:
	pushl	-4(%ebp)
	pushl	$format
	call	printf
	addl	$8, %esp        # printf( "%d\n", sum )

	leal	-12(%ebp), %eax # 戻り値(継続)は%eaxへ

	movl	(%ebp), %ebx
	movl	4(%ebp), %ebp
	jmp	*%ebx           # super_goto



	# c = -4(ebp)
	# cont = 0(ebp), 4(ebp)
	# n = 8(ebp)
nanika_loop:
	movl	%esp, %ebp
	subl	$4, %esp    # cont *c

	pushl	$500
	pushl	$300

	pushl	%ebp        # 継続を積む
	call	do_nanika

	# もどってきた継続 = %eax
	cmpl	$0, 8(%ebp)
	je	.L3

	decl	8(%ebp) # n--

	movl	(%eax), %ebx
	movl	4(%eax), %ebp  # super_goto c

	jmpl	*%ebx # super_goto

.L3:

	movl	(%ebp), %ebx
	movl	4(%ebp), %ebp
	jmp	*%ebx



main:
	movl	%esp, %ebp
	pushl	$10         # 10回ループ
	pushl	%ebp
	call	nanika_loop

	movl	%ebp, %esp
	ret

まったく、なんということだろうか。低級言語であるC言語には扱えない概念だけど、アセンブリ言語なら扱える概念というのが存在するのである。Common Lispや、OCamlでさえ、ファーストクラスオブジェクトとして持ってない概念を、アセンブリ言語ファーストクラスオブジェクトとして扱えるのだ!!一級市民だ!!
いや、アセンブリ言語におけるファーストクラスオブジェクトがなんなのかわからないけど。まあ、レジスタ二個ぐらいに入るんだったら、多分一級市民。


と、いうのは、非常に嬉しいような感じなんだけど、このトリックは実はespを減らさない、というところにあるので、なにをどうやっても、そのうちスタックオーバーフローしてしまうのだった。


…いや、ほら、なんていうか、昨今のGC技術の発展によってですね。使わないスタックフレームを捨てたり折りたたんだりとかは自動でやってくれるんですよねぇ…多分。


いや、まあ、そんな感じで、どうでもいい遊び。


なんだけど、これはそれなりに重要なことを表してると思うのだけど。
スタックフレームを柔軟にぐにょぐにょできるのは、非常に興味深い、ということ。で、それは、つまり、「C言語やら、既存の仮想マシンやらのようにフレームをいじれないやつをコンパイラのバックエンドに使ってはいけない」と、いうこと。


Schemeにおける継続、Rubyにおけるイテレータ、ブロック、Pythonにおけるジェネレータ、と、「実行途中の状態を環境ごと閉じ込めてしまう」、という概念は、非常に便利で、応用の幅が広いことは、そういうのを使ったことがある人なら、誰だって知ってることだろう。
しかし、C言語や既存のVMは、そういう概念を扱うことができない。
継続、もしくは、そんな感じのものを実現しようとしたら、効率と美しさを放棄して、無理矢理、そういう概念をエミュレーションするしかなくなる。なんて無駄!なんたる無駄ッ!
CPUを直接弄れば、シンプルかつ、効率よく扱うことができるというのに!たとえ、安全ではないにしても。


コンパイラは、安全な世界と、危険だけど、効率の良い世界とを繋ぐかけ橋として存在しなければならないはず。コンパイラが生成したあとの世界が安全である必要なんてない。そう、つまり、つまりは、アレだ。コンパイラはいつだって、機械語を生成しないといけない。