Словари Python изнутри: хеш-таблица CPython
Хеширование, открытая адресация и переписывание словаря в 2016 году, которое сделало порядок вставки побочным эффектом реализации — а через один релиз сделало его гарантией языка.
Полное техническое изложение
TL;DR
- Словарь — это не список пар: место каждого ключа вычисляется заранее, из числа, которое даёт функция
hash(). - Внутри на самом деле два массива: почти пустая «доска с номерками» и плотная «вешалка», куда записи ложатся строго в порядке добавления.
- Порядок ключей сохраняется именно поэтому — и с Python 3.7 на это можно полагаться, это гарантия языка.
- Когда таблица заполняется примерно на две трети, Python заводит новую, побольше, и раскладывает ключи заново. Порядок при этом не меняется.
Что на самом деле происходит внутри d[key]
Словарь Python не хранит пары "ключ-значение" в списке, где для поиска нужно проверить каждый элемент. Вместо этого он использует хеш-таблицу: массив ячеек, где место для каждого ключа вычисляется заранее, по числу.
Разберём это по шагам — тема та же, что и в полной версии статьи, просто без формул из исходного кода C.
Шаг 1: у каждого ключа есть число
Когда вы пишете d["python"] = 3, первое, что делает Python, — превращает "python" в число с помощью функции hash(). Это число — как индекс в гардеробе: оно не хранит сам ключ, но говорит, куда его положить.
Для целых чисел всё просто: hash(5) — это почти всегда просто 5. Для строк — сложнее: Python специально делает так, чтобы хеш одной и той же строки был разным при каждом новом запуске программы. Это защита: если бы хеш строки всегда был предсказуем, кто-то мог бы специально подобрать миллион ключей с одинаковым хешем и замедлить программу до предела. Ниже можно попробовать оба варианта на реальных числах (для строк — на упрощённом примере, настоящий алгоритм Python нельзя воспроизвести между запусками — в этом и смысл).
Шаг 2: число превращается в ячейку
У таблицы есть конкретное число ячеек — всегда степень двойки (8, 16, 32...). Чтобы превратить большое число-хеш в номер одной из этих ячеек, Python берёт несколько последних битов хеша. Это очень быстрая операция — быстрее, чем деление с остатком, хотя по смыслу делает то же самое.
Шаг 3: а если ячейка занята?
Два разных ключа время от времени попадают в одну и ту же ячейку — это называется коллизией. Python не останавливается на первой попавшейся свободной ячейке подряд (это работало бы плохо), а прыгает по таблице в специальном порядке, который использует части хеша, ещё не задействованные на шаге 2. В итоге ключ довольно быстро находит свободное место, даже если таблица уже неплохо заполнена.
Таблица из 8 слотов (mask = 7). Кликните по занятым слотам ниже, чтобы создать коллизии — цепочка проб пересчитается по настоящей формуле CPython.
- i₀ = hash & mask = 37 & 7 = 5
- проба 1: perturb = 1, i = (i·5 + perturb + 1) & mask = 3
- → ключ занимает слот 3 после 1 проб(ы).
Шаг 4: таблица растёт сама
Если таблицу заполнить почти полностью, поиск в ней замедляется — ячеек-кандидатов на "почти пусто" уже почти не остаётся. Поэтому Python следит за заполненностью: как только таблица заполнена примерно на две трети, он создаёт новую, обычно вдвое больше, и переносит туда все ключи заново. Именно поэтому при добавлении элементов в словарь размер его внутренней таблицы время от времени скачком увеличивается — это нормально и относится к обычной работе словаря, а не к ошибке или "утечке памяти".
Интерактивная визуализация
Ниже — тот же гардероб, только по шагам. Слева-сверху доска с номерками, снизу — вешалка с записями. Нажмите «Воспр.» и посмотрите на два момента: на шаге 3 номерок оказывается занят и ключу приходится искать другой, а на шаге 6 доску целиком меняют на большую — но вешалку при этом не трогают, записи остаются в том же порядке.
Ключи здесь — обычные целые числа, и это не случайность: у целых hash(n) равен самому n, поэтому любой номер слота можно проверить в своём Python. У строк хеш при каждом запуске новый, так что такие числа проверить было бы нельзя.
Пустой словарь. dk_indices — 8 слотов, во всех DKIX_EMPTY (показаны как ·). dk_entries пуст. Полезная ёмкость таблицы из 8 слотов — USABLE_FRACTION(8) = 5 записей.
Что стоит запомнить
Три вещи про словарь Python, на которые можно полагаться в реальном коде: порядок ключей сохраняется таким, каким вы их добавляли (это гарантия языка с версии 3.7); поиск, вставка и удаление работают в среднем очень быстро, почти не завися от размера словаря; и — конкретные числа вроде "заполнение на две трети" или "новый размер таблицы" — это внутренняя механика CPython, которая теоретически может отличаться в другой реализации Python. Если хочется увидеть точные формулы и код, на которых всё это основано, — в статье выше есть версия "Подробно".
TL;DR
- Словарь CPython — это два массива: разреженный массив индексов
dk_indicesи плотный, упорядоченный по вставке массив записейdk_entries. - Поиск — открытая адресация с пробированием через
perturb, а не цепочки в бакетах. Коэффициент заполнения ограничен ⅔. - Сохранение порядка появилось в 3.6 как деталь реализации этой раскладки и стало гарантией языка в 3.7.
- Разделение ключей (key-sharing dict, PEP 412) — причина, по которой словари экземпляров дешевле, чем кажутся.
Зачем это знать?
Потому что словари — это субстрат языка, а не просто один из контейнеров. Каждое обращение к атрибуту, каждое пространство имён модуля, каждый набор keyword-аргументов — это операция со словарём. Если в вашем сервисе горячий цикл, то словарь в нём почти наверняка есть, даже если вы его не писали руками.
Практическое следствие: у CPython не одна реализация поиска по словарю, а несколько специализаций, и переключение между ними видно в цифрах. Самое наглядное доказательство — оптимизация из 3.11: если все ключи словаря строковые, интерпретатор перестаёт хранить в таблице их хеши, и sys.getsizeof(dict.fromkeys("abcdefg")) падает с 352 до 272 байт. Добавьте в такой словарь один нестроковый ключ — и таблица переходит в общий режим. Ни один из этих переходов не виден в исходном коде на Python: чтобы объяснить регрессию, надо знать про раскладку.
Модель в голове
Представьте гардероб. Разреженный массив dk_indices — это доска с номерками на стене: почти пустая, расширять её дёшево. Плотный массив dk_entries — это вешалка с самими пальто, развешанными строго в порядке поступления. Хеш говорит, какой номерок читать; номерок говорит, куда идти по вешалке.
Расширение таблицы перерисовывает доску и никогда не перевешивает пальто. Именно поэтому порядок обхода выживает при расширении — и именно поэтому это свойство изначально было побочным эффектом раскладки, а не обещанием языка:
The order-preserving aspect of this new implementation is considered an implementation detail and should not be relied upon.
Дословно: «сохранение порядка в этой новой реализации считается деталью реализации, и полагаться на неё не следует». Так авторы CPython описали побочный эффект компактного словаря в 3.6 — за один релиз до того, как это же поведение стало официальной частью языка.
Устройство: две таблицы вместо одной
«Разреженный» и «плотный» — это не эпитеты
Два слова, без которых дальше ничего не сойдётся.
Разреженный массив — тот, в котором свободных ячеек намеренно больше, чем нужно. Хеш-таблице с открытой адресацией это не роскошь, а условие работы: ключ ищется по формуле «взять хеш, отрезать младшие биты, посмотреть в слот», и если свободных слотов не останется, поиск свободного места и разрешение коллизий выродятся в перебор всей таблицы. Поэтому CPython не даёт таблице заполниться больше чем на две трети (USABLE_FRACTION) — минимум треть слотов всегда пустует, и это не потери, а рабочий запас.
Плотный массив — противоположность: заполняется подряд с нулевой позиции, без пропусков, и растёт только с хвоста. Пробелы в нём появляются лишь как след удалённых ключей и живут до ближайшей пересборки.
Компактный словарь состоит из обоих сразу — и весь фокус в том, какой из них сделан разреженным.
Зачем вообще нужен dk_indices
До Python 3.6 массив был один. Вот та самая структура из CPython 3.5:
struct _dictkeysobject {
Py_ssize_t dk_refcnt;
Py_ssize_t dk_size;
dict_lookup_func dk_lookup;
Py_ssize_t dk_usable;
PyDictKeyEntry dk_entries[1];
};dk_entries здесь — и есть хеш-таблица: слот номер i — это dk_entries[i], целая запись из хеша, ключа и значения. То есть разреженным был массив 24-байтовых записей, и обязательная пустая треть стоила по 24 байта за слот.
Идея компактного словаря (письмо Реймонда Хеттингера в python-dev, декабрь 2012) в одной фразе: пусть разреженным будет дешёвый массив, а дорогой — плотным. Разреженность нужна самой хеш-таблице, а не записям, поэтому хеш-таблицу вынесли в отдельный массив маленьких целых чисел, и вся «воздушная подушка» стала стоить по одному байту за слот вместо двадцати четырёх.
Считается это прямо на пальцах. Таблица на 8 слотов, в ней 5 живых ключей:
| Python 3.5 | Python 3.6+ | |
|---|---|---|
| хеш-таблица | 8 × 24 = 192 Б | dk_indices: 8 × 1 = 8 Б |
| записи | — (они же и есть таблица) | dk_entries: 5 × 24 = 120 Б |
| итого | 192 Б, из них 72 Б впустую | 128 Б, впустую — 8 байт индексов |
На таблице в 65 536 слотов (43 690 записей) — 1 572 864 байта против 1 179 632: те самые «на 20–25% меньше памяти», которыми release notes 3.6 описывают новую реализацию.
А теперь ответ на вопрос «зачем эта прослойка вообще»: без неё поиск ключа стал бы линейным. dk_entries отсортирован по времени вставки, а не по хешу, — по нему нельзя вычислить, где лежит ключ, можно только перебрать все записи подряд. dk_indices — это и есть хеш-таблица: адрес слота получается из хеша одной операцией hash & mask, и в слоте сразу лежит номер нужной записи. Одно обращение вместо перебора, O(1) вместо O(n).
Порядок вставки при этом получился побочным эффектом: раз записи складываются в плотный массив подряд, обход этого массива и выдаёт ключи в порядке добавления. Никакой отдельной структуры для порядка в словаре нет.
Две структуры рядом
dk_indices — разреженный массив, тот самый, размер которого равен dk_size и всегда является степенью двойки. В слоте лежит не ключ и не значение, а маленькое целое число — индекс записи во втором массиве. Или одно из служебных значений: DKIX_EMPTY (−1) для пустого слота и DKIX_DUMMY (−2) для слота, из которого ключ удалили. Ширина одного слота подобрана под размер таблицы — из комментария к dk_indices в pycore_dict.h:
/* The size in bytes of an indice depends on dk_size:
- 1 byte if dk_size <= 0xff (char*)
- 2 bytes if dk_size <= 0xffff (int16_t*)
- 4 bytes if dk_size <= 0xffffffff (int32_t*)
- 8 bytes otherwise (int64_t*) */
char dk_indices[];Вот почему «расширять доску дёшево»: словарь из восьми слотов тратит на dk_indices 8 байт, а не восемь полноценных записей. Даже при таблице на 65 тысяч слотов это два байта на слот.
dk_entries — плотный массив самих записей, в порядке вставки. И вот что в записи лежит на самом деле:
typedef struct {
/* Cached hash code of me_key. */
Py_hash_t me_hash;
PyObject *me_key;
PyObject *me_value;
} PyDictKeyEntry;Три поля по машинному слову — на 64-битной сборке ровно 24 байта на запись. Ключ и значение — это указатели: сами объекты живут где-то ещё в куче, словарь хранит только адреса и держит на них ссылки. А me_hash — кеш: хеш ключа считается один раз при вставке и сохраняется рядом, чтобы не вызывать hash() заново при каждом сравнении в цепочке проб и при каждом расширении таблицы.
Как эти массивы заполняются
Вставка нового ключа — это всего четыре события, и порядок у них не тот, который кажется естественным. Вот тело insert_combined_dict из dictobject.c, сокращённое до записей в память:
Py_ssize_t hashpos = find_empty_slot(mp->ma_keys, hash);
dictkeys_set_index(mp->ma_keys, hashpos, mp->ma_keys->dk_nentries);
if (DK_IS_UNICODE(mp->ma_keys)) {
PyDictUnicodeEntry *ep = &DK_UNICODE_ENTRIES(mp->ma_keys)[mp->ma_keys->dk_nentries];
STORE_KEY(ep, key);
STORE_VALUE(ep, value);
}
else {
PyDictKeyEntry *ep = &DK_ENTRIES(mp->ma_keys)[mp->ma_keys->dk_nentries];
STORE_KEY(ep, key);
STORE_VALUE(ep, value);
STORE_HASH(ep, hash);
}
STORE_KEYS_USABLE(mp->ma_keys, mp->ma_keys->dk_usable - 1);
STORE_KEYS_NENTRIES(mp->ma_keys, mp->ma_keys->dk_nentries + 1);Читается это так:
find_empty_slotищет свободный слот вdk_indices— по хешу и, при коллизии, по последовательности проб (о ней ниже). Ключ и значение в этом поиске не участвуют вообще: слот выбирают биты хеша.dictkeys_set_indexпишет в найденный слот текущее значениеdk_nentries— номер записи, которой ещё нет. Индекс в доску попадает раньше, чем создана сама запись.- Только теперь заполняется запись в
dk_entriesпо индексуdk_nentries: два указателя, а в общем режиме ещё и хеш. dk_usableуменьшается на единицу,dk_nentries— увеличивается. Первый счётчик — это остаток до расширения (USABLE_FRACTION), второй — граница заполненной части плотного массива.
Ничего больше при вставке в память не пишется. Ни ключ, ни значение никуда не копируются: STORE_KEY и STORE_VALUE сохраняют адреса уже существующих объектов и увеличивают их счётчики ссылок.
Ниже — те же четыре шага в интерактивном виде: кнопка «Воспр.» проигрывает вставку по фазам, переключатель вверху меняет раскладку записи с общей на строковую.
PyDict_MINSIZE == 8В слоте лежит не ключ и не значение, а индекс записи в dk_entries — маленькое целое. Пустой слот хранит DKIX_EMPTY (−1), а слот после удаления — DKIX_DUMMY (−2). Именно поэтому расширять эту доску дёшево: при восьми слотах она занимает 8 байт, а не 8 полноценных записей.
Пустой словарь. dk_indices — 8 слотов по одному байту, во всех DKIX_EMPTY (−1). dk_entries — плотный массив на USABLE_FRACTION(8) = 5 записей по 24 байт; пока не заполнено ни одной. Всё, что произойдёт дальше, — это два записанных числа и три (в другом режиме — два) указателя.
Второй вид записи: когда все ключи — строки
У записи есть и вторая форма. Если все ключи таблицы строковые, CPython использует другую структуру:
typedef struct {
PyObject *me_key; /* The key must be Unicode and have hash. */
PyObject *me_value;
} PyDictUnicodeEntry;Поля хеша здесь нет вообще — 16 байт вместо 24. Хеш при этом не теряется: у строкового объекта он уже закеширован внутри самого объекта, так что держать вторую копию в таблице незачем. Какой из двух вариантов используется, записано в поле dk_kind (DICT_KEYS_GENERAL или DICT_KEYS_UNICODE; есть и третий, DICT_KEYS_SPLIT, — о нём ниже). Это и есть механизм экономии, о которой говорит changelog 3.11: sys.getsizeof(dict.fromkeys("abcdefg")) — 272 байта вместо прежних 352. Переключиться между режимами можно в визуализации выше.
Именно порядок в dk_entries и даёт словарю то, что с Python 3.7 стало официальной гарантией языка, а не деталью реализации: обход dict идёт в порядке вставки ключей.
Есть и третий вариант — split table (PEP 412, "key-sharing dictionary"): если у множества объектов одного класса одинаковый набор строковых атрибутов, они могут разделять один и тот же dk_indices/ключевую часть, а значения хранить каждый в своём ma_values. Это оптимизация для __dict__ экземпляров, а не для словарей общего назначения — в основном тексте статьи речь про обычный, "combined" словарь.
Шаг 1: от ключа к числу
Первое, что происходит при d[key] = value или d[key], — вызов hash(key). Дальше многое зависит от типа ключа:
- Для
int— хеш почти всегда совпадает со значением:hash(n) == nдля большинства целых (с редукцией по модулю простого числа Мерсенна 2⁶¹−1 на 64-битных сборках — см.sys.hash_info). Исключение: -1. CPython резервирует -1 как код ошибки для C-функций, возвращающихPy_hash_t, поэтому несколько его хеш-функций (дляbytes,float, указателей — см.Python/pyhash.c) явно подменяют вычисленное -1 на -2. Дляintдействует то же соглашение:hash(-1) == -2. - Для
str(иbytes) — с PEP 456 это SipHash13, причём с seed, случайным для каждого запуска процесса (PYTHONHASHSEED). Это осознанное решение о безопасности: до PEP 456 предсказуемый хеш строк давал классическую DoS-атаку "hash flooding" через specially crafted ключи. Практическое следствие:hash("cpython")в двух разных запускахpython3— это, как правило, два разных числа.
Ниже — тот же самый первый шаг, key → hash(key), в интерактивном виде. Для int показано настоящее значение hash(); для str — намеренно упрощённая демонстрационная функция (не SipHash), потому что настоящий хеш строки невоспроизводим между запусками по построению.
Шаг 2: от числа к слоту
Хеш — это произвольное целое число, а таблица — массив из dk_size слотов, где dk_size всегда степень двойки. Начальный кандидат на слот — самая дешёвая операция, которая тут вообще возможна:
mask = dk_size - 1;
i = hash & mask;& mask вместо % dk_size — обычный трюк для степеней двойки: младшие биты hash и есть остаток от деления, без деления как такового.
Коллизии: последовательность проб CPython
Два разных ключа почти неизбежно дадут одинаковый i рано или поздно. Здесь у CPython не линейное пробирование и не что-то на основе двойного хеширования из учебника — своя рекуррентная формула, прямо из комментария в dictobject.c:
perturb = hash;
i = hash & mask;
while (slot_at(i)->occupied) {
perturb >>= PERTURB_SHIFT; /* PERTURB_SHIFT == 5 */
i = (i*5 + perturb + 1) & mask;
}Идея: обычное линейное пробирование (i = i + 1) слишком регулярно — при частичном заполнении таблицы оно даёт длинные цепочки коллизий, коррелирующие с самим mask. Добавка perturb, которая на каждом шаге сдвигается вправо на 5 бит, постепенно "подмешивает" в последовательность проб старшие биты исходного хеша — те самые биты, которые & mask в чистом виде вообще не использует. В сумме получается псевдослучайный, но полностью детерминированный обход всех dk_size слотов.
Проверьте формулу сами — таблица ниже пересчитывает реальную последовательность проб CPython для введённого hash и отмеченных как занятые слотов:
Таблица из 8 слотов (mask = 7). Кликните по занятым слотам ниже, чтобы создать коллизии — цепочка проб пересчитается по настоящей формуле CPython.
- i₀ = hash & mask = 37 & 7 = 5
- проба 1: perturb = 1, i = (i·5 + perturb + 1) & mask = 3
- → ключ занимает слот 3 после 1 проб(ы).
Рост таблицы: когда и во сколько раз
Хеш-таблица, заполненная под завязку, вырождается в линейный поиск — CPython не доводит до этого и держит запас. Три числа из dictobject.c полностью определяют, когда и как таблица растёт:
#define PyDict_MINSIZE 8
#define USABLE_FRACTION(n) (((n) << 1)/3) /* resize при заполнении на 2/3 */
#define GROWTH_RATE(d) ((d)->ma_used*3)Свежий словарь начинается с dk_size = 8 и dk_usable = USABLE_FRACTION(8) = 5 — ровно то, что описывает комментарий в исходнике: "8 allows dicts with no more than 5 active entries". Каждая успешная вставка уменьшает dk_usable на 1; когда он должен был бы стать отрицательным, перед вставкой запускается resize: новый минимальный размер — GROWTH_RATE(mp), то есть ma_used * 3, а фактический новый dk_size — ближайшая степень двойки, не меньшая этого значения. Если удалений не было, ma_used * 3 почти всегда попадает в диапазон "следующая степень двойки после удвоения", отсюда и комментарий в исходнике: "dicts double in size when growing without deletions".
Интерактивная визуализация
Пройдите по шагам шесть вставок в таблицу из 8 слотов. Обратите внимание на пробирование при коллизии на шаге 3 и на расширение на шаге 6 — массив записей копируется дословно, ни одна запись не меняет позицию.
Ключи здесь целые, а не строковые, и это сделано намеренно: hash(n) == n, поэтому каждый номер слота ниже можно проверить в своём интерпретаторе, а хеш строки невоспроизводим между запусками (см. шаг 1).
Пустой словарь. dk_indices — 8 слотов, во всех DKIX_EMPTY (показаны как ·). dk_entries пуст. Полезная ёмкость таблицы из 8 слотов — USABLE_FRACTION(8) = 5 записей.
Тот же словарь целиком, если хочется убедиться самому:
import sys
d = {}
for k in (3, 6, 11, 1, 7):
d[k] = "…"
print(sys.getsizeof(d)) # таблица ещё на 8 слотов
d[2] = "…" # шестая вставка пересекает порог 2/3
print(sys.getsizeof(d)) # выросла: dk_indices теперь на 16 слотов
print(list(d)) # [3, 6, 11, 1, 7, 2] — порядок вставки уцелелОбновление и удаление: три разных пути
Про вставку нового ключа сказано всё, но d[k] = v — это ещё и обновление уже существующего ключа, а del d[k] устроен совсем иначе. Три пути ведут себя по-разному, и разница видна невооружённым глазом.
Обновление: ничего не двигается
Если поиск нашёл ключ, insertdict до создания записи вообще не доходит:
if (old_value != value) {
uint64_t new_version = _PyDict_NotifyEvent(
interp, PyDict_EVENT_MODIFIED, mp, key, value);
assert(old_value != NULL);
assert(!_PyDict_HasSplitTable(mp));
if (DK_IS_UNICODE(mp->ma_keys)) {
PyDictUnicodeEntry *ep = &DK_UNICODE_ENTRIES(mp->ma_keys)[ix];
STORE_VALUE(ep, value);
}
else {
PyDictKeyEntry *ep = &DK_ENTRIES(mp->ma_keys)[ix];
STORE_VALUE(ep, value);
}
mp->ma_version_tag = new_version;
}
Py_XDECREF(old_value);Переписывается одно поле — me_value, а старое значение получает Py_DECREF. Ни dk_indices, ни me_key, ни me_hash не трогаются, dk_nentries и dk_usable не меняются. Отсюда практическое следствие, о котором часто спорят: присваивание существующему ключу не меняет порядок обхода. Ключ остаётся на своей позиции в dk_entries, а значит и в итерации.
Удаление: надгробие вместо записи
delitem_common трогает обе структуры. Ниже — её тело, сокращённое до записей в память:
Py_ssize_t hashpos = lookdict_index(mp->ma_keys, hash, ix);
STORE_USED(mp, mp->ma_used - 1);
mp->ma_keys->dk_version = 0;
dictkeys_set_index(mp->ma_keys, hashpos, DKIX_DUMMY);
PyDictKeyEntry *ep = &DK_ENTRIES(mp->ma_keys)[ix];
old_key = ep->me_key;
STORE_KEY(ep, NULL);
STORE_VALUE(ep, NULL);
STORE_HASH(ep, 0);В слоте вместо индекса появляется DKIX_DUMMY (−2) — «надгробие». Пометить слот как пустой нельзя, и в исходнике прямо написано почему:
Dummy slots cannot be made Unused again else the probe sequence in case of collision would have no way to know they were once active.
Если бы слот стал DKIX_EMPTY, поиск ключа, который когда-то из-за коллизии перепрыгнул через этот слот дальше по цепочке проб, останавливался бы на нём — и ключ, лежащий в словаре, перестал бы находиться.
Запись в dk_entries при этом обнуляется на месте: на её позиции остаётся дыра. И — самое важное для следующего раздела — счётчик свободной ёмкости не восстанавливается. Комментарий в исходнике объясняет и это: раз в dk_indices остаются надгробия, увеличить dk_usable нельзя, хотя живых ключей стало меньше.
d = {1: 1, 2: 2, 3: 3}Исходное состояние: три ключа в таблице из 8 слотов. Ключи целые, поэтому hash(n) = n и слот — это n & 7. В слотах 1, 2, 3 лежат индексы 0, 1, 2, записи в dk_entries идут подряд в порядке вставки.
Отдельно стоит запомнить последний кадр: удалить ключ и вставить его обратно — не то же самое, что обновить значение. Слот в dk_indices переиспользуется (find_empty_slot останавливается и на пустом слоте, и на надгробии), но запись создаётся новая, в хвосте, — и ключ уезжает в конец порядка обхода.
Так растёт ли dk_indices бесконечно?
Из предыдущего раздела складывается тревожная картина: каждая вставка тратит ёмкость, каждое удаление оставляет мусор и ничего не возвращает. Если долго вставлять и удалять, таблица должна пухнуть без предела.
Не должна — и вот почему. Ёмкость dk_usable действительно утекает, но когда она доходит до нуля, очередная вставка запускает пересборку, а размер новой таблицы считается от числа живых ключей — GROWTH_RATE(mp) — это ma_used * 3, и ma_used учитывает только живые. Мёртвые записи и надгробия в этот расчёт не входят и при копировании просто исчезают. Поэтому размер таблицы определяется тем, сколько ключей в словаре сейчас, а не тем, сколько операций над ним сделали.
d = {}Пустой словарь не владеет таблицей вообще: ma_keys указывает на разделяемый пустой объект ключей, поэтому sys.getsizeof(d) — 64 байта, и ни dk_indices, ни dk_entries ещё не выделены.
Из этого следуют три вещи, каждую из которых легко проверить самому.
Первое: при постоянном цикле «вставил — удалил» словарь не растёт. Миллион пар вставка/удаление при сотне живых ключей меняет размер ровно один раз, в самом начале:
import sys
d = {i: i for i in range(100)}
prev, changes = sys.getsizeof(d), []
for i in range(1_000_000):
d[("k", i)] = i
del d[("k", i)]
if sys.getsizeof(d) != prev:
changes.append((i, prev, sys.getsizeof(d)))
prev = sys.getsizeof(d)
print(len(d), changes) # 100 [(70, 4688, 9304)]
print(sys.getsizeof(d)) # 9304 — и дальше константаВторое: таблица умеет и сжиматься. Словарь на тысячу ключей, из которых осталось десять, после первой же пересборки ужимается почти в шестьдесят раз:
import sys
d = {i: i for i in range(1000)}
for i in range(990):
del d[i]
print(sys.getsizeof(d)) # 36952 — удаления не вернули ничего
for n in range(1, 400): # цикл вставка/удаление: живых по-прежнему 10
d[("n", n)] = n
del d[("n", n)]
print(sys.getsizeof(d)) # 632Третье: сами по себе удаления не освобождают память. Пересборка бывает только при вставке, поэтому словарь, из которого всё удалили и в который больше не пишут, продолжает держать старую таблицу:
import sys
d = {i: i for i in range(1000)}
for i in range(1000):
del d[i]
print(len(d), sys.getsizeof(d)) # 0 36952 — пусто, но память занята
d.clear()
print(sys.getsizeof(d)) # 64 — вот теперь освободиласьПрактический вывод ровно один: если из большого словаря удалили почти всё и дальше он будет жить долго, а вставок в него не предвидится — вызовите clear() или соберите новый словарь. Во всех остальных случаях механизм сам себя чистит.
Кадры на Рис. 3 и Рис. 4 — не модель, а замер: состояние реального словаря, прочитанное прямо из памяти интерпретатора. Скрипт ниже воспроизводит его целиком (ctypes тут читает приватные структуры CPython — это годится для того, чтобы посмотреть, и категорически не годится для рабочего кода).
import ctypes
import sys
class DictKeys(ctypes.Structure):
_fields_ = [("dk_refcnt", ctypes.c_ssize_t),
("dk_log2_size", ctypes.c_uint8),
("dk_log2_index_bytes", ctypes.c_uint8),
("dk_kind", ctypes.c_uint8),
("dk_version", ctypes.c_uint32),
("dk_usable", ctypes.c_ssize_t),
("dk_nentries", ctypes.c_ssize_t)]
class Dict(ctypes.Structure):
_fields_ = [("ob_refcnt", ctypes.c_ssize_t),
("ob_type", ctypes.c_void_p),
("ma_used", ctypes.c_ssize_t),
("ma_version_tag", ctypes.c_uint64),
("ma_keys", ctypes.POINTER(DictKeys)),
("ma_values", ctypes.c_void_p)]
def state(d):
obj = Dict.from_address(id(d))
keys = obj.ma_keys.contents
size = 1 << keys.dk_log2_size
base = ctypes.addressof(keys) + ctypes.sizeof(DictKeys)
slots = list((ctypes.c_int8 * size).from_address(base))
return (f"used={obj.ma_used} size={size} usable={keys.dk_usable} "
f"nentries={keys.dk_nentries} bytes={sys.getsizeof(d)}\n {slots}")
d = {1: 1, 2: 2, 3: 3}
print("три вставки ", state(d))
d[1] = 100
print("обновление ", state(d)) # счётчики не изменились
del d[2]
print("удаление ", state(d)) # в слоте 2 появилось -2, usable прежний
d[2] = 22
print("вставка назад", state(d), list(d)) # [1, 3, 2] — ключ уехал в конецПод капотом: история версий
Самый часто перевираемый факт о словарях — когда на порядок вставки стало можно полагаться. Это не одно событие, а два, разнесённые ровно на один релиз:
| Версия | Изменение | Статус порядка |
|---|---|---|
| 3.5 | Разреженный массив записей: (хеш, ключ, значение) лежат прямо в слотах. Порядок обхода следует слотам хеша. | Произвольный |
| 3.6 | Компактный словарь (bpo-27350, INADA Naoki по идее Raymond Hettinger): раздельные dk_indices и dk_entries, памяти на 20–25% меньше. | Упорядочен — деталь реализации |
| 3.7 | Сохранение порядка вставки объявлено частью спецификации языка. | Упорядочен — гарантия языка |
| 3.11 | Словари со сплошь строковыми ключами перестали хранить хеши: sys.getsizeof(dict.fromkeys("abcdefg")) — 272 байта вместо 352 (bpo-46845). | Гарантия та же |
| 3.13 | Экспериментальная сборка без GIL (PEP 703). Раскладка словаря в ней не менялась. | Гарантия та же |
Что из этого — гарантия языка, а что — деталь реализации
Стоит разделять два уровня:
- Гарантия языка (можно полагаться в проде): словари сохраняют порядок вставки — с Python 3.7 это часть спецификации, а не побочный эффект (см. changelog 3.7). Тип ключей
int/str/произвольный hashable,dict[key]— амортизированный O(1). - Деталь реализации CPython (может измениться в другой версии или в PyPy/GraalPy): конкретные числа 8 / ⅔ / ×3, формула
PERTURB_SHIFT = 5, само разделение наdk_indices/dk_entries, key-sharing dict из PEP 412, отказ от хранения хешей для строковых ключей в 3.11. Код, который явно завязан на эти числа (а не просто на публичное поведение словаря), — красный флаг при код-ревью.
Частые заблуждения
«Словари упорядочены, потому что CPython сортирует ключи».
Ничего не сортируется. Порядок — это физический порядок плотного массива dk_entries, куда пишут только в конец. Удалите ключ и добавьте снова — он уедет в конец; сортировка так не сделала бы.
«Порядок вставки был гарантирован языком всегда, как минимум с 3.6».
В 3.6 это была особенность конкретной реализации CPython — changelog прямо просил на неё не полагаться (цитата выше), и другие интерпретаторы могли вести себя иначе. Частью спецификации языка порядок стал только в 3.7.
«Коллизия — это связный список, как в учебной хеш-таблице».
CPython использует открытую адресацию. При коллизии вычисляется i = (i*5 + perturb + 1) & mask с подмешиванием старших битов полного хеша, поэтому последовательности проб различаются даже у ключей, попавших в один и тот же начальный слот.
«Раз hash("строка") детерминирован внутри программы, его можно сравнивать между запусками или сохранять на диск».
Хеш строк и bytes — SipHash с seed, случайным для каждого запуска процесса (PEP 456, PYTHONHASHSEED). Внутри одного запуска он стабилен, между запусками — как правило, разный.
Проверка знаний
Словарь с 5 ключами вырос (resize) до 16 слотов — без единого удаления по пути. Что верно для самого следующего обхода?
Связанные темы
Источники и что читать дальше
12 ИСТОЧНИКОВ
- Objects/dictobject.c — реализация словаряИсходный код CPython. Исходники CPython на закреплённом теге. Пробирование, USABLE_FRACTION, GROWTH_RATE и проектные комментарии, на которые опирается вся статья. Тег CPython 3.13.0.https://github.com/python/cpython/blob/v3.13.0/Objects/dictobject.c
- Include/internal/pycore_dict.h — раскладка структур словаряИсходный код CPython. Определения PyDictKeyEntry и PyDictUnicodeEntry, константы DKIX_*, перечисление dk_kind и правило ширины слота в dk_indices. Тег CPython 3.13.0.https://github.com/python/cpython/blob/v3.13.0/Include/internal/pycore_dict.h
- Objects/dict-common.h (CPython 3.5) — словарь до компактной раскладкиИсходный код CPython. Структура _dictkeysobject с массивом dk_entries, который сам и был разреженной хеш-таблицей. Нужна, чтобы посчитать, сколько стоила пустая треть до 3.6. Тег CPython 3.5.0.https://github.com/python/cpython/blob/v3.5.0/Objects/dict-common.h
- Python/pyhash.c — хеш-функции интерпретатораИсходный код CPython. Соглашение о −1 как коде ошибки и подмене его на −2, реализация SipHash для строк и bytes. Тег CPython 3.13.0.https://github.com/python/cpython/blob/v3.13.0/Python/pyhash.c
- What's New In Python 3.6 — новая реализация dictОфициальная документация. Release notes PSF. Здесь зафиксировано «на 20–25% меньше памяти» и прямая оговорка, что порядок — деталь реализации.https://docs.python.org/3/whatsnew/3.6.html
- What's New In Python 3.7 — «dict objects preserve insertion order»Официальная документация. Release notes PSF. Фраза, превратившая деталь реализации в гарантию языка.https://docs.python.org/3/whatsnew/3.7.html
- What's New In Python 3.11 — оптимизации словаряОфициальная документация. Словари перестали хранить хеши, когда все ключи — строки: 352 → 272 байта на 64-битной сборке. Вклад INADA Naoki, bpo-46845.https://docs.python.org/3/whatsnew/3.11.html
- What's New In Python 3.13 — экспериментальная сборка без GILОфициальная документация. Режим free-threading помечен как экспериментальный; раскладка словаря в нём не менялась.https://docs.python.org/3/whatsnew/3.13.html
- sys.hash_info — параметры хешированияОфициальная документация. Ширина хеша, модуль простого числа Мерсенна и выбранный алгоритм хеширования строк для конкретной сборки.https://docs.python.org/3/library/sys.html#sys.hash_info
- PEP 412 — Key-Sharing DictionaryPEP. Mark Shannon, 2012. Split-table словари, из-за которых словари экземпляров дешевле, чем кажутся.https://peps.python.org/pep-0412/
- PEP 456 — Secure and interchangeable hash algorithmPEP. Christian Heimes, 2013. SipHash и рандомизация seed как защита от hash-flooding.https://peps.python.org/pep-0456/
- python-dev: исходное предложение компактного словаряСписок рассылки. Письмо Raymond Hettinger, декабрь 2012 — идея раскладки, которую четыре года спустя реализовал INADA Naoki. На него ссылается changelog 3.6.https://mail.python.org/pipermail/python-dev/2012-December/123028.html