Словарь — не просто структура данных, а фундамент языка: на нём стоят неймспейсы, атрибуты объектов, ключевые аргументы и импорт. Из его устройства выводятся и гарантированный порядок ключей, и контракт хеша, и то, почему изменяемый объект теряется в множестве.
d = {"a": 1, "b": 2, "c": 3}
del d["b"]
d["d"] = 4
print(list(d))
['a', 'c', 'd']Новый ключ встал в конец, а не на освободившееся место.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'Определили только равенство — потеряли хешируемость, которая была.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Объект одновременно отсутствует в множестве и находится в нём.print(hash(-1), hash(-2))
-2 -2Два разных числа с одинаковым хешем — и это не случайность.Из урока 03 известно: поиск по списку — перебор, потому что список знает только адреса. Словарь решает ту же задачу иначе — он вычисляет, где искать.
Хеш-таблица с открытой адресацией. Позиция ключа берётся из его хеша; если место занято чужим ключом, проба смещается по детерминированной последовательности, пока не найдётся свободное или совпадающее.
Компактная раскладка (с 3.6). Данные лежат не в разреженной таблице, а в двух массивах: плотный массив записей в порядке вставки — хеш, ключ, значение — и массив индексов в этот массив, размером с таблицу. Индексы занимают 1, 2, 4 или 8 байт по размеру словаря.
Записи дописываются в плотный массив подряд, поэтому обход словаря идёт в порядке вставки. Это не было целью: раскладку придумали ради экономии памяти, а упорядоченность получилась даром.
Фрагмент A показывает, что порядок именно «вставки», а не «сортировки»: удаление помечает запись как пустую, но не сдвигает остальные, а новый ключ дописывается в конец.
Дальше — история, которую стоит запомнить целиком, потому что она повторяется во всём языке. В 3.6 упорядоченность была деталью реализации 🔧, о чём прямо предупреждали в примечаниях к релизу. К 3.7 на неё оперлось слишком много кода, и её сделали гарантией языка 🔒. Реализация превратилась в спецификацию — ровно потому, что люди начали ею пользоваться.
Чтобы поиск работал, нужно одно правило: равные объекты обязаны иметь равный хеш. Обратное не требуется — разные объекты могут совпасть по хешу, это коллизия, она разрешается пробированием.
Отсюда фрагмент B. Определив __eq__, ты изменил понятие равенства — и унаследованный __hash__, основанный на идентичности объекта, стал ему противоречить. Python не даёт этого сделать: при определении __eq__ без __hash__ он выставляет __hash__ = None, и объект перестаёт быть хешируемым.
print(Point.__hash__)
# → None
Это не наказание, а защита от молчаливой поломки: два равных объекта с разными хешами разъехались бы по разным корзинам, и словарь начал бы содержать дубликаты, которые сам не находит.
Хеш вычисляется в момент вставки и запоминается в записи. Если после этого объект изменился так, что его хеш стал другим, поиск пойдёт в новую корзину — а объект лежит в старой.
Фрагмент C: b in s возвращает False, потому что ищем по новому хешу. При этом объект физически в множестве и виден при обходе. Он не потерян и не удалён — он недостижим по ключу, что гораздо неприятнее.
Отсюда выводится, почему список нельзя сделать ключом, хотя технически хеш от него посчитать можно: изменяемость означает, что такая ситуация возникнет обязательно. Python предпочёл запретить заранее. И отсюда же — frozen=True у dataclass, которую собираются класть в множество, и frozenset как отдельный тип.
hash(-1) равен −2Фрагмент D. В C-API функция вычисления хеша возвращает -1 как признак ошибки. Значит настоящее значение −1 использовать нельзя, и хеш для −1 сдвинут на −2. Одна коллизия, встроенная в язык ради дешёвой сигнализации об ошибке.
Обычные коллизии разрешаются пробированием, и последовательность проб подобрана так, чтобы задействовать и старшие биты хеша, а не только младшие: иначе ключи с одинаковым остатком выстроились бы в один кластер. Практический смысл — плохая хеш-функция в твоём классе деградирует словарь до линейного поиска, но не сломает его.
Хеш строки зависит от случайного значения, выбираемого при старте процесса 🔧. Поэтому hash("x") в двух разных запусках даёт разные числа.
Сделано это после атак, где злоумышленник подбирал ключи с одинаковым хешем и превращал разбор запроса в квадратичный. Побочное следствие важно практически: хеш нельзя сохранять между запусками — ни в файл, ни в кэш, ни как ключ шардирования. Для устойчивого хеша нужен явный алгоритм из hashlib.
Порядок гарантирован для dict, но не для set 🔒 у первого, 🕳 у второго. Множество использует другую раскладку, и порядок его обхода зависит от хешей и истории вставок. Полагаться на него нельзя, хотя он и выглядит стабильным в пределах запуска.
O(1) — амортизированная и в среднем. При заполнении сверх двух третей таблица растёт, и все ключи перехешируются; отдельная вставка в этот момент стоит O(n). А при вырожденной хеш-функции поиск деградирует к линейному.
Размеры индексов и моменты ресайза — детали реализации 🔧. Компактную раскладку ввели в 3.6, разделяемые ключи для инстансов — в 3.3, хранение значений в преheader объекта — в 3.11. Гарантируется поведение и сложность, а не байты.
Корень R2: неймспейс — это словарь. Отсюда понятно, почему словарь в Python оптимизировали настолько агрессивно: это не одна из структур данных, а несущая конструкция. Каждый доступ к глобальной переменной, каждый атрибут объекта, каждый импорт, каждый вызов с именованными аргументами проходит через словарь.
Компактная раскладка появилась именно из этой роли. Инстансы одного класса имеют одинаковый набор имён атрибутов, значит ключи можно хранить один раз на класс, а в объекте держать только массив значений — то самое разделение ключей, которое в уроке 04 объясняло, почему property и managed dict устроены так, как устроены. Экономия памяти на объектах и упорядоченность словарей — два следствия одной оптимизации.
Компактная раскладка — это CSR-представление разреженной матрицы. Там тоже два массива: плотный с ненулевыми значениями подряд и индексный, отображающий позицию в этот плотный массив. Мотивация идентичная: разреженная структура тратит память на пустоту, поэтому пустоту держим в дешёвом индексном массиве, а данные — плотно.
Переносится и следствие: как обход ненулевых элементов в CSR идёт в порядке их хранения, так и обход словаря идёт в порядке вставки. Упорядоченность в обоих случаях — свойство раскладки, а не отдельная функция.
Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.
Потому что порядок в словаре появился как побочный эффект полезной оптимизации, а у множества такой оптимизации не было.
Компактная раскладка 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). Все они 🔧 и все меняли раскладку.
Вывод, полезный за пределами словаря: в зрелом языке базовые структуры продолжают переписывать десятилетиями, и любой тест, опирающийся на их байты, — мина замедленного действия.
__eq__ без __hash__, ты теряешь хешируемость, потому что иначе словарь начал бы содержать дубликаты, которых сам не находит. Хеш запоминается при вставке, поэтому изменяемый объект после мутации остаётся в множестве, но становится недостижим по ключу — и поэтому ключом может быть только неизменяемое. Хеш строк рандомизирован при старте процесса, значит сохранять его между запусками нельзя.
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 в поле, либо вычислять ключ явно — строкой или кортежем — и не делать хешируемым сам объект. Второе часто честнее: ключ кэша и объект домена — разные сущности, и смешивать их не обязательно.
Классический приём «склеим поля в строку и будем ключом» стоит дороже, чем кажется.
cache[f"{user_id}:{region}"] = value # так обычно пишут
cache[(user_id, region)] = value # так дешевле
| 500 000 обращений | время |
|---|---|
| готовая строка-ключ | 0.022 с |
| кортеж-ключ | 0.030 с |
| строка, собираемая на каждом обращении | 0.087 с |
Сам поиск по строке чуть быстрее, чем по кортежу, — у строки хеш кэшируется в заголовке (урок 02), у кортежа считается каждый раз. Но в реальном коде строку приходится собирать, а это создание нового объекта, форматирование и вычисление хеша с нуля. Отсюда трёхкратная разница с кортежем, который просто ссылается на уже существующие объекты.
Когда это заметно: горячий путь с сотнями тысяч обращений — маршрутизация, дедупликация, агрегация метрик. Когда нет: редкие обращения, где читаемость важнее. И отдельный плюс кортежа помимо скорости — он не ломается, если в поле окажется двоеточие.
Прогони четыре фрагмента, сверяясь с предсказанием. Затем:
{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 не равен сам себе, но является собой.
Lib/functools.py, класс _HashedSeq. Три строки внутри lru_cache: обёртка над кортежем аргументов, которая вычисляет хеш один раз в конструкторе и возвращает сохранённый из __hash__.
Заметь, зачем это нужно. Ключ кэша ищется в словаре при каждом вызове, и без обёртки хеш кортежа аргументов пересчитывался бы каждый раз — а это обход всех элементов. Стандартная библиотека здесь буквально платит одним классом за то, чтобы следствие 2 отработало один раз, а не на каждом обращении.
Второе место — Lib/collections/__init__.py, OrderedDict. Он остался в библиотеке после того, как обычный словарь стал упорядоченным, и документация честно объясняет, чем он всё ещё отличается: равенством с учётом порядка и методом move_to_end. Хороший пример того, как гарантия языка не отменяет специализированный тип, а сужает область его применения.
Из L1 ты знаешь про два массива. Здесь — как они растут и что видно в замерах.
Размеры на практике:
sys.getsizeof({}) 64
после 1 ключа 224
после 6 352
после 11 632
после 22 1168
Пустой словарь — только заголовок, таблица не выделена вовсе. Первая вставка выделяет таблицу на восемь позиций разом. Дальше рост происходит при заполнении примерно на две трети: таблица удваивается, все записи перекладываются, индексы пересчитываются. Порог в две трети выбран как размен между памятью и длиной проб — при более плотном заполнении коллизии учащаются нелинейно.
Последовательность проб в CPython не линейная и не квадратичная, а такая: i = (i * 5 + 1 + perturb) & mask, где perturb начинается с полного хеша и сдвигается вправо на каждом шаге. Смысл в том, чтобы первые пробы задействовали старшие биты хеша: если брать только младшие, ключи, отличающиеся в старших разрядах, кластеризовались бы в одном месте.
Разделяемые ключи. Для словарей-атрибутов инстансов таблица ключей выносится на класс, а объект держит только массив значений. Отсюда экономия из урока 04: тысяча объектов одного класса не платит тысячу раз за одинаковые имена полей.
Что стоит уметь измерять: sys.getsizeof на словаре растёт скачками — по ним видно моменты ресайза; если известно, сколько ключей будет, у dict нет способа зарезервировать место заранее, но есть обходной путь через создание из готового итерируемого, где размер известен.
Тег v3.11.15.
Objects/dictobject.c, шапка файла — большой комментарий с описанием компактной раскладки и мотивацией. Читается за десять минут и заменяет половину этого урока.lookdict_index и lookdict_split — пробирование и путь для разделяемых ключей.Objects/setobject.c — другая раскладка, без плотного массива; отсюда и отсутствие гарантии порядка.in по списку линеен, а здесь нетobj.x насквозь — разделяемые ключи, о которых там шла речь, устроены здесьHashMap против открытой адресации: устойчивость к плохим хешам против локальностиhashlib, другая задачаObjects/dictobject.c — читать целиком. Лучший существующий текст про компактную раскладку, написан теми, кто её делал.__hash__ — выборочно: контракт и правило про автоматическое обнуление при определении __eq__.