2025, Sep 24 11:18

Стабилен ли порядок итерации множества set в Python в одном запуске

Разбираем, стабилен ли порядок итерации set в Python в пределах одного запуска, почему он меняется между запусками и как закрепить порядок через PYTHONHASHSEED.

Множества в Python — изменяемые и по определению неупорядоченные. Это общеизвестно, но на практике часто возникает вопрос: если в рамках одного запуска программы пройтись по множеству дважды, не изменяя его между проходами, может ли порядок итерации «съехать» из‑за внутреннего перехеширования или какой‑нибудь скрытой магии аллокатора? Иными словами, стабилен ли порядок обхода неизменённого множества в пределах одного исполнения?

Воспроизводим проблему

Ниже — минимальный пример. Создаём множество, фиксируем результат первого прохода и позже проверяем, даёт ли второй проход ту же последовательность.

bag = {1, 4, 3, 5}
snapshot = [x for x in bag]
# другая логика, которая не трогает множество
assert [x for x in bag] == snapshot

Что происходит на самом деле

Согласно глоссарию Python, хешируемый объект имеет хеш‑значение, которое не меняется на протяжении его жизни. Документация стандартной библиотеки для set говорит, что элементы множества должны быть хешируемыми. В совокупности это означает следующее: порядок обхода множества определяется расположением элементов в хеш‑таблице, сформированным по их хешам, а хеши этих объектов не меняются. Следовательно, неизменяемое в течение запуска множество интерпретатор не будет ни перехешировать, ни перераспределять внутри, и порядок итерации останется тем же в пределах этого исполнения.

Списки сохраняют порядок вставки. Когда вы преобразуете множество в список, список отражает порядок, который в этот момент выдаёт множество; если позже — при неизменном множестве — повторить преобразование, порядок в списке в рамках того же запуска будет таким же.

Практический подход

Если вашему коду нужен стабильный снимок порядка обхода множества в пределах одного запуска, сделайте снимок и используйте его повторно. Утверждение ниже будет истинным, пока множество не меняется.

pool = {1, 4, 3, 5}
first_pass = list(pool)
# посторонняя работа, которая не изменяет `pool`
assert list(pool) == first_pass

О различиях между запусками

Между отдельными запусками интерпретатора Python рандомизирует хеши. Поэтому порядок итерации для одного и того же набора значений может отличаться от запуска к запуску. Это видно, если сформировать список, отфильтрованный по хешу объекта, затем преобразовать его в множество, а после — обратно в список. Список сохраняет порядок множества, и этот порядок может различаться между запусками из‑за рандомизации хешей и коллизий хеша.

for i in 1 2 3 4; do
  python3.12 -c 'import string as alpha; seq = [f"ba{ch}" for ch in alpha.ascii_lowercase if hash(f"ba{ch}") % 8 == 2]; print(f"{seq}\n{set(seq)}\n{list(set(seq))}\n")'
done

Результат

['baa', 'bah', 'bai', 'bam', 'bap']
{'baa', 'bap', 'bah', 'bai', 'bam'}
['baa', 'bap', 'bah', 'bai', 'bam']
['bag', 'bai', 'bal', 'bax']
{'bai', 'bag', 'bal', 'bax'}
['bai', 'bag', 'bal', 'bax']
['bae', 'bai', 'baq', 'bax']
{'baq', 'bae', 'bax', 'bai'}
['baq', 'bae', 'bax', 'bai']
['bae', 'bap', 'bax']
{'bae', 'bap', 'bax'}
['bae', 'bap', 'bax']

Если нужен воспроизводимый порядок между запусками, можно отключить рандомизацию хеша, установив PYTHONHASHSEED в постоянное значение. С фиксированным сидом порядок будет одинаковым от запуска к запуску.

for i in 1 2 3 4; do
  PYTHONHASHSEED=1 \
  python3.12 -c 'import string as alpha; seq = [f"ba{ch}" for ch in alpha.ascii_lowercase if hash(f"ba{ch}") % 8 == 2]; print(f"{seq}\n{set(seq)}\n{list(set(seq))}\n")'
done

Результат

['bac', 'bah', 'bak', 'baw']
{'bac', 'bak', 'bah', 'baw'}
['bac', 'bak', 'bah', 'baw']
['bac', 'bah', 'bak', 'baw']
{'bac', 'bak', 'bah', 'baw'}
['bac', 'bak', 'bah', 'baw']
['bac', 'bah', 'bak', 'baw']
{'bac', 'bak', 'bah', 'baw'}
['bac', 'bak', 'bah', 'baw']
['bac', 'bah', 'bak', 'baw']
{'bac', 'bak', 'bah', 'baw'}
['bac', 'bak', 'bah', 'baw']

Почему это важно

Понимание поведения итерации множеств помогает избежать скрытых ошибок. В пределах одного запуска, если множество не изменяется, на ранее наблюдавшийся порядок можно опираться. Между разными запусками порядок не переносим, если не отключить рандомизацию хешей. Эта разница критична при написании тестов, сериализации данных, полученных из множеств, или при сравнении результатов нескольких запусков.

Выводы

Если вы обходите множество несколько раз в рамках одного исполнения программы и не мутируете его, порядок останется стабильным в течение этого запуска. Списки сохраняют порядок, полученный от множества на момент преобразования, поэтому достаточно один раз сделать снимок и использовать его дальше. Между отдельными запусками ожидайте расхождений, потому что Python рандомизирует хеши; если нужен воспроизводимый порядок между запусками для отладки или тестирования, установите PYTHONHASHSEED в постоянное значение.

Статья основана на вопросе с StackOverflow от SLebedev777 и ответе LMC.