[小ネタ]Pythonのループ内でリストの要素を削除する時の注意点

Pythonのループ内でリストの要素をそのままremoveすると要素が1つ飛ばしで処理されちゃう話
2019.03.03

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

こんにちは、サーバーレス開発部の岡です。 普段Pythonを使っている人であれば当たり前の仕様かもしれませんが、Pythonでリストの要素をループ内で削除するときは少し工夫が必要です。 初心者だとハマりどころかと思うので、メモとして残しておきます。

ループ内で削除してみる

data = [0,1,2,3,4,5,6,7,8,9]

for i in data:
    data.remove(i)

期待する結果としては空のリストです。 ですが結果を見てみると、

print(data)
// [1, 3, 5, 7, 9]

なぜか奇数が残りました。 一瞬バグかと思いますがPythonの仕様です。

公式ドキュメントを見てみると、以下のように書かれていました。

注釈 ループ中でのシーケンスの変更には微妙な問題があります (これはミュータブルなシーケンスのみ、例えばリストで起こり得ます)。 どの要素が次に使われるかを追跡するために、内部的なカウンタが使われており、このカウンタは反復のたびに加算されます。 このカウンタがシーケンスの長さに達すると、ループは終了します。このことから、スイートの中でシーケンスから現在の (または以前の) 要素を除去すると、(次の要素の位置が、既に処理済みの現在の要素のインデックスになるために) 次の要素が飛ばされることになります。 同様に、スイートの中でシーケンス中の現在の要素以前に要素を挿入すると、現在の要素がループの次の週で再度扱われることになります。 こうした仕様は、厄介なバグにつながります。 これは、シーケンス全体のスライスを使って一時的なコピーを作ることで避けられます。

どうやら、リスト内で要素を削除すると次の要素が飛ばされてしまうようです。 なので奇数が残ったんですね。

リストのコピーは以下の書き方で作れるようです。

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

先程の動作を再度確認してみます。

data = [0,1,2,3,4,5,6,7,8,9]
for i in data[:]:
    data.remove(i)
print(data)
// []

想定通りの挙動になりました。

removeより内包表記を使おう

データを取り出したいだけであれば内包表記の方が速度的にも有利でバグも生まれにくいです。 同じオブジェクトを操作したいケースでなければ、内包表記か別のリストを作ってappendしましょう。

参考

Python公式ドキュメント