02 December 2023

Lisp Advent Calendar 2023参加記事

スクリプティングと聞いて多くの人がまず思い浮かべるのは、PythonやBashのような言語かもしれない。 しかし、この記事ではCommon Lispがスクリプト作成において強力なツールたりえることを示したい。

しばしば面倒になるコマンドラインオプションをCommon Lispのマクロによって簡単に取り扱える例を示す。

Common Lispでスクリプトを書くには? ⇒ Roswellスクリプトがオススメ

Common Lispの各処理系には大抵の場合スクリプティングモードがある。 例えばSBCLの場合は--scriptオプションが用意されている。

よりポータブルな方法としては、Common Lispの処理系マネージャRoswellのスクリプティングモードを使うのがいいだろう。これにより処理系ごとの違いを吸収したり、スタンドアロンの実行ファイルを作ったりできる。

実行ファイルにすることで、OSやCPUのアーキテクチャが同じであれば別の環境に持っていってもそのまま動くことが期待できる。また、依存ライブラリのバージョンを固定することにもなる。 ただし、Common Lisp外の共有ライブラリを呼び出している場合には別途インストールが必要になる。 例えばdexadorなどのHTTPクライアントを使う場合はopensslなどが必要になる。

Roswellがインストールされていれば、以下のようにするとスクリプトのテンプレートが生成される。

$ ros init cat
Successfully generated: cat.ros

生成した直後のファイルの中身はこのようになっている。 これは何もしないで終了するスクリプトだ。このファイルのmain関数を若干書きかえてHello, world!と出力するようにする。

#!/bin/sh
#|-*- mode:lisp -*-|#
#|
exec ros -Q -- $0 "$@"
|#
(progn ;;init forms
  (ros:ensure-asdf)
  #+quicklisp(ql:quickload '() :silent t)
  )

(defpackage :ros.script.cat.3910589992
  (:use :cl))
(in-package :ros.script.cat.3910589992)

(defun main (&rest argv)
  (declare (ignorable argv))
  (format t "Hello, world!~%"))
;;; vim: set ft=lisp lisp:

ros initで生成されたcat.rosファイルは実行可能フラグが付いているので以下のように実行することができる。

$ ./cat.ros
Hello, world!

cat.rosファイルはただのテキストファイルでしかないが、ros build <rosファイル>で実行ファイルにすることができる。これにより実行時にライブラリのロード、コンパイルの時間が無くなるため起動時間がかなり速くなる。

$ ros build cat.ros
compressed 32768 bytes into 390 at level 9
compressed 21168128 bytes into 3439445 at level 9
compressed 7143424 bytes into 2289021 at level 9
compressed 1998848 bytes into 494908 at level 9
compressed 12058624 bytes into 3048156 at level 9

$ ls -lh cat
-rwxr-xr-x 1 wiz wiz 12M Dec  3 20:13 cat

$ time ./cat.ros
Hello, world!

real	0m0.296s
user	0m0.236s
sys	0m0.060s

$ time ./cat
Hello, world!

real	0m0.075s
user	0m0.058s
sys	0m0.017s

ros build <rosファイル> --disable-compressionとすると実行ファイルの圧縮をしなくなるので、ファイルサイズの増大と引き換えにさらに起動が速くなる。

$ ros build cat.ros --disable-compression

$ ls -lh cat
-rwxr-xr-x 1 wiz wiz 44M Dec  3 20:15 cat

$ time ./cat
Hello, world!

real	0m0.006s
user	0m0.000s
sys	0m0.006s

コマンドラインオプションをどう扱うか

さて、上記のRoswellスクリプトにはmain関数が含まれており、これがスクリプト実行時に呼ばれる。 このmain関数の引数argvにスクリプトの呼び出し時に付けたコマンドラインオプションが文字列のリストとして渡される。

コマンドラインオプションを扱うときは、この引数リストをパースし、バリデーションをしたり挙動を変えたりする必要がある。

以前cl-online-learningを作ったときには、コマンドラインオプションを扱うために、Common Lispの関数定義に似せたインターフェースを持つマクロdefmainを自分で定義していた(Gistの記事)。 これはコマンドラインオプションをLispの関数の必須パラメータやキーワード引数のように書けるというものだった。

その後、似たことするマクロライブラリdefmainがQuicklispに入っているのを見つけたので、この記事ではそれを紹介することにする。 奇しくも名前も同じでdefmainという。サブコマンドなどにも対応している。

また、同様の目的を持ったライブラリにunix-optsというものもある。 これはコマンドラインオプションをCLOSオブジェクトとして扱うため、より自由度は高いようだが、ほとんどの場合はdefmainで事足りるのではないかと思う。

defmainを使ってcatを作ってみる

簡単な例として、catコマンドを自作してみることにする。

まず、次のようなmain関数の定義を考える。

(defmain (main) ((version "Show version information and exit." :flag t)
                 (number "Number of lines to output." :default 0)
                 (log  "Filename to write log to.")
                 (lang "Character code of input" :env-var "LANG" :short "L")
                 &rest files)
  "cat - concatenate files and print on the standard output"

  (format t "version: ~A, type: ~A~%" version (type-of version))
  (format t "number: ~A, type: ~A~%" number (type-of number))
  (format t "log: ~A~%" log)
  (format t "lang: ~A~%" lang)
  (format t "files: ~A~%" files))

defmainの引数リストのように見える部分が各コマンドラインオプションに対応している。 これらの引数リストは、関数の仮引数のように本体内から参照できる。

