孤独にそっくり

開いている窓の前で立ち止まるな

実践Common Lispを読む 第7章〜第9章

Practical Common Lisp
原書はデザイン全然違いますね。

こんにちは。
今回は、第7章~第9章まで読みました。

第7章 マクロ:標準的な制御構文の構築

言語の定義の中には標準ライブラリが含まれているが、標準ライブラリは言語の核(コア)を使って書かれている。あるいは、定義されていなくても、自分で実装できる。
そもそも、我々が「言語」だと思っているものの多くは、ライブラリである。
Common Lispでは、関数やクラスによって、「言語」を拡張することができるが、別の方法として、「マクロ」がある。
そこで、標準的なライブラリをマクロで実装してみようというわけです。

*WHENとUNLESS

Lispのifはthen-form else-formなので、Cやなんかみたいに、いくつも実行式を書けない。prognはめんどい
xが真ならば、全て実行する式はないの?→WHEN
マクロで書くとこんな感じ

;whenをマクロで作る
(defmacro when (condition &rest body)
  `(if ,condition (progn ,@body)))
*AND OR NOT

条件式で便利な論理演算子
正確にはNOTは引数を取り、真偽を反転させる関数で
AND ORはマクロ
論理積論理和
ANDはNILがあると、すぐにNILを返す
もしすべての部分フォームがNILでなければ、最後の値が返る

*繰り返し

繰り返しは、DO,DOLIST,DOTIMES,LOOPなどある
LOOPはLispっぽくない(後の章で詳しく見る)

*DOLIST,DOTIMES
(dolist (var list-form)
  body-form*)

perlのforeachやpythonのforと同じようなやつらしい
returnで脱出できる

;実行例
CL-USER> (dolist (x '(1 2 3)) (print x)(if (evenp x) (return)))
1
2
3

;入れ子構造で400まで表示する
(dotimes (x 20)
  (dotimes (y 20)
    (format t "~3d " (* (1+ x) (1+ y))))
  (format t "~%"))
*DO
;(変数名 初期値 評価式)Cのfor文っぽい

(do (variable-definition*|var init-form step-form|)
    (end-test-form result-form*)  
 statement*)

;フィボナッチ数列 本体部分がない
(do ((n 0 (1+ n))
     (cur 0 next)
     (next 1 (+ cur next)));ここまで(variable-definition*)
    ((= 10 n) cur))LOOP
(loop
 body-form*)


;フィボナッチ数列
(loop for i below 10
      and a = 0 then b
      and b = 1 then (+ b a)
      finally (return a))

loopは第22章でもやるよ

第8章 マクロ:自分で定義しよう

マクロを正確に理解する障壁になっているのは、マクロがあまりにうまく言語に統合されているせいだ。一見すると、ただの関数にしか見えない。しかし、マクロは関数とは全く異なるレベルの操作であるし、完全に違う種類の抽象化だ。

*マクロ展開時

全てのマクロが完全に展開されて、初めて結果として得られるコードがコンパイルされて実行される。
マクロが実行される時間はマクロ展開時と呼ばれる。これは、通常の実行時とは異なる。
マクロ展開時は、実行時に存在するデータにアクセスできない。
マクロが渡すのは、マクロフォームの部分フォームを含んだ未評価のLispオブジェクトで、マクロがやるのは、ソースコードを作り出すことだ。

*DEFMACRO
(defmacro name (parameter*)
 "省略可能なドキュメンテーション"
 body-form*)

マクロを書くステップ
1.マクロ呼び出しのサンプルを書き、その展開形を書く。あるいは逆でもいい
2.そのマクロ呼び出しのサンプルから、1.で自分が書いた展開形を生成するコードを書く。
3.マクロによる抽象化に漏れがないか確認する。

*試しに書いてみる: do-primes

効率が悪いが、あくまでデモンストレーション
補助関数

;与えられた数が素数か判定
(defun primep (number)
  (when (> number 1)
    (loop for fac from 2 to (isqrt number) never (zerop (mod number fac)))))
;与えられた引数と同じかそれより大きい次の素数を返す関数
(defun next-prime (number)
  (loop for n from number when (primep n) return n))

;こんな風に書きたい
;0以上19以下の素数それぞれに対してループ本体が1回だけ実行される
(do-primes (p 0 19)
  (format t "~d " p))
;展開形
(do ((p (next-prime 0) (next-prime (1+ p))))
    ((> p 19))
  (format t "~d" p))

マクロのパラメータは分配パラメータリストといって、フォームの構造をばらして変数に分解してくれる
また、他にも特別な機能があって、&restの同義語として&bodyが使える
&bodyはなんかすごい

;do-primesの定義
(defmacro do-primes ((var start end) &body body)
  `(do ((,var (next-prime ,start) (next-prime (1+ ,var))))
       ((> ,var ,end))
     ,@body))

