自作Lispインタプリタを公開して後悔しないように

12月 2nd, 2013

(この記事はLisp Advent Calendar 2日目のためのエントリです。)

「え、お前の家、カレーに味噌汁付けてるの!?」

頑張って作っていたLispインタプリタがついに完成。友達に自慢してソースコードを見せてみたら
「え、なんでそんな実装になってるの? それって変じゃない?」
といった反応が返ってきたことはありませんか。きっとありますよね。昔から「人の数だけLispがある」と言われていまして、書く人によって色々と違いが出てくるのは当然です。しかし、どんな違いが出てくるのかを知らないと、要らぬ混乱を招く可能性があります。

この記事を読むことで、一言に「自作Lispインタプリタ」と言っても、色々なやり方があることを知り、友人の心ない発言に傷ついたり、逆に人を傷つけないようにする知識を身につけた気分になれます。

「あの人は自分でごみ集めを書いてくれたのに」

Lispインタプリタを書くにあたって、一番最初に決めなければいけないのが、実装に使う言語です。世の中には星の数ほどプログラミング言語がありますが、Lispを作るにあたって重要になるのはごみ集め(garbage collection)が付いているかどうかです。ご存知の通り、Lispにはごみ集めが(非常に特殊な実装を除き)必ず付いてきます。JavaScriptなどごみ集めが付いている言語を使う場合は、自分では何もしなくてもうまくいきますが、C言語などごみ集めが付いていない言語を使う際には自分でごみ集めを実装しなければなりません。そしてそれは(慣れないと)非常に難しいのです。

では、ごみ集めが付いている言語で書けば安心だな、と思ったら大間違いです。
世の中には「Lispを作った」と聞くと、Lispをどう作ったかより、ごみ集めをどう作ったかの方に注目する人たちがいるのです。そういった人に「ごみ集めは実装に使った言語の方にあったのでなにもやってません」と言うと、両親がコロコロコミックと間違えて別冊コロコロコミックを買ってしまった時のお子さんのような表情をされることになるでしょう。残念な顔をされても落ち込まないように気をつけてください

「お前のLispインタプリタ、タグがデータに付いてるの!?」

使う言語を決めて、実際にLispインタプリタを書き始めようとしたときに、一番最初に悩むのは「Lispのデータをどう表すか」ということでしょう。
「数は実装側の数値型を使えばいいし、シンボルは文字列か何かでも使っておこう。コンスセルはポインタが2つあったら作れるな」
などと考えた後に
「ちょっとまて。Lispは実行時まで型が分からないのだから、どこかに型の情報を入れておかないとダメだ」
と気づくでしょう。そして次のようなプログラムを書く人も多いのではないでしょうか。

typedef struct {
  int tag;  // データの種類
  union data {  // 実際のデータ
    ..
  }
} LispObject;

LispObject* obj = カッコイイアロケーション();

まあ、実際のデータの部分はunionだったりchar[1]だったりchar[0]だったり人それぞれですが、自信満々にこういった実装を人に見せると予想外の反応をさせることあります。データサイズとか構造体のpaddingとか細かいところに対する反応ではなく、まるで
「え、お前の家、カレーに味噌汁を付けてるの!?」
といった感じの驚きの反応が返ってくるのです。

前述のとおり、Lispを作る際にはどこかに型の情報を入れておく必要がありますが、どこにいれるかは、2つのやり方があります。ひとつは先程のようにデータと一緒に格納する方法。そしてもうひとつはポインタに格納するという方法です。

typedef void* LispObject;
#define MAKE_CONS(x) ((LispObject)((uintptr_t)(x) | 1))
#define MAKE_SYM(x) ((LispObject)(((uintptr_t)(x) << 3) | 3))
#define MAKE_NUM(x) ((LispObject)(((uintptr_t)(x) << 3) | 7))
#define MAKE_OTHER(x) ((LispObject)(uintptr_t)(x))

