Learning Platform
Глоссарий Troubleshooting
Урок 10.05 · 28 мин
Начальный
anti-patternsdict performancedataclass hashcommon mistakes

Чек-лист «как сделать dict медленным»

В этом уроке соберём все ошибки, которые превращают O(1) dict в кошмар. Если в вашем коде есть один из этих паттернов, найдите и почините — обычно это даёт 10-100× ускорение бесплатно.

Анти-паттерн 1: mutable как ключ — TypeError

Ключ dict должен быть hashable, а значит immutable. Попытка использовать list, dict или set как ключ приводит к ошибке:

d = {}
try:
    d[[1, 2, 3]] = "value"
except TypeError as e:
    print(e)   # unhashable type: 'list'

try:
    d[{"a": 1}] = "value"
except TypeError as e:
    print(e)   # unhashable type: 'dict'

Решения:

# вместо list — tuple
d[(1, 2, 3)] = "value"     # OK

# вместо set — frozenset
d[frozenset({"a", "b"})] = "value"  # OK

# вместо dict — tuple отсортированных items
d[tuple(sorted({"a": 1, "b": 2}.items()))] = "value"

Это не оптимизация, это корректность. Mutable ключи неhashable намеренно: если бы их можно было использовать, их мутация после вставки сломала бы dict.

Анти-паттерн 2: огромные ключи (длинные строки/байты)

Хеш строки — O(длины). Чем длиннее ключ, тем дольше каждая операция:

import time

# короткие ключи
d = {}
start = time.perf_counter()
for i in range(100_000):
    d[f"k{i}"] = i
t_short = time.perf_counter() - start

# длинные ключи
d = {}
start = time.perf_counter()
for i in range(100_000):
    d["a" * 1000 + str(i)] = i
t_long = time.perf_counter() - start

print(f"short keys: {t_short*1000:.0f} ms")
print(f"long keys:  {t_long*1000:.0f} ms")
print(f"ratio: {t_long/t_short:.1f}x")

На M-class CPU:

  • short keys (~5 байт): ~25 ms
  • long keys (1004 байт): ~150 ms
  • Ratio ~6×

Хеш строки a*1000 стоит ~700 ns против ~10 ns для короткой. На 100К вставок это +70 ms.

Решения:

  1. Используйте int-ключи там, где возможно. Особенно если ключ — это UUID, конвертируйте в int (UUID.int).
  2. Если строки большие и читаются повторно — переходите на хеш через hashlib.blake2b(data, digest_size=8) -> int. Это даёт 8-байтные ключи и стабильный хеш между запусками.
import hashlib

def short_key(s: str) -> int:
    return int.from_bytes(hashlib.blake2b(s.encode(), digest_size=8).digest(), 'big')

# на 100K вставок длинных строк через short_key
# выигрыш ~3-4× по сравнению с прямым использованием строк

Анти-паттерн 3: dataclass без frozen=True

from dataclasses import dataclass

@dataclass
class User:
    user_id: int
    name: str

u = User(1, "Anna")
try:
    s = {u}            # хотим положить в set
except TypeError as e:
    print(e)           # unhashable type: 'User'

По умолчанию dataclass:

  • Генерирует __eq__ (если eq=True, дефолт).
  • Устанавливает __hash__ = None — потому что mutable + custom eq.

Это намеренная защита: если объект mutable, его хеш может измениться, и он потеряется в dict/set.

Решение: frozen=True.

@dataclass(frozen=True)
class User:
    user_id: int
    name: str

u = User(1, "Anna")
s = {u}                # OK
d = {u: "value"}       # OK
print(hash(u))         # генерируется автоматически

frozen=True делает две вещи:

  1. Запрещает мутацию (попытка u.name = "X" даёт FrozenInstanceError).
  2. Автогенерирует __hash__ = hash от tuple всех полей.

Это правильный паттерн для бизнес-объектов, которые кладёте в dict/set.

Анти-паттерн 4: «полу-плохой» __hash__

Хуже отсутствия __hash__ — кастомный, который кажется работающим, но даёт коллизии:

class Order:
    def __init__(self, user_id, product_id, qty):
        self.user_id = user_id
        self.product_id = product_id
        self.qty = qty

    def __hash__(self):
        return hash(self.user_id)   # хешируем только по user_id

    def __eq__(self, other):
        return (self.user_id == other.user_id and
                self.product_id == other.product_id and
                self.qty == other.qty)

Что плохого? Если у одного user_id много заказов (что нормально), все они получают один и тот же хеш. Когда вы кладёте 100К заказов от 1000 разных user’ов, в среднем 100 заказов имеют один хеш. Это даёт probing-цепочки длиной до 100 — операции замедляются в 50-100 раз.

