2011-11-27

"On Lisp" Paul Graham 著

前書きには「既にLispに親しんでいることを前提としている」とある。「けして泥酔した初心者は手を出さないでください!」と聞こえてくるが、これも怖いもの見たさというものか。慣れない言語を読んでいると不思議な感覚に見舞われる。それは、片言の外国語を喋る時の感覚に似ていることだ。文法を考えずに、とりあえず知っている単語を並べてみる。これぞリスト型言語の極意というものか。プログラミング言語を学習するのに、構文を意識せずに読んでいるのも珍しい。これぞ構文のない言語と言われる所以か。
まずは、立ち読みから始める。釘付けになったのは、後方で紹介される実装例で、継続、マルチプロセス、非決定性アルゴリズ、そしてオブジェクト指向言語を装うCLOSである。しかもマクロで実装してやがる。驚くべきは、この古典言語がこれらの機能を具えられるほどの拡張性が既にあったということだ。これは、Lispを度外視してもじっくり読んでみたい。ついに衝動には勝てず、買ってしまうのであった。

本書の特徴は、半分以上がマクロ機能に割かれることである。これはプログラミング言語の書籍では珍しいのではないか。Lispはマクロ機能が強力だと聞くが、なるほど、なかなかの感動ものだ。関数型言語なのでもちろん関数についても書かれているが、あくまでも基本という位置づけでしかない。
「あるプログラミング言語のエッセンスを一文で伝えるのは難しいが、John Foderaroの言葉はかなりそれに近い。: Lispはプログラム可能なプログラミング言語である。」
独自の言語をLispで書いて、その言語でプログラムする。まさに「On Lisp!」というわけか。この本の不思議なところは、コードがたくさん埋め込まれているわりには、抽象的に眺められることである。ユーザ定義のマクロや関数が、あたかも組み込み構文や組み込み関数であるかのように映る。逆に言えば、ネーミングのセンスが悪ければ、たちまち読みづらいプログラムになる。それは、どんなプログラミング言語でも同じだけど...
Lispが強力なのは、様々な機能が集合体となっているからだという。動的メモリの割り当て、ガベージコレクション、実行時型指定、オブジェクトとしての関数、リストを生成する組み込みパーサ、リスト表現を受け付けるコンパイラ、インタラティブな環境などなど...しかし、これらの機能は切り売りされる。近年のプログラミング言語はLispの特徴を多く取り入れている。

マクロと言えば...アセンブラ言語から馴染んできたアル中ハイマーな古代人は強力な武器として崇めていた。もう20年ぐらい前になろうか、おいらは小規模なマイコン用プログラムを書いていた。16KBから32KB程度のもので、リアルタイムOSを導入するには大袈裟な規模だ。当時は「組込み系」なんて言葉も使っていなかった。この世界でもC言語が台頭しつつあったが、コンパイル効率が悪いために冗長度が大きく、メモリ資源に制約のある小規模システムでは致命的となる。構文解析や最適化の癖に合わせてコーディングルールを決めたりしたものだ...
アセンブラ言語の経験があれば、関数とマクロの実装の違いはすぐにイメージできるだろう。関数の実装は、変数の退避などが割り込み処理と似ていて結構面倒だ。CPUが持つ汎用レジスタに役割の規則を作ったりと、なにかと神経を使う。引数はC言語ユーザが嫌うポインタで渡す方が楽だったりする。その点、マクロは単純に展開されるだけなので、関数呼び出しのオーバヘッドがない。だが、呼ばれるたびに展開され、メモリを消費するので頻繁には使えない。また、余計な姿を隠蔽するのが得意なだけに、変数の束縛範囲まで曖昧にしてしまう。ただ、関数を呼び出すためのメッセージ役を与えると癖になる。要するに、マクロの主な役割は翻訳としての機能であった。
この点、Lispは呼び出し側から見てマクロと関数を区別しない。これだけでも強力な要因となりうる。おまけに、マクロ内の変数名が重複しないように、ローカル変数の生成機能(with-gensyms)まである。マクロでは変数名でいつも悩まされるので、うれしい機能だ。
更に、アセンブラ言語レベルでオブジェクト指向っぽいこともやっていた。オブジェクト指向なんて言葉も知らない時代だ。クラスのような雛形を関数で実装するとインスタンスになってしまうので、マクロの方が相性がいい。それは、本書で紹介されるCLOSでも味わえる。変数のアクセスは、マクロメッセージを経由し、set/get系で抽象化できる。カプセル化の概念も知らずに自然に変数を隠蔽していたような気がする。上位階層から眺めると、マクロメッセージが並んだ独自の言語に見える。まさに「On アセンブラ!」というわけだ。
しかし、アセンブラ言語はCPUのシリーズよって方言があるのが厄介である。そして、時代はC言語系へと流れていった。それでも、プリミティブな部分だけ言語に依存させ、マクロで隠蔽するやり方を好むのは変わらない。ちなみに、その頃からの弊害か?いまだに継承の概念は実装のイメージがしずらく、好きになれないけど。
...アル中ハイマーのマクロのイメージとは、こんなもんだ。しかーし、Lispのマクロ機能はそんな比ではない。頭は既にKernel Panic !!!

