Common Lisp Pitfalls

Meta info

対象読者

複数の処理系で可能な限り可搬的になるようコードを書きたい初級〜中級CLer。

現時点での対象処理系

SBCL、CLISP、ECL、CCL

Introduction

僕がハマってきたピットフォール群をメモ的にコレクションしていきたい。

ピットフォールは主に以下の種類に分けられる。

先頭から順番に読むもよし、気になるオペレータ名で検索をかけるもよし。

なお、記事が追加される場合は先頭に追加していくこととする。 また、項目は重複する可能性があるものとする。

READ-SEQUENCE

言語仕様では第二引数の型エラーについて触れられていない。 多くの処理系では第二引数がストリームでない場合TYPE-ERRORを発するが、そうでない処理系もある。

CCLは第一引数が空シーケンスの場合、第二引数がなんであれ成功裏に0を返す。

? (read-sequence #() :not-stream)
=> 0

通常このような愚かなコードは書かないが、「引数がストリームでない場合エラーとなる」というようなテストを書いている場合想定外に成功するという形で出会うことがある。

VALUES as type specifier

通常の型指定子は第一返り値の型を示すものであり、暗黙理に多値が返る可能性があることを示している。

(the integer (values 0 1)) ; <--- ok

多値の型指定をする場合にはVALUES型指定子が使える。

(the (values integer integer)(values 0 1)) ; <--- ok

ただ、上記の場合も暗黙理に第三返り値以降の多値が返る可能性があることを示していることとなる。

(the (values integer integer)(values 0 1 2)) ; <--- ok

例えば返り値は1つだけでけして多値が返ることはないということを示したいなら&OPTIONALを使って以下のようにする。

(the (values integer &optional)(values 1 2)) ; <--- not ok

LOOP

:ON節に非NILアトムが渡ってきた場合、エラーではなくNILとなる。 仕様によりエンドチェックはATOMで行われると定められている。

(loop :for a :on 'non-nil-atom :collect a) => NIL

*MACROEXPAND-HOOK*

変数*MACROEXPAND-HOOK*が受け取る関数のAPIは(expander form env)である。 使い方としては、何らかの処理を行った後、MACRO-FUNCTIONであるEXPANDERにFORMとENVとを渡す形でFUNCALLしてあげれば良い。 例えばCLHSには以下のような例がある。

(defun hook (expander form env)
   (format t "Now expanding: ~S~%" form)
   (funcall expander form env)) =>  HOOK 
(defmacro machook (x y) `(/ (+ ,x ,y) 2)) =>  MACHOOK 
(macroexpand '(machook 1 2)) =>  (/ (+ 1 2) 2), true 
(let ((*macroexpand-hook* #'hook)) (macroexpand '(machook 1 2)))
>>  Now expanding (MACHOOK 1 2) 
=>  (/ (+ 1 2) 2), true

気をつけなければならないのは、このような書き方では外側にある別な*MACROEXPAND-HOOK*関数をシャドウしてしまう点だ。 これにより、内側のフックは機能するが外側のフックが機能せず全体として期待と異なる振る舞いになってしまう場合が起こりうる。 これを避けるためにはクロージャを利用して以下のようにすると良い。

(defun hooker(outer-hook)
  (lambda(expander form env)
    ...
    (funcall outer-hook expander form env)))

(let((*macroexpand-hook*(hooker *macroexpand-hook*)))
  ...)

HOOKER関数はその時点での外側のフック関数を引数として補足し、フック関数を返す関数である。

変数*MACROEXPAND-HOOK*はスペシャル変数、すなわち動的な束縛を行うので、外側の変数を補足することができない点要注意。 以下のコード例は無限ループに陥る。

(let((*macroexpand-hook*
       (lambda(expander form env)
         ...
         (funcall *macroexpand-hook* expander form env))))
  ...)

LAMBDAの中の*MACROEXPAND-HOOK*の値は動的な(レキシカルでない)値なので、LAMBDA自信になる。 よって一度呼び出されると、自分自信を無限再起呼び出しし続けることとなる。

IGNORE-ERRORS with multiple-value.

マクロIGNORE-ERRORSはformがERRORをシグナルするとそのコンディションを捕まえて(VALUES NULL CONDITION)を返す。 formがERRORをシグナルすることがなければformの返り値をそのまま返す。 ここでいう「そのまま」は、formが多値を返したならその多値をそのまま返すという意味である。 よって、たとえばMULTIPLE-VALUE-BINDなどを用いて「第二返り値があるかないか」だけで失敗か否かをチェックしようとするとformが多値を返したときに混同してしまう。

第二返り値がCONDITION型かどうかをチェックしている場合でも、formが成功裏にCONDITIONを第二返り値として返したら混同してしまう。

IGNORE-ERRORSの第二返り値を使いたい場合はめんどくさがらずHANDLER-CASEを書くべきである。

WRITE, *PRINT-PRETTY*

割と多くの処理系で(function hoge)というリストは表示できない。 #'hogeになってしまう。 筆者が調べた限りでは、SBCLは*PRINT-PRETTY*NILに束縛することで期待通り出力できるようだ。 CLISP, ECL, CCLではリスト(function hoge)の出力方法は見つけられなかった。 仕様ではこの点については触れられていない。 同様に(quote hoge)というリストも割と多くの処理系で表示できない。

#+sbcl
(write '(function hoge) :pretty nil)
(FUNCTION HOGE) ; <--- output
#'HOGE      ; <--- return

#-sbcl
(write '(function hoge) :pretty nil)
#'HOGE      ; <--- output
#'HOGE      ; <--- return

ドキュメンテーション自動生成ツール開発中に、メソッドの各シグネチャを出力する際、シグネチャが(function function)だった場合に#'FUNCTIONと出力されてしまうという形で出会った。

STRING family

STRINGのファミリーは引数に文字列指定子を受け付ける。 すなわち、文字、シンボルも受け付けられる。

(string= () "NIL") ; => T
(string= :a #\A) ; => T

文字列同士でしか比較をしたくない場合はEQUALEQUALPを使う。

IMPORT, EXPORT, UNEXPORT, SHADOWING-IMPORT, SHADOW

第一引数はあくまでリストである。 便宜的にシンボル自身も受け付けるが、あくまで基本はリストである。

筆者はこれを反対に覚えてしまいハマった。 具体的にはNILIMPORTしようしたがIMPORTされなかった。 NILを裸で渡した場合、「シンボルのリストを受け付けたが、中身は空であった」と解釈される。 NILを操作したい場合、必ずリストに括って渡さなければならない。

CONSTANTP with &WHOLE

CONSTANTPは受け取った引数がマクロフォームであった場合、マクロ展開を行う可能性がある。 この点は仕様上明示的に処理系依存とされている。

もしマクロフォームが&WHOLEラムダリストキーワードで受けたフォームを返した場合、無限マクロ展開に陥る。 これを回避するためには*MACROEXPAND-HOOK*を束縛し、条件によって大域脱出(GORETURN-FROMTHROW)を行えばよい。

MACROEXPAND-1 with &WHOLE

マクロ展開関数が&WHOLEラムダリストキーワードで受けたフォームを返した場合、直感的には展開が行われていないので第二返り値がNILになりそうなものだが、Tとなる。 仕様では、引数のフォームがマクロフォームであれば第二返り値はTとなる。

If form is a macro form, then the expansion is a macro expansion and expanded-p is true.

第二返り値の名前がEXPANDED-Pであることが、誤解の原因と言える。

LIST*

無引数で呼び出した場合の挙動に関しては仕様上触れられていない。 NILが返る処理系とエラーになる処理系とがある。

#+(or clisp sbcl ccl)
(list*) => ERROR
#+ecl
(list*) => NIL

*PRINT-LENGTH*

プリティプリンタ周りの実装は可搬的でないケースが多い。 構造体の表示に関しては仕様でも触れられていない。 CCLでは型名もスロット名も「リスト内の要素」と解釈されている。 SBCL,ECLでは型名はカウントせず、スロット:値の対を一要素と解釈されている。 CLISPでは構造体自体は言わばアトムであると解釈されている。

(defstruct foo a b c d)
=> FOO

(let((*print-length* 2))
  (print(make-foo :a (list 1 2 3 4 5))))

#+clisp
#S(FOO :A (1 2 ...) :B NIL :C NIL :D NIL)

#+(or sbcl ecl)
#S(FOO :A (1 2 ...) :B NIL ...)

#+ccl
#S(FOO :A ...)

READ

これは処理系のバグに相当するが、+.-.Ansiスタンダードでは数ではないとされているが、ECLでは0に解釈される。

(read-from-string "+.") => implementation-dependent.
                        ; Symbol +. in spec.
                        ; 0 in ECL.

通常問題になることは無いと思われるが、Common LispでCommon Lispのパーザを書き、それをテストしたところ遭遇した。

BACKQUOTE

バッククォートの実装は処理系依存である。 多くの処理系でバッククォートはマクロに展開され、すなわちコンパイル時に等価なフォームが生成されるが、そうでない処理系も存在する。 具体的にはCCLはフォーム生成をリード時に行う。

'`(hoge ,@(cdr '(1 2 3))) => implementation-dependent.
                          ; `(HOGE ,`(CDR '(1 2 3))) in many impls.
                          ; (LIST* 'HOGE (CDR '(1 2 3))) in CCL.

SIGNAL

SIGNALの振る舞いは、受け取ったコンディションを元にハンドラを探し、ハンドラがあればコールしてまわり、どのハンドラもコントロールフロー制御をしなければ最終的にNILを返すというものである。

トップレベルにハンドラがあるかどうかは処理系依存となる。

(signal 'error) => implementation-dependent. NIL or invokes debugger.

*STANDARD-OUTPUT* *STANDARD-INPUT*

多くの処理系では、たとえば*STANDARD-OUTPUT**STANDARD-INPUT*を束縛することはエラーとなるが、そうでない処理系も存在する。 たとえばCCLでは両シンボルは*TERMINAL-IO*へのaliasとして機能している。

(let((*standard-output* *standard-input*))
  ...)
=> implementation-dependent. Error or works.
(output-stream-p *standard-input*) => implementation-dependent. T in CCL.

通常このような馬鹿げたコードを書くことはないが、「アウトプットストリームを期待している関数にインプットストリームを渡すとエラーになる」という文脈のテストコードを書く際などに、想定外に成功するという形で現れる。

CASE ECASE CCASE

NILないしTをキーにしたい場合は必ず括弧にくくらねばならない。

(case var
  (nil :this-clause-is-never-chosen.)
  ((nil) :ok.)
  (t :this-clause-is-treated-as-default-clause.)
  ((t) :ok.))

LOOP

:MAXIMIZE:MINIMIZEが実行されなかった場合の返り値は未定義。

(loop :for i :in () :minimize i) => unspecified. NIL or 0.

終端チェック節の後に変数束縛節を使うのはinvalid。 期待通り動く処理系とそうでない処理系とがある。

(loop :for i :in '(1 1 1 #\1)
      :while (integerp i)
      :for c = (code-char i) ; <--- invalid.
      :do ...)

DEFTYPE

再帰定義は未定義。 上手く動く処理系とそうでない処理系がある。

(deftype strings()
  (or null (cons string strings)))
=> STRINGS
(typep :hoge strings)
=> unspecified. Works or infinite loop.

マクロとしてのANDは左から右に評価されるが、型指定子としてのANDはその限りではない。

(typep :hoge '(and integer (satisfies evenp)))
=> unspecified. Works or signals error.

DOCUMENTATION

これは処理系のバグに相当するが、ECLではSETFできない。 仕様ではSETF出来る。

;; @ECL
(setf(documentation 'hoge 'function) "docstring")
=> "docstring"
(documentation 'hoge 'function)
=> NIL

MAKE-STRING-INPUT-STREAM WITH-INPUT-FROM-STRING

これは処理系独自拡張になるが、ECLでは文字列指定子(string-designator)が使える。

;; @ECL
(with-input-from-string(s :hoge)
  (read s))
=> HOGE ; Error in spec.

(with-input-from-string(s #\c)
  (read s))
=> C ; Error in spec.

SETF FDEFINITION

SETF可能でも、それがSETF Expanderを持つとは限らない。

(defstruct foo bar)
=> FOO
(fdefinition '(setf foo-bar)) => unspecified.

(fdefinition '(setf car)) => unspecified.

NIL

これは可搬的なのだが、分かりづらいので。

NILは型名でもある。 型名としてのNILは「無」を表す。 そのためあらゆる型のsubtypeである。

(subtypep nil nil) => T

また、「無」を表すので、けしてどの型でもない。 すなわち自分自身でもない。

(typep nil nil) => NIL

値としてのNILの型名はNULLである。

(typep nil 'null) => T

筆者個人は例えば以下のようなコードを書き、

(typep '(0) '(cons (eql 0) nil))

Tを期待するもNILが返ってきて、「何故だ」と悩んだ挙句、「あぁ、NILじゃない、NULLだ」となることが、まま、ある。

SYMBOL

エスケープされた文字を含むシンボルの表示方法はポータブルではない。

\#hoge
=> |#HOGE|
; otherwise
=> \#HOGE

PATHNAME

リテラルで書く場合、変な値が入る場合がある。

(pathname-version #P"") => :NEWEST

これは処理系独自拡張なのだが、シンボルを受け付ける処理系もある。

(pathname :/foo/bar/bazz) => #P"/foo/bar/bazz" ; Error in spec.

*

0を掛けた場合、0になるとは限らない。

(* 0 0.0) => 0 or 0.0

CONDITION

PRINCした場合、メッセージが表示されるとは限らない。

(princ (nth-value 1 (ignore-errors (/ 2 0))))
=> unspecified. "Division by zero." or #<DIVISION-BY-ZERO #X123456>

SYMBOL-FUNCTION FDEFINITION

シンボルがマクロや特殊形式の場合、関数オブジェクトが入っているとは限らない。

(symbol-function 'when) => unspecified.

CONCATENATE

これは処理系独自拡張なのだが、SEQUENCE-DESIGNATORとしてARRAYを受け付ける処理系もある。

(concatenate 'array #(1 2 3) #(4 5 6)) => #(1 2 3 4 5 6) ; Error in many impls.

COERCE

シーケンスを配列に出来る処理系とそうでない処理系がある。

(coerce '(1 2 3) 'array) => implementation-dependent. #(1 2 3) or signals error.

MAKE-ARRAY

どのような値で初期化されるかは未定義。

(make-array 1) => unspecified. #(0) or #(nil)