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
に代入し、変数 b
に a
を代入。その後に 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
参考
対処法
なんでこんなこと知っているのかというと、何度もハマったからである。これが原因のバグを何度見たことか。。。
たとえば、作為的な例だが
{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:なので作為的な例なのだが。。。