急に禍々しくなりました。おまけにどのデータが何バイト境界にアラインされているかなどを意識しないと大変なことになってしまいます(前者の実装でもそれは大事ですが)。しかしながら、実際の処理系ではこういった実装が多いのも事実です。現実問題として、ポインタを辿って型を確認するよりも、ポインタそのものを見て型が分かったほうがメモリの参照が減り、キャッシュミスなども減少しそうです。

残念なことに、一度データに型を持たせる実装をしてしまったら、それをポインタに型を持たせる実装に書き換えるのはかなり大変です。80年代のbit (共立出版) にもこの後悔が載っている記事があったような気がします。既にデータに型を持たせる実装をしてしまったのであれば、
「俺は自分の意志でこの実装にしたんだよ!」
と主張し、心を強く持ちましょう。

ちなみに、高級な言語を使っている場合、そもそもこの議論ができない可能性があります(特に実装側の言語にtypeofのようなものがある場合など)。そのような場合は間違えてワイルドミニ四駆を買ってしまった子供のような顔をされる可能性もありますが、ワイルドミニ四駆はワイルドミニ四駆で楽しいものです。心を強く持ちましょう。

「お前、ケツに挿入するの!?」

Lispインタプリタの実装をしていると、リスト操作をたくさん書くことになりますが、リストを作るときには人の癖が出ます。例えば、S式を読み込んでリストに直すリーダのプログラムを考えてみてください。文字列を先頭から読みながら、次々とコンスセルをつないでいくわけですが、さて、あなたは前につなげますか?後ろにつなげますか?

普段、リストを多用する言語を使っていない人はリストを後ろに伸ばしていく実装をする人が多いのではないでしょうか。毎回リストを辿るのはバカバカしいですが、リストの末尾を指すポインタをもったりすると真っ当な感じになります。

Cons head;
head.cdr = NIL;
Cons* tail = &head; 
while (...) {
  ...
  Cons* cell = newCons(element, NIL);
  tail->cdr = cell;  // 末尾に挿入
  tail = tail->cdr;
}
return head.cdr;

しかし、リストの末尾に要素を追加していくのではなく、先頭に要素を追加していって、最後に反転する実装にすると、驚くほどシンプルに書けます。

Cons* ret = NIL; 
while (...) {
  ...
  ret = newCons(element, ret);  // 先頭に挿入
}
return nreverse(ret);

後ろ向きに伸ばす実装でもキレイにかけていれば問題はないのですが、汚い実装になっていると、「あっ・・・(察し)」という反応が返ってくるかもしれません。ちなみに、高級な言語を使っている場合、(以下略)

「そこはどうした? あー、それか。それはアレだね」

さて、最後はみんな大好き、末尾再帰最適化と一級継続のお話です。これを自分で考えて実装しようと思うとかなりの苦労が強いられますが、それがまた楽しいところです(書籍などで先に冴えたやり方を知ってしまった人はレーシングラグーンをやらないのと同程度に人生を損しています)。
「次に呼ぶ関数を入れたオブジェクトを返そうか、いや、gotoで色々と頑張って......むしろスタックを連結リストにしてしまったらどうだろう」
色々と考えた末になんとか完成するでしょうが、なんとも人に説明しがたいものが出来上がるのではないでしょうか。しかし、興味をもたれやすいのも末尾再帰最適化と一級継続といった部分なのです。「どうやって作ったの?」と聞かれてしどろもどろの説明になりがちですが、

大抵の場合、しどろもどろの説明で通じます。

それどころか、「あー、分かる分かる」と、どこからともなく次々と人が現れ、「その実装だと効率が」「いや、けどそれは手間だから趣味で作るぶんには」と次々と自分の実装についての話を始め、自分のLispインタプリタの自慢話を始めたはずが、いつの間にか人のLispインタプリタの話を聞くことになったりします。Lispインタプリタを書いたことがある人はいったいどれほどいるんだ。ちょっと多すぎるぞ、という気分になるでしょう。群がってくる人がいない場合は、周りの人たちにLispインタプリタを実装させましょう

