セットを繰り返し処理しながらセットに追加したり、セットから削除したりすると、なぜこれだけ多くの反復が行われるのですか?


62

Pythonのforループを理解しようとすると、{1}1回の反復で結果が得られるか、Cや他の言語のように反復を行うかによって、無限ループに陥るだろうと思いました。しかし、実際にはどちらもしませんでした。

>>> s = {0}
>>> for i in s:
...     s.add(i + 1)
...     s.remove(i)
...
>>> print(s)
{16}

なぜ16回反復するのですか?結果は{16}どこから来ますか?

これはPython 3.8.2を使用していました。pypyでは期待どおりの結果になり{1}ます。


17
追加する項目に応じて、への各呼び出しs.add(i+1)(および場合によってはへの呼び出しs.remove(i))は、セットの反復順序を変更し、forループが作成したセットイテレータが次に何を参照するかに影響を与える可能性があります。イテレータがアクティブな間は、オブジェクトを変更しないでください。
chepner

6
私も気づいt = {16}、次いでt.add(15)tは集合{16、15}であることをもたらします。問題はどこかにあると思います。

19
これは実装の詳細です。16は15よりもハッシュが低い(これは@Anonが気づいたことです)。そのため、セットの種類に16を追加すると、イテレータの「すでに見た」部分に追加され、イテレータが使い果たされました。
Błotosmętek

1
de docsを読んだ場合、ループ中にイテレータを変更するとバグが発生する可能性があるという注意書きがあります。参照:docs.python.org/3.7/reference/...
マルチェロ・ファブリツィオ

3
@Błotosmętek:CPython 3.8.2では、hash(16)== 16およびhash(15)==15。この動作は、ハッシュ自体が低いことに起因するものではありません。要素は、ハッシュ順でセットに直接格納されません。
user2357112はモニカ

回答:


87

Pythonは、(もしあれば)このループがいつ終了するかについては何も約束しません。反復中にセットを変更すると、要素のスキップ、要素の繰り返し、およびその他の奇妙さが発生する可能性があります。そのような振る舞いに決して頼らないでください。

私が言おうとしていることはすべて実装の詳細であり、予告なく変更されることがあります。いずれかに依存するプログラムを記述した場合、プログラムは、Python実装とCPython 3.8.2以外のバージョンの任意の組み合わせで機能しなくなる可能性があります。

ループが16で終了する理由の簡単な説明は、16が前の要素よりも低いハッシュテーブルインデックスに偶然に配置される最初の要素であるということです。完全な説明は以下のとおりです。


Pythonセットの内部ハッシュテーブルは常に2のべき乗のサイズを持ちます。サイズ2 ^ nのテーブルの場合、衝突が発生しなければ、要素はハッシュテーブルの、ハッシュの最下位ビットnに対応する位置に格納されます。あなたはこれが実装されているのを見ることができますset_add_entry

mask = so->mask;
i = (size_t)hash & mask;

entry = &so->table[i];
if (entry->key == NULL)
    goto found_unused;

ほとんどの小さなPython intはそれ自体にハッシュします。特に、テスト内のすべてのintはそれ自体にハッシュされます。これはで実装されていlong_hashます。セットには、ハッシュに等しい下位ビットを持つ2つの要素が含まれないため、衝突は発生しません。


Pythonセットイテレータは、セットの内部ハッシュテーブルへの単純な整数インデックスを使用して、セット内の位置を追跡します。イテレーターは、次の要素が要求されると、そのインデックスから始まるハッシュテーブル内の入力済みのエントリを検索し、格納されているインデックスを見つかったエントリの直後に設定して、エントリの要素を返します。あなたはこれを見ることができますsetiter_iternext

while (i <= mask && (entry[i].key == NULL || entry[i].key == dummy))
    i++;
si->si_pos = i+1;
if (i > mask)
    goto fail;
si->len--;
key = entry[i].key;
Py_INCREF(key);
return key;

セットは最初、サイズ8のハッシュテーブルと、ハッシュテーブルの0インデックス0にあるintオブジェクトへのポインターで始まります。イテレータもインデックス0に配置されます。反復処理を行うと、要素がハッシュテーブルに追加されます。要素は次のインデックスに追加されます。これは、ハッシュによって要素が配置されるためです。これが常にイテレータが参照する次のインデックスです。削除された要素には、衝突解決のために、古い位置にダミーマーカーが保存されています。で実装されていることがわかりますset_discard_entry

entry = set_lookkey(so, key, hash);
if (entry == NULL)
    return -1;
if (entry->key == NULL)
    return DISCARD_NOTFOUND;
old_key = entry->key;
entry->key = dummy;
entry->hash = -1;
so->used--;
Py_DECREF(old_key);
return DISCARD_FOUND;

場合4セットに追加され、セットの要素とダミーの数は、その十分に高いなるset_add_entry呼び出し、トリガーがハッシュテーブルの再構築しますset_table_resize

if ((size_t)so->fill*5 < mask*3)
    return 0;
return set_table_resize(so, so->used>50000 ? so->used*2 : so->used*4);

