y2q_actionman’s ゴミクズチラ裏

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

今日のバグ (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))) ; 確実に二つの値を渡す

まとめ

勝手に仕様を思い込むと困った目にあう。