落とし穴


Common Lispでありがちなミス、はまりどころについてです。



操作篇

巨大なリスト/配列をプリントして大変なことになる

REPLで

(make-array (* 1024 1024 16) :element-type '(unsigned-byte 8))

したら処理系が落ちた…

原因

例の場合、16777216個の0を表示することになり、表示が大変なことになるのが理由です。

(progn
  (setq foo (make-array (* 1024 1024 16) :element-type '(unsigned-byte 8)))
  nil)

などとすれば良いでしょう。
どうしても表示させたい場合は、

(setq *print-length* 100)

等、表示に制限を付ければ大抵大丈夫です。


対話的に変更した部分をコードとして保存し忘れる

REPLを使っているときによくあるパターンです。

関数や変数などの定義を追加したり変更したのに、ソースコードへ保存するのを忘れてしまうと「アプリケーションを再起動したら正しく動作しなくなった」「実際の動作とコードの内容がどうも一致しない」なんてことになります。

それほど深刻ではない例を紹介します。ライブラリを作ったときに、シンボルをエクスポートし忘れた場合を考えましょう。次のコードではapplicationパッケージでのyの呼び出しはエラーになります。

(defpackage :library
  (:use :cl)
  (:export :x))  ; yをエクスポートし忘れている
(in-package :library)

(defun x () (print "x"))
(defun y () (print "y"))

(defpackage :application (:use :cl :library))
(in-package :application)

(y)
;>> Error: Undefined function Y called with arguments () .

これを修正するためにREPLからapplication::yをアンインターンして、library::yをインポートします。

APPLICATION> (unintern 'y)
T
APPLICATION> (import 'library::y)
T
APPLICATION> (y)

"y" 
"y"

正しく動作するようになりました。が、ここでlibraryパッケージの定義を修正するのを忘れたままにしてしまうと、次回同じコードをロードしたとき、また同じエラーが発生します。この例では原因の発見は簡単ですが、シンボルのシャドウイングなどが絡んだりすると、予想外に面倒なバグになりかねません。


マクロやインライン関数を変更したときに再コンパイルを忘れる

変更したコードを部分的に評価しながら開発していると引っかかることがあります。

マクロやインライン関数の定義は評価しても、それを使っている側の関数をコンパイルし直さないと、以前の定義のままで動作し続けてしまいます。用途を考えれば当然なのですが、マクロやインライン関数がコンパイル時に展開されるのが原因です。コード全体をコンパイルしてから実行するタイプの開発では問題になりにくい部分でもあります。

例えば、平面上の座標をコンスで表現してみましょう。make-pointで座標を表現するオブジェクトを作り、point-xとpoint-yでオブジェクトのx座標とy座標にアクセスすることにします。point-xとpoint-yは計算の過程で何度も呼ばれることが予想されること、carcdrを呼んでいるだけなことから、インライン化した方が良いでしょう。print-pointはpoint-xとpoint-yを利用して座標を表示する関数です。

> (defun make-point (x y) (cons x y))
MAKE-POINT
> (declaim (inline point-x point-y))
NIL
> (defun point-x (p) (car p))
POINT-X
> (defun point-y (p) (cdr p))
POINT-Y
> (defun print-point (p)
    (format t "~&(~a, ~a)~%" (point-x p) (point-y p)))
PRINT-POINT
> (print-point (make-point 0 1))
(0, 1)
NIL

ここで、内部で使っているデータ構造をベクタに変更します。この例では変更にあまり意味はありませんが、実際にプログラムを書くときは、性能を改善したりするために後から内部構造を変更する機会もあるでしょう。point-xとpoint-yはcarとcdrの代わりにeltを使うことにします。

> (defun make-point (x y) (vector x y)) ; 内部構造をベクタに変更
MAKE-POINT
> (defun point-x (p) (elt p 0))
POINT-X
> (defun point-y (p) (elt p 1))
POINT-Y
> (print-point (make-point 0 1))        ; carとcdrが呼ばれてエラーに
ERROR: value #(0 1) is not of the expected type LIST.

print-pointはpoint-xとpoint-yを利用して座標を表示しますが、以前のcarとcdrを使った定義がインライン展開されているため、point-xとpoint-yの定義を変更しただけでは正しく動作しません。

> (defun print-point (p)
    (format t "~&(~a, ~a)~%" (point-x p) (point-y p)))
PRINT-POINT
> (print-point (make-point 0 1))
(0, 1)
NIL

もう一度print-pointを定義し直すと正しく動作するようになります。


ローカルな関数を定義しようとしてdefun内でdefunを使う

Schemeの流儀に慣れている人はやってしまいがちです。defunの中でもdefunを使うことはできますが、ローカルな関数にはなりません。

関数aの中でだけ使う関数bを定義したいとします。

(defun a ()
  (defun b () (print "b"))
  (b))
;=> A

;; 一見期待通りに動いているように見えるが
(a)
;=> "b"
;-> "b"

;; bはaの外からも呼べてしまう
(b)
;=> "b"
;-> "b"

ローカルな関数を定義するときはfletlabelsを使います。

;; ローカルな関数fはfletの中でだけ有効
(flet ((f ()
         (print "local")))
  (f))
;=> "local"
;-> "local"

;; 外からは見えない
(f)
;>> Undefined function F called with arguments () .

applyで関数を呼ぶときに引数として大きなリストを渡す

リストの各要素を引数として関数に渡すときにapplyは便利ですが、一定以上大きなリストを渡すと問題になります。

(apply #'append (make-list 5 :initial-element '(t)))
;=> (T T T T T)

(apply #'append (make-list (1+ call-arguments-limit) :initial-element '(t)))
;>> Error: Too many arguments.

関数に渡すことのできる引数の数はcall-arguments-limitまでなので、それ以上の大きさのリストをapplyの最後の引数として渡すことはできません。

CLHS: Constant Variable CALL-ARGUMENTS-LIMIT

An integer not smaller than 50 and at least as great as the value of lambda-parameters-limit, the exact magnitude of which is implementation-dependent.

と書かれているように、call-arguments-limitの値は処理系によって変わります。例えば、32ビットのClozure CL 1.8では65536です。これが大きいかどうかは個人の感覚によって変わってくるでしょうが、移植性を考える場合、規格では50より大きい値は保証されていないことに気をつけてください。

なお、先ほどの例でcall-arguments-limitを超える大きさのリストを扱う場合は、

(reduce #'append list-of-lists :from-end t)
(mapcan #'copy-list list-of-lists :initial-element '(t)))

といった書き方をすることで問題を回避できます。


fixnum同士の計算結果をfixnumと仮定する

fixnum同士の計算でもfixnumになるとは限りません。

(typep (+ most-positive-fixnum 1) 'fixnum)
;=> NIL

を考えると分かりやすいのではないでしょうか。1は当然fixnumですが、most-positive-fixnumはfixnumが取り得る最大の値ですから、1を足してもfixnumの範囲を超えてしまいます。

従って、

(+ (the fixnum x) (the fixnum y))

というコードも通常はfixnumを返すものとしてコンパイルされません。fixnumを返すことを保証してコンパイラに最適化を促す場合、

(the fixnum (+ (the fixnum x) (the fixnum y)))

とする必要があります。


大きなファイルを読み込むときに同じ大きさの配列や文字列を作る

ファイルを読み込むとき、(unsigned-byte 8)の配列や文字列をバッファとしてデータを読み込むことは良くありますが、ファイルの大きさと同じ配列や文字列を作って一気にデータを読み込もうとした場合、大きさがarray-total-size-limit以上になってしまい、配列や文字列を作ることができないことがあります。

これは32ビットの環境で主に問題になります。CLHS: Constant Variable ARRAY-TOTAL-SIZE-LIMITから引用しますが、array-total-size-limitは

A positive fixnum, the exact magnitude of which is implementation-dependent, but which is not less than 1024.

と決められているので、文字列を含むすべての配列はfixnum以上の大きさにはできません。

32ビットの環境でのfixnumの上限は2^29 - 1のことが多いので、(unsigned-byte 8)の配列や文字列に単純にデータを読み込む場合、多くの処理系では512MB以上の大きさのファイルは扱えません

また、array-total-size-limitは1024以上の正のfixnumであれば良く、fixnumの上限でなければいけないとは決められていないので、もっと小さいサイズの配列しか扱えない場合もあります。実際、32ビット環境のClozure CLでのarray-total-size-limitは2^24で、工夫をしないと16MB以上の大きさのファイルを読み込めません。(制限を回避する方法はいくつかありますが、どれもデータの操作が難しくなります)


不要なオブジェクトのコピーをする

例えば、subseqで部分文字列を取り出すとします。

(subseq "abcdef" 2 4)   ;=> "cd"

ここで、返される文字列に破壊的な変更をしたくなったとき、元の文字列に影響することを恐れて、文字列をcopy-seqでコピーしたくなるかもしれません。

ですが、それは不要な作業です。

CLHS: Accessor SUBSEQに書かれている通り、

subseq always allocates a new sequence for a result; it never shares storage with an old sequence. The result subsequence is always of the same type as sequence.

subseqから返される文字列は元の部分文字列のコピーだと決められていています。そのため、破壊的な変更をする場合でもコピーの必要はありません。

;; 二重にコピーすることになって効率的ではない
(let* ((sub (copy-seq (subseq "abcdef" 2 4))))
  (nstring-upcase sub))

Common Lispの関数では、こういった、明確にコピーを返すように動作が決められていることがありますので、効率の良いプログラムを書くために知っておくと良いでしょう。


maphashで戻り値を期待する

maphashは常にnilを返します。map系の関数はどれも意味のある値を返すため、それを期待してはまる人が多いようです。

;; ((:X . 0) (:Y . 1) (:Z . 2))が返ってきたりはしない
(let ((h (make-hash-table)))
  (setf (gethash :x h) 0)
  (setf (gethash :y h) 1)
  (setf (gethash :z h) 2)
  (maphash (lambda (k v)
             (format t "~a: ~a~%" k v)
             (cons k v))
           h))
;=> NIL
;-> Z: 2
;   Y: 1
;   X: 0

ハッシュテーブルの値を加工して返したい場合は、変数を介してmaphashの外に値を持ち出すか、loopを利用するのが定番です。

(let (r (h (make-hash-table)))
  (setf (gethash :x h) 0)
  (setf (gethash :y h) 1)
  (setf (gethash :z h) 2)
  (maphash (lambda (k v) (push (cons k v) r)) h)
  r)
;=> ((:X . 0) (:Y . 1) (:Z . 2))

(let ((h (make-hash-table)))
  (setf (gethash :x h) 0)
  (setf (gethash :y h) 1)
  (setf (gethash :z h) 2)
  (loop for k being each hash-key in h using (hash-value v) collect (cons k v)))
;=> ((:Z . 2) (:Y . 1) (:X . 0))

inline宣言の位置

グローバルな関数をインライン展開させたい場合、関数定義より前inline宣言をしておかなければなりません。CLHS: Declaration INLINE, NOTINLINEでは

The inline proclamation preceding the defun form ensures that the compiler has the opportunity save the information necessary for inline expansion, and the notinline proclamation following the defun form prevents f from being expanded inline everywhere.

と書かれていて、inline宣言が関数定義より前にある場合しかインライン展開を保証していません。Common Lisp the Language, 2nd Edition9.2. Declaration Specifiersにも、

Looking at it the other way, the compiler is not required to save function definitions against the possibility of future expansions unless the functions have already been proclaimed to be inline.

と書かれています。同節に例があるので引用します。

(defun huey (x) (+ x 100))         ; コンパイラはこれを覚えておく必要はない
(declaim (inline huey dewey))
(defun dewey (y) (huey (sqrt y)))  ; おそらくhueyの呼び出しは展開されない
(defun louie (z) (dewey (/ z)))    ; おそらくdeweyの呼び出しは展開される

ローカルな関数をインライン展開させたい場合、fletlabelsの本体部分の前でinline宣言をします。

(flet ((f (x y) (+ x y)))
  (declare (inline f))
  (f 1 2))

この場合、他のグローバルな関数に影響を与えてしまうので、トップレベルでinline宣言をしてはいけません。


ライブラリのトップレベルでの最適化宣言

トップレベルにdeclaimproclaimによる最適化宣言があるファイルをロードすると、処理系の最適化フラグが変更されます。特定のファイルに対して最適化をかけようとして、そのファイルのトップレベルで最適化宣言をしてしまうと、利用者が知らないうちにシステム全体に対する最適化の設定が変わり、混乱させてしまうかもしれません。

特定の関数だけ最適化フラグを変更したい場合にはdeclarelocallyを使います。

単に関数ごとにdeclareを使って最適化フラグを設定すると、デバッグやテストのときに設定を変更する手間がかかります。そこで、あらかじめ最適化フラグをdefvarなどで定義しておき、それぞれの関数のdeclareではそれを使うようにすると便利です。

;; 最適化フラグを決めておき
(defvar *optimize-flags* '(optimize speed (debug 0) (safety 0)))

;; リードするときに置き換える
(defun f ()
  (declare #.*optimize-flags*)
  ...)

これはCL-PPCREなどで使われている手法です。実際に使われている様子を見たい場合はCL-PPCREのコードを読んでみると良いでしょう。

locallyを使う場合はより単純です。locallyの内部のコードは指定した最適化フラグを使ってコンパイルされるようになります。

(locally (declare (optimize speed (debug 0) (safety 0)))
  ...)

参考資料


Last modified : 2012/12/07 06:04:20 JST
CC0 1.0
Powerd by WiLiKi 0.6.1 on Gauche 0.9