dict и хешируемость

Словарь — не просто структура данных, а фундамент языка: на нём стоят неймспейсы, атрибуты объектов, ключевые аргументы и импорт. Из его устройства выводятся и гарантированный порядок ключей, и контракт хеша, и то, почему изменяемый объект теряется в множестве.

L1 · 9 минL2 · 14 мин📱 телефонсверено · CPython 3.11, linux x86-64

1Предскажи

Фрагмент A
d = {"a": 1, "b": 2, "c": 3}
del d["b"]
d["d"] = 4
print(list(d))
['a', 'c', 'd']
Новый ключ встал в конец, а не на освободившееся место.
Фрагмент B
class Point:
    def __init__(self, x): self.x = x
    def __eq__(self, other): return self.x == other.x

s = {Point(1)}
TypeError: unhashable type: 'Point'
Определили только равенство — потеряли хешируемость, которая была.
Фрагмент C
class Box:
    def __init__(self, x): self.items = [x]
    def __hash__(self): return hash(tuple(self.items))
    def __eq__(self, o): return self.items == o.items

b = Box(1)
s = {b}
print(b in s)
b.items.append(2)
print(b in s, any(x is b for x in s))
True
False True
Объект одновременно отсутствует в множестве и находится в нём.
Фрагмент D
print(hash(-1), hash(-2))
-2 -2
Два разных числа с одинаковым хешем — и это не случайность.

2Механизм

Из урока 03 известно: поиск по списку — перебор, потому что список знает только адреса. Словарь решает ту же задачу иначе — он вычисляет, где искать.

Хеш-таблица с открытой адресацией. Позиция ключа берётся из его хеша; если место занято чужим ключом, проба смещается по детерминированной последовательности, пока не найдётся свободное или совпадающее.

Компактная раскладка (с 3.6). Данные лежат не в разреженной таблице, а в двух массивах: плотный массив записей в порядке вставки — хеш, ключ, значение — и массив индексов в этот массив, размером с таблицу. Индексы занимают 1, 2, 4 или 8 байт по размеру словаря.

Следствие 1 · порядок ключей — побочный эффект компактности

Записи дописываются в плотный массив подряд, поэтому обход словаря идёт в порядке вставки. Это не было целью: раскладку придумали ради экономии памяти, а упорядоченность получилась даром.

Фрагмент A показывает, что порядок именно «вставки», а не «сортировки»: удаление помечает запись как пустую, но не сдвигает остальные, а новый ключ дописывается в конец.

Дальше — история, которую стоит запомнить целиком, потому что она повторяется во всём языке. В 3.6 упорядоченность была деталью реализации 🔧, о чём прямо предупреждали в примечаниях к релизу. К 3.7 на неё оперлось слишком много кода, и её сделали гарантией языка 🔒. Реализация превратилась в спецификацию — ровно потому, что люди начали ею пользоваться.

Следствие 2 · контракт хеша, и что он запрещает

Чтобы поиск работал, нужно одно правило: равные объекты обязаны иметь равный хеш. Обратное не требуется — разные объекты могут совпасть по хешу, это коллизия, она разрешается пробированием.

Отсюда фрагмент B. Определив __eq__, ты изменил понятие равенства — и унаследованный __hash__, основанный на идентичности объекта, стал ему противоречить. Python не даёт этого сделать: при определении __eq__ без __hash__ он выставляет __hash__ = None, и объект перестаёт быть хешируемым.

print(Point.__hash__)
# → None

Это не наказание, а защита от молчаливой поломки: два равных объекта с разными хешами разъехались бы по разным корзинам, и словарь начал бы содержать дубликаты, которые сам не находит.

Следствие 3 · изменяемость ломает поиск

Хеш вычисляется в момент вставки и запоминается в записи. Если после этого объект изменился так, что его хеш стал другим, поиск пойдёт в новую корзину — а объект лежит в старой.

Фрагмент C: b in s возвращает False, потому что ищем по новому хешу. При этом объект физически в множестве и виден при обходе. Он не потерян и не удалён — он недостижим по ключу, что гораздо неприятнее.

Отсюда выводится, почему список нельзя сделать ключом, хотя технически хеш от него посчитать можно: изменяемость означает, что такая ситуация возникнет обязательно. Python предпочёл запретить заранее. И отсюда же — frozen=True у dataclass, которую собираются класть в множество, и frozenset как отдельный тип.

Следствие 4 · коллизии и почему hash(-1) равен −2

