Deep Engineering
Ядро·Опубликовано·3.6 · 3.13·45 МИН

Словари Python изнутри: хеш-таблица CPython

Хеширование, открытая адресация и переписывание словаря в 2016 году, которое сделало порядок вставки побочным эффектом реализации — а через один релиз сделало его гарантией языка.

Полное техническое изложение

TL;DR

  • Словарь CPython — это два массива: разреженный массив индексов dk_indices и плотный, упорядоченный по вставке массив записей dk_entries.
  • Поиск — открытая адресация с пробированием через perturb, а не цепочки в бакетах. Коэффициент заполнения ограничен ⅔.
  • Сохранение порядка появилось в 3.6 как деталь реализации этой раскладки и стало гарантией языка в 3.7.
  • Удаление оставляет в таблице надгробие и не возвращает ёмкость, но размер таблицы задаётся числом живых ключей, а не историей операций.

Зачем это знать?

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

Практическое следствие: у 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.

— What's New in Python 3.6 — New dict implementation

Дословно: «сохранение порядка в этой новой реализации считается деталью реализации, и полагаться на неё не следует». Так авторы CPython описали побочный эффект компактного словаря в 3.6 — за один релиз до того, как это же поведение стало официальной частью языка.

Устройство: две таблицы вместо одной

«Разреженный» и «плотный» — это не эпитеты

Два слова, без которых дальше ничего не сойдётся.

Разреженный массив — тот, в котором свободных ячеек намеренно больше, чем нужно. Хеш-таблице с открытой адресацией это не роскошь, а условие работы: ключ ищется по формуле «взять хеш, оставить от него младшие биты, посмотреть в слот с этим номером», и если свободных слотов не останется, поиск свободного места и разрешение коллизий выродятся в перебор всей таблицы. Поэтому CPython не даёт таблице заполниться больше чем на две трети (USABLE_FRACTION) — пока удалений не было, минимум треть слотов пустует — это не потери, а рабочий запас. Что делают с этим запасом удаления, разобрано ниже, в разделе «Так растёт ли dk_indices бесконечно?».

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

Компактный словарь состоит из обоих сразу — и весь фокус в том, какой из них сделан разреженным.

Зачем вообще нужен dk_indices

До Python 3.6 массив был один. Вот та самая структура из CPython 3.5:

Objects/dict-common.h (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.5Python 3.6+
хеш-таблица8 × 24 = 192 Бdk_indices: 8 × 1 = 8 Б
записи— (они же и есть таблица)dk_entries: 5 × 24 = 120 Б
итого192 Б, из них 72 Б впустую128 Б, впустую — 8 байт индексов

Экономия здесь — треть. Но она не постоянная, и это стоит знать до того, как переносить расчёт на большие таблицы: слот dk_indices шире одного байта не всегда. В него надо уместить номер записи, поэтому ширина растёт ступенями — 1 байт до dk_size 255, 2 до 65 535, дальше 4 и 8 (запуск печатает эту лестницу и сверяет формулу с sys.getsizeof).

Отсюда на таблице в 65 536 слотов (43 690 записей) получается 1 572 864 байта против 1 310 704 — экономия уже 16,7 %, а не треть. Заявленные в «Что нового в Python 3.6» «на 20–25 % меньше памяти» приходятся на таблицы среднего размера: на dk_size = 256 выходит ровно 25,3 %. Чем шире индекс, тем меньше выигрыш.

А теперь ответ на вопрос «зачем эта прослойка вообще»: без неё поиск ключа стал бы линейным. 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:

Include/internal/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 байт, а не восемь полноценных записей. Даже при таблице на 32 тысячи слотов это два байта на слот, и только с 65 536 — четыре.

dk_entries — плотный массив самих записей, в порядке вставки. И вот что в записи лежит на самом деле:

Include/internal/pycore_dict.h
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, сокращённое до записей в память:

Objects/dictobject.c — insert_combined_dict()
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);

