M.Hiroi's Home Page
http://www.geocities.jp/m_hiroi/

Functional Programming

お気楽 Scheme プログラミング入門

[ PrevPage | Scheme | NextPage ]

マクロ (1)

今までいろいろな関数を作ってきましたが、いずれも引数を評価するものでした。つまり、define で定義できる関数は引数を評価するタイプで、シンタックス形式のように引数を評価しない関数を定義することはできません。Scheme (Lisp) でプログラミングする場合、ほとんどの処理は define で定義する関数で作ることができますが、シンタックス形式のように引数を評価しない関数を定義した方が便利な場合もあります。このようなとき、役に立つのが「マクロ (macro) 」です。

Scheme の場合、マクロは二種類あります。一つは仕様書 (R5RS) で定義されているマクロで、これを「健全なマクロ」といいます。これに対し、昔から Lisp で使われているマクロを「伝統的なマクロ」といいます。多くの Scheme 処理系では、どちらのマクロも使えるようになっています。もちろん、Gauche にも伝統的なマクロが用意されています。今回は伝統的なマクロについて説明します。

●C言語のマクロ

もし、あなたがC言語ユーザーであれば、マクロはお馴染みの機能ではないかと思います。ところが、Scheme (Lisp) のマクロはC言語とはちょっと毛色が変わっているので、慣れ親しんだマクロだと思っていると、とんでもないことになります。まず最初に、C言語で使われているマクロについて簡単に説明しましょう。

たとえば、C言語ではファイルの終了を表すのに EOF という記号を使います。次の例を見てください。

リスト : C言語のマクロ (1)

while( (code = fgetc( stdin )) != EOF ){
  ... 処理 ...
}

fgetc() は、ファイルからデータを読み込むC言語の関数です。これは、ファイルが終了するまでデータを読み込む、という処理をC言語でプログラムしたものです。EOF は記号といっても、Scheme (Lisp) のシンボルと同じではありません。C言語にはシンボルのような機能はないのです。

実はこの EOF は、ファイル終了を表す整数値 (-1) なのです。単に数値を書くだけでは、その数値が何を意味をしているのか、前後の関係を理解しないと判断できません。つまり、ファイル終了時に関数 fgetc() が -1 を返すことを覚えていないと、この処理内容を理解することはできません。私達は、数値よりも意味のある記号の方が覚えやすいですよね。プログラムの場合も、単なる数値よりも記号を使った方が処理内容を把握しやすいのです。

そこで、EOF のような記号定数を定義しておき、同じ記号が出てくるたびにそれを一定の文字列 (EOF であれば -1) に置き換える、というような機能が欲しくなります。これが「マクロ」です。C言語では次のように定義します。

リスト : C言語のマクロ (2)

#define  EOF   (-1)

#define はマクロを定義する命令です。そして、EOF を -1 に置き換えることを「マクロ展開」といいます。C言語では、この操作を「プリプロセッサ」というプログラムが担当します。プリプロセッサはC言語のソースファイルを読み込み、マクロ定義を取り除きマクロ展開した結果を新しいファイルに書き込みます。そして、このファイルをコンパイルするのです。コンパイルの前にプリプロセッサが動作するところが、C言語の特徴といえるでしょう。

このようにC言語のマクロは、記号を定義した文字列に置き換える、という機能なのです。これに対して Lisp のマクロは、まさに Lisp らしいといえる機能を持っています。C言語との対比でいえば、「S 式を置き換える」と表現することができます。

●伝統的なマクロ

それでは、伝統的な Lisp におけるマクロの使い方を説明しましょう。Lisp ではマクロを関数のように定義します。Gauche の場合、伝統的なマクロを定義するには define-macro を使います。

define-macro の構文は define と同じです。define-macro で定義されたマクロは、次のような特徴を持ちます。

この 2 番目の機能が Lisp におけるマクロの特徴です。これを図に示すと、次のようになります。

[S式] ─  評価  → [新しいS式] ─ 評価 → [マクロの返り値]
      (マクロ展開)

                   図 : マクロの動作

S 式を評価することで新しい S 式を組み立てます。この部分がマクロ展開に相当します。そして、その S 式を評価した値がマクロの返り値となります。S 式を組み立てるということは、自動的にプログラムを作ることと同じですね。これは、リストにプログラムとデータの 2 つの役割を持たせている Lisp だからこそ可能なことなのです。

