このままでいいか

アニメとか,プログラミングとか,研究とか,思ったことをだらだらと書く感じで

Common Lispでシステム構築する際のパス名の取得について

この記事は,関西Lispユーザ会アドベントカレンダー2017の14日目の記事です.

はじめに

Common Lispには便利なライブラリが増えてきているとは言え,新しく始めようと思ったときに困難に感じるであろうポイントがまだまだ少なくありません.

Common Lispで開発をする際に,Quicklispなどを用いて可搬なプログラムを書くときにはパスの扱い方を考える必要がある場合があり,つまずきポイントになったという経験はないでしょうか.自分はありました.

幾つか記事を挙げている方もいらっしゃいましたが,実際のシステム構築に際してのパス名取得方法についての言及は見当たりません.この記事の方法で実現できることはたしかにありますが,この方法が現在主流のものなのかは不明です.

誰か優しい方がコメントしてくださるのを待つとしましょう.

本エントリでは,そんな,システムを構築する際に検索しても意外とヒットしない印象のある,パス名の取得法としてASDFが提供する関数を紹介します.

動作確認はMacOSXにて,CCLとSBCLでのみ行っていますので,それ以外は保証できません.あしからず.

また,LispWorksやACLなどの商用処理系ではもっと簡単ののかもしれませんが,筆者は使ったことも使う予定もないのでわかりません.どなたか優しい人がコメントしてくださるのを待ちましょう.

無駄に長い記事になってしまいましたが,大事なことは,ほぼ一番下にだけ書いてますので,読み飛ばしていただけます.

感想,まさかり,なんでも来てください!って感じです.ではいきましょう.

あまりパス名について困らない状況

予め指定したいパスが決まっている場合のパス名取得法については多くの方々が書いてくださっており,十分な資料がありますが一応一部は追試しておきます.

処理系を起動したディレクトリのパス名を返す関数

処理系を起動したディレクトリが,ホームディレクトリ下のLispディレクトリ(/Users/tomoki/Lisp)であったとしましょう.

このとき,「処理系起動時のディレクトリ」を返す,処理系依存の比較的少ない関数として,truename関数が使えます.

CL-USER> (truename ".")
#P"/Users/tomoki/Lisp/"

というようにディレクトリの絶対パスを返してくれます.

処理系依存の関数で言えば,

SBCLなら

CL-USER> (sb-posix:getcwd)
#P"/Users/tomoki/Lisp/"

CCLなら

CL-USER> (ccl:current-directory)
#P"/Users/tomoki/Lisp/"

で同じ結果が得られます.

開発中のLispファイルと処理系起動時のディレクトリが同じならこれで問題を感じることはあまりないかもしれません.

例えば,Lispディレクトリ下のtest.lispをロードしたいときには,

CL-USER> (load "test.lisp")
#P"/Users/tomoki/Lisp/test.lisp"

絶対パスを指定しなくてもロード可能です.さらに,もし処理系のカレントディレクトリからの相対ディレクトリが若ているのであれば,例えばLisp下のdeepディレクトリ(/Users/tomoki/Lisp/deep/test.lisp)にいたとしても,

CL-USER> (load "./deep/test.lisp")
#P"/Users/tomoki/Lisp/deep/test.lisp"

;;; もしくは

CL-USER> (load "deep/test.lisp")
#P"/Users/tomoki/Lisp/deep/test.lisp"

というように,相対指定で取得できます.逆にLispの上のディレクトリにあったとしても,取得できます.

CL-USER> (load "../test.lisp")
#P"/Users/tomoki/test.lisp"

ホームディレクトリのパス名を取得する

ユーザの開発環境における,ホームディレクトリを出力する関数も用意されています.user-homedir-pathname関数です.

CL-USER> (user-homedir-pathname)
#P"/Users/tomoki/"

というように,システムのホームディレクトリを絶対パスで返してくれます.当然この値は処理系を起動したディレクトリとは関係なく,一定の値を返してくれるので,固定のファイルを指定するときに有効かもしれません.

例えば,ホームディレクトリ下のpublicディレクトリにおいてあるtest.lispファイルのパスを指定したいときには,処理系をLispディレクトリ(/Users/tomoki/Lisp/)で起動しているときも,その下のdeepディレクトリで起動しているときも以下のように同様の記述でアクセスできます.