Читается это так:

  1. find_empty_slot ищет свободный слот в dk_indices — по хешу и, при коллизии, по последовательности проб (о ней ниже). Ключ и значение в этом поиске не участвуют вообще: слот выбирают биты хеша.
  2. dictkeys_set_index пишет в найденный слот текущее значение dk_nentries — номер записи, которой ещё нет. Индекс в доску попадает раньше, чем создана сама запись.
  3. Только теперь заполняется запись в dk_entries по индексу dk_nentries: два указателя, а в общем режиме ещё и хеш.
  4. dk_usable уменьшается на единицу, dk_nentries — увеличивается. Первый счётчик — это остаток до расширения (USABLE_FRACTION), второй — граница заполненной части плотного массива.

Ничего больше при вставке в память не пишется. Ни ключ, ни значение никуда не копируются: STORE_KEY и STORE_VALUE сохраняют адреса уже существующих объектов и увеличивают их счётчики ссылок.

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

Второй вид записи: когда все ключи — строки

У записи есть и вторая форма. Если все ключи таблицы строковые, CPython использует другую структуру:

Include/internal/pycore_dict.h
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, — о нём ниже). Это и есть механизм экономии, о которой говорит «Что нового в Python 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) — SipHash с seed, случайным для каждого запуска процесса (PEP 456, PYTHONHASHSEED). Какой именно вариант SipHash собран, показывает sys.hash_info.algorithm. Это осознанное решение о безопасности: до PEP 456 предсказуемый хеш строк давал классическую DoS-атаку «hash flooding» через специально подобранные ключи. Практическое следствие: hash("cpython") в двух разных запусках python3 — это, как правило, два разных числа.

Ниже — тот же самый первый шаг, key → hash(key), в интерактивном виде. Для int показано настоящее значение hash(); для str — намеренно упрощённая демонстрационная функция (не SipHash), потому что настоящий хеш строки невоспроизводим между запусками по построению.

Шаг 2: от числа к слоту

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

Objects/dictobject.c — вычисление слота (реконструкция по исходнику)
mask = dk_size - 1;
i = hash & mask;

& mask вместо % dk_size — обычный трюк для степеней двойки: младшие биты hash и есть остаток от деления, без деления как такового.

Коллизии: последовательность проб CPython

Два разных ключа почти неизбежно дадут одинаковый i рано или поздно. Здесь у CPython не линейное пробирование и не что-то на основе двойного хеширования из учебника — своя рекуррентная формула; вот она в виде цикла, по комментарию из dictobject.c:

Objects/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 и отмеченных как занятые слотов:

Рост таблицы: когда и во сколько раз

Хеш-таблица, заполненная под завязку, вырождается в линейный поиск — CPython не доводит до этого и держит запас. Три числа из dictobject.c полностью определяют, когда и как таблица растёт:

Objects/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).

Тот же словарь целиком, если хочется убедиться самому:

check_order.py
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 до создания записи вообще не доходит:

Objects/dictobject.c — 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 трогает обе структуры. Ниже — её тело, сокращённое до записей в память:

Objects/dictobject.c — 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.

— Objects/dictobject.c, комментарий о состояниях слота

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

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

Отдельно стоит запомнить последний кадр: удалить ключ и вставить его обратно — не то же самое, что обновить значение. Слот в dk_indices переиспользуется (find_empty_slot останавливается и на пустом слоте, и на надгробии), но запись создаётся новая, в хвосте, — и ключ уезжает в конец порядка обхода.

Так растёт ли dk_indices бесконечно?

Из предыдущего раздела складывается тревожная картина: каждая вставка тратит ёмкость, каждое удаление оставляет мусор и ничего не возвращает. Если долго вставлять и удалять, таблица должна пухнуть без предела.

Не должна — и вот почему. Ёмкость dk_usable действительно утекает, но когда она доходит до нуля, очередная вставка запускает пересборку, а размер новой таблицы считается от числа живых ключей — GROWTH_RATE(mp) — это ma_used * 3, и ma_used учитывает только живые. Мёртвые записи и надгробия в этот расчёт не входят и при копировании просто исчезают. Поэтому размер таблицы определяется тем, сколько ключей в словаре сейчас, а не тем, сколько операций над ним сделали.