so->usedハッシュテーブル内のデータが入力されたダミーでないエントリの数です。これは2であるためset_table_resize、2番目の引数として8を受け取ります。これに基づいて、新しいハッシュテーブルのサイズを16にする必要があるとset_table_resize 判断します。

/* Find the smallest table size > minused. */
/* XXX speed-up with intrinsics */
size_t newsize = PySet_MINSIZE;
while (newsize <= (size_t)minused) {
    newsize <<= 1; // The largest possible value is PY_SSIZE_T_MAX + 1.
}

サイズ16のハッシュテーブルを再構築します。すべての要素は、ハッシュに上位ビットが設定されていないため、新しいハッシュテーブルの古いインデックスのままになります。

ループが続くと、要素はイテレータが参照する次のインデックスに配置され続けます。別のハッシュテーブルの再構築がトリガーされますが、新しいサイズは16のままです。

ループが要素として16を追加すると、パターンが壊れます。新しい要素を配置するインデックス16はありません。16の最下位4ビットは0000で、インデックス0に16を置きます。この時点でイテレータの保存されたインデックスは16であり、ループがイテレータから次の要素を要求すると、イテレータはそれがハッシュ表。

イテレータはこの時点でループを終了16し、セットにのみ残します。


14

これは、Pythonでのセットの実際の実装と関係があると思います。セットはアイテムを格納するためにハッシュテーブルを使用するため、セットを反復することはそのハッシュテーブルの行を反復することを意味します。

アイテムを繰り返してセットに追加すると、新しいハッシュが作成されてハッシュテーブルに追加され、16に到達します。この時点で、次の数値は実際にはハッシュテーブルの最後ではなく先頭に追加されます。そして、すでにテーブルの最初の行を反復したので、反復ループは終了します。

私の答えはこれに似た質問の1つに基づいており、実際にはこのまったく同じ例を示しています。詳しくはこちらをお読みください。


5

Python 3のドキュメントから:

同じコレクションを反復しながらコレクションを変更するコードは、正しく行うのが難しい場合があります。代わりに、通常、コレクションのコピーをループするか、新しいコレクションを作成する方が簡単です。

コピーを反復する

s = {0}
s2 = s.copy()
for i in s2:
     s.add(i + 1)
     s.remove(i)

1回だけ繰り返すべきです

>>> print(s)
{1}
>>> print(s2)
{0}

編集:この反復の考えられる理由は、セットが順序付けされていないため、ある種のスタックトレースのようなものを引き起こすためです。リストではなくリストでそれを行う場合s = [1]、リストは順序付けられているため、ループはインデックス0で始まり、次のインデックスに移動して、リストがないことがわかります。ループを終了します。


はい。しかし、私の質問は、なぜそれが16回の反復を行うのかです。
noobオーバーフロー

セットは順不同です。辞書とセットは、ランダムでない順序で反復します。反復するこのアルゴリズムは、何も変更しない場合にのみ保持されます。リストとタプルの場合、インデックスで反復するだけです。3.7.2でコードを試したところ、8回繰り返しました。
エリックジン

他の人が述べたように、反復順序はおそらくハッシュに関係しています
Eric Jin

1
「ある種のスタックトレースのソートを引き起こす」とはどういう意味ですか?コードによってクラッシュやエラーが発生しなかったため、スタックトレースは表示されませんでした。Pythonでスタックトレースを有効にするにはどうすればよいですか?
noobオーバーフロー

1

Pythonは、要素の位置や挿入の順序を記録しない、順序付けられていないコレクションを設定しました。Pythonセットのどの要素にもインデックスがアタッチされていません。そのため、インデックス作成やスライスの操作はサポートされていません。

したがって、forループが定義された順序で機能することを期待しないでください。

なぜ16回反復するのですか?

user2357112 supports Monicaすでに主な原因を説明しています。ここに、別の考え方があります。

s = {0}
for i in s:
     s.add(i + 1)
     print(s)
     s.remove(i)
print(s)

このコードを実行すると、次のように出力されます。

{0, 1}                                                                                                                               
{1, 2}                                                                                                                               
{2, 3}                                                                                                                               
{3, 4}                                                                                                                               
{4, 5}                                                                                                                               
{5, 6}                                                                                                                               
{6, 7}                                                                                                                               
{7, 8}
{8, 9}                                                                                                                               
{9, 10}                                                                                                                              
{10, 11}                                                                                                                             
{11, 12}                                                                                                                             
{12, 13}                                                                                                                             
{13, 14}                                                                                                                             
{14, 15}                                                                                                                             
{16, 15}                                                                                                                             
{16}       

ループやセットの印刷など、すべての要素に一緒にアクセスする場合、セット全体をトラバースするには、事前定義された順序が必要です。したがって、最後の反復では、順序がから{i,i+1}に変更されていることがわかります{i+1,i}

最後の反復の後、i+1すでにトラバースされているため、ループが終了しました。

興味深い事実: 6と7以外の16未満の値を使用すると、常に結果16が得られます。


「16未満の値を使用すると、常に結果16が得られます。」-6または7で試してみてください。そうでないとわかります。
user2357112はモニカ

@ user2357112はモニカをサポートしています。更新しました。ありがとう
エクラビア
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.