Land of Lisp 14章~16章まで読んだ
遂に第Ⅳ部に突入しました。と言っても、今回読んだ14章〜16章は一度読んだだけではほとんど頭に入っていないので、先の章まで読んだら、また復習したいと思っています。
第Ⅳ部 LISPは科学なり
第14章 関数型プログラミングでLispをレベルアップ
関数型プログラミング
関数型プログラミングすごい!ってことが書いてあった。
関数は、渡される引数が等しければ常に同じ結果を返し、外部の変数を参照せず、呼び出されることで変数の値を変えない。関数の目的は引数から値を計算することのみであり、外の世界に影響を与えない。
外部への影響(ビープ音やダイアログなんか)を与える場合、そのコードは「副作用を持つ」と言う。副作用は汚い。
副作用を持つコードは「命令的」であり、汚いからなるべく小さくするべし。
関数型プログラミングは現実には何もできない
サイモン・ペイトン・ジョーンズの言葉
「副作用なしでできることと言ったら、ボタンを押して、箱がしばらくあったかくなるのを見守るだけだ」(これ面白いですね笑)
しかし、やっぱり関数型プログラミングがクレージーで素晴らしいのは、簡潔でエレガントでバグも少ないからだ。
第15章 ダイス・オブ・ドゥーム:関数型スタイルでゲームを書こう
http://www.gamedesign.jp/flash/dice/dice.html
ここのゲームを目標にしているらしい
AIと一対一でやっても強すぎて全然勝てない…
ルール
- プレイヤーがそれぞれ陣地を持っている。そのプレイヤーが持つ陣地には、サイコロがいくつかある
- プレイヤーは自分の手番に何度でも攻撃できるが、最低1回は攻撃しないといけない。指す手がなくなったほうが負け
- 指す手は、敵の陣地となっている隣接する陣地を攻撃すること。プレイヤーの陣地のサイコロが、隣接する敵の陣地のサイコロよりも多ければ攻撃できる。Version1では攻撃は常に成功することにする
- 戦いに勝つと、敗者のサイコロは盤から除かれ、勝者のマスにあったサイコロは1個を残して勝ち取ったマスに移される
- 一方のプレイヤが全ての動きを終えたら、そのプレイヤの軍に補給が行われる。それぞれの陣地に順番にサイコロを一つずつ足していく。足せるサイコロの数は、(敵から獲得したサイコロの総数-1)
- プレイヤに指せる手がなくなったら終了。マスが多い方の勝ち
最初ざっと15章を読んだけど、全然どういうゲームかわからなかった。けど、上のゲームをやってみて、やっと理解した。
使っている道具は今まで見てきたものばかりだけれど、いざ読んでみると、かなり複雑なゲームで頭が爆発しそうになった。
AIとプレイしたけど全く勝てないし表示もわかりづらい…
以下が15章での新しい内容で、ゲームの高速化が目的です
- クロージャ
(defparameter *foo* (let ((x 5)) (lambda () x))) (funcall *foo*) >5
上のような場合、letを抜けたあとでも、値を保持しているが、これはlambdaがガベージコレクタに回収されるまで宣言した変数xが生き続けているかららしい。
よくわからないので一応ここのも読んでみる。
「ことろ」になってるけど、ちょっと難しくてわからないです…S式って何だ…
クロージャは宿題ということで。
- メモ化
とりあえず例
(defun neighbors (pos);隣接するマスを見つける関数 (let ((up (- pos *board-size*)) (down (+ pos *board-size*))) (loop for p in (append (list up down) (unless (zerop (mod pos *board-size*)) (list (1- up) (1- pos))) (unless (zerop (mod (1+ pos) *board-size*)) (list (1+ pos) (1+ down)))) when (and (>= p 0) (< p *board-hexnum*)) collect p))) ;メモ化した関数 ((let ((old-neighbors (symbol-function 'neighbors)) (previous (make-hash-table))) (defun neighbors (pos) (or (gethash pos previous) (setf (gethash pos previous) (funcall old-neighbors pos)))))
何をしているかというと
- ローカル変数に元のneighbors関数を保存
- previousに引数と結果を保存。ハッシュテーブルにしている。
- 新しいneightbors関数を定義。引数をキーにハッシュテーブルを検索。もし、値があればそれを返す。そうじゃない場合は以前のバージョンのneighbors関数を呼び出す。
つまり、何度も効率悪い関数を呼び出さないように工夫しているみたいですね。
- symbol-function : 引数のシンボルに束縛されている関数を取り出すコマンド
- 末尾呼び出し最適化
再帰呼出しの例
;リストの長さを求める関数。再帰的に呼び出すからスタックが大変なことに (defun my-length (lst) (if lst (1+ (my-length (cdr lst))) 0)) ;末尾呼び出し最適化した関数 (defun my-length (lst) (labels ((f (lst acc) (if lst (f (cdr lst) (1+ acc)) acc))) (f lst 0))
リストが空でない場合、自分自身を再帰的に呼び出す。
この書き方をすると、途中までやっていた処理を「忘れる」からスタックが積み上がらない。
BASICのGOTOやCのlongjumpみたいなもんらしいです(どっちも知らないしCでlongjumpなんて使ったことない)。
余分な引数accはアキュムレータと呼ばれるもので、リストの要素を数えている。
また、この関数にはlstが2つあるが(my-lengthの引数とfの変数)、f内部のlstが優先される。これはシャドウィングのせいである。
- シャドウィング:同じ名前の変数があるときに近い方の引数が優先される
第16章 マクロの魔法
マクロ展開とは:Lispがコードを読む直前に標準的なコードに変換してしまうこと
簡単なマクロの例
;マクロを使って、letをlet1に書き換える (defmacro let1 (var val &body body) `(let ((,var ,val)) ,@body)) ;普通のlet。括弧が多い (let ((foo (+ 2 3))) (*foo foo)) >25 ;マクロを使って書いたlet1。スッキリしてる(?) (let1 foo (+2 3) (* foo foo)) >25
マクロの引数の説明
- var : ローカル変数として定義される(foo)
- val : ローカル変数の値を決める(+ 2 3)
- body : 実行されるコードの本体(* foo foo)
- &body : マクロの使われている場所に出てくる、残りの式を全部ネストにしたリストにして次の引数に渡してくれるように?
- スプライシングコンマ(,@) : letコマンドは暗黙のprognを含んでいる?
うーんよくわからない‥
実際の使用例
;2つの数値を足して、副作用として結果を出力する関数を書く (defun add (a b) (let ((x (+ a b))) (format t "The sum is ~a" x) x)) ;let1を使った (defun add (a b) (let1 x (+ a b) (format t "The sum is ~a" x) x))
新しく出てきた言葉
- macroexpand : 元のコードに展開してくれる
- gensym へんてこりんな名前を作ってくれる関数
- アナフォリックマクロ:読んでも全然わからん。
まとめ
こんなんでまとめていいんだろうかって感じですが、とりあえず読んだことを記録しときます。
14章は、そのまま内容を載っけても仕方ないので、メモ書きみたいになっちゃいました。Cみたいな命令形のプログラミングとは違ってすごいですね。
プログラミングをやり始めた頃、「まず、変数a,x,i,nを置きます。」みたいな初期宣言に毎回なんでやねんと思っていたので、関数型プログラミングが自然に全ての要素を必要として(?)書いているのを見ると、すごいなと思いました。まあ、書ける気はしないんですけどね。
15章では、新しいダイス・オブ・ドゥームというゲームが出てきました。なんじゃこりゃーって感じですが、最後のゲームなので楽しみたいと思います。いきなりゲーム木とか出てきてびっくりしました。
16章では遂にマクロが出てきました。ただ、難しいというか、説明がふわっとしていて腑に落ちない感じがあります。あるいは僕があんぽんたんなだけか(たぶんこっち)。
消化しきれていない言葉や概念もありますが、仕方ないのでズカズカ読み進めていこうと思います。あと4章です。
追記 5/15
7人対戦でやったら勝った