今日のバグ (multiple value 関連)
またまた、 Common Lisp を書いていて、つまらない refactoring を経て間抜けなバグを引き起こしてしまいました。 自戒を込めて 記事にしてみます。
ストーリー
元々のコード
三つの関数で構成されたコードがありました。 それぞれの役割は以下のようになっています:
foo
: 値を二つ返す。bar
: 二つの引数をとって何かする。(下記の例では印字)baz
:foo
が返した値をbar
に渡す。
今回は、以下の簡易な例で見ていきます:
(in-package :cl-user) (defun foo (n) ; 値を二つ返す。 (values n (1+ n))) (defun bar (x y) ; 二つの値をとって印字する。 (format t "~A, ~A~%" x y)) (defun baz (n) ; `foo` が返した値を `bar` に渡す。 (multiple-value-bind (x y) (foo n) (bar x y)))
この baz
を使うと、以下のような結果になります。
CL-USER> (baz 0) 0, 1 NIL CL-USER> (baz 1) 1, 2 NIL
仕様変更が入った
このあと、 foo
関数に仕様変更が入りました。引数を確認し、所定の条件が満たされた時にだけ値を計算するようにすることになりました。
条件が満たされたときだけ実行するのは、 when
で囲うだけなので簡単です。
ここの簡易コードでは、引数が正であったとき (plusp
のとき)という条件とし、その時にだけ実行するようにしてみます。
(defun foo (n) (when (plusp n) ; 引数が正のときにだけ値を返すようにした。 (values n (1+ n))))
実行してみると、 (baz 0)
と呼び出した時には NIL, NIL
と印字されていることがわかります。
CL-USER> (baz 1) 1, 2 NIL CL-USER> (baz 0) NIL, NIL NIL
平行してリファクタリングしていた
一方、平行してリファクタリングも行なっていました。
baz
関数は、二つの値を拾い、それをそのまま使って二つの値で関数を呼んでいるだけです。
二つの値を渡すためだけに変数を導入していますが、長ったらしく思っていました。
そこで、 multiple-value-call
を使って短くすることを試みました。この特殊オペレータは、第二引数以降を評価して得られた多値をかきあつめ、第一引数の関数を呼んでくれます。 上記のコードでmultiple-value-bind
を使って手動でやっていたことをそのままやってくれます。
(defun baz (n) (multiple-value-call #'bar (foo n)))
書き換えの前後で、挙動も当初と変わっていないようでした。
CL-USER> (baz 1) 1, 2 NIL CL-USER> (baz 0) 0, 1 NIL
ところがマージすると
この二つの変更をマージして取り込みました。すると、以下の呼び出しは想定通りに動きます:
CL-USER> (baz 1) 1, 2 NIL
しかし、以下の呼び出しはエラーになってしまいました:
CL-USER> (baz 0) invalid number of arguments: 1 [Condition of type SB-INT:SIMPLE-PROGRAM-ERROR]
何が起こったか?
返す値の個数が変わるようになってしまった
「仕様変更」によって書き換えられた以下のコードを見ると:
(defun foo (n) (when (plusp n) ; 引数が正のときにだけ値を返すようにした。 (values n (1+ n))))
このコードは n
が正の時は二つ値を返しますが、 n
が0以下の時には NIL
を一つだけ返します。
CL-USER> (foo 1) 1 2 CL-USER> (foo 0) NIL
このように、引数に応じて返す値の個数が変わるようになってしまいました。
返す値の数の影響を受けるようにしてしまった
返す値の個数は変わってしまいましたが、 multiple-value-bind
を使っている場合はこれが顕在化することはありません。
multiple-value-bind
は、受け取った値の個数が足らない場合、不足する部分を NIL
で埋めてくれるためです:
CL-USER> (multiple-value-bind (x y) (values 100) (list x y)) (100 NIL)
しかし、 multiple-value-call
は受け取った値をそのまま関数に渡します。値の個数もそのまま反映されます:
CL-USER> (multiple-value-call #'list (values 100)) (100)
今回、 baz
関数を以下のように書き換えてしまいました。
(defun baz (n) (multiple-value-call #'bar (foo n)))
bar
関数にはちょうと二つの値を渡さなければいけませんが、上の書き方だと bar
関数に渡す値の個数は (foo n)
の箇所が返す値の個数に依存しています。そして、foo
関数は引数に依存して渡す値の個数が二つでは無くなってしまうので・・「引数の数が違うよ」エラーが出てしまうことになりました。
どうすればよかったか
返す値の数は一定にしたほうが安心かも
引数に応じて返す値の個数が変わると、上のように困ることが たまに あります。個数は揃えておいたほうがいいかもしれません。
foo
関数は、以下のように書いておけば少し安心でしょうか:
(defun foo (n) (if (plusp n) (values n (1+ n)) (values nil nil)))
必須パラメータに渡す値は明示的に渡した方がよい
multiple-value-call
を使った結果、必須パラメータが足らなくなってしまったことが直接のエラーの原因でした。
必須パラメータは multiple-value-bind
で取得して明示的に渡した方がよいと思います。
multiple-value-call
で可変個の値を渡すのは、 &optional
や &rest
で可変個と指定したパラメータに限定した方がよさそうです。
この方針に従うと、今回は baz
関数の書き換えはすべきではありませんでした。元のままの方がよかったでしょう:
(defun baz (n) (multiple-value-bind (x y) (bar n) (foo x y))) ; 確実に二つの値を渡す
まとめ
勝手に仕様を思い込むと困った目にあう。