📁

Common Lispアプリケーションのディレクトリ構成の一例

2023/03/05に公開

TL;DR

以下のルールに従うことでメンテナンス性を高めます。

1. クラス毎にパッケージを作る
2. package-inferred-systemを使う
3. ローカルニックネームを使う

はじめに

複雑なアプリケーションを構築する際に1度はディレクトリ構成で悩んだことがあると思います。フレームワークによってはルールを敷いているケースもありますが、そういったフレームワークを使わずにCommon Lispを書く場合の一例を示します。

ディレクトリ構成

ディレクトリ構成の話をする上で避けられないのでフレームワークという言葉を先に出しておきましたが、ここではモジュール分割のルールとしてのディレクトリ構成についてのみ考えます。そのためアーキテクチャ的なディレクトリ構成、すなわちモジュール分割の粒度については考えません。
そもそも言語によってはモジュール分割とディレクトリ構成が不可分である場合もありますが、Common Lispではその辺り自由に書けるのでライブラリ毎にやり方がばらばらな無法地帯となっています。

名前空間

モジュール分割で考えることの1つが名前空間です。この際LISP-1とか2って話は置いておきます。
名前空間=モジュールな言語もありますが、ここでは機能的にまとまりのあるファイル群をモジュールと呼ぶことにします。概念を以下のように整理してみました。

Common Lispでは名前空間をパッケージという単位で区切っています。パッケージの定義は以下のように行います。

(defpackage #:名前空間X
  ;; CL パッケージに含まれる全てのシンボル(言語機能)をインポート
  (:use #:cl)
  ;; 外部パッケージから見えるシンボル一覧
  (:export #:シンボルX_1
           #:シンボルX_2))

(余談ですがSBCLは内部エンコーディングがUTF-32なので上記をそのまま処理出来ます。CLISPとかも同様)

ある名前のパッケージ定義は1つのファイルで1度だけ行うものですが、その中にあるシンボルの内容は異なるファイル・異なるパッケージ内で定義することも可能です。例えば以下のように。

src/module-a.lisp
(defpackage #:module-a
  (:use #:cl)
  (:export #:inner
           #:outer))
(in-package #:module-a)
(defun inner () (format t "defined in ~a~%" #.*package*))
src/module-b.lisp
(defpackage #:module-b
  (:use #:cl)
  (:import-from #:module-a
                #:outer))
(in-package #:module-b)
(defun outer () (format t "defined in ~a~%" #.*package*))
(load "src/module-a.lisp")
(load "src/module-b.lisp")
(module-a:outer)
;; 出力< defined in #<PACKAGE "MODULE-B">

このようにCommon Lispでは名前空間とファイルシステムをある程度分けて考えることができます。名前空間(=パッケージ)を定義しなければならない点を除けば、C++の名前空間とヘッダー・ソースファイルの関係に似ていますね。

クラスと名前空間

Common Lispではクラスを定義しても名前空間が作られません。
すなわち以下のようなことになります。

(defclass callable () ())
(defgeneric call (obj)
  (:method ((obj callable))
    (format t "~a can't call.~%" obj)))

(defclass cat (callable) ())
(defmethod call ((obj cat))
  (format t "meow!~%"))

(defclass dog (callable) ())
(defmethod call ((obj dog))
  (format t "bowwow!~%"))

(let ((neko (make-instance 'cat)))
  ;; (neko:call) とか (cat:call neko) ではない
  (call neko))
;; 出力< meow!

これはこれで悪くないのですが、名前衝突の可能性も高い上にcallの定義元がどこにあるのかパッと見では判断が付かないと思います。これを解決するため、ルール1「クラス毎にパッケージを作る」 を導入します。

(defpackage #:callable
  (:use #:cl)
  (:export #:callable
           #:call))
(in-package #:callable)
(defclass callable () ())
(defgeneric call (obj)
  (:method ((obj callable))
    (format t "~a can't call.~%" obj)))

(defpackage #:cat
  (:use #:cl)
  (:import-from #:callable)
  (:export #:cat))
(in-package #:cat)
(defclass cat (callable:callable) ())
(defmethod callable:call ((obj cat))
  (format t "meow!~%"))

(defpackage #:dog
  (:use #:cl)
  (:import-from #:callable)
  (:export #:dog))
(in-package #:dog)
(defclass dog (callable:callable) ())
(defmethod callable:call ((obj dog))
  (format t "bowwow!~%"))

(let ((neko (make-instance 'cat:cat)))
  ;; (cat:call neko) にしたければ、
  ;; (define-symbol-macro cat:call callable:call) とかする方法もあるけど微妙
  (callable:call neko))
;; 出力< meow!

やや冗長ですが、見通しは良くなったと思います。

パッケージ名の衝突問題

Common Lispのパッケージは入れ子に出来ないため、外部ライブラリと名前が衝突する可能性があります。特にパッケージのニックネーム(エイリアス)が衝突することが多いです。例えばrtg-mathというゲーム向けの数学ライブラリはrtg-math.quaternionsパッケージにqというニックネームをつけていますが、これはqueuesというライブラリ(bordeaux-threadsという有名なライブラリの依存)のqueuesパッケージのニックネームでもあります。
(ちなみにライブラリのコードを変えずにユーザーのソースコード上でこの衝突を回避する方法は知りません。誰か教えてください……)

さて、先の通りパッケージは入れ子に出来ませんが、入れ子のパッケージ名を強制する方法があります。それが package-inferred-systemです。これはパッケージ定義からシステム(Common Lispにおけるアプリケーション・ライブラリ単位)の依存関係を推測するものですが、以下のような仕様となっています。

  • 1パッケージ = 1ファイル
  • パッケージ毎にシステムが作られる
  • パッケージ名はファイルのシステム相対パス(拡張子を除く)にしなければならない(project/module-a/callable のように。ルートディレクトリは設定で変更可能)
  • パッケージ定義の:use:import-fromから自動で依存関係を推測してシステムが読み込まれる

package-inferred-systemを使うにはシステム定義ファイルを以下のように書きます。

my-project.asd
(asdf:defsystem #:my-project
  :class :package-inferred-system
  :pathname "src/"
  :depends-on (#:my-project/entry-point))

これを ルール2「package-inferred-systemを使う」 とします。

ローカルニックネーム

上でニックネームの衝突について触れましたが、使われる側にニックネームを定義するのではなく、使う側でニックネームを付ける:local-nicknames という機能があります。

src/entry-point.lisp
(defpackage #:my-project/entry-point
  (:use #:cl)
  (:local-nicknames (#:dog #:my-project/dog)))

Pythonでimport numpy as npと書けるのと同じですね。基本的にこれを使うことで名前省略しつつニックネームの衝突を回避出来ます。これがルール3「ローカルニックネームを使う」 です。
(あるいは:use-reexportでパッケージのエイリアスを作っても良いかも? 未検証)

まとめ

以下の3つのルールを導入しました。

  • クラス毎にパッケージを作る
  • package-inferred-systemを使う
  • ローカルニックネームを使う

これにより以下の効果が得られます。

  • クラスに関するシンボルとそれ以外を判別しやすくなる
  • パッケージ定義から関連するモジュールやクラスが分かるため読みやすくなる
  • パッケージ名が衝突しにくくなる(殆どの場合衝突しない)
  • システム依存関係が自動で解決される

実際、自分のプロジェクトをこのルールで運用していて、今のところ上手く行っています。(1つのパッケージに2クラス以上書いてしまっているところもありますが…)
参考になったら嬉しいです。

参考

Discussion