Решение: хешируйте по всем полям, участвующим в __eq__:

class Order:
    def __hash__(self):
        return hash((self.user_id, self.product_id, self.qty))

Или используйте @dataclass(frozen=True) — он автоматически делает правильно:

@dataclass(frozen=True)
class Order:
    user_id: int
    product_id: int
    qty: int
# __hash__ = hash((user_id, product_id, qty)) — автоматически

Анти-паттерн 5: list-based dedup

Самая частая ошибка junior’ов:

# плохо
unique = []
for item in data:
    if item not in unique:    # O(n) проверка
        unique.append(item)

# хорошо — set
unique = list(set(data))      # O(n) total, теряем порядок

# хорошо — dict.fromkeys, сохраняем порядок (3.7+)
unique = list(dict.fromkeys(data))

На 1M записей разница 1000-10000× в зависимости от числа дублей.

Анти-паттерн 6: дублирующие lookup

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

# плохо — два lookup'а
if "name" in user_dict:
    name = user_dict["name"]
else:
    name = "Unknown"

# хорошо — один lookup
name = user_dict.get("name", "Unknown")
# плохо — три lookup'а внутри цикла
for user_id in user_ids:
    if user_id in lookup:
        if lookup[user_id]["country"] == "RU":
            ru_users.append(lookup[user_id])

# хорошо — один lookup, кешируем
for user_id in user_ids:
    user = lookup.get(user_id)
    if user and user["country"] == "RU":
        ru_users.append(user)

Разница на сотнях миллионов операций — секунды.

Анти-паттерн 7: dict перестроение при каждом вызове

def is_in_whitelist(item):
    whitelist = ["alice", "bob", "carol", ...]  # пересоздаётся каждый вызов
    return item in whitelist

Это вдвойне плохо:

  1. Каждый вызов создаёт новый list (alloc + GC).
  2. in list — O(n).

Решение: вынести структуру в module-level или использовать lru_cache:

_WHITELIST = frozenset(["alice", "bob", "carol"])  # один раз

def is_in_whitelist(item):
    return item in _WHITELIST   # O(1) lookup

frozenset вместо set — потому что неизменяемый, можно делать module-level безопасно.

Анти-паттерн 8: ненужное преобразование dict-keys в list

# плохо
keys_list = list(d.keys())
if "key" in keys_list:    # O(n) поиск в list!
    ...

# хорошо
if "key" in d:            # O(1) — dict сам поддерживает in
    ...

dict.keys() — это view, не list. in на dict работает за O(1) напрямую. Преобразование в list создаёт временную копию и теряет O(1) свойство.

Реальный случай: dataclass без __hash__ vs hash(frozenset(...))

Допустим, у вас приложение с permissions. Один пользователь имеет набор прав. Вам нужен dict, где ключ — «множество прав», значение — название роли.

Подход 1: dataclass без frozen

@dataclass
class Permissions:
    perms: frozenset[str]

# не сработает — Permissions не hashable
roles = {Permissions(frozenset({"read"})): "viewer"}   # TypeError

Подход 2: dataclass с frozen=True

@dataclass(frozen=True)
class Permissions:
    perms: frozenset[str]

roles = {
    Permissions(frozenset({"read"})): "viewer",
    Permissions(frozenset({"read", "write"})): "editor",
}

# работает, но overkill: класс ради одного поля

Подход 3: прямо frozenset

roles = {
    frozenset({"read"}): "viewer",
    frozenset({"read", "write"}): "editor",
    frozenset({"read", "write", "delete"}): "admin",
}

# работает, минимум кода
user_perms = frozenset({"write", "read"})
print(roles[user_perms])   # "editor"

Если хранится одно поле — используйте напрямую (frozenset, tuple). Если несколько — @dataclass(frozen=True). Никогда — обычный dataclass для ключей.

Профилирование: где у вас dict ест время

Используйте cProfile, чтобы найти горячие dict-операции:

import cProfile
import pstats

def my_pipeline():
    # ваш код с dict
    ...

profiler = cProfile.Profile()
profiler.enable()
my_pipeline()
profiler.disable()

stats = pstats.Stats(profiler).sort_stats('cumulative')
stats.print_stats(20)

Что искать:

  • Много вызовов __hash__ или __eq__ на одной строке — подозрение на коллизии.
  • Время в dict.__contains__ или dict.__getitem__ несоизмеримо большое — медленный hash() или плохой hash.
  • Множественные обращения к одному ключу — паттерн «дублирующие lookup».