1. ボトムアップデザイン
どんなプログラミング言語を使ってもボトムアップで記述することはできる。ただ、Lispはそれをこなせる最も自然な器で、開発当初から拡張可能なように設計されてきたという。言語の大半は関数の集合体であるが、それらはユーザ定義のものと区別しない。関数もまたデータと区別されず、Lisp全体がリスト型データ構造となっている。
「ユーザがLispコードを生成するLisp関数を書けるということだ。」
ところで、人間が思考する上で言語の役割は大きい。思考を明確にしたければ、自己を操る言語で書き下ろしてみることだ。分かっているつもりでも、実際に記述してみると、案外分かっていなかったことに気づかされる。自然言語には、思考を整理し客観的な視点を与える役割がある。それは、プログラミング言語でも原理は同じであろう。実装できないものを仕様に盛り込んでも空論に終わる。ならば、プロトタイプを実際に書いてみる方が手っ取り早い。ウォーターフォール・モデルのような思考の一方向性が行き詰まりやすいことは、多くの設計者が知っている。デザインは書いているうちに進化していく。計画で最も重要なのは柔軟性ということになろうか。
本書は、Lispを柔軟性に最適な言語としながら、同時にLispユーザが堕落する可能性を指摘している。それは、言語とアプリの相性に敏感になり過ぎて、他の言語では柔軟性が得られないと思い込んでしまうことだ。なるほど、言語を宗教的に崇めると、逆に思考の柔軟性が奪われるというわけか。人類が絶対的な価値観に到達できないからには、万能で完璧な言語を編み出すことはできないだろう。誰もが英語かぶれになれば、人間社会の思考は英語的になる。言語は手段に過ぎないが、精神と結びつくとこれほど強力な武器もない。よって、個人の肌に合った言語を模索するしかあるまい。

