この記事は Lisp Advent Calendar 2017 の 17 日目の記事です。


eval は強力ではあるものの、その強力さ故に現代的な Lisp ではあまり使われない。

Lisp 以外の eval のある言語、例えば JavaScript ではその存在はほとんど忘れられているような気がするし、 Ruby でも instance_evalclass_eval といった表現力を弱めた変種が使われることはあれど、素の eval が使われることはほとんどないように思う。

Lisp の場合でも、普通は eval を使わずに簡単な DSL を作って自分でインタプリタを書いてしまうことが多いのだけど、 DSL が複雑になってくると少し面倒さを感じないでもない。

そんなときに R7RS Scheme の eval がちょうどうまく使える例があったので紹介したい。

R7RS Scheme の eval

R7RS (と R6RS も) Scheme の eval は第二引数に環境指定子を受け取る(R5RS でも環境指定子は受け取るが、環境指定子を新たに作り出す方法がないのでひとまずおいておく)。

環境指定子は environment 手続きで作り出すことができ、 environment 手続きは import と同じ import 指定を受け取る。

例えば、

(eval expr
      (environment '(except (scheme base)
                            define
                            define-record-type
                            define-syntax
                            define-values)))

のようにすると、 expr を Scheme プログラムとして解釈するが、 (scheme base) ライブラリで公開されている束縛のうち、定義に関連する構文は使用できないようにする、ということができる。

Scheme では defineif といった構文も、単にライブラリから公開されている束縛なので、環境を限定することで、簡単に Scheme のサブセットの評価器を手に入れることができる。

簡単な設定ファイル

Lisp プログラムでちょっとした設定ファイルが欲しい場合、 JSON や YAML を使ってもよいのだけど、特に理由がないのなら、単にS式をファイルに書いてそのまま read してしまうのが簡単だ。

例えば下のような JSON を書くよりも、

config.json
{
    "log-file": "/path/to/debug.log",
    "max-log-size": 10485760
}

次のように連想リストをファイルに書いてそれを読み込むようにする。

config.scm
(
 ;; ログファイルのパス
 (log-file . "/path/to/debug.log")
 ;; ログの最大サイズ
 ;; 10485760 = 10 * 1024 * 1024 = 10MB
 (max-log-size . 10485760)
 )

使うときは単に、このファイルを read すればよい。

main.scm
(import
  (scheme base)
  (scheme file)
  (scheme read))

(call-with-input-file "config.scm" read)

計算できるようにする

さて、上の例では max-log-size を 10MB に設定するのに、計算後の値を書いた上でコメントにその意図を書いた。これについては、設定値を数値でなく文字列にしてしまい、 "10Mi" と書くと 10 * 2^20 の意味と解釈するようにする事も考えられるが、場当たり的な感がぬぐえない。

単純に、設定ファイルの中で四則演算くらいできるようにしてしまおう。

settings.scm
(list
 ;; ログファイルのパス
 (cons 'log-file "/path/to/debug.log")
 ;; ログの最大サイズ
 (cons 'max-log-size (* 10 1024 1024))
 )

以前の設定ファイルは、ファイルに連想リストを書き下したものだったが、ここからは Scheme のサブセットで連想リスト値を生成するプログラムを記述したものを設定ファイルと呼ぶことにする。

設定ファイル側を使う側では、読み込んだS式を Scheme のサブセットとして eval する。使えるのは四則演算とリストを構築するための手続きだけなので、危ないことはできない。

main.scm
(import
  (scheme base)
  (scheme eval)
  (scheme file)
  (scheme read))

(eval
 (call-with-input-file "config.scm" read)
 (environment '(only (scheme base)
                     list cons quote
                     + - * /)))

見た目を変える

さて、先程の設定ファイルは、設定ファイルというよりは、いかにも Lisp プログラムを書き出したものという感じで生々しい。もう少し見た目をお化粧してみよう。

キー・値ペアの quote を隠す構文と、 (* 1024 1024) の部分を別ライブラリに切り出しておく。

settings.scm
(define-library (settings)
  (export : Mi)
  (import (scheme base))
  (begin
    (define Mi (* 1024 1024))
    (define-syntax :
      (syntax-rules ()
        ((_ key val)
         (cons 'key val))))
    ))

eval に渡す環境でこのライブラリを参照するようにする。さらに、 list という名前もこちたいので objectrename してみる。

main.scm
(import
  (scheme base)
  (scheme eval)
  (scheme file)
  (scheme read))

(eval
 (call-with-input-file "config.scm" read)
 (environment '(rename (only (scheme base)
                             list
                             + - * /)
                       (list object))
              '(settings)))

設定ファイルはこんな風に書けるようになった。

settings.scm
(object
 ;; ログファイルのパス
 (: log-file "/path/to/debug.log")
 ;; ログの最大サイズ
 (: max-log-size (* 10 Mi))
 )

環境変数を読む

こうなってくると、設定をファイルからだけでなく環境変数からも読みたくなってくる。

そんな場合も、単に eval に渡す環境でそれが見えるようにしてやればよいだけだ。 (scheme process-context)get-environment-variable はちょっと長いので getenv という名前で呼べるようにしておこう(2017-12-17T22:15 JST: R7RS に合わせて (srfi 98) ではなく (scheme process-context) にした)。

main.scm
(import
  (scheme base)
  (scheme eval)
  (scheme file)
  (scheme read))

(eval
 (call-with-input-file "config.scm" read)
 (environment '(rename (only (scheme base)
                             list
                             + - * /)
                       (list object))
              '(rename (scheme process-context)
                       (get-environment-variable getenv))
              '(settings)))

条件分岐

環境変数を読む場合、値が設定されていなければ適当なデフォルト値を使うようにするのが普通だろう。 get-environment-variable は値が設定されていない場合 #f を返すので、設定ファイルで条件分岐が書けるようになっていればよい。

自前で設定ファイルの処理系を書いていると、構文を増やすのは少し手間がかかるが、 eval なら単に Scheme の構文が見えるようにしてやればいい。

main.scm
(import
  (scheme base)
  (scheme eval)
  (scheme file)
  (scheme read))

(eval
 (call-with-input-file "config.scm" read)
 (environment '(rename (only (scheme base)
                             list
                             and or if
                             + - * /)
                       (list object))
              '(rename (scheme process-context)
                       (get-environment-variable getenv))
              '(settings)))

設定ファイルの方はこうなる。

config.scm
(object
 ;; ログファイルのパス
 (: log-file (or (getenv "LOG_FILE")
                 "/path/to/debug.log"))
 ;; ログの最大サイズ
 (: max-log-size (* 10 Mi))
 )

さらにもっといろいろ

こうやって設定ファイルを拡張していくと、ローカル変数を使いたくなったら let を追加したり、手続きを書けるようにしたくなったら lambda を追加したり、といくらでも表現力を増やしていける。

だが、ファイル入出力APIやその他危険な手続きを呼び出せるようにしていないから大丈夫、と油断はできない。

lambda を書けるようにすると、それだけで設定ファイルはチューリング完全になり、設定ファイルの停止性は保証できなくなる。 let の方も Scheme の let は名前つき let があるので停止しない設定ファイルが書けてしまう(後者は let* を公開するようにすれば回避できるが)。

欲しいものが設定ファイルのための DSL なら、きちんと Domain Specific の領分は守るようにすべきなのだ。