関数的プログラミング

前の章では,LispとLispのプログラムが共に一つの材料から成り立っている様子を説明した. どの建材でも同様だが,材料の性質は建物の性質とそれを建てる方法に影響を与える.

この章では,Lispの世界で広く使われている建築技法の種類を説明する. これらの方法に精通すれば,もっと思い切った種類のプログラムを書こうという気になる. 次の章では,Lispで可能になる,特に重要な種類のプログラムを説明する: 古い「計画−実装」方式で開発されるのでなく,進化していくプログラムだ.

関数的デザイン

オブジェクトの性格はそれを作る要素に影響を受ける. 例えば木造の建物は石造りの建物とは違った見掛けになる. 木や石そのものを見る機会が全くない人でも, 建物の全体的な形からそれが何でできているかは分かるだろう. Lispの関数の性格はLispのプログラムの構造に対して似たような影響を及ぼしている.

関数的プログラミングとは,副作用ではなく, 値を返すことで動作するプログラムを書くことだ. 副作用とはオブジェクトの破壊的な変更(rplacaの使用等)や 変数への代入(setqの使用等)を含む. 副作用を使う数が少なく,その影響範囲もローカルなものであれば, プログラムの読み取り,テスト,デバッグは簡単になる. Lispのプログラムが必ずこの方法で書かれてきた訳ではないが, 時を追ってLispと関数的プログラミングは次第に分かち難いものになってきた.

関数的プログラミングが別のプログラミング言語でのプログラミングと どれ程違うのかは,例を見れば分かるだろう. 何かの理由であるリストが逆順になったものの要素が欲しいとしよう. そのときリストを逆順にする関数を書くのではなく, リストを引数とし,同じ要素で逆順のリストを返す関数を書く.

(defun bad-reverse (lst)
  (let* ((len (length lst))
         (ilimit (truncate (/ len 2))))
    (do ((i 0 (1+ i))
         (j (1- len) (1- j)))
        ((>= i ilimit))
      (rotatef (nth i lst) (nth j lst)))))
\caption{リストの要素の順を逆転させる関数.} \label{fig:ReversingList}

第\ref{fig:ReversingList}図はリストを逆順にする関数だ. これはリストを配列として扱い,要素の場所を使って逆転させる. 返り値に意味はない:

> (setq lst '(a b c))
(A B C)
> (bad-reverse lst)
NIL
> lst
(C B A)

名前が示すように,bad-reverseはLispでのよいスタイルとは程遠い. それどころか伝染病のような醜さを持っている: つまりこれが副作用によって働き, また呼び出し側に対しても関数的プログラミングの理想を妨げるということだ. 悪者役にされてしまったがbad-reverseには長所が1個ある. これは2個の値を入れ替えるときのCommon Lispでの慣用法を示している. rotatefマクロは任意の数の汎変数(generalized variable, setfの第1引数として与えることができる式)の値を逆に並び替える. そして引数が2個だけの時にはそれらを交換する. 対照的に,第\ref{fig:ReversingList}図は逆順のリストを返す関数を示している. good-reverseでは返り値として逆順のリストが得られ,元のリストはそのままだ.

> (setq lst '(a b c))
(A B C)
> (good-reverse lst)
(C B A)
> lst
(A B C)

かつては人の容貌を見ればその人の性格が分かると思われていた. これが人間に対して正しかろうとそうでなかろうと, Lispプログラムに対しては一般的に正しい. 関数的プログラムは命令的プログラムと異なった容貌を持っている. 関数的プログラムの構造は全て式内部の引数の構成によって決まり, また引数がインデントされているので,そのインデント方法は多岐に渡る. 関数的プログラムのコードはページ内を流れていくように見える \footnote{特徴的な例についてはbumpページを参照.}. 命令的プログラムのコードは固定的でブロックに別れていて,ちょうどBasicに似ている.

(defun good-reverse (lst)
  (labels ((rev (lst acc)
           (if (null lst)
               acc
               (rev (cdr lst) (cons (car lst) acc)))))
    (rev lst nil)))
\caption{要素の順が逆転したリストを返す関数.} \label{fig:ReversedList}

眺めるだけでも,bad-reversegood-reverseの どちらがいいかが伝わってくる. そして短いだけでなくgood-reverseは効率的でもある: O(n^2)ではなくO(n)なのだ.

Common Lispには組み込みでreverseがあるので,それを書く手間は要らない. この関数はしばしば関数的プログラミングに対する表面的な誤解をもたらすので, 軽く見ておく価値はある. good-reverseと同様,組み込みのreverseも値を返すことで働く ---その引数は手付かずだ. しかしLispの学習途中の人は, それがbad-reverseのように副作用によって働いていると思うかもしれない. プログラムのどこかでリストlstを逆順に変えたいとき

(reverse lst)

と書いて,どうして呼び出しの効果が出ないのだろう,と思うかもしれない. 実際は,そういった関数の副作用が欲しいときには呼び出しコードの中で 副作用が起きるようにしてやらなければいけない. つまり,代わりに

(setq lst (reverse lst))

と書く必要があるということだ. reverse等のオペレータは,副作用でなく返り値のために呼ばれるよう意図されている. 自分のプログラムもこのスタイルで書く価値がある ---Lispの生まれ持った長所のためばかりでなく, そうしないとLispに逆らってプログラムを書くことになるからだ.

bad-reversegood-reverseとの比較で無視していた点の一つは, bad-reverseはコンシングをしない点だ. 新しい構造を作るのでなく,元のリストに対して動作している. これは危険となりうる ---そのリストはプログラムのどこかで必要になっているかもしれない. しかし効率のためにはそれが必要なときもある. そんな場合のために,Common LispにはO(n)オーダーの破壊的な逆転関数 nreverseがある\note{nreverse}.

破壊的関数とは渡された引数に変更を及ぼせる関数だ. しかし破壊的関数さえも普通は返り値のために使われる: nreverseは引数として与えられたリストを使い回すと思わなければいけないが, それがリストを逆順に変えてくれると思ってはいけない. これまでのものと同様,逆順になったリストは返り値によって得られる. 関数の途中に

(nreverse lst)

と書いても,それ以後lstが逆順になっていると思ってはいけない. 大抵の処理系では次のようなことが起きる:

> (setq lst '(a b c))
(A B C)
> (nreverse lst)
(C B A)
> lst
(A)

lstを逆順に変えるためには, 普通のreverseでやるのと同じようにlstに返り値を代入しなければいけない.

ある関数が破壊的だと書かれていても, その関数が副作用のために呼ばれる筈の関数だということではない. 危ない点は,幾つかの破壊的関数がそういった印象を与えることだ. 例えば

(nconc x y)

(setq x (nconc x y))

とはほぼ同じ効果を持つ. 前者の慣用法によるコードを書くと,正しく働くように思えることもあるかもしれない. しかしxがnilのときには思った通りの動作をしないだろう.

Lispオペレータのうち,副作用のために呼ばれるよう意図されているものはほんの僅かだ. 一般的に言って,組み込みオペレータは返り値のために呼ばれるよう意図されている. sortremovesubstitute等の名前に惑わされてはいけない. 副作用が必要なら,返り値をsetqで代入すること.

まさにこのルールが,副作用を不可避なものにしている. 関数的プログラミングを理想とするというのは, プログラムが決して副作用を使ってはいけないということではない. ただ必要以上に使うべきでないということだ.

この習慣を育てるには時間がかかるかもしれない. 一つの方法は,以下のオペレータは税金がかかっているつもりで扱うことだ:

set setq setf psetf psetq incf decf push pop pushnew
rplaca rplacd rotatef shiftf remf remprop remhash

あとlet*もそうだ. この中に命令的プログラムが潜んでいることがしばしばある. これらのオペレータに税金がかかっているつもりになるのは, よいLispのプログラミング・スタイルへ向かう手助けとして勧めただけで, それがよいスタイルの基準なのではない. しかし,それだけでもずいぶん進歩できるだろう.

他のプログラミング言語では,副作用を使う理由で最も大きいものは, 多値を返す関数が必要になることだ. 関数が1個の値しか返せなければ,他の値はパラメータに変更を加えることで「返す」しかない. 幸運なことに,Common Lispではその必要はない. どの関数も多値を返せるからだ.

例えば組み込み関数truncateは2個の値 (切り捨て結果の整数と切り捨てられた部分)を返す. 典型的な処理系では,トップレベルでtruncateを呼ぶと両方を表示する:

> (truncate 26.21875)
26
0.21875

呼び出し側のコードが値を1個しか取らないときは,1番目が使われる:

> (= (truncate 26.21875) 26)
T

multiple-value-bindを使うことで, 呼び出し側コードは両方の返り値を捉えることができる. このオペレータは変数のリスト,関数呼び出し,コード本体を引数に取る. 本体が評価されるときには,関数呼び出しからの返り値は各々が変数に代入されている:

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

最後に,多値を返すにはvaluesオペレータを使う:

> (defun powers (x)
    (values x (sqrt x) (expt x 2)))
POWERS
> (multiple-value-bind (base root square) (powers 4)
    (list base root square))
(4 2.0 16)

全体的に見て関数的プログラミングはいい方法だ. それはLispで使うには特にいい. と言うのもLispは関数的プログラミングを支援するために進化してきたからだ. reversenreverse等の組み込みオペレータは このように使われることを意図している. valuesmultiple-value-bind等のオペレータは, 関数的プログラミングを簡単にするために特に用意されたものだ.

命令的プログラミングの裏返し

関数的プログラミングの狙いは, もっと普通のアプローチ,命令的プログラミングの狙いと対比させると はっきり見えてくるかもしれない. 関数的プログラムは,それが欲しがるものを求める. 命令的プログラムは,何をすべきかの指示を求める. 関数的プログラムの 「aと,xの第1要素の2乗から成るリストを返せ.」:

(defun fun (x)
  (list 'a (expt (car x) 2)))

命令的プログラミングではこうだ. 「xの第1要素を求め,それを2乗せよ. そしてaと,先程2乗した値から成るリストを返せ.」:

(defun imp (x)
  (let (y sqr)
    (setq y (car x))
    (setq sqr (expt y 2))
    (list 'a sqr)))

このプログラムを両方の方法で書けるLispユーザは幸運だ. 命令的プログラミングだけに適したプログラミング言語もある ---Basicと,ほとんどのマシン語だ. 実際,impの定義はほとんどのLispコンパイラがfunに対して 生成するマシン語と構成が似ている.

コンパイラが代わりにやってくれるのに,どうしてそんなコードを書くのだろう? 多くのプログラマには,この疑問は浮かびすらしない. プログラミング言語はそのパターンを私達の考えに焼き付ける: 命令的プログラミングに慣れてしまった人は, プログラムを命令的プログラミング用語で考えるようになってしまった結果, 実際に関数的プログラミングより命令的プログラミングの方が易しく思えるのかもしれない. この精神的習慣は,乗り越える価値のあるものだ ---そうさせてくれるプログラミング言語を持っているなら.

他のプログラミング言語の卒業生にとっては, Lispを使い始めるのは始めてスケートリンクの上に立つのと似ているかもしれない. 氷の上で動き回るのは陸上で動き回るのより実際はずっと簡単だ ---スケート靴を使えば. そうするまではこのスポーツが何だというのかと一人ぽつんと悩むことになるだろう.

スケートと氷の関係は,関数的プログラミングとLispとの関係と同じだ. それらを組み合わせれば,優雅に,しかも苦労せずにあちこち回れるようになる. しかし別の種類の移動方法に慣れてしまっていると,始めは何やら感じが掴めないだろう. 第二言語としてLispを学ぶときにはっきり理解しにくい点の一つは, 関数的プログラミングのスタイルを学ぶことだろう.

幸運なことに,命令的プログラムを関数的プログラムに変換するうまい方法がある. この方法は完成したコードに適用することから始めるといい. すぐに勘が働くようになり,コードを書きながら変換できるようになるだろう. そうすればあっという間に, 始めから関数的プログラミングの用語でプログラムを考えるようになるだろう.

その方法は,命令的プログラムは関数的プログラムを裏返しにしたものと思うことだ. 関数的プログラムが命令的プログラムの中に隠れているのを見つけるには, ただ裏返しにすればいい. この方法をimpで試してみよう.

最初に気付くのは,先頭のlet内でysqrを作っていることだ. これはよくないことが続く前触れだ. 実行時のevalの呼び出しと同様, 初期化されていない変数は滅多に使われないので, 一般的に言ってプログラム内で変なことをした現れと見なしていい. そういった変数はしばしばプログラムを留める画鋲のようなもので, プログラムが自然な形になろうとするのを妨げている.

しかしここではそれは暫く無視し,関数の終わりまでまっすぐ進んでみる. 命令的プログラムで最後に起きることは,関数的プログラムでは一番最初に起きる. だから最初の一歩は最後に行われるlistの呼び出しを掴み, プログラムの残りをその中に詰め込むことだ ---シャツを裏返しにするように. そしてシャツを袖からカフスにかけて次第に裏返していくように, 同じ変換方法を繰り返し適用し続けていく.

後ろから見ていって,sqr(expt y 2)で置き換え

(list 'a (expt y 2)))

が得られる. 次にy(car x)で置き換えると

(list 'a (expt (car x) 2))

となる. こうしてコードの残りは最後の式の中に詰め込まれたので, それを捨て去ることができるようになった. ここに至るまでに変数ysqrの必要性はなくなったので, letも削ることができる.

最終結果は始めのものより短く,理解も容易だ. 元のコードでは最後に(list 'a sqr)という式が現れたが, これではsqrの値がどこから来るのかすぐには明らかにならなかった. 今は返り値の元になるものは道路地図のように目の前に広がっている.

この章での例は短いものだったが,この方法のスケールは次第に拡大する. 実際,大規模な関数に適用すればそれだけ価値が出てくる. 副作用を起こす関数でさえ,副作用を起こさないパーツへときれいに分解できる.

関数的インタフェイス

副作用が起こす問題には大きいものと小さいものがある. 例えば次の関数ではnconcを呼んでいるが,参照透明性は保たれている \footnote{参照透明性の定義についてはpoohページを参照.}.

(defun qualify (expr)
  (nconc (copy-list expr) (list 'maybe)))

これを任意の引数で呼んでも,引数が同じなら必ず同じ(equalを満たす)値を返す. 呼び出し側から見ればqualifyは純粋な関数的コードであるも同然だ. そのことは,実際に引数を書き換えてしまうbad-reverse(kaboomページ) には当てはまらない.

全ての副作用を一括りに悪者にする代わりに, 問題のある場合とない場合を区別する方法があれば便利だろう. 大雑把に言うと,関数が,他の誰のものでもないオブジェクトを書き換えるのは無害だ. 例えばqualify内のnconcは無害だと言える. それは第1引数として与えられたリストは新たにコンシングされるからだ. 他のどの関数もそれに関わることはない.

一般的な場合では,オブジェクトをどの関数が支配しているかではなく, どの関数呼び出しが支配しているかについて考えなければいけない. 次の例では変数xを支配しているものは他に何もないが, 呼び出しの作用は,次の呼び出し時に明らかになる.

(let ((x 0))
  (defun total (y)
    (incf x y)))

だからルールはこうあるべきだ: 任意の関数呼び出しが, 自分だけが支配するオブジェクトを安全に書き換えられるようにする.

何が引数と返り値を支配するのだろう? 関数呼び出しは返り値として受け取るオブジェクトを支配するが, 引数として渡されるオブジェクトは支配しない,というのがLispの慣習のようだ. 引数に変更を加える関数は「破壊的」との呼び名で区別されるが, 返ってくるオブジェクトに変更を加える関数には特に呼び名がない.

例えば次の関数は慣習に従っている:

(defun ok (x)
  (nconc (list 'a x) (list 'c)))

これは慣習に従わないnconcを呼んでいるが, nconcが切り張りするリストは新しく作られたもので, okに引数として渡されるリストとは違う. だからokそのものに問題はない.

しかしこれが僅かに違って

(defun not-ok (x)
  (nconc (list 'a) x (list 'c)))

と書かれていたら, nconcの呼び出しはnot-okに渡された引数に変更を加えるだろう. 多くのLispプログラムはこの慣習を(少なくともローカルな範囲では)破っている. しかしokで見たように,ローカルな範囲で慣習を破っても, 関数呼び出しが慣習を破ることにはならない. そして上の条件を満たす関数は,純粋に関数的なコードの長所を多く保つだろう.

純粋に関数的なものと全く区別の付かないプログラムを書くには, もう一つ条件を付け加えなければいけない. 関数は,このルールに従わない他のコードとオブジェクトを共有してはいけない. 例えば次の関数は副作用を持たない.

(defun anything (x)
  (+ x *anything*))

この関数の返り値はグローバル変数 *anything* に依存する. だから他の関数がこの変数の値を変えることがあれば, anythingの返す値はどうとでもなりうる.

呼び出しても自分だけが関わるものにしか変更を加えないように書かれたコードは, 純粋に関数的なコードに引けを取らない. 上記の条件を全て満たす関数は, 少なくとも外部に関数的インタフェイスを提供している: それを同じ引数で2回呼び出せば,同じ結果が得られる筈だ. そして,次の章で見るように,これがボトムアップ・プログラミングに必須のポイントだ.

破壊的な操作の問題点の一つは,グローバル変数と同様, それがプログラムのローカル性を損ないうる点だ. 関数的なコードを書いているときは,焦点を絞ることができる: 書いている関数から呼び出されたり,書いている関数を呼び出す関数だけを考えればいい. 何かに破壊的な変化を加えようとすると,この長所は失われてしまう. その関数はどこで使ってもいい筈だったのに.

上記の条件は,純粋な関数的コードで得られる完全なローカル性を保証する訳ではないが, 状況は幾分なりとも改善される. 例えば次のようにfgを呼び出すものとしよう:

(defun f (x)
  (let ((val (g x)))
  ; ここでvalを書き換えていいものか?
  ))

fvalに対してnconcで何かを連結するのは安全だろうか? gがidentityのときはそうではない: そのときは,元々はfそのものに引数として渡されたものを書き換えてしまうだろう.

だからLispの慣習を確かに守っているプログラムでも, f内で何かを書き換えたいならその向こうを見通さなければいけないだろう. と言っても,それ程遠くを見通す必要はない: プログラム全体について心配しなくても, fの下の部分ツリー(subtree)だけを考慮すればいい.

上の慣習から導かれるのは, 関数は安全に書き換えられないものを返してはいけないということだ. だから返り値にクォート付きオブジェクトを含むような関数を書くのは避けるべきだ. ここでexclaimを,その返り値がクォート付きリストを含むように定義し

(defun exclaim (expression)
  (append expression '(oh my)))

呼び出し後に返り値に破壊的な操作をすると

> (exclaim '(lions and tigers and bears))
(LIONS AND TIGERS AND BEARS OH MY)
> (nconc * '(goodness))
(LIONS AND TIGERS AND BEARS OH MY GOODNESS)

関数内のリストに変更を及ぼすことになりうる:

> (exclaim '(fixnums and bignums and floats))
(FIXNUMS AND BIGNUMS AND FLOATS OH MY GOODNESS)

exclaimをそういった問題に対して堅固なものにするには,こう書くべきだ:

(defun exclaim (expression)
  (append expression (list 'oh 'my)))

関数がクォート付きリストを返すべきでないというルールには,大きな例外が1個ある: マクロを生成する関数だ. マクロ展開関数は,生成するマクロ展開がコンパイラにそのまま受け取られるなら, その中にクォート付きリストを安全に含むことができる.

それ以外では,一般的に言ってクォート付きリストは疑っていい. それらの多くは大抵in(boomページ)等のマクロで実現すべきものだ.

インタラクティブ・プログラミング

前の章ではプログラムの構成のよい方法として関数的なプログラミング・スタイルを提示した. しかし関数的プログラミング・スタイルはそれだけのものではない. Lispプログラマは美的な理由だけで関数的プログラミング・スタイルを取っているのではない. 作業が簡単になるからそうするのだ. Lispの動的な環境では,関数的プログラムは並外れた速さで書くことができ, 同時に並外れた信頼性が高い.

Lispではプログラムのデバッグは比較的簡単だ. たくさんの情報が実行時に利用可能で,エラーの原因をトレースするのに役立つ. しかしもっと重要なのは,プログラムをテストするときの簡単さだ. プログラムをコンパイルして全体を一度にテストする必要がない. 関数はトップレベル・ループから個々に呼び出してテストできる.

テストを随時行うことには大変価値があるので, Lispのプログラミング・スタイルはその長所を取り入れて進化してきた. 関数的プログラミング・スタイルで書かれたプログラムは関数1個毎に理解できるが, 読む方の側からはこれが大きな長所だ. しかし関数的プログラミング・スタイルは,テストを随時行うことにも完全に適応している: このスタイルで書かれたプログラムは関数1個毎にテストできる. ある関数が外部を参照もせず,変更もしないなら,どんなバグもすぐに明らかになる. そういった関数は返り値を通してのみ外部に影響を及ぼすのだ. 返り値が予想通りである限り,それを返したコードは信頼できる.

実際,熟練Lispプログラマはテストしやすいようにプログラムをデザインする:

  1. 彼らは副作用を使う部分を幾つかの関数に隔離し, プログラムの大部分は純粋に関数的なプログラミング・スタイルで書けるようにする.
  2. 関数が副作用を使うのを避けられないなら, 彼らは少なくともそこに関数的インタフェイスを盛り込もうとする.
  3. 彼らは関数に明確な目的を一つだけ与える.

関数を書くと,それを代表的な状況のどれかでテストし,済んだら次の状況に移る. 煉瓦がそれぞれ予想通りの仕事をすれば,壁はしっかり立つだろう.

Lispでは,壁自体もうまくデザインすることができる. 1分間の転送の遅れがある中で,遠く離れた誰かと会話するのを想像して欲しい. 次にとなりの部屋の誰かと会話するのを想像して欲しい. 同じ会話が速く済むだけではなく,会話の種類が変わるだろう. Lispでは,ソフトウェア開発は面と向かって話をするようなものだ. コードを書くにつれてテストができる. そして簡単に方針転換できることは, それが会話に及ぼすのと同じくらい劇的な影響をソフトウェア開発に及ぼす. そのとき,同じプログラムを速く書いているだけではない. 別の種類のプログラムを書いているのだ.

それはどうやって? テストが素早く済めば,もっと頻繁にテストができる. Lispでは(他のどのプログラミング言語でもそうだが), ソフトウェア開発はコード書きとテストのサイクルから成る. しかしLispではサイクルがとても短い: 関数1個毎,いや関数の部分毎でもいい. あらゆる部分を書くにつれてテストすれば, エラーが起きたときにどこを見ればいいか分かるだろう: 最後に書いた部分だ. 単純に聞こえるが,この原則はボトムアップ・プログラミングを堅固なものにしている要因の 大きな割合を占めている. それは更なる自信をもたらし, Lispプログラマは(少なくともある一時) 古い「計画−実装」スタイルのソフトウェア開発からフリーになれるのだ.

第1.1節では,ボトムアップ・デザインが進化するプロセスであることを強調した. プログラミング言語を構築しつつ,それでプログラムを書くことができる. このアプローチが有効なのは下部のコードを信頼できるときの話だ. その層を本当にプログラミング言語として使いたいなら, 遭遇したバグはプログラミング言語そのものではなく自分のアプリケーション内にある,と (他のプログラミング言語を使ったときと同じように)確信できるようでなくてはいけない.

だから新しく作った抽象的構造はこの重い責任を背負わなければならず, しかもそれらは必要に応じて作られるようでなければいけない. 結構な話だ. Lispでは両方が実現できる. プログラムを関数的スタイルで書き,それを随時テストしていけば, 即座に目的を達せるような柔軟性と, 普通なら注意深く立てた計画と結びつけて考えられるような信頼性が得られる.


←: 関数     ↑: On Lisp     →: ユーティリティ関数

Copyright (c) 2003-2011 野田 開     NODA Kai <nodakai@gmail.com>