y2q_actionman’s ゴミクズチラ裏

内向きのメモ書きを置いてます

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.lispmain1 関数, main2.lispmain2 関数を実装し、公開する。

これらの関数のうち、ユーザに公開したいのは main1main2 関数のみであり、 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 も同様に構成される。

最後に、公開したい main1main2 の 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 についてはこの説明に留める。 詳しくは以下を見ていただきたい:

二つの構成法の比較

one-package-per-systemone-package-per-file のどちらが良いのかは、色々と議論されている。 適当に検索してみたところ、いくつか記事が見つかる:

ここで挙げた記事はそれぞれ読んで頂きたい。ここでは、筆者の個人的な趣味で比較してみる。

外向けのシンボルを一覧で見られるファイルが欲しい。

筆者は、「ユーザ向けに 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.lispmain2.lispin-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-systemone-package-per-file とを組み合わせることを考えてみる。

アプローチ

要点は以下の通り:

  • 外向けシンボルを入れる defpackagepackage.lisp に作る。 (one-package-per-system と同様)
  • ファイルに個別のパッケージを作る。 (one-package-per-file と同様)
  • reexport の類は使わず、 外向けのシンボルに直接定義する。

上の例で考えると、まずは package.lispone-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 について見ると:

  • foofoo.lisp にあることが :export から分かる。 (one-package-per-file の利点)
  • package.lisp を見れば foo が外向けに export されていないことが分かる。(one-package-per-system の利点)

これらにより、 foo の処遇はより明白に出来たように思われる。

package-inferred-system とも併用可

package-inferred-system は、

  1. one-package-per-file で書き、
  2. package 名にファイル名を埋めこみ、
  3. :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 から export されるシンボルの home package は、 package.lispdefpackage した 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 で読み込めることまでは確認している。 しかし、まだ思い付きレベルで実際のプログラムをこの構成で書いてはいない。次に書くときには、この構成で書いてみようと思う。

github.com

*1:cl-annot の @export のような外見上のハックも不要になるかもしれない。

*2:https://g000001.cddddr.org/3632891604#comment-1855146829

*3: reexport を使って構成する必要はない

*4:こんな安易な思い付きに先人がいないとは思えないので、先駆者を見つけ次第、追記していきたい。