[Practical Scheme]

Schemer's way

->English

10/5/2001 初出
5/30/2002 追記
6/10/2002 英語版へのリンク追加

「プログラミング言語は満載した機能を特色の第一とするものではない。
あとになって機能の追加が必要と判明するような弱点と制限を取り除いて設計すべきである。」
(アルゴリズム言語Schemeに関する第五改訂報告書、犬飼 大訳 [1])。

-----------------------------

言語の機能とライブラリ

ポピュラーな言語に親しんできたプログラマの多くは、 Schemeに触れた時、こう感じるんじゃないか。 「一体こんなに機能の少ない言語で、どんなプログラムが書けるっていうんだ。」

Schemeの規格書はほんの50ページしか無い。 Schemeプログラマはそれを言語の簡潔さの証とかなんとか言ってるけど、 入出力は最低限のものしかないし、作ったファイルを消すことさえ出来ない。 文字列処理もC言語の標準ライブラリ以下じゃないか。 スクリプティングに使うにしても、いまどき正規表現が無いなんて。 それに見たところ、ループ構文も貧弱だし、ループを途中で中断するのも めんどくさそうだ。再帰で書けって? 冗談じゃない、 コンピュータサイエンスのクラスの課題ならともかく、 俺はプロダクションコードを書いているんだぜ。 あ、あと、オブジェクトは? Basicでさえオブジェクト指向なこの時代に、 オブジェクト指向でない言語なんて使う価値があるのか? Schemeを広めたいのなら、もっと言語を時代に合うように拡張すべきなんじゃないか?

これらの指摘は、ある面で正しく、またある面で間違っている。 Schemeにライブラリ機能が貧弱だというのは正しい。 どんなScheme愛好家だって、 Schemeの規格にあるライブラリだけでプロダクションコードを書こうという 人はいない。けれども、多くのSchemeプログラマは、 Schemeの規格をこれ以上大きくしようとも考えていない[2]。 これは別に矛盾ではない。何故なら、Schemeプログラマは 言語の機能ライブラリの機能とは 別のものだと考えているからだ。

この両者の違いは厳密に定義できるものではないのだが、 簡単に言えば、ライブラリの機能を拡張するにはその言語で 新しいライブラリ関数を書けば済むのに対し、言語の機能を 拡張するには言語実装そのものに手を加えなければならない という違いと言えるだろう。