各オプションに付いているキーワード引数は次のような意味を持つ。

  • :flag tが付いている場合、そのオプションが指定されているかいないかのbooleanが変数に束縛される
  • :flag tが付いていない場合はそのオプションは引数を取る
  • :defaultが指定されている場合、オプション省略時に変数にはデフォルト値が束縛され、指定されていなければnilが束縛される
    • デフォルト値が整数の場合は文字列ではなく、readされたオブジェクトが変数に束縛される(いまひとつ微妙な仕様に思える)
  • restパラメータにはオプションではないコマンドライン引数がリストとして渡される
  • 変数名がlong nameになり、変数名の頭文字が自動的にshort nameになる
    • 変数の頭文字が同じものが複数あるとエラーになるので、:shortで別の文字を指定することもできる
    • この場合、loglang-lがぶつかるのでlangの方を-Lにした
  • :env-varを指定すると、指定した環境変数からデフォルト値を取ってくるようになる

次にこのスクリプトを保存して、いくつかの場合で呼び出してみる。

このスクリプトを-hオプション付きで起動すると以下のようにヘルプが表示される。

$ ./cat.ros -h
Usage: cat main [-hv] [OPTIONS] FILE...

cat - concatenate files and print on the standard output
  -h, --help                  Show help on this program.
  -v, --version               Show version information and exit.
  -n, --number=OBJ            Number of lines to output.
                              Default: 0
  -l, --log=STR               Filename to write log to.
  -L, --lang=STR              Character code of input
                              Environment: LANG

引数なしで呼び出した場合。この場合は :defaultを指定した numberとシステムの環境変数を参照しているlang以外はnilになっていることが分かる。

$ ./cat.ros
version: NIL, type: NULL
number: 0, type: BIT
log: NIL
lang: en_US.UTF-8
files: NIL

全てのオプションを指定した場合。

$ LANG=C ./cat.ros -v -n 10 --log="/tmp/cat.log" file1 file2
version: T, type: BOOLEAN
number: 10, type: (INTEGER 0 4611686018427387903)
log: /tmp/cat.log
lang: C
files: (file1 file2)

存在しないオプションを指定した場合。このようなエラー処理は明示的に書かなくても自動的に出してくれることが分かる。

$ ./cat.ros --not-exist-option=foo
Unknown command-line option "not-exist-option" with argument "foo".

とはいえ、型のバリデーションなどは大したことはやってくれないので、ある程度は自分でやる必要があるようだ。例えばこの場合numberに整数以外を指定してもエラーにはならない。

最後に、このスクリプトをオプションを実際に使って挙動を変えるように書き換えてみる。

#!/bin/sh
#|-*- mode:lisp -*-|#
#|
exec ros -Q -- $0 "$@"
|#
(progn ;;init forms
  (ros:ensure-asdf)
  #+quicklisp(ql:quickload '(:defmain) :silent t))

(defpackage :ros.script.cat.3910589992
  (:use :cl :defmain))
(in-package :ros.script.cat.3910589992)

(defconstant +version+ "0.0.1")

(defun guess-code (lang)
  (check-type lang (or string null))
  (unless lang
    (return-from guess-code :default))
  (let* ((dot-position (position #\. lang))
         (code (if dot-position
                   (subseq lang (1+ dot-position))
                   lang)))
    (cond ((equal code "UTF-8") :utf-8)
          ((equal code "eucJP") :euc-jp)
          ((or (equal code "SJIS") (equal code "shift_jis")) :shift-jis)
          (t :default))))

(defmain (main) ((version "Show version information and exit." :flag t)
                 (number "Number of lines to output." :default 0)
                 (log  "Filename to write log to.")
                 (lang "Character code of input" :env-var "LANG" :short "L")
                 &rest files)
  "cat - concatenate files and print on the standard output"

  (when version
    (format *standard-output* "cat version: ~A~%" +version+)
    (return-from main))

  (unless (typep number 'integer)
    (format *error-output* "Error: number must be integer.~%")
    (return-from main))

  (unless files
    (format *error-output* "Error: File required.~%")
    (return-from main))

  (dolist (file files)
    (unless (uiop:file-exists-p file)
      (format *error-output* "Error: File ~A does not exist.~%" file)
      (return-from main)))

  (let ((i 1))
    (dolist (filespec files)
      (with-open-file (f filespec :direction :input :external-format (guess-code lang))
        (loop for line = (read-line f nil nil)
              while (if (zerop number)
                        line
                        (and (<= i number) line))
              do (format *standard-output* "~a~%" line)
                 (incf i)))))

  (when log
    (with-open-file (f log :direction :output :if-exists :supersede)
      (format f "cat finished!~%"))))
;;; vim: set ft=lisp lisp:

以下のように、本物のcatコマンドと同じように使うことができる。ビルドすれば起動時間も十分実用的になる。

$ time ./cat /tmp/file1 /tmp/file2
a1
b1
c1
a2
b2
c2
d2

real	0m0.020s
user	0m0.012s
sys	0m0.008s

$ time cat /tmp/file1 /tmp/file2
a1
b1
c1
a2
b2
c2
d2

real	0m0.001s
user	0m0.001s
sys	0m0.000s

まとめ

コマンドラインオプションの扱いに関して、defmainというマクロライブラリを紹介した。

このマクロは、Common Lispの関数定義に似たインターフェースを持ち、コマンドラインオプションを簡単にパースし、プログラムの挙動を変更するのに役立つ。

こういった定型的なパターンを記述する方法がマクロライブラリとして実装できるところもCommon Lispをスクリプティングに使う際のメリットの一つ。