【Python】辞書をループ中に削除してエラーになるときの直し方

PythonのTopに戻る


辞書をforで走査している途中にキーを削除すると、RuntimeErrorになることがある。不要な項目を除くときは、条件に合う項目から新しい辞書を作るか、削除対象のキーを先に集める。元の辞書を参照している他の処理にも変更を見せたいかどうかで、この二つを使い分ける。

走査対象を途中で変えない

辞書のitems()やkeys()は、その時点の内容をすべてコピーしたものではなく、辞書に結び付いたビューである。走査中にキーを削除・追加すると、次にどの項目を読むべきかという前提が崩れる。小さな例で動いたように見えても、途中で構造を変える書き方を基本にしない方がよい。

以下では、測定件数が0の試料だけを除く。同じ条件で三つの方法を比較するため、例ごとに新しい辞書を用意している。最初のtryは失敗を再現するためのものであり、例外を握りつぶして処理を続ける方法を勧めているわけではない。

失敗例と二つの修正方法

example.py

data = {"A": 2, "B": 0, "C": 3}
try:
    for key, count in data.items():
        if count == 0:
            del data[key]
except RuntimeError as exc:
    print(type(exc).__name__)
else:
    raise AssertionError("Expected an iteration error")

original = {"A": 2, "B": 0, "C": 3}
keep = {key: count for key, count in original.items() if count != 0}
print("new:", keep)
assert original == {"A": 2, "B": 0, "C": 3}
assert keep is not original

data = original.copy()
alias = data
targets = [key for key, count in data.items() if count == 0]
for key in targets:
    del data[key]
print("in place:", alias)
assert data == keep and alias is data

for key in data:
    data[key] *= 2
print("values:", data)
assert data == {"A": 4, "C": 6}
for source in ({}, {"A": 0}, {"A": 1}):
    result = {k: v for k, v in source.items() if v != 0}
    assert all(v != 0 for v in result.values())

実行結果

RuntimeError
new: {'A': 2, 'C': 3}
in place: {'A': 2, 'C': 3}
values: {'A': 4, 'C': 6}

keepは条件を満たす組だけを新しい辞書へ集める。削除方式では、targetsという独立したリストを作り終えてからdataを変更する。走査対象と変更対象が分かれているので、辞書のサイズが変わっても反復は乱れない。

元を残すなら辞書内包表記

データを絞り込んだ結果だけが欲しいときは、辞書内包表記が読みやすい。条件を「残す項目」として書くため、削除対象の否定を何重にも追う必要がない。元の辞書と結果を比較できるので、集計前後の件数を確認したい場合にも扱いやすい。

ただし、新しい辞書は浅い入れ物である。値がリストや辞書なら、その中身は元と共有される。結果の値を書き換えて元まで変わることを避けたい場合には、必要な深さのコピーも併せて考える必要がある。今回は整数だけなので、内側の可変データは存在しない。

data = { … }と変数へ再代入しても、他の変数が参照している以前の辞書は変わらない。例えば関数内で新しい辞書を作った場合には、それを戻り値として返す。呼び出し元の辞書が自動で入れ替わったと考えないようにしたい。

同じ辞書を更新するなら削除キーを集める

外部のコードが同じ辞書を持っており、そこで変更後の状態を見せる必要があるなら、キーを集めてからdelで削除する。この方法は辞書そのものを置き換えない。コード中のaliasがdataと同じオブジェクトのままなのは、その確認である。

list(data)を作ってキーを一通り走査する方法でもよいが、削除する項目が少ない場合は対象キーだけを集める方が意図が明確になる。どちらも追加のリストを作るため、巨大な辞書では一時的なメモリ量も考える。処理が安全になる代わりに、コピーが無料になるわけではない。

削除リストを作ってから実際に消すまでの間に別の処理が同じ辞書を変更するなら、キーがなくなっていることもある。この単一スレッドの例では起こらないが、共有状態では別途同期が必要である。data.pop(key, None)で例外だけを消しても、競合そのものは解決しない。

値の更新とキーの増減を区別する

既存キーの値だけを入れ替える操作は、キーの追加・削除とは区別できる。最後の例はキーを保ったまま整数値を2倍にしている。条件判定中に新しいキーを作ったり、別の関数が削除したりしていないかも含めて確認したい。

途中で失敗した辞書は、すでに一部が変更されている可能性がある。その辞書を再利用してやり直すと、最初と違う入力を処理することになる。失敗例を試すときは、元データを用意し直してから比較する。例外が発生すれば変更がすべて元へ戻る、という仕組みではない。

「同じ辞書を保つ必要があるか」を最初に決めると選択が簡単になる。通常の抽出処理なら新しい辞書、共有している管理表の整理なら対象キーの先行収集を選び、空の辞書・全件削除・削除なしも確認しておくと安心である。

動作確認と参考資料

掲載例はLinux・CPython 3.12.14で動作確認した。OS固有のコマンドや環境ごとに変わるパスは、本文中の条件を確認して使ってほしい。

関連するTips


PythonのTopに戻る