分配パラメータリストはエラーも教えてくれるから便利

*バッククォートシンタックス

バッククォートのシンタックスは、リストを生成するコードを書くためのわかりやすい方法でもある。

;「,」は部分式をアンクォートできる
`(,a b) = (list a 'b)
;@のときは、その値(リスト)が外側のリストに挿入される
`(a ,(list 1 2) c) = (a (1 2) c)
`(a ,@(list 1 2) c) = (a 1 2 c) -(1)
;(1)の式は次のように書ける
(append (list 'a) (list 1 2) (list 'c))

macroexpand-1は一段階マクロ展開した結果を返す
*漏れをふさぐ
Joel Spolskyのエッセイ『漏れのある抽象化の法則』
すべての抽象化にはある程度は漏れがあり、完全な抽象化は存在しないが、簡単にふさげる漏れを我慢すべきということではない。

例えばこのようなコードのときに漏れる

(do-primes (p 0 (random 100))
  (format t  "~d " p))
;実行してみる
CL-USER> (macroexpand-1 '(do-primes (p 0 (random 100)) (format t  "~d " p)))
;結果
(DO ((P (NEXT-PRIME 0) (NEXT-PRIME (1+ P))))                                       ((> P (RANDOM 100)))                                                       
  (FORMAT T "~d " P))                                                          T    

これでは、ループの終了判定で、毎回RANDOMが呼び出されるから、意図したループとは異なる
この漏れをふさぐには、endを一度だけ評価してあとで使うように保存すればいいが、その際に、
1.評価順序、2.変数名に気をつけなくてはいけない

最終形はこうなる

(defmacro do-primes ((var start end) &body body)
  (let ((ending-value-name (gensym)));絶対にかぶらない変数名の生成
    `(do ((,var (next-prime ,start) (next-prime (1+ ,var)))
	  (,ending-value-name ,end))
         ((> ,var ,ending-value-name))
      ,@body)))