CL-USER> (load (merge-pathnames "public/test.lisp" (user-homedir-pathname)))
#P"/Users/tomoki/Public/test.lisp"

というように取得できます.ここで初出のmerge-pathnamesですが,これは名前の通り,パス名を結合したものを返す関数です.ここで注意すべきは,このmerge-pathnames関数は

(merge-pathnames [ファイルのパス] [その上のディレクトリのパス])

と書くようになっており,逆に書いてしまうと

CL-USER> (merge-pathnames (user-homedir-pathname) "public/test.lisp")
#P"/Users/tomoki/test.lisp"

というような狙いとは異なる挙動をしてしまう(たちが悪いのは,一応ちゃんと動いているので見逃しがち?)ため,(当然ながら)順番は正しく書く必要があります.

同じことが別の関数でも可能です.これを実現する関数は一つではなく複数あります.

それらの基本的なパス名まわりの関数は以下のopampさんの記事に詳しいです.

opamp.hatenablog.jp

これらのパス名管理では解決が困難な状況

さて,ここからが本稿の本来の目的である,自分はなかなか見つけられなかったパス名管理の方法についてです.

自分の環境内で開発していて,ファイルの場所が明らかな場合はここまでの手法で十分に対応できます.

では,どのような場合に困るかというと,ASDFなどを用いて可搬なシステムを構築しようとしたときです.

具体的な例を示しましょう.

Quicklispを用いてシステムを構築することを考え,開発環境でのローカルディレクトリの構造は以下だと想定します.

Users/
  └ tomoki/
      └ public/
           └ test-pack/ <- これがQuicklispパッケージディレクトリ
                └ sample.asd
                └ test1.lisp
                └ tmp.txt

各ファイルの中身については,

;;; sample.asd
(in-package :cl-user)
(defpackage sample-asd
  (:use :cl :asdf))
(in-package :sample-asd)

(defsystem sample
  :components ((:file "test1")
           (:static-file "tmp.txt")))
;;; test1.lisp
(in-package :cl-user)
(defpackage sample
  (:use :cl))
(in-package :sample)

(defun get-string-from-file (file-name)
  (let ((return-string ""))
    (with-open-file (file-var file-name :direction :input :if-exists nil :if-does-not-exist nil)
      (loop for line = (read-line file-var nil)
            while line
            do (setf return-string (format nil "~A~A~%" return-string line))))
    (format nil "~A" return-string)))

(defun copy-tmp-file ()
  (with-open-file (file-var "~/tmp.txt" :direction :output :if-exists :append :if-does-not-exist :create)
    (write-line (get-string-from-file "/Users/tomoki/public/test-pack/tmp.txt") file-var)))
  • tmp.txt
これはテスト用のファイルです.
このファイルの中身を,ユーザの$HOME/tmp.txtに書き込みたいという状況設定.
あなたならどうする?

このプロジェクトは,当プロジェクトが持っているtmp.txtの中身を読み出し,ユーザのホームディレクトリ下にtmp.txtという名前のファイルとして書き込み,保存するというものです.

このとき,Quicklispのローカルプロジェクト読み込み先のディレクトリが仮に

Users/
  └ tomoki/
      └ .quicklisp/
           └ local-projects/

だったとすると,このsampleパッケージをQuicklispが読み込めるように,シンボリックリンクを貼ります.

ln -nfs /Users/tomoki/public/test-pack /Users/tomoki/.quicklisp/local-projects/

これでREPLからローカルのQuicklispプロジェクトを読み込めます.このプロジェクトを読み込み,実際にcopy-tmp-fileを動かすと,確かにホーム下にファイルができます.

CL-USER> (ql:quickload :sample)
To load "sample":
  Load 1 ASDF system:
    sample
; Loading "sample"
[package sample]
(:SAMPLE)

CL-USER> (sample::copy-tmp-file)
"これはテスト用のファイルです.
このファイルの中身を,ユーザの$HOME/tmp.txtに書き込みたいという状況設定.
あなたならどうする?
"

このプロジェクトの中でパス名が問題になるのは,test1.lisp内のget-string-from-file関数の呼び出しの箇所です.

(get-string-from-file "/Users/tomoki/public/test-pack/tmp.txt")

ここで指定しているのは,ローカルプロジェクトのディレクトリなのでべた書きしても問題なく動いています.

では,これを配布することを考えたときにはどうでしょう.

ユーザがどんな方法で,どこにダウンロードするのかどうかはわかりません.