Чек-лист для review

Когда смотрите чужой код:

  1. [ok] Все ли ключи immutable? Любая попытка положить list или mutable объект?
  2. [ok] Если custom класс — есть ли @dataclass(frozen=True) или явные hash/eq?
  3. [ok] Нет ли if x in some_list в цикле — пора в set/dict?
  4. [ok] Нет ли дублирующих lookup d[k] d[k] рядом?
  5. [ok] Длинные строковые ключи — есть ли возможность сократить (UUID.int, blake2b)?
  6. [ok] Постоянные структуры (whitelist’ы, mappings) — вынесены в module-level frozenset/dict?
WARNING

Самый частый dict-баг в junior DE-коде — это сочетание плохого __hash__ (зависит от одного поля при многополевом __eq__) и большой исходник (100К+ записей). Симптом: код работает на dev-данных, но виснет на проде. Лекарство: всегда хешировать по тем же полям, что в __eq__, или использовать @dataclass(frozen=True).

Попробуй сам

import time

# 1. Найдите анти-паттерны в этом коде
# (см. ниже исправленные варианты)
def slow_process(data):
    seen = []
    result = []
    for item in data:
        if item not in seen:
            seen.append(item)
            result.append(item.upper())
    return result

# исправленный
def fast_process(data):
    seen = set()
    result = []
    for item in data:
        if item not in seen:
            seen.add(item)
            result.append(item.upper())
    return result

# сравните на 100K элементах с 90% дублей
data = ["key_" + str(i % 10000) for i in range(100_000)]

start = time.perf_counter()
slow_process(data)
print(f"slow: {(time.perf_counter()-start)*1000:.0f} ms")

start = time.perf_counter()
fast_process(data)
print(f"fast: {(time.perf_counter()-start)*1000:.0f} ms")

# 2. dataclass без frozen — что будет с set?
from dataclasses import dataclass

@dataclass
class User:
    user_id: int
    name: str

try:
    s = {User(1, "A"), User(2, "B")}
except TypeError as e:
    print(f"\nTypeError: {e}")

# с frozen
@dataclass(frozen=True)
class UserFrozen:
    user_id: int
    name: str

s = {UserFrozen(1, "A"), UserFrozen(2, "B"), UserFrozen(1, "A")}   # дубль
print(f"frozen set: {len(s)} elements")   # 2 — дубль отсеян

# 3. Преобразование длинных строк в короткие хеши
import hashlib

def short(s):
    return int.from_bytes(hashlib.blake2b(s.encode(), digest_size=8).digest(), 'big')

long_keys = ["very_long_string_with_uuid_" + str(i) for i in range(100_000)]

start = time.perf_counter()
d = {}
for k in long_keys:
    d[k] = 1
t1 = time.perf_counter() - start

start = time.perf_counter()
d = {}
for k in long_keys:
    d[short(k)] = 1
t2 = time.perf_counter() - start

print(f"\nlong str keys:  {t1*1000:.0f} ms")
print(f"hashed int keys: {t2*1000:.0f} ms")
print(f"ratio: {t1/t2:.1f}x")

В следующем модуле переходим к деревьям. Hash-таблицы остаются за нами — вы теперь знаете, как они работают, что внутри, и как не делать их медленными.

__slots__ и @dataclass: frozen=True и __hash__ Hash Join и data skew: когда hash-таблица деградирует
Проверка знанийKnowledge check
Junior сделал @dataclass(eq=True) для класса Order, но забыл frozen=True. При попытке Order(...) в set получает TypeError: unhashable type. Почему Python специально это запрещает?
ОтветAnswer
Python намеренно делает mutable dataclass с custom __eq__ неhashable — это защита от классического бага. Инвариант hash-таблицы требует, чтобы хеш ключа НЕ менялся за время жизни объекта. Если объект mutable (можно сделать order.qty = 999), его хеш изменится после мутации, и dict/set "потеряет" ключ — будет искать его в новом слоте по новому хешу, а лежит он в старом по старому. Python ставит __hash__ = None при сочетании "eq=True (custom equality) + mutable" — это сигнал "ты, разработчик, должен решить осознанно: либо frozen=True (immutable + auto-hash), либо unsafe_hash=True (опасно — оставляешь mutable, но обещаешь не мутировать после вставки в hash-таблицу)". Это паттерн "защита от outdated объектов в hash-таблице". Без этой защиты dict бы тихо ломался при мутации, что один из самых трудно отлаживаемых багов.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 6. Почему попытка положить `list` как ключ в dict даёт TypeError?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5