one-package-per-system と one-package-per-file との比較、そして融合
概要
Common Lisp では、どのように package を構成するかについて、大きく分けて二つの方法がある。
- 伝統的な、1つの system に1つの package を用意する方法。(以下、 one-package-per-system と呼称。)
- 比較的最近に提案された、1つのファイルごとに1つの package を用意する方法。(以下、 one-package-per-file と呼称。)
この記事では、この二つの方法を比較したり、組み合わせたりしてみることを考える。
二つの構成法の概説
対象とする system のファイル構成
以下のような構成を考える。
┬ foo.lisp │ └ 内部向け関数 foo ├ main1.lisp │ └ 外向け関数 main1 └ main2.lisp └ 外向け関数 main2
foo.lisp は、 foo
という関数を定義する。この関数を使って、 main1.lisp は main1
関数, main2.lisp は main2
関数を実装し、公開する。
これらの関数のうち、ユーザに公開したいのは main1
と main2
関数のみであり、 foo
は system の開発者向けで外向けには公開しないとする。
one-package-per-system での構成方法
伝統的な one-package-per-system では、全てを格納する package を先に用意し、 package.lisp
等の名前のファイルに定義する。
package.lisp には、以下のように記述する(package名は hoge
とした):
;;; package.lisp (defpackage #:hoge (:use #:cl) (:export #:main1 #:main2))
内部関数を実装する foo.lisp は、この package への in-package
から始める:
;;; foo.lisp (in-package #:hoge) (defun foo () ...)
この定義では foo
シンボルに defun
しており、これは hoge
パッケージから export されていないので内部関数と分かる。
外向け関数を実装する main1.lisp も、 in-package
から始める:
;;; main1.lisp (in-package #:hoge) (defun main1 () ...)
同様に main1
シンボルに defun
しているが、こちらは hoge
パッケージから export されているので、外向けの関数を定義していることになる。
main2.lisp も同様に構成される。
全体として見ると、以下のようになる:
┬ package.lisp │ └ (defpackage #:hoge │ (:export #:main1 #:main2)) │ ├ foo.lisp │ ├ (in-package #:hoge) │ └ (defun foo () ...) │ ├ main1.lisp │ ├ (in-package #:hoge) │ └ (defun main1 () ...) │ └ main2.lisp ├ (in-package #:hoge) └ (defun main2 () ...)
one-package-per-file での構成方法
新興の one-package-per-file では、各ファイルに個別の package を持たせるという構成法である。
個々のファイルは defpackage
から始める。まずは foo.lisp の例を見る:
;;; foo.lisp (defpackage #:hoge/foo (:use #:cl) (:export #:foo)) (in-package #:hoge/foo) (defun foo () ...)
この定義では foo
シンボルに defun
しており、そしてこの foo
はこのファイルの package から export されている。
外向け関数を実装する main1.lisp も、同じく defpackage
から始める:
;;; main1.lisp (defpackage #:hoge/main1 (:use #:cl #:hoge/foo) (:export #:main1)) (in-package #:hoge/main1) (defun main1 () ...)
hoge/main1:main1
の実装には hoge/foo:foo
が必要なので、それを export する :hoge/foo
を :use
している点が肝要となる。
main2
も同様に構成される。
最後に、公開したい main1
と main2
の package が分かれているとユーザにとって不便なので、これをまとめる package を用意する。
この package は、慣例的に <system名>/all
と名付けられ、 all.lisp に配置される。
;;; all.lisp (defpackage #:hoge/all (:nicknames #:hoge) (:use #:cl #:hoge/main1 #:hoge/main2) (:export #:main1 #:main2))
上の定義では、二つの外向けシンボルを定義する package から、それらを :use
して取り込み、再び列挙して :export
している。
もし、「hoge/main1
package で export されたシンボルはどうせ全部 export する」と分かっていれば、これはいかにも二度手間である。
この手間を省くため、 uiop:define-package
という、 defpackage
の亜種を用いて、以下のように記述することが一般的なようだ:
;;; all.lisp (uiop:define-package #:hoge/all (:nicknames #:hoge) (:use-reexport #:hoge/main1 #:hoge/main2))
:use-reexport
は、ある package で export されたシンボルを、 use
して export
する際に使う、略記法である。
全体として見ると、以下のようになる:
┬ foo.lisp │ └ (defpackage #:hoge/foo │ (:export #:foo)) │ ├ main1.lisp │ └ (defpackage #:hoge/main1 │ (:use #:hoge/foo) │ (:export #:main1)) │ ├ main2.lisp │ └ (defpackage #:hoge/main2 │ (:use #:hoge/foo) │ (:export #:main2)) │ └ all.lisp └ (uiop:define-package #:hoge/all (:use-reexport #:hoge/main1 #:hoge/main2))
package-inferred-system について
one-package-per-file と関連して出てくるのが、 package-inferred-system である。
上の one-package-per-file での defpackage
定義を見ると、 package 名の末尾からファイル名を類推できるように見える。
さらに、 package の :use
関係は、そのまま package の依存関係と捉えることが出来る。
この二つの点に着目し、package の :use
や :import-from
などからファイルの依存関係を推測して load するという仕組みである。
この記事では、 package にだけ着目するつもりなので、 package-inferred-system についてはこの説明に留める。 詳しくは以下を見ていただきたい:
- How to write a modern Lisp library with ASDF3 and Package Inferred System | David Vázquez
- package-inferred-systemでモダンなLispライブラリを書く方法 - t-cool (上の記事の日本語訳)
二つの構成法の比較
one-package-per-system と one-package-per-file のどちらが良いのかは、色々と議論されている。 適当に検索してみたところ、いくつか記事が見つかる:
- Why do many Common Lisp systems use a single packages.lisp file? - Stack Overflow
- One package per (or file project something-else)? : Common_Lisp
- #:g1: Common Lispのパッケージを考える #1
ここで挙げた記事はそれぞれ読んで頂きたい。ここでは、筆者の個人的な趣味で比較してみる。
外向けのシンボルを一覧で見られるファイルが欲しい。
筆者は、「ユーザ向けに export されたシンボル一覧がどこかにあると便利」だと思っている。
この情報は、 one-package-per-system なら、 defpackage
を持つ package.lisp ファイルに記述されているので、それを覗けばよいだけである。
一方、 one-package-per-file だと、相当するものは all.lisp ファイルになるのだが、 uiop:define-package
の :use-reexport
を使っていると困ったことになる。
上の例を再掲すると、 all.lisp には以下のように記述される:
;;; all.lisp (uiop:define-package #:hoge/all (:nicknames #:hoge) (:use-reexport #:hoge/main1 #:hoge/main2))
上の例の hoge/main1
package は、たまたまその名前から類推できる名前のシンボルしか export してしないが、そんな保証はどこにもない。
実際に上の package 定義によって export されるシンボルは、:use-reexport
の対象 package の定義を見にいかなければ知ることは出来ない。
あるファイルでだけuseしたい package があった場合
次は、 one-package-per-file に有利な点を書いてみる。
複数ファイルある内、その一部でだけ別の package を :use
したいという場合、 one-package-per-system だと融通が効かなくて困ってしまう。
例えば、上の例での main1
の実装にだけ、 CL-JSON を使いたくなったとする。
one-package-per-file なら、 main1.lisp の package :use
に並べればよい。この変更が他のファイルに波及することはない。
;;; main1.lisp (defpackage #:hoge/main1 (:use #:cl #:cl-json ; ここに足す。 #:hoge/foo) (:export #:main1)) (defun main1 () ...)
一方、 one-package-per-system だと、 package.lisp にある defpackage
で :use
することになるのだが:
;;; package.lisp (defpackage #:hoge (:use #:cl #:cl-json) ; ここに足す (:export #:main1 #:main2))
このpackage は main1.lisp だけでなく foo.lisp と main2.lisp も in-package
しているので、これらファイルの全てに影響がある。
大きい package に use-package
した結果、思わぬシンボルが衝突してエラーになるというのは、 Common Lisp を使っているとよくある面倒事例だと思う。
内部関数をどこに定義するか
これまでの例で、 「foo
は system の開発者向けで外向けには公開しない。」と言ってきたが、こういうシンボルの定義をどこに配置するかはどちらの構成法でも問題になり得る。
one-package-per-system では、 package.lisp を見れば foo
が外向けに export されていないことは明白である。
しかし、 foo
がどのファイルで定義されているかを知る術はない。大抵、「foo.lisp は、 foo
という関数を定義する。」のような推測をするが、そんな保証はない。
実際、古いコードを読んでいると、結構気ままな位置に内部向けシンボルへの定義がされていたりして、タグジャンプや grep を頑張らないと見つからない、なんてことも結構ある。
一方、 one-package-per-file では、 foo
を export するのは foo.lisp だと明示されており、この点で定義されている場所を探すことに問題はない。
しかし、この foo
を他の package が :use
し、 export し直している可能性がある。
one-package-per-system とは逆に、foo
が外向けには公開されていないことを確認することが面倒になっている。
特に :use-reexport
のような機能を使っていると、全ての defpackage
を見なければ全貌を掴むことは出来ないと思われる。
二つの構成法を融合してみる
ここで、 one-package-per-system と one-package-per-file とを組み合わせることを考えてみる。
アプローチ
要点は以下の通り:
- 外向けシンボルを入れる
defpackage
を package.lisp に作る。 (one-package-per-system と同様) - ファイルに個別のパッケージを作る。 (one-package-per-file と同様)
- reexport の類は使わず、 外向けのシンボルに直接定義する。
上の例で考えると、まずは package.lisp を one-package-per-system と同様に以下のように書く:
;;; package.lisp (defpackage #:hoge (:use #:cl) (:export #:main1 #:main2))
foo.lisp は、 one-package-per-file と同様に書く:
;;; foo.lisp (defpackage #:hoge/foo (:use #:cl) (:export #:foo)) (in-package #:hoge/foo) (defun foo () ...)
外向け関数を実装する main1.lisp も、同じく defpackage
から始めるが、外向けシンボル (main1
) をそのファイルの package には作らず、外向け package である hoge
に直接定義してしまう:
;;; main1.lisp (defpackage #:hoge/main1 (:use #:cl #:hoge/foo)) (in-package #:hoge/main1) (defun hoge:main1 () ...) ; 外向け package に直接定義する!
もしくは、定義する対象のシンボルを外向け package から取り込んでもよい:
;;; main2.lisp (defpackage #:hoge/main2 (:use #:cl #:hoge/foo) (:import-from #:hoge #:main2)) (in-package #:hoge/main2) (defun main2 () ...) ; import した hoge:main2 に定義
全体として見ると、以下のようになる:
┬ package.lisp │ └ (defpackage #:hoge │ (:export #:main1 #:main2)) │ ├ foo.lisp │ └ (defpackage #:hoge/foo │ (:export #:foo)) │ ├ main1.lisp │ ├ (defpackage #:hoge/main1 │ │ (:use #:hoge/foo) │ └ (defun hoge:main1 () ...) │ └ main2.lisp ├ (defpackage #:hoge/main2 │ (:use #:hoge/foo) │ (:import-from #:hoge #:main2) └ (defun main2 () ...) ; 'hoge:main2' に定義するのと同様。
最後の (defun main2 () ...))
については、不要でも package 名をつけて (defun hoge:main2 () ...))
と書いて、外向けシンボルに定義しているということを見た目に分かり易くするということも考えられる。 *1 *2
融合した場合の利点
上で二つの構成法を比較した点を見てみる
外向けのシンボルを一覧で見られるファイルがある
one-package-per-system と同様、 package.lisp ファイルを見て defpackage
を見ればよい。
あるファイルでだけパッケージをuseするのが簡単
one-package-per-file と同様、各ファイルにある package の :use
に並べればよい。この変更が他のファイルに波及することはない。
内部関数の定義が明白
system 内部でだけ使う関数 foo
について見ると:
foo
は foo.lisp にあることが:export
から分かる。 (one-package-per-file の利点)- package.lisp を見れば
foo
が外向けに export されていないことが分かる。(one-package-per-system の利点)
これらにより、 foo
の処遇はより明白に出来たように思われる。
package-inferred-system とも併用可
package-inferred-system
は、
- one-package-per-file で書き、
- package 名にファイル名を埋めこみ、
:use
や:import-from
で package の依存関係を示す
ことが出来れば使用できる。 *3
このため、以下のように package 名や :import-from
を使えば、 package-inferred-system
で読み込みできる:
┬ package.lisp │ └ (defpackage #:hoge/package ; ファイル名 'package.lisp' を '/' の後に入れる。 │ (:nicknames #:hoge)) │ (:export #:main1 #:main2)) │ ├ foo.lisp │ └ (defpackage #:hoge/foo │ (:export #:foo)) │ ├ main1.lisp │ ├ (defpackage #:hoge/main1 │ │ (:use #:hoge/foo) │ │ (:import-from #:hoge/package #:main1) ; :import-from で明示。 │ └ (defun hoge:main1 () ...) ; あえて package 名を付けて外向けであることを明示 │ └ main2.lisp ├ (defpackage #:hoge/main2 │ (:use #:hoge/foo) │ (:import-from #:hoge/package #:main2) ; :import-from で明示。 └ (defun hoge:main2 () ...) ; 同上
defsystem の :depends-on
を (:hoge/main1 :hoge/main2)
と指定すれば、 package-inferred-system
が各ファイルを辿って読み出してくれる。
このように構成すれば、以下のような場合にも package.lisp を読むだけで対応できるかもしれない。
package-inferred-systemだと関数が色んなパッケージに散ってしまっていちいち必要なものをimportするのがめんどくさいので開発用に全部のパッケージをimportしたクソデカパッケージを作っている。名前衝突するのでrepl-utilitiesのshadowed-importを使う
— Satoshi Imai (@masatoi0) 2019年1月24日
融合アプローチの留意点
色々と利点だけ書いてきたが、もちろん留意点がある。
外向け package から export されるシンボルの home package は、 package.lisp で defpackage
した package になる。
一方、one-package-per-file の場合は、 reexport 元の package が home package になっている。
この home package の違いが問題になる可能性はある。
ただ、one-package-per-file の構成だとSLIMEの補完が変になる という話もあるので、もしかしたら融合アプローチの方が有効かもしれない。
まとめ
色々書いてきたが、一言でいうと one-package-per-file でも 「reexport を止めて、従来通りに外向け package を構成すればいいのでは」 というだけの思い付きである。*4
実際にこの構成で小さい defsystem を書き、 package-inferred-system
で読み込めることまでは確認している。
しかし、まだ思い付きレベルで実際のプログラムをこの構成で書いてはいない。次に書くときには、この構成で書いてみようと思う。
*1:cl-annot の @export のような外見上のハックも不要になるかもしれない。
*2:https://g000001.cddddr.org/3632891604#comment-1855146829
*3: reexport を使って構成する必要はない
*4:こんな安易な思い付きに先人がいないとは思えないので、先駆者を見つけ次第、追記していきたい。