また,コンパイルされたLispファイルはキャッシュとして,特定のキャッシュディレクトリ残り,そのファイルを実行する形になるため,実行中のファイルの場所=パッケージをダウンロードしたディレクトリ,という構図に必ずしもなるわけではありません.

では,そのような場合の解決法としては,ASDFに備わっている関数を用いることが考えられます.

仮に,ユーザがこのパッケージを

home/
  └ someone/
      └ project/
           └ test-pack/ <- これがQuicklispパッケージディレクトリ
                └ sample.asd
                └ test1.lisp
                └ tmp.txt

というようなディレクトリにダウンロードし,Quicklispのlocal-projectsにシンボリックリンクを貼ったとします.

自分が使うのは基本的に以下の2種類の関数(system-source-directoryとsystem-relative-pathname)です.

;;; quicklispでsampleプロジェクトを読み込む
(ql:quickload :sample)
To load "sample":
  Load 1 ASDF system:
    sample
; Loading "sample"
[package sample]
(:SAMPLE)

CL-USER> (asdf:system-source-directory 'sample) ;; パッケージのルートディレクトリパスを返す
#P"/home/someone/project/test-pack/"

;;; パッケージ内の特定ファイルのパスを返す
CL-USER> (asdf:system-relative-pathname 'sample "tmp.txt")
#P"/home/someone/project/test-pack/tmp.txt"

これらの関数は,パッケージの読み込み元のソースファイルの場所を探し出し,そのパスを返してくれるので,システムの中に組み込んでおけば,常に動的にユーザの環境に合わせたシステムルート,およびファイルのパスが取得できます.

使い方は,

(asdf:system-source-directory [パッケージ名のシンボル])
;; => パッケージのソースファイルのルート(ASDファイルがあるディレクトリ)の絶対パス
(asdf:system-relative-pathname [パッケージ名のシンボル] [ファイル名(文字列)]
;; => ファイルの絶対パス

というような結果になります.例では,REPLで実行してこの結果が返ってきた例を示していますが,呼び出したい元であるsampleパッケージ内でsampleパッケージディレクトリを探すように書いても正しく機能します.

ただし,ASDFを用いていること,ASDファイルにコンポーネントとして当該ファイルを含むことが記述されていることが条件です.今回の例だと,tmp.txtファイルがプログラム自体に関係ないので,static-fileコンポーネントとしてASDファイルに記述がなければ,これをsystem-relative-pathnameで探し出すことはできません.

それが問題なければ使えます!

試しに何かのパッケージをQuickloadして,その場所を探してみましょう.(初めてインストールするものでない場合,過去にインストールした場所を参照する可能性があります)

CL-USER> (ql:quickload :series)
To load "series":
  Install 1 Quicklisp release:
    series
; Fetching #<URL "http://beta.quicklisp.org/archive/series/2013-11-11/series-20131111-git.tgz">
; 148.31KB
==================================================
151,865 bytes in 0.06 seconds (2585.12KB/sec)
; Loading "series"
[package series]..................................
..................................................
..................................................
..................................................
.................................
(:SERIES)

CL-USER> (asdf:system-source-directory 'series)
#P"/Users/tomoki/.quicklisp/dists/quicklisp/software/series-20131111-git/"

はい,いけてます.

ハマりどき(?)

自分がパス名でハマったのは,あるパッケージを可搬にし,そのパッケージを読み込んだとき,はじめにそのパッケージ内にある設定ファイルを対象環境のホームディレクトリコピーしたいという状況でした.

手動でコマンドを打てるときは良いですが,Herokuなどで,スクリプトだけを用いてそのような作業を自動的に行う際には,こんな感じのハマりどころに陥ることがあるのではないでしょうか.

その他,ファイルやディレクトリパスの操作などで使えるライブラリとして,cl-fadや,uiopなどがあります.

どれが現在の最適解なのでしょうか.

まとめ

イマイチつかみどころのない記事になってしましましたが,可搬なシステムを開発するときに注意すべきパス名の落とし穴について書きました.

運良くどこかのだれかの役に立てばラッキーです.

間違いや,より良い方法があるのであれば,ベテランの方々,ぜひご教授願います!

Quicklispでのパスの取り扱いは複雑だから,今日もうまくいっくかわからないしパスで!的なオチはどうですか?下手すぎる?すいませんでした...

以上!

参考