;gensymはマクロ展開の一部として実行されるから、コードの展開形には含まれない
(DO ((ENDING-VALUE (NEXT-PRIME 0) (NEXT-PRIME (1+ ENDING-VALUE)))  (#:G1371 10)) 
((> ENDING-VALUE #:G1371))     (PRINT ENDING-VALUE))
*マクロを書く際の大まかなルール

・マクロを呼び出すときに出現するのと同じ順序で評価されるようにすべての部分フォームを配置する
・部分フォームが一度だけ評価されるようにする
・展開形の中で使う名前はGENSYMを使って作る

*マクロを書くマクロ

さっきのgensymのletはよく書くから、マクロにしちゃう

(defmacro with-gensyms ((&rest names) &body body)
  `(let ,(loop for n in names collect `(,n (gensym)))
     ,@body))

第9章 実践:ユニットテストフレーム

テストするには、いちいち値を調べるのはめんどくさいから、ブール値でやる
各テストの論理積を取る関数を書けばいい

(defun test-+()
  (and
   (= (+ 1 2) 3)
   (= (+ 1 2 3) 6)
   (= (+ -1 -3) -4)))

どこで失敗したか知りたいときは、次のようになる

(defun test-+ ()
  (format t "~:[FAIL~;pass~] ... ~a~%" (= (+ 1 2) 3) '(= (+ 1 2) 3))
  (format t "~:[FAIL~;pass~] ... ~a~%" (= (+ 1 2 3) 6) '(= (+ 1 2 3) 6))
  (format t "~:[FAIL~;pass~] ... ~a~%" (= (+ -1 -3) -4) '(= (+ -1 -3) -4)))

でも繰り返しでダサい

(defun report-result (result form)
  (format t "~:[FAIL~;pass~] ... ~a~%" result form))

これもまだダサい
コードをデータとして扱いたいときは、マクロの出番
(check (= (+ 1 2) 3))
という風に書いて
(report-result (= (+ 1 2) 3) '(= (+ 1 2) 3))
にしたい

(defmacro check (form)
  `(report-result ,form ',form))

更に、checkの繰り返しも任意の引数を取るようにする

(defmacro check (&body forms)
  `(progn
     ,@(loop for f in forms collect `(report-result ,f ',f))))

フォームの並びをprognで包み込むよく使う手らしい
こうすると、次のように書ける

(defun test-+ ()
  (check
   (= (+ 1 2) 3)
   (= (+ 1 2 3) 6)
   (= (+ -1 -3) -4)))
*戻り値を手直しする

TかFか知りたくても、ANDはFを得た時点でNILを返して終わる
それじゃ困るから、マクロcombine-resultsを書く
こんなふうになってほしい

(let (result t)
  (unless (foo) (setf result nil))
  (unless (bar) (setf result nil))
  (unless (baz) (setf result nil))
  result)

resultは抽象化が漏れる可能性あり
with-gensymを使う

(defmacro combine-results (&body forms)
  (with-gensyms (result)
    `(let ((,result t))
       ,@(loop for f in forms collect `(unless ,f (setf ,result nil)))
       ,result)))

prognの代わりにcombine-resultsを使えば論理和がわかる

(defmacro check (&body forms)
  `(combine-results
     ,@(loop for f in forms collect `(report-result ,f ',f))))

やってみたら

pass ... (= (+ 1 2) 3)
pass ... (= (+ 1 2 3) 6)
pass ... (= (+ -1 -3) -4)
NIL 

え?

pass ... (= (+ 1 2) 3)                                                                               
pass ... (= (+ 1 2 3) 6)                                                                             
FAIL ... (= (+ -1 -3) -5)                                                                            
NIL    

ん?

なんでやねん…
あとで考える

*良いレポートのために

新しいテスト関数は次

(defun test-* ()
  (check
   (= (* 2 2) 4)
   (= (* 3 5) 15)))

checkで結果を吐き出すので、

(defun test-arithmetic ()
  (combine-results
   (test-+)
   (test-*)))

更に、なんの関数か示せれば便利だけど、report-resultに情報を渡すときにcheckからは
report-resultのコードが見えないからcheckも書き直さないといけない
けど、ダイナミック変数を使えば大丈夫

(defvar *test-name* nil)
(defun test-+ ()
 (let ((*test-name* 'test-+))
  (check
   (= (+ 1 2) 3)
   (= (+ 1 2 3) 6)
   (= (+ -1 -3) -4))))

(defun test-* ()
 (let ((*test-name* 'test-*))
  (check
   (= (* 2 2) 4)
   (= (* 3 5) 15))))

(defun report-result (result form)
  (format t "~:[FAIL~;pass~] ... ~a: ~a~%" result *test-name* form))
*抽象化の余地

テスト関数はみんな同じ形をしている
test-nameも二回出てきている
もっと抽象化だ

(defmacro deftest (name parameters &body body)
  `(defun ,name ,parameters
     (let ((*test-name* ',name))
       ,@body)))

(deftest test-+ ()
  (check
   (= (+ 1 2) 3)
   (= (+ 1 2 3) 6)
   (= (+ -1 -3) -4)))
*テストの階層

それまでに呼び出されたテスト関数の名前のリストを保持するようにするためにlet以下を書き換える

(defmacro deftest (name parameters &body body)
  `(defun ,name ,parameters
     (let ((*test-name* (append *test-name* (list ',name))))
       ,@body)))

(deftest test-arithmetic ()
  (combine-results
    (test-+)
    (test-*)))
(deftest test-math ()
  (test-arithmetic))

完全なコードは以下の通り

(defvar *test-name*)
(defmacro deftest (name parameters &body body)
  `(defun ,name ,parameters
     (let ((*test-name* (append *test-name* (list ',name))))
       ,@body)))
(defmacro check (&body forms)
  `(combine-results
    ,@(loop for f in forms collect `(report-result ,f ',f))))
(defmacro combine-results (&body forms)
  (with-gensyms (result)
    `(let ((,result t))
       ,@(loop for f in forms collect `(unless ,f (setf ,result nil)))
       ,result)))

(defun report-result (result form)
  (format t "~:[FAIL~;pass~] ... ~a: ~a~%" result *test-name* form)
  result)

*全体の経緯
ANDでテストレポートを書いたけど、もっとわかりやすくしたかった
いくつかのコードを関数にまとめたreport-resultがそれだ
でも、この関数は二回同じ式を書くからめんどい
そこで、checkによって、それを解消した
checkを書くうちに、checkは一回でいいと気がついた
ひとまずcheckのAPIが完成した
それからもし、便利なANDがあればなと思って、combine-resultを書いた
あとは、テスト結果の出力を改良することだった
deftestでテスト関数のコードのパターンを抽象化した

pass ... TEST-+: (= (+ 1 2) 3)                                                                        
pass ... TEST-+: (= (+ 1 2 3) 6)                                                                      
pass ... TEST-+: (= (+ -1 -3) -4)                                                                     
pass ... TEST-*: (= (* 2 2) 4)                                                                        
pass ... TEST-*: (= (* 3 5) 15)                                                                       
T   

これでやっと、実践へ乗り出せると思ったけど、なんかうまくいってない…なんでだろう。

まとめ

ひとまずポチポチ写経しながら書いてみましたが、うまく動かず。test-nameに階層のリストが入っていないのはなぜでしょうか。
内容が濃くて大変です…。ほとんど書き写すくらいになっている気がします。寝不足なので、ひとまずこれで〆ておきます。次回から実践だそうです。