Из этого следуют три вещи, каждую из которых легко проверить самому.

Первое: при постоянном цикле «вставил — удалил» словарь не растёт. Миллион пар вставка/удаление при сотне живых ключей меняет размер ровно один раз, в самом начале:

churn.py
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 — и дальше константа

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

shrink.py
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

Третье: сами по себе удаления не освобождают память. Пересборка бывает только при вставке, поэтому словарь, из которого всё удалили и в который больше не пишут, продолжает держать старую таблицу:

no_shrink.py
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 — вот теперь освободилась

Практический вывод ровно один: если из большого словаря удалили почти всё и дальше он будет жить долго, а вставок в него не предвидится — пересоберите его: d = dict(d). Если оставшиеся ключи уже не нужны, таблицу целиком освободит d.clear(). Во всех остальных случаях механизм сам себя чистит.

Кадры на Рис. 3 и Рис. 4 — не модель, а замер: состояние реального словаря, прочитанное прямо из памяти интерпретатора. Скрипт ниже воспроизводит его целиком (ctypes тут читает приватные структуры CPython — это годится для того, чтобы посмотреть, и категорически не годится для рабочего кода).

dict_state.py
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] — ключ уехал в конец

У O(1) есть худший случай, и он O(n)

Всё, что выше сказано про пробирование, держится на одном допущении: хеши разбросаны. Если они не разбросаны, механизм не ломается — он вырождается, и вырождается ровно в перебор.

Измеряется это не секундомером, а счётчиком: сколько раз словарь позвал __eq__ за один поиск (bench/dict/hash_collisions.py).

nплохой хеш: первый ключплохой хеш: последнийнормальный хеш
161161
641641
25612561
1024110241
4096140961

Число сравнений равно числу ключей — это O(n) буквально, а не «медленнее». И число это от машины не зависит вовсе: считается сам алгоритм.

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

Время приложено как иллюстрация, не как утверждение о вашей машине: от n = 16 к n = 4096 плохой хеш подорожал примерно в 250 раз (в опубликованном прогоне — в 257,7, на повторных выходило от 240 до 290), нормальный — в 1,0. Числа сняты на 3.13.7.

Отдельно — цена самого протокола, без всяких коллизий. Хеш int и str считает код на C; у пользовательского класса __hash__ — это вызов функции Python на каждом поиске:

int                                        45,4 нс
str (кеш хеша в объекте)                   37,1 нс
объект без __hash__/__eq__ (по id)         35,5 нс
объект с __hash__/__eq__ на Python        143,6 нс

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

Строка про int медленнее str не опечатка и объясняется тем, о чём статья уже писала выше: словарь со строковыми ключами идёт по специализированной lookdict_unicode, а с целыми — по общей lookdict; вдобавок у строки хеш посчитан один раз и лежит в самом объекте.

Практический вывод короткий. O(1) у словаря — это утверждение про хеш, а не про словарь. Свой __hash__, возвращающий константу или зависящий от одного поля из десяти, превращает поиск в перебор молча — ни исключения, ни предупреждения не будет.

Под капотом: история версий

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

ВерсияИзменениеСтатус порядка
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 и не только.
  • Деталь реализации CPython (может измениться в другой версии или в PyPy/GraalPy): конкретные числа 8 / ⅔ / ×3, формула PERTURB_SHIFT = 5, само разделение на dk_indices/dk_entries, key-sharing dict из PEP 412, отказ от хранения хешей для строковых ключей в 3.11. Код, который явно завязан на эти числа (а не просто на публичное поведение словаря), — красный флаг при код-ревью.

Чем измерено

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

Унести в работу

  • Полагаться на порядок вставки можно: с 3.7 это гарантия языка. Для 3.6 changelog прямо просил на неё не полагаться, и код, написанный тогда, опирался на особенность реализации.
  • Не сохранять hash() строки между запусками: это SipHash с seed, случайным для каждого запуска процесса (PEP 456, PYTHONHASHSEED). Внутри одного запуска он стабилен, между запусками — нет.
  • Не представлять коллизию связным списком: CPython использует открытую адресацию и пробует следующий слот по i = (i*5 + perturb + 1) & mask.

