この記事は, Lisp Advent Calendar 2019の1日目の記事です.
Qiitaで私をフォローしてくださっている方も居るので, 昨年とは違って今年はQiitaに記事を書こうと思います.
この記事のライセンスはCC-BYとします.
また記事中のcldpfプロジェクトに含まれるコードはMIT Licenseでライセンス付けされています.
この記事の趣旨
2019年の秋頃から著者が書いているCommon Lisp製のポッドキャスト配信補助ツールcldpfを題材に, ASDFのpackage-inferred-systemを用いて、ライブラリ内のモジュールの依存関係を解決したプロジェクトの書き方について簡単に書く.
対象読者
この記事の対象読者は, Common Lispでの簡単な関数の使い方には触れたが, その後ライブラリの形にまとめての配布等にまだ手が出ていない方々.
取り扱うこと
-
defpackage
を用いて, パッケージを定義して依存関係を明示すること -
asdf
,defsystem
,package-inferred-system
を用いて, システムを定義して, システム内のパッケージの依存関係を解決すること
取り扱わないこと
- Common Lispの開発環境構築
- 具体的な開発フロー
- 歴史的な事柄
- cldpfが具体的にどういうことをするのか
環境構築に関しては, t-sinさんがLisp Advent Calendar 2017の記事として書かれた"いまから始めるCommon Lisp"などを参考にしてください.
具体的な開発フローを述べるところまでは, この記事でカバーすることは出来ないし, (誰もに共有できる)実践となるものは私が持っていないので, ここでは書かない.
歴史的事情は, 私が知らないので書かない. つまり, "私が学んだところ, 今こうなっているようだ"は書くが, 何故そうなっているのかは書かない.
cldpfはただの題材として扱う.
Common Lispにおけるモジュールの単位の簡単なおさらい
パッケージ
Common Lispでは, モジュールの単位としてpackageという概念が存在する.
packageは, 平たく言えば定義された関数や定数, グローバル変数などを名前空間を分けて(他のpackageのそれらから)分離することで, 管理する単位である.
同じpackageに取り込まれた(一例として, そのpackageの中で定義されると取り込まれる)関数名などは, 同じ名前空間に属する1.
それ以外にも, packageの仕組みは, あるpackage内で定義された関数名などを別のpackageで用いる方法等(インポート, エクスポート)も提供している. (packageは, defpackage
を用いて定義される.)
これらはある名前空間内にどの様な名前があるのかを管理するシステムであり, プログラムのロードの仕組みとは別である.
また, 複数のpackageをひとまとまりにして取り扱う方法も提供されていない.
システム
Common Lisp自体の仕様ではないが, 多くの広く使われている処理系が取り入れているASDFというライブラリによって,
一つ以上のpackageをひとまとまりにして, systemという概念で運用している.
これらは, defsystem
を用いて定義される.
この定義の中で, 他のsystemやファイル間との依存関係を指定することが出来て, asdfに用意されている命令で, systemに関連するファイルをロードすることが出来るようになる.
package-inferred-system
ASDFのバージョン3.1.2以降で, package-inferred-systemという仕組みを使えるようになった.
これは, packageとsystemそしてソースコードの書かれたファイルを1対1に対応させ, defpackage内の依存関係を解決しながら適切にロードすべきファイルを判断してロードするというような仕組みである.
それ以前では, defsystem
を用いる際に, systemを構成するファイルや依存関係を書いていたが, package-inferred-systemを使うことを書くだけで,
適切にpackageとファイルを対応させておけば, その様な依存関係を明示する必要はなく便利である.
パッケージの定義
ここからは, cldpfを題材にして, defpackage
とdefsystem
をどう書くかということを書く.
前提としてpackage-inferred-systemを使う.
cldpfの構成
cldpfのファイルディレクトリ構成から, 主となる部分を構成するCommon Lispのソースコードを抜粋すると以下の通りになる. (2019/11/10 時点)
.
├── cldpf.lisp
├── audio.lisp
├── feed.lisp
├── html.lisp
├── item.lisp
├── list.lisp
├── path.lisp
├── program.lisp
...
この内, cldpf.lisp
が, このソフトがユーザに提供するAPIについて書かれているメインのファイルとなる.
他のファイルは, このソフト(cldpf)を動かすために必要な機能をcldpf.lisp
(あるいは他のファイル)に提供している.
例えば, audio.lisp
はmp3ファイルを適切な場所に配置するために, path.lisp
はcldpfが必要とする(ファイルやディレクトリの)パスに関する関数が定義されている.
(CLでの開発に限らず)多くのプロジェクト(ソフト)で同じことだと思うが, ある関連づいた機能や要素に関するソースコードをファイル毎に分けて管理することはままあり, ここでもその様な構成になっている.
これらの具体的な内容は置いておいて, ファイル先頭のdefpackage
の部分だけ抜き出す.
また, defpackage
と書いてきたが, ここではそれに変わるuiop/package:define-package
を用いている.
ここでの使用法だと, defpackage
と大きく変わらない. (uiop/package:define-package
をdefpackage
に置き換えても良い.)
代表して, cldpf.lisp
, path.lisp
, program.lisp
のuiop/package:define-package
部分を列挙する. (以下のファイルの内容は, 2019/11/10 時点のものである.)
(uiop/package:define-package :cldpf/cldpf (:nicknames) (:use :cldpf/path :cl)
(:shadow)
(:import-from :cldpf/audio :get-file-length
:copy-audio)
(:import-from :cldpf/path)
(:import-from :uiop/pathname
:ensure-directory-pathname :merge-pathnames*)
(:import-from :local-time :+rfc-1123-format+
:format-timestring :now)
(:import-from :cldpf/html :make-note-page
:make-index-page)
(:import-from :cldpf/item :item-list-template)
(:import-from :cldpf/feed :make-feed)
(:import-from :cldpf/program :make-program)
(:import-from :cldpf/list :write-items-list
:read-items-list :read-feed-list :write-feed-list
:read-item-list :read-program-list)
(:export :update-pages :update-item :make-item
:make-program :add-item)
(:intern))
(uiop/package:define-package :cldpf/path (:nicknames) (:use :cl) (:shadow)
(:import-from :uiop/pathname
:ensure-directory-pathname :merge-pathnames*)
(:export :get-feed-list-path
:get-items-list-path :get-program-list-path
:get-items-dir-path :get-item-list-path
:get-pages-dir-path :get-notes-dir-path
:get-audios-dir-path :get-index-file-path
:get-feed-file-path :get-note-file-path
:get-audio-file-path
:get-index-template-path
:get-note-template-path)
(:intern))
(uiop/package:define-package :cldpf/program (:nicknames) (:use :cldpf/path :cl) (:shadow)
(:import-from :uiop/filesystem :file-exists-p
:directory-exists-p)
(:import-from :uiop/pathname :merge-pathnames*
:ensure-directory-pathname)
(:export :make-program) (:intern))
いくつかの、 最低限の説明をする.
パッケージ名
uiop/package:define-package
の第一引数で, パッケージ名を指定する.
package名は, 前述のpackage-inferred-systemを使うのでファイル名に拠って決める.
今は, cldpf.asd
という後述するシステムを定義するファイルと, すべて同階層に配置されているので,
cldpf.lisp
, path.lisp
, program.lisp
が, それぞれ
cldpf/cldpf
, cldpf/path
, cldpf/program
となる.
ソースコードをまとめてプロジェクトのディレクトリより一階層掘って配置することも多く見られるパターンかと思う.
その様な場合, 例えば, hoge
という名前のシステムを定義するhoge.asd
が/home/user-name/hoge/hoge.asd
という形で配置されている.
つまり, /home/user-name/hoge/
がプロジェクトのディレクトリである時には,
package-inferred-systemの規約に沿った/home/user-name/hoge/src/piyo.lisp
で定義されるパッケージ名は, デフォルトではhoge/src/piyo
となる.
これを指定して別のパッケージ名にする方法もある. (システムの定義のところで説明する.)
外部への提供 :export
パッケージを作ることで名前空間を分けて, 機能毎に関数などをまとめることが出来るが, そのようにしてまとめられた関数はどこかで使われるものである.
そのため, パッケージで定義されている関数を他のパッケージで使えるようにする必要がある.
Common Lispでは, hoge::piyo
の様にパッケージ名の後に:
を2つ付けることで, そのパッケージに含まれるすべての変数や関数(シンボル)2にアクセスすることが出来るが,
それでは, どのシンボルをパッケージが提供しようとしているのかわからない.
つまりパッケージの作者が参照されることを意図していないものにまで参照することが出来てしまうので, この方法は推奨されているやり方とは言えない.
パッケージの作者は, パッケージを定義する際に:export
というキーワードを使って, 外に提供するシンボルを明示的に指定することが出来る.
例えば, 上で挙げたprogram.lisp
で定義されるcldpf/program
というパッケージは, (:export :make-program)
と書いてあるので, このパッケージの外側にmake-program
を提供している.
そのため, cldpf/program:make-program
とすることで, この関数を使うことが出来る.
また, use-package
などで, そのパッケージに含まれるシンボルを別のパッケージに取り込むことが出来るが, この際にも:export
で指定したシンボルだけがパッケージ名のプリフィクスなしに使える.
(つまり別のパッケージに取り込まれる.)
他のパッケージでの定義を使う :import-from
と:use
今度は, 逆に別のパッケージから今から作ろうと(定義しようと)しているパッケージに取り込むシンボルの話である.
先程書いた, make-program
は, cldpfの中で, ポッドキャストの一つの番組を管理するディレクトリを作る関数である.
cldpf/cldpf
パッケージは, この関数を使ってユーザが管理するポッドキャストの番組を新たに追加する機能をユーザに提供する.
cldpf/cldpf
の定義の中には, (:import-from :cldpf/program :make-program)
という箇所があり, これによりcldpf/cldpf
内では,
make-program
とするだけで, cldpf/program:make-program
を指すことになる.
:import-from
の第一引数はパッケージ名で, その後に取り込みたいシンボルが続く.
ここでは, 明示的に何というシンボルを取り込んだかということを書いておきたかったので, cldpf/program
が提供するシンボルは一つだが,
:import-from
を使って, 明示的に取り込むシンボルを選択した.
例えば, cldpf/program
の定義では, (:import-from :uiop/filesystem :file-exists-p :directory-exists-p)
とあるが,
uiop/filesystem
では, delete-file-if-exists
などが提供されているが, それらはuiop/filesystem:delete-file-if-exists
としなければ,
cldpf/program
の中で使えないのに対して, uiop/filesystem:file-exists-p
は, file-exists-p
だけで使える.
一方, cldpf/path
は, :use
の中に書かれている. この様に(:use :hoge)
とすると, パッケージhoge
が提供する(エクスポートした)シンボルをすべて取り込む. 3
また, 逆にシンボルを取り込みたくは無いが, 依存関係だけ明示したい場合もある.
つまり, あるパッケージのエクスポートするシンボルを使う場合には明示的にhoge:piyo
のように使いたいが,
これを呼び出す(評価する)際には, パッケージhoge
がロードされていてほしいと言うような場合である.
この時には, :import-from (:hoge)
の様に, 取り込むシンボルを書かずに, :import-from
を書けば良い.
システムの定義
さて, パッケージの定義についての後はシステムの定義である.
システムは.asd
ファイルで定義する. cldpf.asd
より一部抜粋する.
(defsystem "cldpf"
:depends-on("cldpf/cldpf")
:class :package-inferred-system
:author "myaosato"
:licence "MIT"
:mailto "tetu60u@yahoo.co.jp"
:in-order-to ((test-op (test-op "cldpf/tests"))))
ここで, システムcldpf
を定義している.
asdf
がこのファイルを読める用にディレクトリに配置した後に, (asdf:load-system :cldpf)
などとすることで, システムがロードできる. 4
package-inferred-system
package-inferred-systemを有効にするために, :class
に:package-inferred-system
を指定する.
これで前述したpackage-inferred-systemが有効になる.
パスの変更
パッケージの定義のところでも述べたように, ソースコードをsrc/
などのディレクトリに配置した際に, パッケージ名に入るsrc
が不要だと思うこともある.
もう一度例を書くと, hoge
というシステム名のシステムを定義するhoge.asd
が/home/user-name/hoge/hoge.asd
という形で配置されている.
つまり, /home/user-name/hoge/
がプロジェクトのディレクトリである時には,
package-inferred-systemの規約に沿った/home/user-name/hoge/src/piyo.lisp
で定義されるパッケージ名は, デフォルトではhoge/src/piyo
となる.
この時に, :pathname
を使って, 以下のように書くことも出来る.
(defsystem "hoge"
:depends-on ("hoge/piyo")
:class :package-inferred-system
:pathname "src/"
:author "hoge"
:licence "MIT"
:mailto "hoge@example.com")
この場合には, /home/user-name/hoge/src/piyo.lisp
で定義されるパッケージ名は, hoge/piyo
となる.
システムの依存関係
最後に依存関係とシステム内のpackageについて書く.
package-inferred-systemを使わない場合には, このシステムに含まれるファイルをcomponentsとして列挙していく方法が取られていた.
package-inferred-systemでは, ファイルとパッケージ, システムが一対一に対応しているので, システムに含まれるファイルもシステムの依存関係として記述する.
そのため:depends-on("cldpf/cldpf")
とするだけで, このシステムをロードした時に, このシステム用意したファイルが読み込まれる.
cldpf/cldpf
は, この様に明に指定するが, ここからパッケージの定義(:import-from
や:use
)を追って,
このシステムcldpf/cldpf
, つまりパッケージcldpf/cldpf
であり, ファイルcldpf.lisp
が依存するパッケージ(システム, ファイル)の依存関係を解決してくれる.
これで, 関連する機能毎にファイルを分けて, パッケージとシステムを定義する最低限の方法についての説明は終わり.
書けなかったこと, 書かなかったこと
- パッケージ定義の時の
:nicknames
,:shadow
,:shadowing-import-from
,:intern
などの設定 - テスト用のシステム(等関連するシステム)の定義の仕方
- システム定義の時の
:author
,:licence
,mailto
,in-order-to
などの設定
最後に
意外と長くなってしまって疲れました.
尻すぼみになってしまった感じもしますし, 書けなかったことも多いので, 他にも記事を書きたいなと思っています.
そのためには, 私が知らないと行けない知識がもう少しありますが.
多くのプログラミング言語でもそうかと思いますが, 文法や標準ライブラリ内の簡単な関数を覚えて動くプログラムが書けるようになっても, それらを配布する形にまとめるまでに壁を感じる初学者が多いのでは無いかと思います.
ここで書いたことが, 自身のシステムの配布の方法がよく分からないために, コードの配布をためらっている人たちの後押しを出来たら嬉しく思います.
参考文献や関連資料
-
正確に書くなら関数と変数の名前空間も別れているが, ここではそのレベルの違いは気にしていない. また正確には関数が取り込まれるわけではなく関数名が取り込まれるので、関数と変数名の名前空間が別れていることとpackageシステムはレイヤーが違う. ↩
-
他の言語をつかっている方向けに変数と関数などというまどろっこしい書き方をしていたが, 面倒になったので以降シンボルと書く. Common Lispではパッケージがシンボルを含み. そのシンボルが値(関数も含めて)を参照する. はず, 詳しい人補足してください. ↩
-
cldpf/path
は他に比べると多くのシンボルをexportしていて, いちいち書くのが面倒になった. この書き方の良し悪しはよく分からない...... ↩