Schemeで書くかCで書くか
Scheme処理系をSchemeで書くのは、evalを使うという反則技無しでも、 Cで書くより楽である。その快適さを知っているSchemeインプリメンテータは、 Cで処理系を書く場合でも、最低限のプリミティブだけをCで書いておいて残りはSchemeで… という誘惑にどうしてもかられてしまう。
それに、そのアプローチはエンジニアリング的にも悪いことではない。 なるべく基本的な部分や性能が必要な部分だけをCで書いたいわばmicro-Scheme処理系を まずしっかりと作り、処理系の残りの部分はmicro-Schemeを使って書く。そうしておけば、 処理系自体の保守が楽だし、拡張する場合もmicro-Scheme処理系をリコンパイルしたり する手間が無くて良い。多くのScheme処理系はこのアプローチを取っているようである。
Gaucheでは、しかし、Schemeで書けるところも敢えてCで書いているところが多い。 私はこれまで何度かSchemeを組み込み言語として利用してきたのだが、 その経験をもとにそういうデザインにしている。
ひとくちに組み込み言語と言っても、使われ方には色々なバリエーションがあり、 それによって要求されるデザインも異なってくる。 「汎用組み込みスクリプティング言語」を標榜する処理系が何度出て来ても、 新しく書かれるミニ言語処理系が絶えないのには、それなりの理由があるのだ。 私が経験した事例には、次のような制約があった。
- 実行時に、その言語のための環境(スクリプトライブラリ、初期化ファイルなど)を フルセットで持てるとは限らない。 最悪の場合、アプリケーションはそのバイナリ単体で動作することが要求される。
- アプリケーションの実行の主体はC/C++で書かれたコードで、 その中から頻繁にちょっとしたリスト処理や文字列処理なんかが呼び出される。
このような制約は、いわゆる「現場」では決して特殊なものでは無いと私は考える。 若干説明を補足しよう。
実行時の環境
あなたは、ちょろっと作った便利なツールの初期設定ファイルを読み込むために、 組み込み言語をツールにリンクした。初期設定ファイルに書かれているのは ほとんどが簡単な数値の設定などだが、時々複雑なことをしたいこともあるので、 ちゃんとした言語を導入しておくのは便利だ。 さて、あなたのツールの評判を聞きつけた、関連会社に出向している同僚が、 それを出先で使いたいからとバイナリをコピーしていった。 ところが同僚は結局そのツールを走らせることさえ出来なかった。組み込み言語には、 初期化時に /usr/local/foo/foo-init.scm を読み込むことがハードコードされていたのだ。 さらにごく基本的な関数を使う場合でさえ、 /usr/local/foo/ 以下のライブラリが無いと動かないのだった。
もちろん、まっとうな方法は、きちんとツールをパッケージ化して、 持って行った先で./configure + make + make install することである。 しかし、時間に追われる現場で、いつそのように使われるかもわからないツールも全て パッケージ化しておくのは容易ではない。 さらに、ターゲットシステムがディスクレスだったら (ディスクレスワークステーションとかいう 意味でなく、ターゲットシステムのOSとアプリケーションが一度ブートしたらファイル参照無しで 走ることが期待されているようなシステム) 外部に別のファイルを要求するという 設計自体が足枷になる。
また、大量の外部ファイルを必要とする組み込み言語を使ったアプリケーションが 複数あって、それぞれがその外部ファイル群を共有したくない場合、アプリケーションの数だけ ファイル群を用意しなければならない。そんな場合があるのかって? プロダクション環境では あるんだなこれが。
アプリケーションAとアプリケーションBが組み込み言語fooを使っていたとする。 さて、プロジェクトAlphaはアプリケーションAに深く依存しており、 一方でプロジェクトBetaはアプリケーションBに深く依存している。 ともに締切を控えてスタジオは24時間体制だ。ここで、アプリケーションAに不都合が 見付かった。それを修正するにはどうしてもfooの外部ライブラリにパッチを当てなければならない。 そうしなけりゃプロジェクトAlphaは締切に間に合わない。ところが、ここでうかつに パッチを当てて、もしアプリケーションBに予期せぬ問題が出たら? どっちも時間はかつかつで、プロダクションを止めてパッチの安全性の評価をしている暇なんて無い…
実際、アプリケーション間の依存関係が複雑になればなるほど、 ある変更がもたらす影響を評価するのが難しくなる。単純なバグフィックスでさえ、 ひょっとすると現在走っているあるプロセスがたまたまそのバグの動作に依存してしまっている かもしれないのだ。そしてそれを根本的に直している時間が無いということは頻繁にある。 だからと言って何もかも別々に持つのはあまりに効率が悪いんで、バランスの問題になるのだが、 要は、時間的にタイトなプロダクション環境では共有しているものを気軽にアップデート出来ない という現実が存在するってことだ。巨大な外部ファイル群が無いと動きません、 というライブラリは、なかなかに使いにくいのだ。
C/C++からの呼び出し
組み込みを意図しているScheme処理系なら、SchemeからCで書かれたルーチンを呼び出すことも、 CからSchemeで書かれたルーチンを呼び出すことも自由にできるはずだ。 が、現実での使用は、基本的なパーツをCで書いてSchemeからそれを呼ぶって形になることの方が多い。 Schemeで書いた部分の方が変更が容易だからだろう。
ところで、ここに一つの現実がある。世の中にはSchemeプログラマよりも C/C++プログラマの方が遥かに多い (そしてSchemeが使える人は大抵の場合C/C++も使える)。 だからチームで開発をする時、C/C++でメインのロジックを書くことになるのは自然な流れだ。
簡単な例をあげよう。memq をSchemeで書くのは簡単だ。さて、 リスト構造を多用するロジックでは、このオペレーションは有用だから、 C/C++ルーチンからもこの機能を呼びたいだろう。 どうやって? S式を文字列で作ってreplに渡すのは多げさだし、 いちいちwrapper functionを書くのも綺麗じゃない。むしろ、 memqなんて基本的なパーツはCで書いとくべきなんだ。
でもそしたら、例えばリスト中から指定要素を除くなんて操作 (CommonLispでは remove、SRFI-1ではdelete) はどうか。Schemeの世界で実装すれば、 例えば比較関数に任意のクロージャを渡したりなんてことが簡単に書ける。 でも、比較をeq?やequal?に限れば、 この操作もC/C++から呼べたらとても便利なわけだ。
Scheme処理系のランタイムは、C/C++からのインタフェースが完備していれば, それ自体が強力なC/C++のためのリスト処理ライブラリになり得る。 そのメリットは大きい。
Gaucheの方針
上記の議論を突き詰めると、一切外部のSchemeファイルを使わずに 全部C/C++で書けば良いってことになっちゃうんだが、 それじゃせっかく言語処理系を書く意味がない。結局、どこでバランスを取るかって問題になる。
Gaucheでは、良く使いそうで、かつCから呼んで便利そうな機能はなるべくCで書くようにした。 この「良く使いそう」「便利そう」というところは思いっきり主観なのだが、基本方針は:
- R5RSの関数は原則としてCから呼べるようにする
- Gaucheの通常の開発でほぼ必ず使われる機能 (requireとか) もCから呼べるようにする。
- SRFI-1 (List library), SRFI-13 (String library), SRFI-14 (Character set library) 中の、コアの部分はCからも呼べるようにする
- 但し、クロージャを渡すようなインタフェースになっているものは C との親和性が 悪いので除く。
てな感じだ。4.はVMとの絡みで、クロージャを受け取る関数はAPIが一気に複雑に なってしまうので、なるべく避けた。 微妙なのはSRFI-1の拡張されたmemberみたいな関数で、 これはオプショナルに比較関数を取れるような仕様になっている。 しかし、比較関数をeq?, eqv?, equal?に限ればCからも Scm_Memberみたいな形で呼べると便利。 というわけで、C関数はpre defineされた比較関数のみに対応し、 Schemeレベルで、オプショナルな引数がその他のクロージャの場合は Schemeで定義した関数を呼ぶようにするという仕組みにしてある。 詳しくはsrc/list.c及びlib/srfi-1.scmを参照のこと。
また、Gaucheには一応初期化ファイル (gauche-init.scm) があるが、 これはサンプルという位置付けで、組み込みアプリケーションは初期化ファイル無しでも 動作するように作ってある (初期化ファイルを読んでいるのはライブラリ関数Scm_Init() ではなく、インタプリタgoshの本体の方、つまりアプリケーションレベルである)。 必要ならば組み込みアプリケーションは独自の初期化ファイルを読んでも良い。