2. 関数型プログラミング
関数型プログラミングが抽象化の可能性を大きくするのは、関数型を返り値とすることができ、関数型を引数に渡せるという特徴があるからであろうか。だから、無理やり一行に収められる。暗号文っぽくなるけど。関数がデータオブジェクトになりうるからには、オブジェクトはそれを呼び出す方法も提供される。Lispでは、それをapply関数が担うようだ。定義した関数をオブジェクトに対応させるために通常 #'オペレートを使うという。

 > (apply #'(lambda (x y) (+ x y)) '(1 2))

apply関数は、引数にリストを渡している。これが気に入らなければ、funcallを使えばいい。
 > (funcall #'+ 1 2)

「関数プログラミングとは、副作用ではなく、値を返すことで動作するプログラムを書くことだ。」
関数の基本的な思想は、副作用を期待するのではなく、返り値のためにあると考えるという。そして、引数に変更を加える関数を「破壊的」と呼び、意識的に区別される。普通のプログラミング言語では返り値は基本的に一つであろう。多値を返す場合はポインタで参照することになる。これが副作用を使う理由になる。破壊的にしたくなければ、ポインタを入力用と出力用で分けるといった面倒なことをやる。
一方、Lispはリスト型データ構造をしているので、もともと返り値に多値が想定されている。しかも、破壊的思想を避ける方向にあるという。尚、「多値」という言葉が使われるが、ここでは集合体のようなデータ構造を意味し、論理学的な多値と意味が違うので注意されたし。
また、関数型プログラミングは命令型プログラミングの裏返しだという。
「関数型プログラムでは、それが欲しがるものを求める。命令型プログラムでは、何をすべきかの指示を求める。」
自分で目的を考えるか、指示されないと動けないとの違いか?まるで現代社会を皮肉っているような。ただ、Lispにだって命令型のように書くことができる。それが幸か不幸かは別にして。
関数型では、一般的に初期値を与えない変数を書く習慣がないという。それだけでも危険度は違う。命令型では、初期値のない変数を書くのは普通に行われる。初期値を与えるように努力したところで、抜けてもエラーが起こるわけでもない。だが、Lispでは問題が起こるという。関数型で最初にトラブることは、命令型では最後にトラブると指摘している。関数の呼び出しは、その呼び出し自身が支配するオブジェクトを完全に書き換えることができるという。
「関数呼び出しは、返り値として受け取るオブジェクトを支配するが、引数として渡されるオブジェクトを支配しない。」
これがLispの慣習だという。ここでは副作用を持つ関数を作ってはならないと言っているのではない。そういう習慣がないというだけで、必要ならば意識して作るということだ。破壊的な関数がトラブルの元になりやすいのも事実で、破壊的にしたければ、setqなどで確実に明示すればいい。

3. マクロ
「マクロ定義とは実質的にLispコードを生成する関数だ - つまりプログラムを書くプログラムだ。」
Lispコードがコンパイルされる時、パーサがソースを読んで出力をコンパイラに送る。パーサの出力は、Lispのオブジェクトのリストから成るという。
驚くのは次だ!なんとマクロはパーサとコンパイラの間の中間形式を操作できるというのだ。コンパイラが読む情報を書き換えられるということは、コンパイラを書き換えられるのと同じではないか。マクロを展開するプログラム自身がマクロであってもいいわけだ。Lispの正体とはマクロ言語なのか?なんとも自己矛盾に陥りそうな再帰的仕組みにも映る。だから、再帰的アルゴリズムと相性がいいのか?多くのプログラミング言語でマクロ機能は搭載されるが、これはマクロの概念を超越していそうだ。実際、Lispのマクロは式を返すし、関数のように振舞う。
構造化プログラミングの推進者は、マクロを避ける傾向にあるという。それは、マクロがどこででも使われることと、その不可視性にあろうか。そういえば、C言語を導入した頃、マクロよりも構文を強く意識するようになり、まず関数で実装することを考えるような気がする。実際、ハウツウ本の例題が関数の実装で溢れている。関数とマクロの使い方が違うとなれば、分けて管理したくもなる。
だが、その区別がなくなりメモリ消費などの制約から解放されるとなれば、話は変わる。とはいえ、マクロにだって欠点が少なからずある。気になるのは、単に展開される仕組みなだけに、再帰的処理では自己矛盾を引き起こすことと、変数の束縛である。関数はレキシカルな領域に独自の世界を作るが、マクロは展開されるだけなので呼び出し側の環境を破壊する恐れがある。本書は、マクロを使って再帰的処理の書き方も紹介してくれるが、そこまでしなくても...変数の束縛では、letやprogなどの特殊形式を用いれば、恐れるほどではない。

4. 継続
継続とは、動作中のプログラムを凍結すること。Lispには、プログラムの状態を保持し、後に再開できる能力があるという。デバッグのための拡張機能が、この古き言語に既にあったというのか。
Schemeが、Common Lispと大きく異なる点の一つに、継続を明示的にサポートする機能があるそうな。Schemeでは、継続は関数と同格のファーストクラスオブジェクトで、クロージャの一般化と解釈できるという。ここでいうクロージャとは、関数とそれが生成された時点で見えていたレキシカル変数へのポインタをまとめたもの。
もしかして、継続の実装はガベージコレクションと同格なのか?などと想像してしまう。具体的には、call-with-current-continueation という組み込みオペレータで継続にアクセスできるらしい。

 (call-with-current-continueation
   (lambda (cc)
  ...))

関数と同格だから、どこにでも埋め込めそう。パフォーマンスも落ちるんだろうなぁ...

5. 非決定性アルゴリズム
日常生活における非決定性とは、ごく自然体である。ほとんど目的もなく気まぐれで行動しているのだから。これぞ人工知能の世界か。尚、Lisp開発者のマッカーシー先生は、「人口知能」という言葉の提唱者でも広く知られる。1980年代、AIブームが起こった。その代表的言語といえば、Prologであろうか。大学のゼミでも専攻している奴らがいた。もっともソフトグループと称しながら、ソフトボールに励んでいたようだが...
本書は、埋め込み言語としてPrologの実装例が紹介される。しかし、プログラムというものは目的意識から生じるものだ。非決定性という不確実性にどう立ち向かうのか?そのアルゴリズムは極めて単純で、その基本機能は、選択と失敗、そして未来計算で抽象化される。具体的には、chooseとfail、あるいはcutというオペレータの実装だ。そして、失敗と判断された経験の蓄積情報のデータ構造が鍵を握る。その処理は、蓄積情報が繰り返し検索され、パターンマッチングとその再帰的処理が根幹となることが想像できる。まさにLispの得意技か。データのツリー構造がパターンマッチングと適合することも味合わせてくれる。だが、経験が多く蓄積されるほど、判断に要する処理時間も長くなり、実用性から遠ざかるだろう。そこで、未来計算をいかに合理的に行うかが問題となる。経路検索を合理化するならば、情報はある程度まとめたい。プログラム的にはメモリを解放したい。では、どのタイミングで情報を捨てるのか?これは確率的な問題であり、用途に応じて決定されることになろう。つまり、cutとは「直感」の実装なのか?人間の思考なんて二分岐の繰り返しであって、その要素は、選択と後悔、そして未来への期待ぐらいなものか。

6. CLOS (Common Lisp Object System)
Common Lispには、オブジェクト指向プログラムを書くためのオペレータが揃っているという。それが、CLOSだ。もともとLispには、オブジェクトの属性でカプセル化の性質があり、ポリモーフィズムも、親から属性とメソッドを継承する性質も備わっているという。なのでLispをオブジェクト指向言語だと説く風潮があるそうな。その時代に名前がなかっただけのことか?
CLOSは、素のLispにオブジェクト指向の流れを組み込んで、形を整えたものだという。ただ、多重継承となると厄介なようだ。オブジェクト指向でも多重継承は混乱の元となる。
本書は、オブジェクト指向言語Lispを強調するために、わざわざCLOSを使わずにオブジェクト指向の実装例を披露してくれる。だが、酔っ払いは素直にCLOSに目を奪われる。
クラスの定義にdefclass、インスタンス生成にmake-instanceを使う。インスタンス変数やメソッドに相当するものにスロットがあるが、CLOSでは、スロットとメソッドを区別するようだ。defclassでスロットをセットで宣言し、メソッド定義にはdefmethodを用いる。なるほど、まったく違和感がない。

 -- GNU CLISP 2.48 での実行例 --
 :accessor は、スロットへのアクセスメソッド
 :initform は、初期値の指定
 :initarg は、引数シンボルの指定

 [1]> (defclass circle ()
        ((radius :accessor circle-radius :initform 1 :initarg :radius)
          (center :accessor circle-center :initform '(1 . 1) :initarg :center)))
 [2]> (defmethod area ((c circle))
        (* pi (expt (circle-radius c) 2)))
 [3]> (defmethod move ((c circle) dx dy)
        (incf (car (circle-center c)) dx)
        (incf (cdr (circle-center c)) dy)
        (circle-center c))
 [4]> (setf ins_circle (make-instance 'circle :radius 2 :center '(2 . 2)))
 [5]> (area ins_circle)
  12.566370614359172954L0
 [6]> (move ins_circle 2 3)
  (4 . 5)

==== ちょっと気になる技をメモしておく ====
1. 複数の返り値を受け取る方法
multiple-value-bind を使うと、複数の返り値を受け取ることができるという。

 > (multiple-value-bind (int frac) (truncate 26.21875) (list int frac))
 (26 0.21875)

2. コンパイルの過激なテクニック
Lispの関数は、個々でもファイル単位でもコンパイルできるという。必要に応じて関数を個別にコンパイルできるというのだ。λ式ですら。こんな過激な行為はやらないだろうけど、ちょっと感動してしまう。

 > (compile 'bar '(lambda (x) (+ x 2)))

3. ダイナミックスコープ
Lispの特徴として、ちょっと気になるのにダイナミックスコープがある。それは、前記事に「初めての人のためのLISP」でも取り上げた。だが、本書の印象はちょっと違う。
Lispコミュニティでは、ダイナミックスコープを廃止する傾向にあるようだ。Common Lispでは、普通はレキシカルスコープになるらしい。クロージャを実現するには、関数と変数束縛がセットになっている必要がある。Common Lispでもクロージャが浸透していると言ってくれる。これは安心させてくる。前記事は、古典的な流れを紹介していたのかもしれない。
また、マクロで弱点になる変数捕捉の対策として、with-gensymsという便利なマクロを紹介してくれる。その実装まで記されるが、既に組み込まれているようだ。ただし、clispとCommon Lispでは仕様が違うみたい。
 Common Lisp : (defmacro with-gensyms (syms &body body) ...)
 clisp : (defmacro with-gensyms (prefix syms &body body) ...)

4. 末尾再帰のテクニック
再帰関数で関数呼び出しの後に行うべき作業が残っていない場合、すなわち、再帰呼び出しの値が即座に返される場合の関数を末尾再帰と呼ぶそうな。再帰関数でよくやるのが、再帰呼び出しの後、何か処理をして値を返すといったことである。しかし、Common Lispコンパイラは、末尾再帰の関数をループに変換するので、末尾再帰を使うのが望ましいという。これでパフォーマンスも向上しそうな予感がする。現在のCommon Lispコンパイラは、C言語と同等かそれ以上の速いコードが得られるそうな。

5. SchemeとCommon Lispの相違点
  1. Common Lispが、symbol-valueとsymbol-functionを区別するのに対して、Schemeは区別しない。Schemeでは、変数は関数でもオブジェクトでもいいので、#'やfuncallが必要ない。
  2. Schemeの名前空間は一つ。だから、代入オペレータが個別にdefunやsetqのように存在しなくていい。代わりに、definとset!がある。グローバル変数は、definで定義しないと、set!で値を設定できない。
  3. Schemeでは、関数はdefinで定義される。defvarだけでなくdefunの役割もある。
  4. Common Lispでは関数の引数は左から順に評価するが、Schemeでは評価順は意図的に未定義。
  5. Schemeでは、tとnil の代わりに、#tと#fがある。
  6. condやcaseでデフォルト節は、Common Lispではtを使うのに、Schemeは、elseを使う。
  7. いくつかの組み込みオペレータの名前が違う。conspは、pair?、nullは、null?、mapcar は、ほぼmapに対応。ちなみに、Lispでは判定文の末尾にpを付ける慣習があるらしいが、「初めての人のためのLISP」では、Schemeのような新参者はpの代わりに?を使うと書かれていた。
...ちょうど目の前にGuileがあるので触れてみたが、道は遠い!

6. メソッドの結合
CLOSでは、基本メソッドと補助メソッドで区別している。基本メソッドとは、普通にユーザが定義するメソッドのこと。補助メソッドには、before, after, around がある。なんとなく、awkのBEGIN ENDの構文に似ているような。

例題)
「Perhaps ... in some sense」の中に任意の文字列を埋め込む。
「Does the King believe that...」の後ろの任意の文字列を埋め込んで、yes/noで答える。
----------------------------------------------------------
#! /usr/bin/clisp

(defclass speaker nil nil)
(defmethod speak ((s speaker) string)
  (format t "~A" string))

(defclass intellectual (speaker) nil)
(defmethod speak :before ((i intellectual) string)
  (princ "Perhaps "))
(defmethod speak :after ((i intellectual) string)
  (princ " in some sense") (terpri))

(defclass courtier (speaker) nil)
(defmethod speak :around ((c courtier) string)
  (format t "Does the King believe that ~A? " string)
  (if (eq (read) 'yes)
    (if (next-method-p) (call-next-method))
    (format t "Indeed, it is a preposterous idea.~%"))
  'bow)

;(speak (make-instance 'intellectual) "life is not what it used to be")
(setf ins_intellectual (make-instance 'intellectual))
(speak ins_intellectual "life is not what it used to be")

;(speak (make-instance 'courtier) "kings will last")
(setf ins_courtier (make-instance 'courtier))
(speak ins_courtier "kings will last")
----------------------------------------------------------
まず、aroundメソッドを評価し、その中で、next-method-p と、call-next-method が評価される。続いて、beforeメソッド、基本メソッド、afterメソッドの順に評価される。関数の返り値は、aroundメソッドの返り値となる。一番外側にaroundメソッドの殻があって、その中にbeforeとafterメソッドがあって、核の部分に基本メソッドがあるような三重構造のイメージか。これを「メソッドの結合」と呼ぶそうな。なかなかおもしろい機能だ。対話式プログラムを書く時に使えそう。
また、あるクラスにおいて、すべての基本メソッドの返り値の和を求める例が紹介される。ほぉぉ...

0 コメント:

コメントを投稿