Фрагмент D. В C-API функция вычисления хеша возвращает -1 как признак ошибки. Значит настоящее значение −1 использовать нельзя, и хеш для −1 сдвинут на −2. Одна коллизия, встроенная в язык ради дешёвой сигнализации об ошибке.

Обычные коллизии разрешаются пробированием, и последовательность проб подобрана так, чтобы задействовать и старшие биты хеша, а не только младшие: иначе ключи с одинаковым остатком выстроились бы в один кластер. Практический смысл — плохая хеш-функция в твоём классе деградирует словарь до линейного поиска, но не сломает его.

Следствие 5 · рандомизация хеша строк

Хеш строки зависит от случайного значения, выбираемого при старте процесса 🔧. Поэтому hash("x") в двух разных запусках даёт разные числа.

Сделано это после атак, где злоумышленник подбирал ключи с одинаковым хешем и превращал разбор запроса в квадратичный. Побочное следствие важно практически: хеш нельзя сохранять между запусками — ни в файл, ни в кэш, ни как ключ шардирования. Для устойчивого хеша нужен явный алгоритм из hashlib.

3Границы модели

Где сказанное перестаёт держать

Порядок гарантирован для dict, но не для set 🔒 у первого, 🕳 у второго. Множество использует другую раскладку, и порядок его обхода зависит от хешей и истории вставок. Полагаться на него нельзя, хотя он и выглядит стабильным в пределах запуска.

O(1) — амортизированная и в среднем. При заполнении сверх двух третей таблица растёт, и все ключи перехешируются; отдельная вставка в этот момент стоит O(n). А при вырожденной хеш-функции поиск деградирует к линейному.

Размеры индексов и моменты ресайза — детали реализации 🔧. Компактную раскладку ввели в 3.6, разделяемые ключи для инстансов — в 3.3, хранение значений в преheader объекта — в 3.11. Гарантируется поведение и сложность, а не байты.

4Корень

Корень R2: неймспейс — это словарь. Отсюда понятно, почему словарь в Python оптимизировали настолько агрессивно: это не одна из структур данных, а несущая конструкция. Каждый доступ к глобальной переменной, каждый атрибут объекта, каждый импорт, каждый вызов с именованными аргументами проходит через словарь.

Компактная раскладка появилась именно из этой роли. Инстансы одного класса имеют одинаковый набор имён атрибутов, значит ключи можно хранить один раз на класс, а в объекте держать только массив значений — то самое разделение ключей, которое в уроке 04 объясняло, почему property и managed dict устроены так, как устроены. Экономия памяти на объектах и упорядоченность словарей — два следствия одной оптимизации.

5Аналогия

Компактная раскладка — это CSR-представление разреженной матрицы. Там тоже два массива: плотный с ненулевыми значениями подряд и индексный, отображающий позицию в этот плотный массив. Мотивация идентичная: разреженная структура тратит память на пустоту, поэтому пустоту держим в дешёвом индексном массиве, а данные — плотно.

Переносится и следствие: как обход ненулевых элементов в CSR идёт в порядке их хранения, так и обход словаря идёт в порядке вставки. Упорядоченность в обоих случаях — свойство раскладки, а не отдельная функция.

6Вопросы пытливого ума

Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.

Порядок словаря стал гарантией — почему у множества нет?

Потому что порядок в словаре появился как побочный эффект полезной оптимизации, а у множества такой оптимизации не было.

Компактная раскладка 3.6 разделила словарь на две части: плотный массив записей в порядке вставки и разреженный массив индексов в него. Это сделали ради памяти — экономия вышла 20–25%. Порядок вставки получился бесплатно: записи и так лежат подряд.

Множество устроено иначе — это по-прежнему одна разреженная таблица, где позиция определяется хешем. Плотного массива в порядке вставки там нет, и делать его незачем: экономии не будет, потому что нет значений.

print({"b": 1, "a": 2})           # → {'b': 1, 'a': 2}   порядок вставки 🔒
print({3, 1, 2})                  # → {1, 2, 3}          похоже на сортировку
print({"три", "один", "два"})     # → порядок зависит от хешей и соли

Маленькие целые выглядят «отсортированными» потому, что hash(n) == n и они ложатся по своим индексам. Это совпадение, а не свойство.

Главное в этой истории не порядок, а прецедент. В 3.6 это была деталь реализации 🔧 с прямым предупреждением в release notes; к 3.7 на неё оперлось столько кода, что её сделали гарантией 🔒. Реализация превратилась в спецификацию — ровно потому, что люди начали ею пользоваться. Тот же путь сейчас проходит детерминированное разрушение по счётчику ссылок, и вот его как раз не гарантируют.

