nptclのブログ

Common Lisp処理系nptの開発メモです。https://github.com/nptcl/npt

load-time-valueを作ろう!

目次

  1. はじめに
  2. 使い方
  3. evalの場合
  4. compile-fileの場合
  5. make-load-formの実行
  6. gensymを扱う
  7. makefileを使う

1. はじめに

load-time-valueという機能を知っていますか?

この機能の実装が死ぬほど面倒だったので、 ちゃんと記録に残そうという気持ちになりました。
もしこれから新たにCommon Lispを作りたいという人がいたら、 ぜひ参考にしてもらえたら幸いです。
そんなクレイジーな人は絶対にいないと思いますけど。

load-time-valueとは、先行して式を評価するという機能です。
special formに分類されているため、関数でもマクロでもありません。

どんな機能なのでしょうか?
とりあえず次の例文を見てみましょう。

(load-time-value 100)
-> 100

引数がそのまま返却されています。
もうちょっと難しいのを。

(load-time-value (+ 10 20 30))
-> 60

これを見てどう思います?
だから何なんだと思いませんか?

load-time-valueは、何のためにあるのかわからない機能です。
ただ値を返すだけでしょ?
ということでnptでも適当に作ったのですが、 よく調べてみると非常に複雑なものでした。
一見簡単そうだけど実はクソみたいなやつだったと言うのは Common Lispではよくあることで、殿堂入りがsubtypepになります。

では一体何が複雑なのか。
どういう所で使われるのか。
これから丁寧に説明して行きます。

まず最初に、とても大切なことを言います。

load-time-valueは、compile-fileのための機能』です。

理解するうえで重要なことなので、 まずは適当に頭の片隅にでも入れておいてください。
それでは、load-time-valueの使い方と実装方法について順に説明して行きます。

2. 使い方

まずは使い方を覚えましょう。
例文で示した通り、第一引数が実行されてその値が返却されます。

(load-time-value 100)
-> 100

式は評価されて返却されます。

(load-time-value (+ 10 20 30))
-> 60

load-time-valueは、式を評価する前に先行して評価されるという機能です。
例えば次の式を考えます。

(+ (+ 10 20)
   (load-time-value (+ 30 40))
   50)

この式で一番最初に評価されるのは、 (+ 10 20)ではなくload-time-valueの引数の(+ 30 40)になります。
つまり次の式に置き換えられます。

(+ (+ 10 20)
   70
   50)

load-time-valueは、先行して評価するという特性上、 空のenvironment環境で評価されるという性質があります。

プロンプト上で実行した例を示します。

(defvar *aaa* 111)

(let ((*aaa* 999))
  (load-time-value *aaa*))
-> 111   ;; sbcl, ccl
-> 999   ;; clisp

結果が分かれましたが、111が正解になります。
(私の考え方ですので絶対ではありません)
補足しますと、clispの方もcompile-fileで実行すれば111が返却されます。

本投稿では全然説明しませんが、 第二引数で読み込み専用にすることができます。

(load-time-value (list 10 20 30) t)
-> (10 20 30)

返却されたリストが読み込み専用になります。
正確には、読み込み専用のメモリ領域に配置されるのが許されるとのこと。
なのでrplacaなんかで変更しようとすると たぶん何かのconditionが発生するのか、 あるいは全く変更が反映されないか、 運が悪ければプロセスごとabortするのだと思います。

それではload-time-valueがどのように処理されるのかを説明します。
例えば次の例文を考えてみます。

(+ (load-time-value 10)
   (load-time-value (+ 20 30)))
-> 60

この文を評価するときは、 まずload-time-valueが先行して評価されるので、 疑似的なコードに書き換えると次のようになります。

(setq gensym1 10)
(setq gensym2 (+ 20 30))

