静かなる名辞

pythonとプログラミングのこと

【python】listをforループで回してremoveしたら思い通りにならない


 pythonプログラミングを始めたばかりの人がよくハマるネタです。日本語Web圏にはイマイチよくまとまった記事がないようなので、まとめておきます。

問題の概要

 たとえば、0から9のリストから偶数だけ取り出そうとして、こんなコードを書いてみます。

>>> lst = [0,1,2,3,4,5,6,7,8,9]
>>> for x in lst:
...     if x%2 != 0:
...         lst.remove(x)
... 
>>> lst
[0, 2, 4, 6, 8]

 一見すると上手く動いているようです。調子に乗って、今度は3の倍数を取り出そうとしてみます。

>>> lst = [0,1,2,3,4,5,6,7,8,9]
>>> for x in lst:
...     if x%3 != 0:
...         lst.remove(x)
... 
>>> lst
[0, 2, 3, 5, 6, 8, 9]

 おかしくなった。なぜでしょう? forがちゃんと動いていない? という感じで、ハマります。

原因

 こういうときはforのループごとにxに代入されている値をprintしてみると、どんなことになっているのかよくわかります。

>>> lst = [0,1,2,3,4,5,6,7,8,9]
>>> for x in lst:
...     print(x)
...     if x%2 != 0:
...         lst.remove(x)
... 
0
1
3
5
7
9
>>> lst
[0, 2, 4, 6, 8]
>>> lst = [0,1,2,3,4,5,6,7,8,9]
>>> for x in lst:
...     print(x)
...     if x%3 != 0:
...         lst.remove(x)
... 
0
1
3
4
6
7
9
>>> lst
[0, 2, 3, 5, 6, 8, 9]

 なんてことでしょう、ちゃんと動いていない!

 ・・・これはドキュメントにも書いてある、pythonのれっきとした仕様です。

注釈 ループ中でのシーケンスの変更には微妙な問題があります (これはミュータブルなシーケンス、すなわちリストなどでのみ起こります)。どの要素が次に使われるかを追跡するために、内部的なカウンタが使われており、このカウンタは反復のたびに加算されます。このカウンタがシーケンスの長さに達すると、ループは終了します。このことから、スイート中でシーケンスから現在の (または以前の) 要素を除去すると、(次の要素のインデクスは、すでに取り扱った現在の要素のインデクスになるために) 次の要素が飛ばされることになります。(※筆者強調) 同様に、スイート中でシーケンス中の現在の要素以前に要素を挿入すると、現在の要素がループの次の週で再度扱われることになります。こうした仕様は、厄介なバグにつながります。

8. 複合文 (compound statement) — Python 3.6.5 ドキュメント

 インタプリタの中では、カウンタで管理しているんですね。それが原因です。

回避策

 とりあえず、公式ドキュメントにはこのような方法が記載されています。

for x in a[:]:
    if x < 0: a.remove(x)

 ここで[:]というのは範囲指定なしのスライスです。これはこのように機能します。

>>> lst = [0,1,2,3,4,5,6,7,8,9]
>>> lst[:]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

 まったく無意味な気がしますが、実は両者は別のオブジェクトになっています。id()関数で確認してみます。

>>> id(lst)
140603063055816
>>> id(lst[:])
140603063055048

 つまり、[:]は中身の同じコピーを作ることができます。こうすればループの対象のリストは変更されないので、問題なくループを回せるという訳ですね。

 でもこういうコードはちょっとかっこ悪いので、内包表記を使った方が基本的にはベターです。

>>> lst = [0,1,2,3,4,5,6,7,8,9]
>>> [x for x in lst if x%3 == 0]  # 条件の反転に注意(残すものの条件を指定する)
[0, 3, 6, 9]
>>> lst  # 上のコードは新しいリストを作る。元のリストは変わらない
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> lst = [x for x in lst if x%3 == 0]  # 再代入するとlstの値が変わる(ただし別のオブジェクトになる)
>>> lst
[0, 3, 6, 9]

 どうしても同じオブジェクトをいじらないといけない、というシチュエーションも往々にしてありますが、そうでなければ内包表記などを使って新しくリストを作る、という発想で書いた方が簡単ですし、へんなバグも生みません。

 余談ですが、pythonではlist.remove()はあまり使わないメソッドです。他にもlist.pop()やlist.insert()などリストを操作するメソッドはたくさんありますが、これらをforループと組み合わせて書くような操作は、大抵の場合は内包表記などで代用できます。そして、その方が元のリストを壊さないので、バグが発生する余地が減ります*1*2

 なので、初心者の方はあまりこういったものに頼らず、まずは内包表記から覚えるか、内包表記がとっつきづらければ空listにappendしていく方法を使うのが良いと思います。

>>> lst = [0,1,2,3,4,5,6,7,8,9]
>>> result = []
>>> for x in lst:
...     if x%3 == 0:
...         result.append(x)
... 
>>> result
[0, 3, 6, 9]

 これはappendで書く場合の例です。実はリスト内包表記とほとんど同じようなことをやっているのですが、最初はこちらの方が読みやすいかもしれません。

まとめ

 pythonってけっこう直感的じゃない仕様があるので、「なんで!?」と思うこともままありますね。でも、どうせ慣れれば、そういう仕様は使わないで済ませられるようになってくるので、大丈夫です*3

 ある程度慣れるまでは、「listをforループで回すときは、回しているlist自体はいじらないで処理する」ことを心がけると気が楽だと思います。

*1:この考え方はけっこう重要です。こういうオブジェクトの状態を変更する操作を破壊的操作といいますが、これはよく把握していないとわかりづらいバグを生みやすいです

*2:他にも、特にlist.remove()はけっこうコストが高い(該当する要素が見つかるまで線形探索する)という理由があり、嫌われがちなメソッドです

*3:むしろプログラムの難読化に応用する人がいそうな気もする