私の経験から言うと、末尾再帰最適化や一級継続の実装法に「これだ!」という強い自信を持っている人は少なく、また、試行錯誤をした人が多いため、自分が思いついた手法というのは、他の人も思いついていて、場合によっては試したことがあるというケースも多いようです。そのためか、この部分に関しては優しい人が多いので、自信を持って自慢しましょう。完成させたら勝ちです

まとめ

ワイルドミニ四駆は意外と楽しい。

参考文献

リリカルLisp ver1.8

9月 16th, 2013

リリカルLisp ver1.8を公開しました。
約6ヶ月ぶりの更新です。

TL;DR: (fib 12)がver1.7と較べて3.2倍程速くなりました。無限ループやスタックオーバフローの際に強制終了ではなくエラーを返すようになりました。

ver1.7のリリース時に
「de Bruijn indexはあまり速さに貢献せず、スペシャルフォームへのアクセスをO(1)にしたのが効果的だった」
と書いている最中に気づいた
「それじゃあグローバル変数のアクセスをO(1)にしたら大幅に速くなるだろ」
という自明なことを実装したのがver1.8となります。
シンボルと値のペアを用意して、グローバル変数を参照する式を直接このペアを参照するように変換することでアクセス時間をO(1)にしています。ただし、式の変換の際にはペアのリストを線形探索するのでO(n)の時間(nはグローバル変数の個数)が必要となります。

肝心の効果のほどですが、
(define(fib n)(if (<= n 1)1(+(fib(- n 1))(fib(- n 2)))))
(fib 12)を評価したところ、ver1.7は16292ms、ver1.8は5097msとなりました。
約3.2倍です。馬鹿みたいに速くなりました。ver1.7とは何だったのか。

もう1つの大きな変更は、評価の安全な中断です。
これまでのバージョンでは、無限ループの(可能性がある)ときは、式の評価を中断するか尋ね、「中断する」が選択された場合はプログラム自体を終了していました。また、スタックオーバフローが起きた際には問答無用でプログラム自体を終了していました。
ver1.8ではプログラム自体を終了する代わりに、(Lispの値としての)エラーを返すように変更しました。

プログラム自体を終了するというのはあんまりなような気がしますが、最初のバージョンでは時間の関係からこのような仕様となっていました。
そして、その後は、面倒で放置し続けていたものをようやく直したという感じです。

今日は観鈴ちんがゴールした日なのでBiwaSchemeでゲーム作った

8月 14th, 2013

という訳で、頑張ってゲームらしいゲームを作りました。

遊ぶ

まあ、過去(8年ほど前?)にDoCoMoの505i向けのiアプリとして書いたゲームの移植なんですが。

今日は風子の誕生日なのでBiwaSchemeでゲーム作った

7月 20th, 2013

というわけで、風子の誕生日なのでBiwaSchemeでゲームを作りました。
今まで私がBiwaSchemeで書いたプログラムの中でも最長…そういっても過言ではないかもしれません。いえ、ずばり過言ではないでしょう。

遊ぶ

まあ、過去にJavaで書いたゲームの移植なんですが。

リリカルLisp ver1.7

3月 3rd, 2013

リリカルLisp ver1.7を公開しました。
約11ヶ月ぶりの更新です。

TL;DR: 主な変更点は高速化です。(fib 12)がver1.6と較べて1.57倍程速くなりました。

今回の変更では環境の構造を大きく変えました。
ver1.6までは環境は連想リスト(のリスト)で表現されており、
ローカル変数、グローバル変数は特に区別されていませんでした。
今回の変更で、ローカル変数はde Bruijn indexを使って表現するようになり、
ローカル変数の環境は連想リストではなく、
値のみ入った(名前が入っていない)リストになりました。

