Diary over Finite Fields

515ひかるの日記と雑文

自分なりに整理してみた: Pythonの mutable オブジェクトの罠

Python の話

最近Pythonの「不可解な挙動」として類似のツイートを2つ見かけた。なので別に反論したいわけでもないのだが、ここに書いておこうと思う。

Python 3.6.2 (default, Jul 20 2017, 03:52:27)
[GCC 7.1.1 20170630] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> a = 1
>>> b = a
>>> a += 1
>>> a
2
>>> b
1
>>> assert a != b
>>> c = [1]
>>> d = c
>>> c.append(2)
>>> c
[1, 2]
>>> d
[1, 2]
>>> assert c != d
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError

上の方のコードは整数 1 を変数 a に代入し、変数 ba を代入。その後に a += 1 すると、 a のみが変更され b は変更されない。

一方、リストのほうは c というリストに d を代入したあとで c を変更したが、同時に d にも変更がなされている。

挙動の理由

なぜこのようなことが起きるのか。

まず、Pythonにおいて変数への代入はオブジェクトへの参照である。そのため、数値だろうがリストだろうが次が成り立つ:

>>> a = 1
>>> b = a
>>> a is b
True
>>> c = [1]
>>> d = c
>>> c is d
True

一方で、数値は immutable であるから、変数 a に再代入をするとき a にはもとの a と異なるオブジェクトへの参照がわたされる。したがって、次のようなことが起こる。

>>> a += 1
>>> a is b
False

しかし、リストの append メソッドは異なる。append はリスト c を自体を変更し、オブジェクトの参照先は d と同一のままである。つまり以下の挙動を示す。

>>> c = [1]
>>> d = c
>>> c.append(2)
>>> c is d
True

たとえば append ではなく、c にリストの足し算を行うと異なる挙動を示す。

>>> c = [1]
>>> d = c
>>> # c += [2] だとc.append(2) と同じ挙動を示す
>>> c = c + [2]
>>> c is d
False

参考

stackoverflow.com

対処法

なんでこんなこと知っているのかというと、何度もハマったからである。これが原因のバグを何度見たことか。。。

たとえば、作為的な例だが

{1: [1, 2, 3, 10], 2: [1, 2, 3, 20], 3: [1, 2, 3, 30], 4: [1, 2, 3, 40], 5: [1, 2, 3, 50], 6: [1, 2, 3, 60], 7: [1, 2, 3, 70], 8: [1, 2, 3, 80], 9: [1, 2, 3, 90]}

という辞書を作りたいとする。最初の3つの要素は同じ、最後の1つだけ異なるという例だ。c = [1, 2, 3] として、 for 文の中で tmp 変数 d を作って、最後の i * 10 の部分だけ append しようなどと考えてコードを書いてしまうと。。。

>>> c = [1,2,3]
>>> dic = {}
>>> for i in range(1, 10):
...     d = c
...     d.append(i * 10)
...     dic[i] = d
...
>>> dic
{1: [1, 2, 3, 10, 20, 30, 40, 50, 60, 70, 80, 90], 2: [1, 2, 3, 10, 20, 30, 40, 50, 60, 70, 80, 90], 3: [1, 2, 3, 10, 20, 30, 40, 50, 60, 70, 80, 90], 4: [1, 2, 3, 10, 20, 30, 40, 50, 60, 70, 80, 90], 5: [1, 2, 3, 10, 20, 30, 40, 50, 60, 70, 80, 90], 6: [1, 2, 3, 10, 20, 30, 40, 50, 60, 70, 80, 90], 7: [1, 2, 3, 10, 20, 30, 40, 50, 60, 70, 80, 90], 8: [1, 2, 3, 10, 20, 30, 40, 50, 60, 70, 80, 90], 9: [1, 2, 3, 10, 20, 30, 40, 50, 60, 70, 80, 90]}

と、こんな感じでもともと欲しかったものとは全然違うものになってしまう。

これを避けるためには、ひとつの方法として deepcopy という関数を使うものがある。これはオブジェクトの実体をコピーする関数だ:

>>> from copy import deepcopy
>>> c = [1, 2, 3]
>>> dic = {}
>>> for i in range(1, 10):
...     d = deepcopy(c)
...     d.append(i * 10)
...     dic[i] = d
...
>>> dic
{1: [1, 2, 3, 10], 2: [1, 2, 3, 20], 3: [1, 2, 3, 30], 4: [1, 2, 3, 40], 5: [1, 2, 3, 50], 6: [1, 2, 3, 60], 7: [1, 2, 3, 70], 8: [1, 2, 3, 80], 9: [1, 2, 3, 90]}

しかしこの場合こんなもの使わなくても、内包表記であっさり書いてしまえる*1

>>> {i: [1,2,3, i * 10] for i in range(1, 10)}

結論

  • 不可解な挙動には一応理由があります(受け入れられるか受け入れられないかは別問題です)
  • 当然対処法もあります。リストや辞書の中身が想定と違うな?と思ったらこの類のバグを疑いましょう
  • 内包表記便利です、使いこなしましょう
  • なんか謎の記事を書いてしまったって気分。

*1:なので作為的な例なのだが。。。