まず、マクロと関数の違いを理解するために、数を 2 乗する処理をマクロと関数で作ってみましょう。関数は簡単ですね。

リスト : 数を 2 乗する関数

(define (square x) (* x x))

マクロは次のように定義します。

リスト : 数を 2 乗するマクロ

(define-macro (m-square x) (list '* x x))

マクロ名は m-square としました。それでは、引数に (+ 1 2) を与えて m-square を評価してみます。

(m-square (+ 1 2))

仮引数 x に (+ 1 2) がセット(評価されないことに注意)

マクロの本体 (list '* x x) を評価する

=> (* (+ 1 2) (+ 1 2)) (S式が組み立てられる)

=> 9                   (S式を評価した結果)

        図 : マクロの実行

関数であれば引数 (+ 1 2) が評価されて、その返り値である 3 が square に渡されますね。マクロの場合、引数は評価されないので、仮引数 x には S 式である (+ 1 2) がそのままセットされます。

次に、マクロ本体を評価します。マクロを使いこなすポイントですが、まず評価したい S 式を組み立てることを考えます。最初の評価で S 式を組み立て、それを評価することで目的の処理を実現するのがマクロなのです。

この場合、引数の 2 乗する (* x x) という S 式を作ればいいわけです。list は引数を要素とする新しいリストを返す関数でしたね。この場合、シンボル * と x の値である (+ 1 2) が要素となったリストが返されます。

これでマクロ展開が終了しました。マクロの仮引数は、マクロ展開されるときだけ有効です。マクロ展開されたS式を評価するときは、それらの値は破棄されます。あとは、この S 式を評価して 9 という値が結果となります。

次に示す関数を引数に与えて square と m-square を評価すると、関数とマクロの違いがよくわかると思います。

gosh> (define (foo x) (format #t "~A " x) x)
foo
gosh> (square (foo 2))
2 4
gosh> (m-square (foo 2))
2 2 4

関数 square は、引数が評価されるので 2 が 1 回だけ出力されます。ところが m-square では、引数は評価されずに渡されて S 式 (* (foo 2) (foo 2)) が組み立てられます。その後、この S 式が評価されるので 2 が 2 回出力されるのです。

●マクロとコンパイラの関係

ところで、昔の Lisp 処理系では、引数を評価するタイプを EXPR 型や SUBR 型、引数を評価しないタイプを NEXPR 型や FSUBR 型と呼び、ユーザーが NEXPR 型の関数を定義することができました。Scheme や Common Lisp の場合、ユーザーが定義できるのは関数とマクロだけです。シンタックス形式の関数を定義する場合はマクロを使うことになります。

マクロを実行する場合、必ずマクロ展開が行われるため、通常の関数よりも実行時間は遅くなります。だったら、NEXPR 型の関数を定義できるようにした方が実行速度の点で有利なはずです。ところが、Scheme や Common Lisp では必要最低限のシンタックス形式を定義し、よく使われる制御構造はマクロで定義されています。これではインタプリタでの動作が遅くなります。

では、なぜ実行速度が遅くなるのにマクロを使っているのでしょう。それは、Common Lisp や多くの Scheme 処理系がコンパイラの使用を前提としているからです。たとえば、Gauche はプログラムをバイトコードにコンパイルしてから実行します。また、Common Lisp の処理系である CLISP も、プログラムをバイトコードにコンパイルすることができます。今の実用的な Scheme (Common Lisp) 処理系のほとんどは、プログラムをバイトコードまたはネイティブコードにコンパイルすることができます。

プログラムでマクロを呼び出している場所は、コンパイル時にマクロ展開されるため、コンパイル済みのコードにはマクロ呼び出しがなくなってしまうのです。つまり、コンパイル済みのコードは、マクロを呼び出す処理とマクロ展開の処理がなくなることにより、確実にインタプリタよりも高速に実行することができるのです。逆にいえば、コンパイラを使わないとマクロを効果的に使うことはできません。ご注意くださいませ。

●スタックの操作

今度は、もう少し複雑な例を見てみましょう。スタックを操作する関数をマクロで定義してみます。最初にスタックについて簡単に説明します。スタックの例として、バネ付きのトレイを取り上げます。次の図を見てください。

   |-----|     |[ A ]|     |[ B ]|     |[ A ]|     |-----|
   |  |  |     |-----|     |[ A ]|     |-----|     |  |  |
   |  |  |     |  |  |     |-----|     |  |  |     |  |  |
   |  |  |     |  |  |     |  |  |     |  |  |     |  |  |
   |  |  |     |  |  |     |  |  |     |  |  |     |  |  |
   +-----+     +-----+     +-----+     +-----+     +-----+
(1) 空の状態  (2) PUSH    (3) PUSH    (4) POP     (5) POP
                  A           B           B           A

                図 : スタックの動作例

初めはトレイが入っていない空の状態です。ここにトレイを上から入れると、重さによってバネを圧縮し、次のトレイを追加できるようになります。もうひとつトレイを乗せると、さらにバネを圧縮し次のトレイを追加できるようになります。バネが限界まで圧縮されると、トレイは追加できません。トレイを取り出す場合は、上にあるトレイから取り出していきます。ひとつ取り出すと、その分バネが伸びて下にあるトレイが上に出てくるので、次のトレイを取り出すことができます。

このトレイをデータと考えてください。データ A をスタックに追加し (2)、次にデータ B を追加します (3)。データを取り出す場合、後から入れたデータ B が先に取り出され (4)、その次にデータ A が取り出されて、スタックが空になります (5)。スタックにデータを追加する操作をプッシュ (PUSH) といい、スタックからデータを取り出す操作をポップ (POP) といいます。このように、スタックは後から入れたデータが先に取り出されるので、「後入れ先出し (Last-In First-Out : LIFO) 」と呼ばれます。

スタックはリストを使うと簡単に実現することができます。たとえば、大域変数 *stack* にスタックを保持することにします。プッシュはリストの先頭にデータを追加していくことで実現できます。これは cons を使えば簡単ですね。データをプッシュする push-stack は、次のようになります。

リスト : データの追加

(define *stack* '())

(define (push-stack x)
    (set! *stack* (cons x *stack*)))

それでは、実際に試してみましょう。

gosh> (push-stack 10)
(10)
gosh> *stack*
(10)
gosh> (push-stack 100)
(100 10)
gosh> *stack*
(100 10)

最初スタックにはデータがありませんから、*stack* は空で初期化しておきます。push-stack を実行するたびに、スタック *stack* にデータが追加されていきます。

次は、データをポップする pop-stack を作ります。ポップはリストの先頭にあるデータを取り出す操作です。データを取り出すには car を使えばいいですね。取り出したデータは *stack* から削除します。これには cdr を使えばいいでしょう。これを素直にプログラムすると、次のようになります。

リスト : データの取り出し

(define (pop-stack)
    (let ((x (car *stack*)))
        (set! *stack* (cdr *stack*))
        x))

let で局所変数 x を定義し、そこに *stack* の先頭要素をセットします。そして、*stack* の値を書き換えて、x の値を返します。なお、begin0 を使うと簡単に定義できるので、興味のある方はプログラムを書き直してみてください。

それでは、実際に試してみましょう。

gosh> (pop-stack)
100
gosh> *stack*
(10)
gosh> (pop-stack)
10
gosh> *stack*
()

確かにスタック *stack* からデータが削除され、そのデータが関数の返り値になっています。

●スタックを操作するマクロ

次はマクロを使って定義しましょう。関数 push-stack と pop-stack は、大域変数 *stack* にスタックを保持しましたが、これから作成するマクロは、スタック用の変数を引数として渡すことにします。Gauche にはマクロ push! と pop! が用意されているので、マクロ名は my-push! と my-pop! にします。my-push! は次のようになります。

リスト : データの追加 (マクロ版)

(define-macro (my-push! place x)
    (list 'set! place (list 'cons x place)))

それでは、実際に試してみましょう。

gosh> (define a '())
a
gosh> (my-push! a 10)
(10)
gosh> a
(10)
gosh> (my-push! a 20)
(20 10)
gosh> a
(20 10)

最初に変数 a を空リストに初期化しておきます。my-push! は list を使って S 式を組み立てます。place には a が、x には 10 がセットされているので、(set! a (cons 10 a)) という S 式が組み立てられます。この S 式が再度評価されて、変数 a にリスト (10) がセットされます。

これで引数 x も評価されることに注意してください。たとえば、x に (+ 1 2) を渡したとしましょう。マクロですから引数 x は評価されませんが、(list 'cons x place) のところで S 式 (cons (+ 1 2) a) が組み立てられ、マクロはその S 式を再度評価するので (+ 1 2) の結果 3 がスタックに格納されます。

my-pop! も同様に実現できます。

リスト : データの取り出し (マクロ版)

(define-macro (my-pop! place)
    (list 'let (list (list 'x (list 'car place)))
        (list 'set! place (list 'cdr place))
	'x))

list を多用しているため複雑になってしまいましたが、これで let の構文を組み立てることができます。もっと簡単な定義方法もあるので心配しないでください。

それでは、実際に試してみましょう。

gosh> a
(30 20 10)
gosh> (my-pop! a)
30
gosh> a
(20 10)
gosh> (my-pop a)
20
gosh> a
(10)

正常に動作していますね。

●バッククオート

ところで、マクロを定義するとき、S 式を組み立てるため list をたくさん使うことになり少々面倒です。実は、「バッククォート ( ` ) 」という機能を使うと、S 式を簡単に組み立てることができます。

バッククォートはクォート ( ' ) と同様に引数の評価を行いません。ですが、バッククォートの中でコンマ ( , ) で始まる S 式があると、その S 式を評価した値で置き換えられます。簡単な例を示しましょう。

gosh> (define var 'pen)
var
gosh> var
pen
gosh> `(this is a ,var)
(this is a pen)

変数 var にはシンボル pen がセットされています。次の S 式の中で ,var は var を評価した値、つまり pen に置き換わるのです。また、S 式の評価結果がリストの場合は、コンマアットマーク (,@) を使うことができます。,@ を使うと、リストをはずした値と置き換わります。,@ を使う場合、値がリストでなければエラーになります。次の例を見てください。

gosh> (define var '(pen))
var
gosh> var
(pen)
gosh> `(this is a ,var)
(this is a (pen))
gosh> `(this is a ,@var)
(this is a pen)

今度は変数 var にリスト (pen) がセットされました。次の S 式の中で ,varは (pen) に置き換わります。そして、その次の S 式の中では、,@var は pen に置き換わるのです。それから、コンマやコンマアットマークはバッククォートの中でしか使うことができません。ほかの S 式の中で評価した場合はエラーとなります。ご注意ください。

それではバッククォートを使って my-push! と my-pop! を書き直してみましょう。

リスト : my-push! と my-pop! の改良

; データの追加
(define-macro (my-push1! place x)
    `(set! ,place (cons ,x ,place)))

; データの取り出し
(define-macro (my-pop1! place)
    `(let ((x (car ,place)))
        (set! ,place (cdr ,place))
	x))

my-push1! は x にコンマ ( , ) がついているので、x を評価した結果がスタックに積まれることに注意してください。バッククォートを使った方が、どんな S 式が組み立てられて評価されるのかよくわかると思います。ですが、関数に比べるとマクロは理解するのが難しいと思います。そのマクロがどんなことをするのか、きちんとコメントを書いておいた方がよいでしょう。

●伝統的なマクロの問題点 (1)

ところで、(my-push! a x) をマクロ展開すると (set! a (cons x a)) になります。展開後の S 式に x が含まれていますね。この x は、どのように評価されるのでしょうか。次の例を見てください。

(let loop ((x 0))                       (let loop ((x 0))
    (cond ((< x 5)                          (cond ((< x 5)
           (my-push! a x)   -- マクロ展開 -->      (set! a (cons x a))
           (loop (+ x 1)))))                       (loop (+ x 1)))))

                図 : my-push! の実行環境

マクロ展開された S 式は、そのマクロを置き換えた状態で評価されます。上の例では、名前付き let の中で my-push! が評価されますが、この部分をマクロ展開後の S 式に置き換えて評価するのです。したがって、変数 x は名前付き let で定義された局所変数として扱われます。

関数呼び出しでは、関数の仮引数やその中で定義された変数を局所変数として扱いますが、それ以外の変数は大域変数として扱われます。ところがマクロの場合、マクロ展開時には関数呼び出しと同じ規則が適用されますが、展開後の S 式を評価するときは、マクロ呼び出し時に定義されている局所変数が有効になるのです。

それでは、次の例はどうなるのでしょうか。

(define x '(1 2 3 4 5))

(let loop ((n 0))            (let loop ((n 0))
    (cond ((< n 5)               (cond ((< n 5)
           (my-pop! x)                  (let ((x (car x)))   ; 
           (loop (+ n 1)))))                (set! x (cdr x)) ; マクロ展開
                                            x)               ; 
                                        (loop (+ n 1)))))

                図 : my-pop! の実行環境

大域変数 x にリストをセットし、my-pop! で取り出します。my-pop! をマクロ展開すると、my-pop! で定義している局所変数 x が大域変数 x を隠蔽するため、このマクロは正しく動作しません。このように、伝統的なマクロはマクロ展開した後で変数名が衝突することがあるのです。これが伝統的なマクロの欠点で、「変数捕捉 (variable capture) 」といいます。

この場合、変数名が衝突しないように新しいシンボルを作成して局所変数として使います。関数 gensym は既存のシンボルと衝突しない新しいシンボルを作成して返します。

一般に、Scheme (Lisp) はシンボルを管理するための「表」を持っています。大昔の Lisp 処理系では、システム内のシンボルを oblist というリストで管理していました。今では、ハッシュ法を使って管理するのが一般的です。ここではシンボル表と呼ぶことにしましょう。普通のシンボルは、このシンボル表に登録されています。gensym はシンボル表に存在しないシンボルを新しく作成するので、既存のシンボルと衝突することはありません。

簡単な例を示しましょう。

gosh> (gensym)
G1
gosh> (gensym "ABC")
ABC2
gosh> (define a (gensym))
a
gosh> a
G3
gosh> (eq? a 'G3)
#f
gosh> (eq? a a)
#t

Gauche の場合、引数なしで gensym を呼び出すと、生成されるシンボル名は "G + 数字" になります。gensym に文字列を指定すると、シンボル名は "文字列 + 数字" になります。gensym で生成されるシンボルは Scheme システムの中で唯一のシンボルなので、同じ名前のシンボルと eq? で比較しても #f が返されます。自分自身を eq? で比較すると、当然ですが #t になります。

このように、シンボル表に登録されていないシンボルは、それ以前の S 式で使われているシンボルと異なるわけですから、let の局所変数をこのシンボルで置き換えれば、他の変数と衝突することはなくなります。

gensym を使うと、my-pop! は次のようになります。

リスト : my-pop! の改良

(define-macro (my-pop2! place)
    (let ((x (gensym)))
        `(let ((,x (car ,place)))
            (set! ,place (cdr ,place))
	    ,x)))

まず最初に、let で局所変数 x を用意し、ここに gensym で生成したシンボルをセットします。次に、この x を使ってマクロ展開する S 式を組み立てます。これは今までのマクロ定義において、x の前にカンマ ( , ) を付けて x を評価するようにします。これで、マクロで使用する局所変数が、他の変数と衝突することを防ぐことができます。

gensym を使った my-pop! のマクロ展開は次のようになります。

(define x '(1 2 3 4 5))

(let loop ((n 0))            (let loop ((n 0))
    (cond ((< n 5)               (cond ((< n 5)
           (my-pop! x)                  (let ((G1 (car x)))  ; 
           (loop (+ n 1)))))                (set! x (cdr x)) ; マクロ展開
                                            G1)              ; 
                                        (loop (+ n 1)))))

                図 : my-pop! の実行環境 (2)

define-macro でマクロ展開されるのは最後の S 式だけなので、gensym でシンボルを生成する処理はマクロ展開されませんが、生成されたシンボル G1 を使って S 式が組み立てられるわけです。これで変数捕捉を回避することができます。

●伝統的なマクロの問題点 (2)

伝統的なマクロを使う場合、もう一つ問題点があります。次のリストを見てください。

リスト : マクロの問題点 (2)

(define-macro (arithmetic-if test neg zero pos)
  (let ((var (gensym)))
    `(let ((,var ,test))
        (cond ((< ,var 0) ,neg)
	      ((= ,var 0) ,zero)
	      (else ,pos)))))

マクロ arithmetic-if は述語 test の返り値が負ならば引数 neg を、0 ならば引数 zero を、正ならば引数 pos を評価します。簡単な実行例を示します。

gosh> (arithmetic-if 0 (print -1) (print 0) (print 1))
0
#<undef>
gosh> (arithmetic-if -10 (print -1) (print 0) (print 1))
-1
#<undef>
gosh> (arithmetic-if 10 (print -1) (print 0) (print 1))
1
#<undef>

このように arithmetic-if は正常に動作していますが、次のように標準関数 < を書き換えると、arithmetic-if は正常に動作しなくなります。

gosh> (let ((< (lambda (x y) (> x y))))
        (arithmetic-if -10 (print -1) (print 0) (print 1)))
1
#<undef>

Scheme の標準関数を書き換えることはめったにないと思いますが、補助的な関数を作ってマクロから呼び出す場合は、その関数の定義を書き換えないように注意してください。

●マクロの再帰定義

マクロは再帰定義することもできます。簡単な例として and をマクロ定義してみましょう。次のリストを見てください。

リスト : and のマクロ定義

(define-macro (my-and . args)
  (case (length args)
    ((0) #t)
    ((1) (car args))
    (else `(if ,(car args) (my-and ,@(cdr args)) #f))))

名前は my-and としました。case はシンタックス形式で、cond と同様に条件分岐を行うときに使います。case は cond より奇妙な構文をもっています。

(case キーとなるS式
      ( キーリスト1 S式A1 S式A2 ... )
      ( キーリスト2 S式B1 S式B2 ... )
         ・・・・・
      ( キーリストM S式M1 S式M2 ... )
      ( else         S式T1 S式T2 ... ))

        図 : case の構文

case は最初にキーとなる S 式を受け取り、そのあと cond と同様に複数の節が続きます。cond には節の先頭に条件部がありましたが、case の場合はキーリストというものがあります。まず、キーとなる S 式を評価します。次に、この評価結果とキーリストに格納された要素を比較します。このとき、キーリスト本体や要素は評価されないことに注意してください。もし、等しいキーを見つけた場合は、その節の S 式を順番に実行します。

  評価されたキー
       ↓
┌──────┐FIND┌────┐          ┌────┐
│キーリストA │─→│S式A1│→・・・→│S式A9│─→┐
└──────┘    └────┘          └────┘    │
       ↓ No                                              │
┌──────┐FIND┌────┐          ┌────┐    │
│キーリストB │─→│S式B1│→・・・→│S式B9│─→┤
└──────┘    └────┘          └────┘    │
       ↓ No                                              │
       ・                                                 │
       ↓                                                 │
┌──────┐FIND┌────┐          ┌────┐    │
│キーリストM │─→│S式M1│→・・・→│S式M9│─→┤
└──────┘    └────┘          └────┘    │
       ↓ No                                              │
┌──────┐Yes ┌────┐          ┌────┐    │
│    else    │─→│S式T1│→・・・→│S式T9│─→┤
└──────┘    └────┘          └────┘    │
                                                          │
                                                          ↓

                図 : case の流れ図

上図を見てください。case ではキーがキーリストの中に含まれているかチェックします。データの比較には述語 eqv? が適用されます。等しいキーを発見したら、その後ろの S 式を順番に実行していきます。

my-and は引数 args の長さを調べ、0 ならば #t がマクロ展開後の S 式となり、それを評価するので結果は #t になります。1 ならば、リスト args の先頭要素がマクロ展開後の S 式になり、それを評価します。それ以外の場合は args の先頭の要素を評価して、真ならば args の残りの要素を my-and に渡してマクロ展開します。偽ならば #f がマクロ展開後の S 式となり、その評価結果は #f になります。

たとえば、(my-and 1 2 3) は次のようにマクロ展開されます。

     (my-and 1 2 3)

            ↓

   (if 1 (my-and 2 3) #f)

            ↓

(if 1 (if 2 (my-and 3) #f) #f)

            ↓

   (if 1 (if 2 3 #f) #f)

図 : (my-and 1 2 3) のマクロ展開

簡単な実行例を示しましょう。

gosh> (my-and)
#t
gosh> (my-and 1)
1
gosh> (my-and 1 2)
2
gosh> (my-and 1 2 3)
3
gosh> (my-and 1 2 #f 3)
#f

正常に動作していますね。


Copyright (C) 2009 Makoto Hiroi
All rights reserved.

[ PrevPage | Scheme | NextPage ]