もちろん、両者の境は曖昧だし、議論する対象によっても変わってくる。 多くの言語では、新しいオペレータを追加するのは言語そのものを 拡張しなければならないだろう。例えばJavaを拡張してPerlの `x' オペレータを実装するには、構文規則から書き直さねばならない。 既存のオペレータに新しい意味を追加するというメカニズムは、 これもいくつかの言語では言語そのものに組み込みの機能となっている。

Lisp系の言語では、オペレータと関数呼び出しとの間に区別が無いため、 オペレータの機能の拡張ということは議論にならない。 新しいオペレータが欲しければ、あるいは 既存のオペレータを拡張したければ、いつでも自分で書けるからだ。

もっと基本的な機能、例えば手続きを呼び出す、というのはどうだろう。 これはおそらく、現存するほとんどのプログラミング言語に備わった機能だ。 手続き呼び出しのメカニズムを持たないほど低レベルな言語でも、 いくつかの簡単なマクロが使えれば手続き呼び出しをエミュレートすることはできる。 だがそういうトリックは透過的でないことが多い。アセンブラで、現在のプログラム カウンタをスタックに退避して指定アドレスへジャンプするマクロを書けば 手続き呼び出しのように使えるが、うっかりしたプログラマがスタックポインタを 変更してしまったら惨劇となる。

手続き呼び出しがどう実装されているか全く気にしないでも使えるようにするには、 そのような内部の操作を言語実装に任せて、 プログラマからはうっかりとは触れないようにしなければならない。 変数スコープの規則(ローカル変数が使えるかどうか)とか、 再帰呼び出しが出来るか、とかいう機能もそうだ。

必要十分にコンピュータを抽象化し、様々なアルゴリズムを簡潔に表現できて、 かつ頑健なプログラムが書けるような言語機能のセットとは何だろう。 Schemeが試みた解答は次のようなものだ (これが全部ではないが)。

おっとっとっと、聞いたことの無い用語があっても引かないで欲しい。 別にわざと話を難しくしようとしているわけじゃないんだ。 ただ、実装の詳細に足を取られないように中立な言葉を使うようにしているだけだ。 わからなかったらとりあえずそんなものがあるってなくらいに思っておいて欲しい。

例えばこんな具合だ。do 〜 while 構文は必要か、とか、いや for文だけでいい、とかいう 議論は実装の詳細だ。結局この議論は、ループのための構文は必要か、という議論に 抽象化できる。で、「末尾再帰の最適化」というものがあれば、 ループのための構文をわざわざ用意しなくても、関数呼び出しで事が足りる、 ということがわかったと思って欲しい。それさえあれば、 ループ構文が欲しい人はマクロでも関数でも自分で好きなように書くことが出来て、 しかも書かれたループ構文はまるで言語組み込みのループ構文のように使うことができるんだ。 (つまり、うっかりしたプログラマがどっかを壊してしまうことなど有り得なくて、 しかも言語組み込みで作ったのと同じ効率で動く)。 だとしたら、ループ構文の是非は言語そのものの本質的な問題じゃない。

こうも言えるかも知れない。Schemeは、いろいろな問題領域へと言語を 適用させてゆくために必要な言語のベースを提供している。 言語そのものに手を加えないでも、プログラマが必要なライブラリセットを自分で書いて、 その問題を解決するために最適な言語のようにすることができるんだ。 問題領域に合わせた小さな言語を書くことはLispの世界では普通に行なわれて来たし、 Schemeも例外ではない[3]。 「裸の」Schemeはそれだけでは何にも使えないように 見えるかもしれないけれど、様々な問題領域へ適合させてゆくために必要十分な機能を 持っていると言える。

この柔軟性には欠点もある。そうやってみんなが自分専用のライブラリを書いてたら、 コードの互換性が問題になったんだ。LispではCommonLispという形で統合された。 Schemeでは言語仕様には手をつけずに、実装レベルでインタフェースをなるべく 合わせましょうよ、というやや緩い方法でライブラリの統一が図られつつある [4]

オブジェクト指向の伝説

最近流行りの言語はどれも「オブジェクト指向」を名乗っていて、 中にはどっちの言語が「より純粋な」オブジェクト指向かっていう 論争が起こったりしている。オブジェクト指向言語で書けば、 最初から全てがオブジェクト指向の枠組にフィットして、わかりやすく、使い回しのきくコードが書ける。 何でSchemeの言語仕様にはオブジェクト指向が入ってないんだろう。

私が思うところ、答えは2つある。

答えその1。オブジェクト指向は、「言語の機能」として備えなければならない程 プログラミング言語にとって本質的なものじゃない。

答えその2。オブジェクト指向の実装方法は一つではなく、問題領域に合わせて 最適な実装方法は異る。言語備え付けの一つの方法にプログラマを縛るよりは、 ライブラリとして必要な実装をプログラマが選択出来るほうが良い。

2番目のものから説明する方がわかりやすいだろう。

C++やJavaの流派のオブジェクト指向を例にとってみよう。 その実装の特徴をいくつか挙げてみる。

  1. プログラムテキスト上でクラスが定義されている。
  2. クラスをテンプレートとして、インスタンスが作られる。
  3. インスタンスは状態を保持するメモリをそれぞれ割り当てられてる。 状態の変化は、そのメモリ上の値が書き変わることにより表現される。
  4. クラスには、そのインスタンスの内部にアクセスすることを許された一連のメソッドが 定義されている
  5. インスタンスの状態には、メソッドからしかアクセスできないものと誰にでもアクセスできる ものがある。
  6. クラスは継承できる。継承したクラスは、親クラスのふるまいを変えたり、 状態を追加したりすることができる。

このうちどれがオブジェクト指向言語たるに必須な要件かは、議論が分かれるところだ。 例えばいくつかのオブジェクト指向言語には、アクセスの制限というものがない。 インタラクティブに開発する場合などはそれが便利だったりする。 CLOSは、大規模な開発にも実績のあるCommonLispのオブジェクトシステムであるが、 アクセス制限は無い。もしどうしてもインスタンス変数を隠したければ、 メタオブジェクトプロトコルを使ってクラス定義をユーザレベルで拡張することが出来る。

クラス-インスタンスという関係が無い言語もある。 self[5]ではすべてはオブジェクトであり、 それをテンプレートとして新たなオブジェクトを作成することが、インスタンシングであり、 サブクラシングである。メタクラスのある言語では、クラスそのものが別のクラスの インスタンスである。

クラス-インスタンスという関係があっても、インスタンスの属するクラスが 実行時に変えられる言語がある。これは、例えば永続的なインスタンスのあるシステムで 開発中にクラスをどんどん変更してゆく場合などに非常に役に立つ。

メソッドがクラスに所属しない言語もある。CLOSなどがそうだ。 メソッドをクラスに従属させるやり方は、クラス毎に名前空間を分けられる などのメリットもあるが、それは名前空間の問題であってオブジェクト指向の実装とは 分けて考えられるべきだ。

インスタンスが固定したメモリに状態を保持する、というのは、純粋な関数型の オブジェクトシステムでは当てはまらない。そのようなオブジェクトシステムでは、 メモリに対する代入というのが一切起こらない。状態の変更は、新しいインスタンスの 生成によって行なわれる。

Oleg Kiselyovは、継承可能なオブジェクト指向言語で、 変更可能なメモリを各インスタンスが持つという モデルでは、「オブジェクト指向でインタフェースと実装を分離する」ということが 本質的に出来ないということを、実例を使って示している[6]。 関数的なアプローチを取ればこの問題点は回避される。

Henry Bakerは、C++やJavaタイプのオブジェクト指向実装に見られるイテレータの問題 を考察し、それが特定の問題には使えないこと、そういう問題には 関数的なアプローチが有効であることを示した[7]

オブジェクト指向アプローチの有効性にけちをつけるつもりはない。 しかし、その実装には様々な方法があり、弱点と利点がある。 その弱点と利点を心得た上でひとつの実装を選択するのには何も問題がないが、 ひとつの実装が万能であるということはないのだ。

少なくともある問題領域に対してオブジェクト指向実装が使えるのなら、 それを言語の機能として持てば便利だろうという議論はできるだろう。 ただ、ここで一番目の答えに戻って来るのだが、 オブジェクト指向実装を備えるために言語仕様まで拡張する必要があるのだろうか。

Schemeには、Schemeだけで書かれたオブジェクトシステムが半ダースはある [8]。 継承やメソッドディスパッチのみならず、あるものは純粋に関数的であったり、 あるものはCLOSのようなメタオブジェクトプロトコルを備えている。 そしてどれもコードとしては小さい。

「Schemeだけで書かれた」がかならずしも「遅い」 ことを意味するものではないことに注意して欲しい;コンパイルしたっていいし、 大抵のSchemeインタプリタはベース言語で拡張する方法を用意しているから、 ボトルネックとなる部分だけチューニングすることもできる。そうしたところで、 それは文字列ライブラリをネイティブコードで実装してチューニングする、 というのと同列だ[9]

Schemeにおいてオブジェクト指向実装をScheme自身で書くのが容易なのは、 ファーストクラスクロージャに負うところが大きい。 Schemeでは、関数をトップレベルだけでなく他の関数の内部で作成することが 出来る。作成された関数はその時点で見えるローカル変数への参照をすべて 保持して、しかもれはもとの関数から抜けてしまっても有効だ。 これをファーストクラスクロージャという。

例えば、あなたがあるソースからデータを読みだし、 シンクにデータを書き出すようなフィルタプログラムを書いたとしよう。 オブジェクト指向的に再利用できるようにするためには、 ソースとシンクをファイルとかに固定するのでなしに、 「データを読み出せるクラス」「データを書き出せるクラス」というふたつの ベースクラスを定義しておいて、それらのオブジェクトを取るようにすればいい。 ファイルであろうが、ソケットであろうが、メモリ上のデータであろうが、 それらのベースクラスを継承している限り、あなたのフィルタプログラムは それを取り扱うことができる。

Schemeではもっと単純だ。2つの関数を渡してやるだけで良い。 一つの関数は呼ばれる度にデータを返す関数、もうひとつは 書き出したいデータがある場合に呼び出す関数。その関数がどのファイル、 ソケット、あるいはメモリを参照しているかという情報は、関数の中に 保持されている。これは実質的に、無名のベースクラスを実行時に作って しまっているに等しい。ここでのポイントは、クラスとか継承とかいう 概念は一切不要であるということだ。

Schemeプログラミングへ

こう書いて来たが、何もSchemeが完全無欠の言語であるというつもりは無い。 上に書いたように、問題領域に合わせて成長させてゆかなければ、Schemeは いつまでも可能性を秘めた種子のままだ。

これは、世の中にScheme実装がいくつもあることへの説明にもなる。 問題領域によって、実装の際に重点をおくべきところが異るから、 それによって実装に差が出来てしまうのだ。ひとつの実装が全ての要求を カバーするなんてことは有り得ない。

それでも、明解な言語仕様が存在することは、他の処理系向けに書かれた コードを読むのを楽にするし、よっぽど処理系特有の変な拡張を使ってない 限り移植も難しくない。

しかし、Schemeでプロダクションコードを書こうとするなら、処理系間の 移植という問題は基本的に忘れるべきだと思う。かわりに、自分の解こうとしている 問題領域にいちばん適した処理系を選び、あとはその処理系が提供する あらゆる拡張機能を使うべきだ。最初から移植を考えて最大公約数的な機能しか使わないというのは ばかげている。

それでも足りなければ自分でどんどんライブラリレベルの拡張を行なえば良い。 可能ならそれを処理系作成者にフィードバックすればなお良い。 もしそのライブラリが一つの処理系を超えて 広く使われて欲しいと思ったら、その時に移植を考えれば良い--- 多くの場合、それはいくつかの処理系依存の関数を書き足すだけで済むはずだ。

幸い、基本的なライブラリに関しては、SRFIによってインタフェースの 統一が促進されている。特に、SRFI-1(リストライブラリ)、 SRFI-13(文字列ライブラリ)、SRFI-14(キャラクタセットライブラリ)は、 それまで各Schemeプログラマがちまちま書いていたような類似のライブラリを 広く包含するから、これからコードを書くなら是非これらのインタフェースを 使うべきだ。これらは多くの処理系でサポートされている。

また、ある程度まとまった大きさのライブラリを書こうとするなら、 一応他の処理系を見て、インタフェースを合わせておくといいだろう。 SLIBの実装があなたの処理系で効率良く走らない場合、SLIBと コンパチブルな実装を書くのには意味がある。それに依存する あなたの他のコードは、他の処理系でもSLIBを使えば走るかもしれない。

Paul Grahamは、"Being Popular" の中で、これからの言語は 良く設計されたライブラリが重要なポイントになると述べている[10]。 Schemeがその条件を満たすように成長できるかどうか、それはわからない。 ただ、どのように成長しようとも、言語仕様が足をひっぱることはないだろう。 それが、冒頭のエピグラムに示したような、Schemeの精神なのだから。


参考文献と脚注

[1]

Richard Kelsey, William Clinger and Jonathan Rees 編、 犬飼大訳、アルゴリズム言語Schemeに関する第五改訂報告書。 http://www.sci.toyama-u.ac.jp/~iwao/Scheme/r5rsj/html/r5rsj_toc.html

[2]

正直に言えば、誰もがもう少し追加したい機能のアイディアを持っている。 ただ、Schemeはその機能が本質的に必要なのかどうか、時間をかけて見定めて、 ようやく言語仕様に追加するというアプローチを取ってきた。 FAQ によれば、当面新しい言語仕様を発行する予定は無いそうだが、各実装やSRFIによる 議論を経てアイディアが練り込まれてゆくのだろう。

[3]

そもそも、最初のSchemeはLispを用いて書かれた。

[4]

ライブラリの規格に関しては、Scheme Requests For Implementation. http://srfi.schemers.org/ で進められている。

[5]

David Ungar and Randall B. Smith, Self: The Power of Simplicity, OOPSLA '87 Conference Proceedings, pp. 227-241, October 1987. 論文と言語実装は http://www.sun.com/research/self/ から手に入る。

[6]

Oleg Kiselyov: Subtyping, Subclassing and Trouble with OOP, http://pobox.com/~oleg/ftp/Computation/Subtyping/.

[7]

Henry Baker, Iterators: Signs of Weakness in Object-Oriented Languages, ACM OOPS Messenger 4(3) pp.18-25, July 1993. http://linux.rice.edu/~rahul/hbaker/Iterator.html.

[8]

Scheme自身によるOOPの実装については、たとえばここ: http://www-2.cs.cmu.edu/afs/cs/project/ai-repository/ai/lang/scheme/oop/0.html

[9]

もちろん効率を考えるなら、特にインタプリタでは言語のエバリュエータそのものに メッセージパッシングの仕組みを組み込んでしまった方が良いわけで、 必ずしも言語 vs ライブラリという明快な区別があるわけではない。 ただ、それはあくまでその実装がそういうチューニングの方向を選んだってことだ。

[10]

Paul Graham, Being Popular, May 2001. http://www.paulgraham.com/popular.html. (和訳).


追記 (5/30/2002)

本稿で述べた「オブジェクト指向」という言葉の曖昧さを、 Jonathan ReesがPaul Grahamへ送ったe-mailの中で述べている。 本稿よりずっと整理されているので参照されたい。

クロージャとオブジェクトの関係については、 HashedWiki:状態管理の2つの方法 および HashedWiki:クロージャとオブジェクト で面白い議論になっている。