Почему hash() строки меняется между запусками процесса?

Это защита от отказа в обслуживании, добавленная в 3.3 после публичной атаки.

Словарь деградирует до линейного поиска, если все ключи попадают в одну корзину. Зная функцию хеширования, злоумышленник может подобрать тысячи строк с одинаковым хешем и отправить их как поля формы или ключи JSON — и обработка запроса из миллисекунд превращается в секунды. Уязвимость затронула почти все языки разом.

Ответ: хеш строк и байтов солится случайным значением, своим на каждый запуск процесса.

$ python3 -c "print(hash('abc'))"
$ python3 -c "print(hash('abc'))"
# два разных числа

$ PYTHONHASHSEED=0 python3 -c "print(hash('abc'))"
$ PYTHONHASHSEED=0 python3 -c "print(hash('abc'))"
# одинаковые

Практические следствия. Хеш строки нельзя сохранять на диск, класть в кеш, использовать для шардирования между процессами или сравнивать между запусками — для этого есть hashlib, который стабилен по определению. И тест, проверяющий порядок обхода множества строк, будет зелёным ровно до следующего запуска 🕳.

PYTHONHASHSEED=0 отключает соль — иногда нужно для воспроизводимости отладки, но в проде это возвращает уязвимость.

Почему список нельзя сделать ключом, если можно было бы хешировать содержимое?

Можно было бы — ровно один раз. Проблема во втором разе.

Контракт хеша состоит из двух частей, и вторая важнее первой: равные объекты имеют равный хеш, и хеш не меняется за время жизни объекта. Словарь запоминает, в какую корзину положил ключ. Если содержимое списка изменится, изменится и его хеш — а запись останется лежать в старой корзине.

class Sneaky(list):
    __hash__ = lambda self: hash(tuple(self))   # «давайте всё-таки хешировать»

d = {Sneaky([i]): i for i in range(200)}
k = Sneaky([500])
d[k] = "значение"

k.append(1)                                     # ключ изменился на месте
print(d[k])
# → KeyError: [500, 1]        не находится даже по тому же объекту
print("значение" in d.values(), len(d))
# → True 201                  запись на месте и недостижима

Запись не потеряна и не повреждена — она просто больше никогда не найдётся. Ни исключения в момент поломки, ни способа это обнаружить. Это худший вид ошибки: тихая и отложенная.

На маленьком словаре тот же код может случайно сработать: индексный массив короткий, старый и новый хеш попадают в одну ячейку, и совпадение находится по идентичности. Это делает ошибку ещё хуже — она проявляется при росте данных.

Поэтому изменяемые встроенные типы принудительно объявлены нехешируемыми — __hash__ = None. Не потому, что вычислить хеш нельзя, а потому, что удержать контракт невозможно. Нужен ключ из последовательности — tuple; нужен ключ из множества — frozenset; нужен ключ из своего класса — реализуй __hash__ только по полям, которые не меняются.

Компактный словарь дал и память, и порядок — почему не сделали раньше?

Потому что идея была неочевидной, а цена ошибки в словаре — вся производительность языка.

Словарь в Python не структура данных из стандартной библиотеки, а несущая конструкция: неймспейс модуля, __dict__ инстанса, таблица методов класса, keyword-аргументы — всё это словари. Любое изменение задевает каждую программу, поэтому туда лезут осторожно и с замерами.

Раскладку 3.6 предложил Раймонд Хеттингер в 2012-м, и до релиза она вылёживалась четыре года. Идея: вместо одной разреженной таблицы записей — плотный массив записей плюс отдельный разреженный массив маленьких индексов. Разреженная часть теперь хранит не 24-байтовые записи, а индексы по 1, 2, 4 или 8 байт в зависимости от размера словаря.

import sys
d = {}
prev = -1
for i in range(40):
    if sys.getsizeof(d) != prev:
        prev = sys.getsizeof(d); print(len(d), prev)
    d[i] = i

Это не единственная оптимизация словаря, и остальные тоже пришли поздно: разделяемые ключи для инстансов одного класса (PEP 412, версия 3.3), перенос значений инстанса в преамбулу объекта (3.11), счётчик версий для инлайн-кешей LOAD_GLOBAL (3.11). Все они 🔧 и все меняли раскладку.

Вывод, полезный за пределами словаря: в зрелом языке базовые структуры продолжают переписывать десятилетиями, и любой тест, опирающийся на их байты, — мина замедленного действия.