de Bruijn indexを付けるにはコードウォークをする必要があるのですが、
すべてのスペシャルフォームのためのコードウォークを書くのは骨が折れるので、
letやcondなどのスペシャルフォーム(Schemeの言葉で言うとlibrary syntax)は、
lambdaやifなど(Schemeの言葉で言うとsyntax)に変換してから、
変換した式を対象にコードウォークをすることにしました。
この式の変換やコードウォークはLispで書くという案もあったのですが、
わずか2900セルしかないNScLisperの貴重なヒープを、
内部用のプログラムで使ってしまうのは問題があるだろうと考え、
すべてNScripter側で実装しました。
実に1299 insertions, 940 deletionsという壮大な変更となりました。
(NScLisperのコードは4000行程度です)

ベンチマークに使ったfibの定義は次のようなものです。

(eval
 '(letrec ((fib
            (lambda (n)
              (if (<= n 1)
                  1
                  (+ (fib (- n 1)) (fib (- n 2)))))))
    (fib 12)))

evalでクォートされた式を評価しているのは、
式の変換などにかかる時間も含めるためです。
式の変換時間(の近似値)を図るために(fib 1)も計測しました。

version (fib 12) [sec] (fib 1) [ms]
1.6 44.935 129
1.7 28.551 183

なかなか良い感じです。(fib 1)も54msしか遅くなっていません。

リリカルLispを作り始めてもう6年になり、
私zickはいつの間にか大学生から不幸なことに社会人になってしまいましたが、
今後も可能な範囲でリリカルLispのメンテを続けていこうと思います。

--

と、ここまでの話だけを見ると
「de Bruijn indexを使うことによって速くなってよかったね!」
と錯覚してしまいそうになりますが、実は違うんです。
本当に、本当に悲しいのですが違うんです。

ver1.6まではifやdefineなどのスペシャルフォームを
FEXPRというオブジェクトとして表現していました。
式を評価する際にifといったシンボルが現れると、これを環境から探し出し、
FEXPRという事がわかったら、対応する処理が実行されるという感じです。
これらのスペシャルフォームは大域環境の末尾の方にあるため、
探索に結構な時間が掛かっていました。
上記fibなどは頻繁にifが現れるため、そのコストは顕著なものとなります。

一方、ver1.7ではFEXPRは無くなり、フォーム自身が特別扱いされるようになりました。
式を評価する際に(if ...)というフォームが現れると、
対応する処理が実行されるという感じです。
その際に環境からシンボルを探す処理は行われません。
また、ディスパッチの処理も簡略化され速くなります。

実は、今回の高速化の大部分は、
このスペシャルフォームに対する変更によりもたらされたもので、
de Bruijn indexはあまり速さに貢献していません。

version (fib 12) [sec] (fib 1) [ms]
1.6 44.935 129
1.7 28.551 183
(FSUBRに対する変更) 31.879 59

あんまりな感じです。十分速いじゃないですか。
式の変換やコードウォークのための変更は千行以上。
FSUBRに対する変更はわずか数百行。
それでこの結果は泣きそうになってしまいます。

でもまあ、de Bruijn indexを付けて、
環境が連想リストからただのリストになったのが大事なのではなく、
コードウォークをできるようにしたのが大切だと考えてます。
これにより、変数を可能な限りスタックに置くような
最適化の下地が出来上がりました。
いつしか、大幅な高速化ができたら、と考えています。

メリークリスプマス!

12月 25th, 2012

【メリークリスプマス!】

この記事は再生紙を使用しています。

ケツに変なものを入れるのは行儀が悪い

12月 24th, 2012

【ケツに変なものを入れるのは行儀が悪い】

improper listを見かけた時に使う言葉。
間違った解釈をされないように注意すること。

ごみ集めが要らないとか言うなんて恥知らず!

12月 23rd, 2012

【ごみ集めが要らないとか言うなんて恥知らず!】

「GC? よく知らないけど不要でしょ」などと言ってる人に対して使う。
GCに詳しい人には言わないように注意しましょう。

caseは値をけえす

12月 22nd, 2012

【caseは値をけえす】

返します。

CLOSを使いこなすのは苦労する

12月 21st, 2012

【CLOSを使いこなすのは苦労する】

“CLOS”を”くろす”と発音する人向け。