(eval `(+ ,gensym1 ,gensym2))  ;; つまり(eval '(+ 10 50))
-> 60

最初にload-time-valueの式を評価して、一時変数にsetqで代入しています。
元の式とは全く関係ない場所で実行されますので、 空のenvironmentで評価されるという制約があるのです。

これはあくまで例なので、 上記のようにコードが展開されて実行されるのではありません。
では具体的にどうなるのでしょうか?
load-time-valueの式は一体いつ評価されるのでしょうか?
readのとき? マクロ実行時? あるいはコード実行時?
これを説明するのが大変であり、本投稿の主題ともいえる内容になります。

次の内容を思い出してください。

load-time-valueは、compile-fileのための機能』です。

これは私が勝手に思ったことです。
しかしload-time-valueを理解するためには必要なことだと思います。

load-time-valuecompile-file以外でも使うことができます。
compile-fileのための機能ですが、例えばevalでも使用できます。
でも、compile-fileのときとevalのときでは、 実装の難易度に大きな差があります。
evalの時の方がものすごく簡単なのです。

本投稿の主題は、compile-fileの処理でload-time-valueを どう扱うかについて説明して行くことですが、 evalload-time-valueについても説明しなければなりません。

まずは先にevalの方を説明します。

3. evalの場合

evalの場合はとても簡単です。
load-time-valueはマクロと同じように扱われます。

下記の式を考えます。

(+ (load-time-value 10)
   (load-time-value (+ 20 30)))

この場合、load-time-valueがマクロのように機能するため、 先行して10(+ 20 30)が評価されます。
そして次のような式を生成します。

(+ 10 50)

この式がevalで評価されることになります。
load-time-valueの式は、eval構文解析の時に評価されるため、 最終的に生成されるコードには、load-time-valueの命令が含まれません。

もう少し話題が続きます。
load関数で読み込んだ場合はどうなるでしょうか?

答えは、一つずつreadをしてからevalで実行するということになります。
つまり、load-time-valueという名前がついているにもかかわらず、 先に全てのload-time-valueを一斉に実行するのではありません。

次のファイルを例に挙げます。

;;  aaa.lisp
(format t "AAA~%")

(load-time-value
  (format t "BBB~%"))  ;; AAAより先に実行されるわけではない

(format t "CCC~%")

(load-time-value
  (format t "DDD~%"))

実行結果は下記の通り。

AAA
BBB
CCC
DDD

何が言いたかったのかというと、 次のようにはなりませんよということです。

BBB
DDD
AAA
CCC
にはならなかった

toplevelという考え方は、実はよくわかっていません。
しかしtoplevelの式を順次実行していき、 必要に応じてload-time-valueが先行評価されるということなのでしょう。
これがevalにおけるload-time-valueの全てです。

最後にcompile関数の話題をして終わります。
compile関数は、evalと全く同じように機能します。

同じコンパイルでも、compilecomple-fileでは扱いが全く違います。
compileとは、ファイルではなく関数であったり、 あるいはLispオブジェクトを機械語に変換する機能です。

コンパイルなのでcompile-fileと似たような感じなのかと想像するかもしれませんが、 load-time-valueに関しては、evalの方と同じです。
compile-fileだけ特別ということなのですね。

例を示すまでもありませんが、例文を示します。

(funcall
  (compile nil '(lambda () (load-time-value 100))))
-> 100

4. compile-fileの場合

ここからがload-time-valueの本題となります。
evalの場合は、マクロみたいにするだけでしたが、 compile-fileの場合はそうもいきません。
もしload-time-valueが現れたら、 その式だけを抜き出して前に持ってくる必要があります。

下記の式を考えます。

(+ (load-time-value 10)
   (load-time-value (+ 20 30)))

これをcompile-fileコンパイルすることを考えます。
生成するコードの内容は次の通り。

  • load-time-value用の配列を作成
  • 配列にload-time-valueの評価値を代入していく
  • 最終的なコードを生成する

load-time-value用の配列は、適当なspecial変数で良いと思います。
例ではload-time-valueが2つあるので、配列の長さも2になります。

疑似コードとして次のような式が生成されます。

(defvar *load-time-value* (make-array 2))

つぎに、load-time-valueの内容を実行します。
疑似コードは次の通り。

(setf (aref *load-time-value* 0) 10)
(setf (aref *load-time-value* 1) (+ 20 30))

最後に最終的なコードを生成します。
次のようなコードをコンパイルします。

(+ (aref *load-time-value* 0)
   (aref *load-time-value* 1))  ;; ★ダメな例

上記の式を連続で実行することで、 load-time-valueが実現できたことになります。

まとめると次の通りとなります。

;;  load-time-value
(defvar *load-time-value* (make-array 2))
(setf (aref *load-time-value* 0) 10)
(setf (aref *load-time-value* 1) (+ 20 30))

;;  コンパイルする式
(+ (aref *load-time-value* 0)
   (aref *load-time-value* 1))  ;; ★ダメな例

ダメな例と記載されている通り、この方法には問題があります。
値の参照にspecial変数を使っているということで、 別の式をコンパイルすると*load-time-value*変数が上書きされ 全く意味がなさなくなるということです。

とくにlambda式の場合なんかは、 評価する時期がわからないため悲惨な結果となります。
こんな感じ。

(lambda ()
  (aref *load-time-value* 1))  ;; ★一体何を参照してるの?

*load-time-value*gensymにするとか色々とやりようはあるのですが、 そもそもグローバルな値を参照するというやり方は あまり良くないと思いました。

どのようなやり方がいいのでしょうか?
special変数ではなく、closureを使うという手もありました。
これはかなり自然で美しい設計だと思います。
そうしようかなとも思ったのですが、 分析が複雑なので断念して、もっと簡単な別の方法を採用しました。

代替案は次の通り。

(+ (load-time-value! 0)
   (load-time-value! 1))

ここで現れるload-time-value!というのは特別な式であり、 faslファイルに出力するときに専用の命令を割り当てるというものです。
出力にはload-time-value!に対応するバイナリコードと一緒に 番号0と番号1が書き込まれます。

faslファイルが生成されたあとはloadで実行できます。
ファイルを読み込むと、まずはファイルに記載されている順番で 命令コードを構築していきます。

最初は下記のコードを構築します。

(defvar *load-time-value* (make-array 2))

構築されたコードは即座に実行されます。
続いてload-time-valueフォームが読み込まれて実行されます。

(setf (aref *load-time-value* 0) 10)
(setf (aref *load-time-value* 1) (+ 20 30))

問題は次の文です。

(+ (load-time-value! 0)
   (load-time-value! 1))

この文がfaslファイルから読み込まれ、 load-time-value!の専用の命令がLispオブジェクトとして構築されるときに、 (load-time-value! 0)というコードを配置するのではなく、 *load-time-value*配列の値にそのまま置き換えます。

つまり、faslファイルから読み込んだときに値の置き換えを行うのです。
読み込まれたコードはload-time-valueなどもともと無かったかのように、 次の式に相当するものとなります。

(+ 10 50)

コードが構築されたら実行します。
実行する前の段階で、special変数*load-time-value*の役割が 終わっているのが分かるでしょうか。
生成されたコードは、何の外部変数にも依存しないものになっている必要があります。

以上で、load-time-valueの機能の説明は終わりです。

例として、toplevelに2つの式があった場合を考えます。
例文は下記の通り。

(+ (load-time-value 10)
   (load-time-value (+ 20 30)))

(format t "~A~%" (load-time-value "Hello"))

まずはload-time-valueの数を見て配列を作成します。

(defvar *load-time-value* (make-array 3))

配列の要素数2ではありません。
ファイル全体の総数である3になります。
次に、最初に式を評価します。

;;  (+ (load-time-value 10)
;;     (load-time-value (+ 20 30)))
(setf (aref *load-time-value* 0) 10)
(setf (aref *load-time-value* 1) (+ 20 30))
(+ (load-time-value! 0)
   (load-time-value! 1))

すでに説明した通りです。
それでは次の式を評価します。

;;  (format t "~A~%" (load-time-value "Hello"))
(setf (aref *load-time-value* 2) "Hello")
(format t "~A~%" (load-time-value! 2))

以上でfaslファイルの生成は完了です。
loadを実行すると次の順番で式が実行されます。

10
(+ 20 30)
(+ 10 50)
"Hello"
(format t "~A~%" "Hello")

5. make-load-formの実行

make-load-formをご存じでしょうか?
この機能の使い方は、以前の投稿make-load-formとは一体なんなんだ - nptclのブログで 詳しく取り上げていますので、もしよかったらどうぞ。

なんでmake-load-formが出てきたんだというと、 生成と初期化を行う際にこれほど便利なものは無いからです。
むしろload-time-valuemake-load-formのためにあると言っても過言ではありません。
いや過言でした。

compile-fileの読み込み中に、CLOSオブジェクトに遭遇した場合は、 make-load-formを実行して保存方法を決定します。
CLOSオブジェクトとはnpt用語なので、 一般的にはインスタンスと呼ばれます。

分かりづらいかもしれないですけど例を挙げます。

;;  aaa.lisp
(eval-when (:compile-toplevel :load-toplevel :execute)
  (defstruct aaa bbb ccc)
  (defmethod make-load-form ((x aaa) &optional env)
    (make-load-form-saving-slots x :environment env)))

(format t "~A~%" (slot-value #s(aaa :bbb 100) 'bbb))

実行してみます。

* (compile-file #p"aaa.lisp" :output-file #p"aaa.fasl")
* (load #p"aaa.fasl")
100
T
*

何をしたのかというと、

(format t "~A~%" (slot-value #s(aaa :bbb 100) 'bbb))

コンパイルして実行したということになります。
#s(aaa :bbb 100)の部分がインスタンスであり、 make-load-formを使ってコンパイルされた部分となります。

インスタンスに遭遇するたびにmake-load-formが実行されるのかというとそうではなく、 以前コンパイルしたものに関しては、すでにmake-load-formが実行されているので、 そのインデックスを参照するコードが返却されます。
つまり、コンパイルされたインスタンスは全部覚えておいて、 同一なものはちゃんとeqで同一になるようにしましょうということです。

(eq #s(aaa :bbb 100) #s(aaa :bbb 100))
-> NIL

(eq #1=#s(aaa :bbb 100) #1#)
-> T

こんなふうに記載されてもよくわかりませんよね。
上記の例を疑似的にclassで出力されたかのように示すと、次のようになります。

(eq #<AAA 0x100> #<AAA 0x200>)
-> NIL

(eq #<AAA 0x300> #<AAA 0x300>)
-> T

ではどのようにコードが生成されるのかを見て行きましょう。
前提として、defstructmake-load-formの設定はすでに済んでいるものとします。
コンパイルするコードを示します。

(eq #s(aaa :bbb 100) #s(aaa :bbb 100))
(eq #1=#s(aaa :bbb 200) #1#)

動作確認はこのようにしてください。
しかし今回は、説明を簡単にするために 下記のようなコードをコンパイルするものとします。

;; 実際にコンパイルしようとするとエラーだよ
(eq #<AAA 0x100> #<AAA 0x200>)
(eq #<AAA 0x300> #<AAA 0x300>)

ではコンパイルしたときの挙動を追っていきましょう。
まずはload-time-valueの数を数えます。
今回の場合は、インスタンスを数えることになります。
式の上では4つですが、同一なものを排除すると3つになります。
つまり生成する疑似コードは下記の通り。

(defvar *load-time-value* (make-array 3))

最初の式をコンパイルします。
make-load-formの生成フォームを実行します。

(setf (aref *load-time-value* 0) (allocate-instance (find-class 'aaa)))
(setf (aref *load-time-value* 1) (allocate-instance (find-class 'aaa)))

生成が終わったら、初期化フォームを実行します。

(setf (slot-value (load-time-value! 0) 'bbb) 100
      (slot-value (load-time-value! 0) 'ccc) nil)
(setf (slot-value (load-time-value! 1) 'bbb) 100
      (slot-value (load-time-value! 1) 'ccc) nil)

これでインスタンスの生成と初期化は完了です。
コンパイルする式は次の通り。

(eq #<AAA 0x100> #<AAA 0x200>)

下記のように変換されます。

(eq (load-time-value! 0) (load-time-value! 1))

これで完了です。
同じようにして2つめの式をコンパイルします。
元の式を示します。

(eq #<AAA 0x300> #<AAA 0x300>)

最初の式とは違い、2つのインスタンスは同一なものなので、 生成と初期化は一度だけです。

(setf (aref *load-time-value* 2) (allocate-instance (find-class 'aaa)))
(setf (slot-value (load-time-value! 2) 'bbb) 200
      (slot-value (load-time-value! 2) 'ccc) nil)
(eq (load-time-value! 2) (load-time-value! 2))

以上でコンパイルは完了です。

6. gensymを扱う

なんでいきなりgensymなんだ。
ということからちゃんと話さなければいけません。
faslファイルでgensymの扱いをどうするかという話になります。

faslファイルというのは、compile-file実行時とload実行時では Lispのheap領域が完全に別物になりますので、 eqによるオブジェクトの同一性なんかを担保したい場合に 一体どうしたらいいんだと迷うことになります。
ひとつの解決案として導入されたのがmake-load-formです。
これはCLOS専用ですが、いろいろと苦労しているのがわかるでしょう。

ではsymbolの場合はどうでしょうか。
internされてる場合は簡単です。
だってpackageがあるのですから。
packageの名前とsymbolの名前さえfaslに書きこんでおけば、 読み込んだときにinternできます。
じゃあgensymはどうするんだという話になります。

いろんな方法があると思います。
nptでは専用のhash-tableを使用していました。
でも、よく考えればload-time-valueの機能が使えるんじゃないか?
ということでこんな章が設けられたわけです。

この話題は、ちょっと複雑です。
技術的にどうこうではなく、歴史的なものが含まれるのではないかと思います。
まずは規約上どうなっているのかを説明します。
もしコンパイルされるファイルにgensymがある場合は、 「symbol-nameが同じものは同一のオブジェクトであるとみなす」ことが許されています。

どういうことかわかりますか?
次の式が許されると言っています。

(eq '#.(gensym 10) '#.(gensym 10))
-> T

(eq '#.(make-symbol "HELLO") '#.(make-symbol "HELLO"))
-> T

いやいやTの訳ないじゃないですか。
gensymはいつだってfreshsymbolのはずですよ?
これ本当にあってるのかなあ。
sbcl, clisp, cclではどれもnilなんですよね。
私の勘違いかもしれませんが話を進めます。

大昔はgensymの扱いに困っていたのではないでしょうか。
本来であれば、gensymの同一判定はeqで行う必要があります。
でも、そんな面倒なことやってられるかよ!
みたいな考え方だったのかもしれません、妄想ですが。
でもCLOS機能が実装され、make-load-formができると、 結局オブジェクトのeq判定を行う必要が生じました。
何でこの時にgensymeq判定にしなかったんでしょうね。

話しを戻しますが、私の考えでは、gensymsymbol-nameで判定するのはダメだと思います。
オブジェクトごとにeqでちゃんと判定して分けましょう。

この動作は規約に違反しているわけではありませんが、 準拠もしていない挙動になります。
でも絶対にeq判定の方がいいでしょう?
symbol-nameで判定されると、バグが混在する可能性が出てきます。
しかもこの手のgensymはマクロで多用されるものなので、 せっかくバグ回避のためにgensymを使ったのに まだ不十分だったということが起こりえます。

On Lispでは次のような話題がありました。

「バグのないプログラムが書けるのに、どうして少々のバグを持ったプログラムを書くのですか?」

同じような理由で、symbol-name判定ではなくeq判定をするのが望ましいと考えます。
eq判定にすればバグがない処理系を実装できるのに、 どうしてsymbol-nameで判定するのですか?

gensymの実装は、make-load-formとほぼ同じです。
gensymが出現した時点で、CLOSと同じようにeqによる判定を行います。
生成するload-time-valueの式は下記の通り。

  • 生成式は(make-symbol name)
  • 初期化は無し

これだけでgensymの実装は完璧です。
あとは式にあるgensymload-time-value!のコードに置き換えるだけです。

例文を示します。
下記の式をコンパイルします。

(eq #:G1 #:G2)

結果は下記の通り。

(defvar *load-time-value* (make-array 2))
(setf (aref *load-time-value* 0) (make-symbol "G1"))
(setf (aref *load-time-value* 1) (make-symbol "G2"))

(eq (load-time-value! 0) (load-time-value! 1))

なお、gensymを生成するコードは、何よりも先に実行してください。
load-time-valueよりも、make-load-formよりも先に実行する必要があります。
もしコンパイルするファイルに複数の式があったとしても、 一番最初にまとめてgensymを作成してしまって問題ありません。
むしろまとめて作成したほうが安全です。

7. makefileを使う

load-time-valueは重複が許されます。
重複とは、例を書いてしまえば次のようになります。

(load-time-value
  (load-time-value 10))

これが案外面倒です。
重複はこんな分かりやすいケースだけじゃないからです。

こんな場合はどうでしょうか。

(load-time-value #<AAA ...>)

load-time-valueの中にインスタンスがあります。
このインスタンスは生成と初期化の処理を持っていますので、 実質load-time-valueが2つ配置されているようなものです。

こういうケースも考えられます。

(defmethod make-load-form ((x aaa) &optional env)
  `(make-instance 'aaa :bbb (load-time-value 10)))

これは、生成フォームにload-time-valueが存在するケースです。

こういうのってどうしたらいいの?
生成フォームを実行する直前に load-time-valueフォームを実行すればいいのです。
理屈ではわかっていますが、複雑な例は次々と出てきます。
規約では、生成フォーム中に別のインスタンスが現れても良いとのこと。
つまり、こんな感じ。

(defmethod make-load-form ((x aaa) &optional env)
  `(make-instance 'aaa :bbb #<BBB ...>))

もちろんload-time-valueフォーム中にインスタンスが現れたっていいわけです。

これもう無理ですね。
単純にこうして、ああして、みたいな決め方ではなく、 依存関係をちゃんと考える必要がありそうです。
でもどうやって?

色々考えたのですが、makefileみたいなのを作って依存関係を表現しようと思います。
面倒すぎますが仕方がありません。
ただ、makefileを分析しろと言われてもどうなんでしょう。
Common Lispではそこそこ簡単にできましたので、 ちょっとやってみましょう。

まずはmakefileのルールを登録するコードを示します。

(defvar *makefile* nil)

(defun makefile (x &rest args)
  (dolist (y args)
    (pushnew (cons x y) *makefile* :test 'equal)))

難しくはないと思います。
例えば、code1code2code3に依存している場合を考えます。
makefileでは次のように記載すると思います。

code1: code2 code3

ではLispで表してみましょう。

(makefile :code1 :code2 :code3)

そのままですね。
依存関係を求めるコードを示します。

(defvar *pass*)
(defvar *error*)

(defun make-gather (name)
  (let (list)
    (dolist (x *makefile*)
      (when (eql (car x) name)
        (push (cdr x) list)))
    list))

;;  ★不完全バージョン
(defun make-execute (name)
  (when (member name *error*)
    (error "The loop occurred by the ~S." name))
  (unless (member name *pass*)
    (let ((*error* *error*))
      (push name *error*)
      (push name *pass*)
      (mapc #'make-execute (make-gather name))
      (format t "Make: ~A~%" name))))

(defun make (name)
  (let (*pass* *error*)
    (make-execute name)))

何をしているのかというと、*makefile*に 依存関係を表すでっかいtree情報が格納されていますので、 それを末端から表示しているだけです。
もし依存関係にループが生じた場合は間違いなので、 チェックを入れています。

本場のmakeは、依存関係を見て更新するファイルを判別する機能です。
今やっていることとは少々違っていますが勘弁してください。

それではcode1の依存関係を出力してみましょう。

(make :code1)
Make: CODE2
Make: CODE3
Make: CODE1

これだけだとよくわからないかもしれないので、 もう少し複雑なルールを。

(makefile :code1 :code2 :code3)
(makefile :code2 :code3 :instance1)
(makefile :code3 :instance2)

実行結果は下記の通り。

(make :code1)
Make: INSTANCE2
Make: CODE3
Make: INSTANCE1
Make: CODE2
Make: CODE1

うまく動作しています。
この結果から分かることは、code1を実行するにあたっては、 一番最初にinstance2を実行し、 連続してcode3, instance1...と順次実行する必要があることを示しています。

それでは今まで説明してきたload-time-valueの処理と どのように結びつけるのかを考えます。
まず、compile-fileで読み込むコードが下記のような場合。

(code1)
(code2)
(code3)

記載されている通り、

  • code1を実行した後にcode2を実行する
  • その後code3を実行する

という処理になります。
依存関係として考えるならば、

  • code2を実行するには、その前にcode1の実行が必要
  • code3を実行するには、その前にcode2の実行が必要

という意味になります。
すなわち下記の通り。

(makefile :code2 :code1)
(makefile :code3 :code2)

実行は最後のコードであるcode3から開始します。

(make :code3)
Make: CODE1
Make: CODE2
Make: CODE3

次にload-time-valueを考えます。
下記のようなコードの場合。

(code1
  (load-time-value 10))

code1を実行するには、先に(load-time-value 10)を実行する必要があります。
とりあえずload-time-valueの式を適当に:load1という名前にします。
つまりこんな感じ。

(makefile :code1 :load1)

load-time-valueの重複も考えましょう。

(code1
  (load-time-value 10))  ;; load1
(code2
  (load-time-value     ;; load2
    (load-time-value   ;; load3
      20)))

つまりはこんな感じになります。

(makefile :code1 :load1)
(makefile :code2 :code1 :load2)
(makefile :load2 :load3)

実行してみます。

(make :code2)
Make: LOAD1
Make: CODE1
Make: LOAD3
Make: LOAD2
Make: CODE2

それでは最後にインスタンスです。
インスタンスmake-load-formにより、 生成フォームと初期化フォームが生成されるため、 これらの依存関係もちゃんと考えなければいけません。

単純に次の場合を考えます。

#<INSTANCE1 ...>

make-load-formにて、生成make1と初期化init1が実行されるとします。

(makefile :instance1 :make1 :init1)
(makefile :init1 :make1)

まず最初のルールは分かると思います。
instance1を実行するには、make1init1が事前に実行される必要があるということ。
次のルールは、生成make1した後じゃないと初期化init1なんてできないと言っています。

実行結果は下記の通り。

(make :instance1)
Make: MAKE1
Make: INIT1
Make: INSTANCE1

正しい順序で実行されるのが分かります。

make-load-formは、気を付けなければならないことがあります。
なぜ生成フォームと初期化フォームが分かれているかというと、 インスタンス同士で相互参照できるようにするためです。
規約では明確に次のことが書かれています。

  • 生成フォームでは相互参照できない
  • 初期化フォームでは相互参照できる

どういうことかやってみましょう。
生成フォームの相互参照というのは、つまり次のようなことです。

(makefile :make1 :instance2)
(makefile :make2 :instance1)

これはダメですよと言っています。
もう少しちゃんと書いて実行してみましょう。

(makefile :instance1 :make1 :init1)
(makefile :init1 :make1)
(makefile :instance2 :make2 :init2)
(makefile :init2 :make2)

(makefile :make1 :instance2)
(makefile :make2 :instance1)

(make :instance1)
-> ★エラー

ここまでは正しい動作です。
問題は初期化フォームに相互参照があった場合です。
つまりこんな感じ。

;;  ★エラーになってしまう
(makefile :init1 :instance2)
(makefile :init2 :instance1)

残念ながら、現状のコードではループが検出されてエラーです。
でもエラーになってはダメ。
規約でそう決められていますので。
じゃあどうしたらいいか。

たぶんですけど、makefileレベルでどうにかなる問題じゃないと思います。
仕方がないので改造します。

init1では、instance1のループを検出しません、という命令を追加します。
次のような感じにしようと思います。

(makefile-loop :init1 :instance1)

登録するコードを示します。

(defvar *makefile-loop* nil)

(defun makefile-loop (x y)
  (push (cons x y) *makefile-loop*))

超手抜きですがalistとして利用することを考えています。

この情報を反映するためのmake-executeを示します。

(defun make-loop (name)
  (let ((cons (assoc name *makefile-loop*)))
    (when cons
      (setq *error* (remove (cdr cons) *error*)))))

(defun make-execute (name)
  (when (member name *error*)
    (error "The loop occurred by the ~S." name))
  (unless (member name *pass*)
    (let ((*error* *error*))
      (make-loop name)  ;; ★ここを追加
      (push name *error*)
      (push name *pass*)
      (mapc #'make-execute (make-gather name))
      (format t "Make: ~A~%" name))))

ではやり直してみます。
まずはinstance1instance2の依存関係を示します。

(makefile :instance1 :make1 :init1)
(makefile :init1 :make1)
(makefile-loop :init1 :instance1)

(makefile :instance2 :make2 :init2)
(makefile :init2 :make2)
(makefile-loop :init2 :instance2)

つぎに、初期化フォームの相互参照を設定します。

(makefile :init1 :instance2)
(makefile :init2 :instance1)

実行結果は下記の通り。

(make :instance1)
Make: MAKE1
Make: MAKE2
Make: INIT2
Make: INSTANCE2
Make: INIT1
Make: INSTANCE1

正しく求めることができました。

以上で依存関係の登録については全てとなります。
本当はgensymもあるのですが、 gensymの生成処理に依存関係などありませんので、 コードの最初の方で一括して確保してしまいましょう。

ではこれらを複合した例文を示します。

(code1 (load-time-value #<INSTANCE1 ...>))
(code2 #<INSTANCE1 ...>)

makefileは下記の通り。

(makefile :code1 :load1)
(makefile :code2 :code1 :instance1)
(makefile :load1 :instance1)
(makefile :instance1 :make1 :init1)
(makefile :init1 :make1)
(makefile-loop :init1 :instance1)

さらにinstance1の初期化フォームinit1には、 instance2が存在していることにしましょう。

(makefile :init1 :instance2)
(makefile :instance2 :make2 :init2)
(makefile :init2 :make2)
(makefile-loop :init2 :instance2)

実行結果は下記の通り。

(make :code2)
Make: MAKE1
Make: MAKE2
Make: INIT2
Make: INSTANCE2
Make: INIT1
Make: INSTANCE1
Make: LOAD1
Make: CODE1
Make: CODE2

あとはこの順番にコードを出力して行けば完了です。

ちょっと複雑すぎやしませんかね。
faslファイルを作るときって、本当にこんなことしなきゃダメなの?