Итог. Словарь — хеш-таблица с открытой адресацией и компактной раскладкой из двух массивов: плотного с записями в порядке вставки и индексного. Отсюда порядок ключей, который сначала был побочным эффектом экономии памяти, а с 3.7 стал гарантией языка. Контракт «равные объекты — равные хеши» защищён автоматически: определив __eq__ без __hash__, ты теряешь хешируемость, потому что иначе словарь начал бы содержать дубликаты, которых сам не находит. Хеш запоминается при вставке, поэтому изменяемый объект после мутации остаётся в множестве, но становится недостижим по ключу — и поэтому ключом может быть только неизменяемое. Хеш строк рандомизирован при старте процесса, значит сохранять его между запусками нельзя.
Дальше — по желанию
контрфактуалC++ unordered_map, Java, GoЦепочки со стабильными ссылками против открытой адресации; Go рандомизирует порядок намеренно
Контрфактуал · те же вопросы у других

Java HashMap использует цепочки: в корзине лежит связный список, при длинной цепочке превращающийся в дерево. Устойчивее к плохим хешам, но каждый узел — отдельный объект, то есть косвенность и память. Python выбрал открытую адресацию: всё в двух массивах, лучше по кэшу, хуже при вырожденных хешах.

C++ std::unordered_map устроен как Java: цепочки в корзинах, каждый узел — отдельная аллокация. Стандарт фактически требует стабильности ссылок на элементы при вставке, и это закрывает дорогу открытой адресации — при ней элементы переезжают. Python такого обязательства не давал и потому смог выбрать раскладку, которая дружит с кэшем: два плотных массива вместо графа узлов. Цена — ссылка на запись словаря живёт только до ближайшего ресайза, и это ровно то, почему изменять словарь во время итерации по нему запрещено 🔒.

Go оставил порядок обхода карты не просто негарантированным, а намеренно случайным: рантайм каждый раз стартует с другого места, чтобы никто не мог опереться на порядок. Прямая противоположность решению Python — где порядок сначала возник случайно, а потом стал обещанием.

Два языка, одна ситуация «порядок технически существует», два противоположных вывода: зафиксировать или намеренно сломать.

🐛 багКлюч, который меняют после вставкиХеш запомнен при вставке — запись не перезаписывается, а дублируется и становится недостижимой
🐛 Баг, который проходит ревью

Кэш по объекту, который потом дополняют. Записи начинают дублироваться, а старые становятся недостижимы.

@dataclass
class Filter:
    field: str
    values: list          # ← изменяемое поле

    def __hash__(self):
        return hash((self.field, tuple(self.values)))

cache = {}
f = Filter("status", ["new"])
cache[f] = run_query(f)

f.values.append("done")   # где-то дальше по коду
cache[f] = run_query(f)   # думаем, что перезаписали

Ревью видит корректный __hash__ — кортеж, всё как учили. Но следствие 3 говорит: хеш посчитан при вставке и запомнен. После append объект ищется по новому хешу, не находится, и вторая строка не перезаписывает запись, а добавляет вторую. В словаре теперь два ключа, которые считаются равными, и первый из них недостижим.

Симптомы отвратительные: кэш растёт, попаданий нет, а len(cache) больше, чем уникальных фильтров. При этом ни исключения, ни предупреждения.

Лечится тем, что вытекает из механизма: ключом может быть только то, что не меняется. Либо @dataclass(frozen=True) и tuple вместо list в поле, либо вычислять ключ явно — строкой или кортежем — и не делать хешируемым сам объект. Второе часто честнее: ключ кэша и объект домена — разные сущности, и смешивать их не обязательно.

⚡ 3×Кортеж вместо склеенной строки0.087 с на сборку строки-ключа против 0.030 с на кортеж
⚡ Втрое дешевле составной ключ

Классический приём «склеим поля в строку и будем ключом» стоит дороже, чем кажется.

cache[f"{user_id}:{region}"] = value      # так обычно пишут
cache[(user_id, region)]     = value      # так дешевле
500 000 обращенийвремя
готовая строка-ключ0.022 с
кортеж-ключ0.030 с
строка, собираемая на каждом обращении0.087 с

Сам поиск по строке чуть быстрее, чем по кортежу, — у строки хеш кэшируется в заголовке (урок 02), у кортежа считается каждый раз. Но в реальном коде строку приходится собирать, а это создание нового объекта, форматирование и вычисление хеша с нуля. Отсюда трёхкратная разница с кортежем, который просто ссылается на уже существующие объекты.