Частые заблуждения

Утверждение

«Словари упорядочены, потому что CPython сортирует ключи».

На самом деле

Ничего не сортируется. Порядок — это физический порядок плотного массива dk_entries, куда пишут только в конец. Удалите ключ и добавьте снова — он уедет в конец; сортировка так не сделала бы.

Утверждение

«Порядок вставки был гарантирован языком всегда, как минимум с 3.6».

На самом деле

В 3.6 это была особенность конкретной реализации CPython — changelog 3.6 прямо просил на неё не полагаться — should not be relied upon (не следует на это полагаться), —, и другие интерпретаторы могли вести себя иначе. Частью спецификации языка порядок стал только в 3.7.

Утверждение

«Коллизия — это связный список, как в учебной хеш-таблице».

На самом деле

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

Утверждение

«Раз hash("строка") детерминирован внутри программы, его можно сравнивать между запусками или сохранять на диск».

На самом деле

Хеш строк и bytes — SipHash с seed, случайным для каждого запуска процесса (PEP 456, PYTHONHASHSEED). Внутри одного запуска он стабилен, между запусками — как правило, разный.

Проверка знаний

Вопрос 1 из 4

Словарь с 5 ключами вырос (resize) до 16 слотов — без единого удаления по пути. Что верно для самого следующего обхода?

Среднийскоро
Хеш-таблицы: общая механика
Продвинутыйскоро
Хеширование и протокол __hash__
Экспертныйскоро
Множества Python изнутри

Источники и что читать дальше

12 ИСТОЧНИКОВ

  1. 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
  2. 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
  3. 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
  4. Python/pyhash.c — хеш-функции интерпретатораИсходный код CPython. Соглашение о −1 как коде ошибки и подмене его на −2, реализация SipHash для строк и bytes. Тег CPython 3.13.0.https://github.com/python/cpython/blob/v3.13.0/Python/pyhash.c
  5. What's New In Python 3.6 — новая реализация dictОфициальная документация. Release notes PSF. Здесь зафиксировано «на 20–25% меньше памяти» и прямая оговорка, что порядок — деталь реализации.https://docs.python.org/3/whatsnew/3.6.html
  6. What's New In Python 3.7 — «dict objects preserve insertion order» (пер.: «объекты dict сохраняют порядок вставки»)Официальная документация. Release notes PSF. Фраза, превратившая деталь реализации в гарантию языка.https://docs.python.org/3/whatsnew/3.7.html
  7. What's New In Python 3.11 — оптимизации словаряОфициальная документация. Словари перестали хранить хеши, когда все ключи — строки: 352 → 272 байта на 64-битной сборке. Вклад INADA Naoki, bpo-46845.https://docs.python.org/3/whatsnew/3.11.html
  8. What's New In Python 3.13 — экспериментальная сборка без GILОфициальная документация. Режим free-threading помечен как экспериментальный; раскладка словаря в нём не менялась.https://docs.python.org/3/whatsnew/3.13.html
  9. sys.hash_info — параметры хешированияОфициальная документация. Ширина хеша, модуль простого числа Мерсенна и выбранный алгоритм хеширования строк для конкретной сборки.https://docs.python.org/3/library/sys.html#sys.hash_info
  10. PEP 412 — Key-Sharing DictionaryPEP. Mark Shannon, 2012. Split-table словари, из-за которых словари экземпляров дешевле, чем кажутся.https://peps.python.org/pep-0412/
  11. PEP 456 — Secure and interchangeable hash algorithmPEP. Christian Heimes, 2013. SipHash и рандомизация seed как защита от hash-flooding.https://peps.python.org/pep-0456/
  12. python-dev: «More compact dictionaries with faster iteration» (пер.: «Более компактные словари с более быстрым обходом»)Список рассылки. Письмо Raymond Hettinger, декабрь 2012 — идея раскладки, которую четыре года спустя реализовал INADA Naoki. На него ссылается changelog 3.6.https://mail.python.org/pipermail/python-dev/2012-December/123028.html