y2q_actionman’s ゴミクズチラ裏

内向きのメモ書きを置いてます

defvarがunboundな変数を作れることを生かしたい

この文章は、 Lisp Advent Calendar 2020 - Qiita の記事として 12/31 に書かれました。 今年は advent calender のことを年の瀬まですっかり忘れており、事ここに至って初めて思い出しております。 改めて当該 advent calender を見てみると思ったよりスカスカだったので、今更ながら参加しても間に合う?だろうという目論見でいくつか書いてみる予定です。

defvar で unbound な変数を作るには

Common Lisp HyperSpec によると、 defvar の構文は以下のようになっています:

defvar name [initial-value [documentation]] => name

この時、 initial-value を与えず、さらに name で指定した変数が unbound だった、もしくはその変数が無かった場合、変数は unbound なままになります。

* (in-package :cl-user)
#<PACKAGE "COMMON-LISP-USER">

* (defvar *foo*) ; *FOO* を unbound な変数として作る
*FOO*

* (boundp '*foo*) ; unbound なので nil が返される。
NIL

* *foo*            ; unbound なので値を読もうとするとエラーになる。
debugger invoked on a UNBOUND-VARIABLE in thread
#<THREAD "main thread" RUNNING {10005205B3}>:
  The variable *FOO* is unbound.
;; (以下略)

この unbound は変数が作れるという仕様を何かに使えないかと思って考えてみました。

用例1: 絶対に初期化が必要な値の置き場に使う

プログラムを書いていると 設定ファイル なるものは憑き物です。 そして、絶対に設定ファイルから読み込んで初期化しないといけない値、というのもありがちです。

こんな例を考えてみます:

  • 変数 *some-api-url* に URL を格納しておく。
  • load-configuration*some-api-url* を初期化する。 この関数を呼び忘れると動かない。
  • call-some-api は、この変数を見て HTTPリクエストを送る。
(defvar *some-api-url*)  ; あえて unbound にする

(defun load-configuration ()
  (setf *some-api-url* "http://example.com"))

(defun call-some-api ()
  (drakma:http-request *some-api-url*))

この例で、 変数 some-api-url の初期化前に call-some-api を呼んでしまうと、unbound-variable エラーになります。 unbound-variable エラーなら、大抵デバッガが変数名を報告してくれるので、デバッグの助けになります:

The variable *SOME-API-URL* is unbound.
   [Condition of type UNBOUND-VARIABLE]
...

Backtrace:
  0: (CALL-SOME-API)
  1: (SB-INT:SIMPLE-EVAL-IN-LEXENV (CALL-SOME-API) #<NULL-LEXENV>)
  2: (EVAL (CALL-SOME-API))

unbound にしなかったら?

筆者は昔、こういう変数を NIL で初期化していたのですが・・

(defvar *some-api-url* nil)
;; あとは同じ

NIL にした状態で同様のことをすると、全く別のエラーになります。 上記の例だと、SBCLのデバッガでは以下のように報告されました:

There is no class named COMMON-LISP:NIL.
   [Condition of type SB-PCL:CLASS-NOT-FOUND-ERROR]

Restarts:
 0: [RETRY] Retry SLIME REPL evaluation request.
 1: [*ABORT] Return to SLIME's top level.
 2: [ABORT] abort thread (#<THREAD "repl-thread" RUNNING {10020A1BE3}>)

Backtrace:
  0: (SB-PCL::FIND-CLASS-FROM-CELL NIL NIL T)
  1: ((FLET SB-THREAD::WITH-RECURSIVE-LOCK-THUNK :IN SB-PCL::INSTALL-OPTIMIZED-CONSTRUCTOR))
  2: ((FLET "WITHOUT-INTERRUPTS-BODY-29" :IN SB-THREAD::CALL-WITH-RECURSIVE-LOCK))
  3: (SB-THREAD::CALL-WITH-RECURSIVE-LOCK #<CLOSURE (FLET SB-THREAD::WITH-RECURSIVE-LOCK-THUNK :IN SB-PCL::INSTALL-OPTIMIZED-CONSTRUCTOR) {4E409AB}> #<SB-THREAD:MUTEX "World Lock" owner: #<SB-THREAD:THREAD..
  4: (SB-PCL::INSTALL-OPTIMIZED-CONSTRUCTOR #<SB-PCL::CTOR NIL (:SCHEME SB-PCL::|.P0.| :HOST SB-PCL::|.P1.| :PORT SB-PCL::|.P2.| ...)>)
  5: ((LAMBDA (&REST SB-PCL::ARGS) :IN SB-PCL::INSTALL-INITIAL-CONSTRUCTOR) NIL NIL NIL NIL NIL NIL NIL NIL NIL)
  6: (DRAKMA:HTTP-REQUEST NIL)
  7: (SB-INT:SIMPLE-EVAL-IN-LEXENV (CALL-SOME-API) #<NULL-LEXENV>)
  8: (EVAL (CALL-SOME-API))
 --more--

報告されたエラーは 「NILという名前のクラスはない」となり、直接的でないエラーになりました。 筆者は昔、こういう状況になり・・

  1. なんじゃこのエラーは
  2. NILが紛れこんだのか。どこで入れてしまったんだ? let してたっけ?
  3. -- NIL の由来を延々と辿り続けること3時間 --
  4. そもそも初期化関数を呼んでなかった。でも NIL になるのはなんでだろう。
  5. NILで初期化したのは俺だった・・

こんな感じで酷い目にあっていました。

用例2: LET でのbind忘れを見つけるのに使う

初期化忘れを発見するのと同様に、絶対に LET しないといけない変数の bind 忘れを発見するのにも使えると思います。

例えば、多くの Lisp 処理系 (SBCLなど)は、個々のスレッド内でスペシャル変数に LET で bind すると Thread Local 変数の割り当てになります。 このような変数は各スレッドで LET する必要があります。そこで、スレッドの外ではあえて unbound で置いといて、スレッド内で LET し忘れると unbound-variable エラーを報告させるということができます。

(defvar *thread-local-resource*)

(defun threads-test ()
  (dotimes (i 5)
    (sb-thread:make-thread
     (lambda ()
       (let ((*thread-local-resource* i)) ; ここで Thread Local な資源を割り当てる。
         ;; Thread Local な資源を使う処理
         (print *thread-local-resource*)
         ;; ...
         )))))

unbound にしなかったら?

筆者はかつてこういう変数も NIL で初期化しており・・

  • エラーによるとNILが入ってる。どこでNILが紛れこんだんだ? → 初期値だった・・
  • NIL以外の使える値にしておいた → LET を忘れてて、グローバルな資源をずっと参照していた・・

という悲しい事件を引き起こしていました。

まとめ

なんか色々あった結果、筆者は最近、変数を初期化したかどうかの assertion の代わりに defvar で unbound に置いておいたりしています。 (もっといい方法を募集しています!)