Когда это заметно: горячий путь с сотнями тысяч обращений — маршрутизация, дедупликация, агрегация метрик. Когда нет: редкие обращения, где читаемость важнее. И отдельный плюс кортежа помимо скорости — он не ломается, если в поле окажется двоеточие.

💻 терминалПроверь в REPLЧетыре фрагмента плюс {1, 1.0, True}, nan как ключ и порядок в set

Проверь

Прогони четыре фрагмента, сверяясь с предсказанием. Затем:

{1, 1.0, True}
# Сколько элементов и почему?

d = {}
d[float("nan")] = 1
d[float("nan")] = 2
len(d)
# А теперь: n = float("nan"); d2 = {n: 1}; n in d2 — что вернёт?

class C:
    __slots__ = ()
    def __eq__(self, o): return True
# Можно ли положить C() в set? Что нужно добавить?

# Без кода: почему set не гарантирует порядок,
# если dict гарантирует, и оба — хеш-таблицы?

Второй вопрос — про то, что поиск в словаре сначала сравнивает по идентичности и только потом по равенству; nan не равен сам себе, но является собой.

исходники_HashedSeq в lru_cacheТри строки, чтобы хеш ключа кэша считался один раз

Где это живёт в реальном коде

Lib/functools.py, класс _HashedSeq. Три строки внутри lru_cache: обёртка над кортежем аргументов, которая вычисляет хеш один раз в конструкторе и возвращает сохранённый из __hash__.

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

Второе место — Lib/collections/__init__.py, OrderedDict. Он остался в библиотеке после того, как обычный словарь стал упорядоченным, и документация честно объясняет, чем он всё ещё отличается: равенством с учётом порядка и методом move_to_end. Хороший пример того, как гарантия языка не отменяет специализированный тип, а сужает область его применения.

L2Раскладка, рост и цена коллизийСкачки роста, пробирование с perturb, разделяемые ключи

Из L1 ты знаешь про два массива. Здесь — как они растут и что видно в замерах.

Размеры на практике:

sys.getsizeof({})                    64
после 1 ключа                        224
после 6                              352
после 11                             632
после 22                            1168

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

Последовательность проб в CPython не линейная и не квадратичная, а такая: i = (i * 5 + 1 + perturb) & mask, где perturb начинается с полного хеша и сдвигается вправо на каждом шаге. Смысл в том, чтобы первые пробы задействовали старшие биты хеша: если брать только младшие, ключи, отличающиеся в старших разрядах, кластеризовались бы в одном месте.

Разделяемые ключи. Для словарей-атрибутов инстансов таблица ключей выносится на класс, а объект держит только массив значений. Отсюда экономия из урока 04: тысяча объектов одного класса не платит тысячу раз за одинаковые имена полей.

Что стоит уметь измерять: sys.getsizeof на словаре растёт скачками — по ним видно моменты ресайза; если известно, сколько ключей будет, у dict нет способа зарезервировать место заранее, но есть обходной путь через создание из готового итерируемого, где размер известен.

L3Где это в исходниках CPythondictobject.c: шапка файла, lookdict_split; setobject.c; PEP 412

Тег v3.11.15.

  • Objects/dictobject.c, шапка файла — большой комментарий с описанием компактной раскладки и мотивацией. Читается за десять минут и заменяет половину этого урока.
  • Там же lookdict_index и lookdict_split — пробирование и путь для разделяемых ключей.
  • Objects/setobject.c — другая раскладка, без плотного массива; отсюда и отсутствие гарантии порядка.
  • PEP 412 — разделяемые ключи; мотивация написана в мегабайтах на реальных приложениях.
связиКуда это ведётL03, L04, L06 · контраст с цепочками и с рандомизацией в Go

Связи

корень R2 · Атрибут резолвится в рантайме, неймспейс — dict
← основа L03 · Контейнеры — почему in по списку линеен, а здесь нет
← основа L04 · obj.x насквозь — разделяемые ключи, о которых там шла речь, устроены здесь
↔ контраст Цепочки в Java HashMap против открытой адресации: устойчивость к плохим хешам против локальности
↔ контраст Go намеренно рандомизирует порядок обхода карты — противоположный вывод из той же ситуации
→ дальше L06 · Классы — тело класса это словарь, и MRO определяет, в каком порядке их обходят
чего здесь нет Криптографические хеши и устойчивое шардирование — это hashlib, другая задача
источникиЧто почитатьШапка dictobject.c 🟥 · __hash__ 🟧 · PEP 412 🟦

Что почитать