🦉

いかにもLispらしいマクロを書いたので解説。

2021/10/07に公開

表題の通り。

LET

まずは仕様の確認から。

Common Lispではローカル変数を作るのにLETという特殊形式を使います。

(let ((var :value))
  var)
=> :VALUE

初期化フォームは省略可能です。
省略された場合NILで初期化されます。

(let ((var))
  var)
=> NIL

初期化フォームを省略する場合は、カッコも省略できます。

(let (var)
  var)
=> NIL

Issue.

プログラミングをしていると、ときに、ローカル変数を宣言だけしておいて、初期化は後で行いたいということだってあります。

そのような場合、初期化前に変数を参照しようとした場合、エラーになって欲しい。
LETは無条件でNILに束縛してしまうので、初期化前のNILと初期化後のNILとを区別できなかったり、初期化前の参照が動いてしまったりとあまり具合がよくありません。

MAY-LET

そこでそんな状況に相応しいマクロMAY-LETを書きました。

初期化フォームがある場合は通常のLETと振る舞いは完全に同じです。

(may-let ((var :value))
  var)
=> :VALUE

初期化フォームが省略された場合は暗黙理にNILが指定されたものと解釈されます。
これもLETと同じ振る舞いです。

(may-let ((var))
  var)
=> NIL

ただし、カッコまで省略された場合は代入前に参照するとエラーを投げます。

(may-let (var)
  var)
=> Error, VAR is not initialized yet.

代入後は期待どおり振る舞います。

(may-let (var)
  (setq var 0)
  var)
=> 0

一度も代入が行われていない場合、コンパイラはクレームをつけます。
一度でも代入されている場合、コンパイラはたとえ代入前参照があっても黙ったままです。
できればそういった代入前参照もコンパイル時に摘発したいところではありますが、労多くして益少しと判断しました。
実行時に適切なエラーが出るので良しとしています。

Under the hood.

マクロ展開した中身を見ながら解説していきましょう。

(macroexpand-1 '(may-let (var)
                  (setq var 0)
		  var)) ; <--- 変数の参照に見えますが、、、

(LET ((#:G111 (LAMBDA () (ERROR "~S is not initialized yet." 'VAR))))
  (FLET ((#:G222 ()
           (FUNCALL #:G111))
         ((SETF #:G222) (NEW)
	   (SETQ #:G111 (LAMBDA () NEW))
	   NEW))
    (SYMBOL-MACROLET ((VAR (#:G222)))
      (SETQ VAR 0)
      VAR)))
(macroexpand-1 '(may-let (var)
                  (setq var 0)
		  var))

(LET ((#:G111 (LAMBDA () (ERROR "~S is not initialized yet." 'VAR))))
  (FLET ((#:G222 ()
           (FUNCALL #:G111))
         ((SETF #:G222) (NEW)
	   (SETQ #:G111 (LAMBDA () NEW))
	   NEW))
    (SYMBOL-MACROLET ((VAR (#:G222))) ; <--- その実、SYMBOL-MACROLETです。
      (SETQ VAR 0)
      VAR)))
(macroexpand-1 '(may-let (var)
                  (setq var 0)
		  var))

(LET ((#:G111 (LAMBDA () (ERROR "~S is not initialized yet." 'VAR))))
  (FLET ((#:G222 ()
           (FUNCALL #:G111))
         ((SETF #:G222) (NEW)
	   (SETQ #:G111 (LAMBDA () NEW))
	   NEW))
    (SYMBOL-MACROLET ((VAR (#:G222))) ; <--- 実際は関数呼び出しであり、その関数は、、、
      (SETQ VAR 0)
      VAR)))
(macroexpand-1 '(may-let (var)
                  (setq var 0)
		  var))

(LET ((#:G111 (LAMBDA () (ERROR "~S is not initialized yet." 'VAR))))
  (FLET ((#:G222 ()
           (FUNCALL #:G111)) ; <--- 背後にある変数に束縛されている関数を呼び出しています。
         ((SETF #:G222) (NEW)
	   (SETQ #:G111 (LAMBDA () NEW))
	   NEW))
    (SYMBOL-MACROLET ((VAR (#:G222)))
      (SETQ VAR 0)
      VAR)))
(macroexpand-1 '(may-let (var)
                  (setq var 0)
		  var))

;; 背後にある変数は初期値としてエラーを投げる無名関数が束縛されています。
(LET ((#:G111 (LAMBDA () (ERROR "~S is not initialized yet." 'VAR))))
  (FLET ((#:G222 ()
           (FUNCALL #:G111))
         ((SETF #:G222) (NEW)
	   (SETQ #:G111 (LAMBDA () NEW))
	   NEW))
    (SYMBOL-MACROLET ((VAR (#:G222)))
      (SETQ VAR 0)
      VAR)))
(macroexpand-1 '(may-let (var)
                  (setq var 0) ; <--- 代入が行われると、、、
		  var))

(LET ((#:G111 (LAMBDA () (ERROR "~S is not initialized yet." 'VAR))))
  (FLET ((#:G222 ()
           (FUNCALL #:G111))
         ((SETF #:G222) (NEW)
	   (SETQ #:G111 (LAMBDA () NEW))
	   NEW))
    (SYMBOL-MACROLET ((VAR (#:G222)))
      (SETQ VAR 0)
      VAR)))
(macroexpand-1 '(may-let (var)
                  (setq var 0)
		  var))

(LET ((#:G111 (LAMBDA () (ERROR "~S is not initialized yet." 'VAR))))
  (FLET ((#:G222 ()
           (FUNCALL #:G111))
         ((SETF #:G222) (NEW) ; <--- SETF関数が呼び出され、
	   (SETQ #:G111 (LAMBDA () NEW)) ; <--- 背後変数をclosureで更新します。
	   NEW))
    (SYMBOL-MACROLET ((VAR (#:G222)))
      (SETQ VAR 0)
      VAR)))

よって上記例では、代入後、背後変数には無名関数(lambda () 0)が入っていることとなります。
コード末尾のVAR参照(に見える関数呼び出し)ではこの関数が呼び出され、期待どおり0が返るというカラクリです。

Conclusion.

初期化前のローカル変数を参照するとエラーになって欲しい、というような言語コアの振る舞いに手を入れるのに、Issueを投げたりプルリクを出したりコミュニティの説得という政治活動を行ったり言語仕様そのものがアップデートされるのを待ったりすることなく、さくっと実現できるのがLisp一族の何よりの魅力です。

Discussion