Learning Platform
Глоссарий Troubleshooting
Урок 09.01 · 25 мин
Начальный
hashinghash functionPython hashdeterministicuniform

Зачем data engineer’у понимать хеш-функцию

Хеш-таблица — главная структура данных в работе data engineer’а. Каждый dict в Python, каждый HashMap в Spark, каждый shuffle-этап в распределённой системе опирается на одну математическую операцию: превратить ключ произвольной формы в число фиксированной длины. Эта операция называется хеш-функция.

Если хеш-функция плохая, dict перестаёт давать вам O(1) lookup. Join двух датафреймов перестаёт распределяться равномерно по партициям. Один воркер получает 80% данных, остальные простаивают. Время выполнения job вырастает в 10 раз. Поэтому знать, что делает hash() и какие у неё свойства, — не теория, а инженерная гигиена.

Определение

Хеш-функция — это детерминированная функция, принимающая на вход данные любого размера (строка, число, кортеж, целый объект) и возвращающая целое число фиксированной длины. В Python это число типа int со знаком, в 64-битной системе — от -2^63 до 2^63 - 1.

print(hash(42))                  # 42
print(hash("hello"))             # -7146178545157038398 (зависит от запуска)
print(hash((1, 2, 3)))           # 529344067295497451
print(hash(3.14))                # 322818021289917443
print(hash(True))                # 1
print(hash(None))                # 0

Обратите внимание: входы разной длины (число, короткая строка, кортеж) — выход всегда одно целое число. Это и есть «фиксированная длина output».

Хеш-функция: вход переменной длины, выход фиксированный

Любые данные на входе сжимаются в одно 64-битное число.

input42int — любой размер, тут 8 байт
input'hello'str — переменная длина, 5 символов
input(1, 2, 3)tuple — фиксированная структура, 3 элемента
input3.14float — 8 байт IEEE 754
hash()hash() — встроенная функция Python. Вызывает __hash__ у объекта или __hash__ типа.
output42всегда int, 64 бита знаковое
output-7146178545157038398случайное по запуску
output529344067295497451детерминирован для tuple из int
output322818021289917443детерминирован для float

Три свойства, на которых всё держится

Хеш-функция считается «хорошей» только если у неё одновременно есть три свойства. Если хотя бы одно ломается, ваша hash-таблица перестаёт работать с заявленной сложностью.

1. Detereministic — детерминированность

Один и тот же вход всегда даёт один и тот же выход. Без этого hash-таблица не может найти ключ, который сама же положила.

print(hash(42) == hash(42))           # True — всегда
print(hash("data") == hash("data"))   # True — внутри одного запуска
print(hash((1, 2)) == hash((1, 2)))   # True

Тонкость: для строк Python применяет рандомизацию при старте процесса (защита от

hash-flooding
). В разных запусках hash("data") даёт разные числа, но в пределах одного запуска — стабилен. Для int рандомизации нет — hash(42) всегда 42.

2. Uniform — равномерность

Хеши должны распределяться равномерно по всему пространству 64-битных чисел. Если функция «любит» определённые остатки от деления, dict с такими ключами выродится — все элементы попадут в несколько одних и тех же слотов.

# хороший хеш — биты выглядят случайными
xs = [hash(f"user_{i}") for i in range(10)]
for h in xs:
    print(bin(h & 0xFFFFFFFF))   # последние 32 бита — мешанина 0/1

Грубая проверка: возьмите 1000 ключей, посчитайте остатки от деления hash(k) % 8. Должно быть примерно по 125 в каждой группе. Если в одной группе 900, а в остальных по 10 — функция неравномерна.

3. Fast — быстрота

Hash должен считаться за O(1) для маленьких объектов и за O(длины) для больших (строк/байт). Без этого dict теряет смысл — если посчитать хеш дороже, чем пройти список линейно, hash-таблица проигрывает.

Для сравнения:

криптографические
хеши (SHA-256) считаются десятки наносекунд на байт — для dict это слишком медленно. Python использует
SipHash 1-3
— компромисс между скоростью и стойкостью.

Как Python хеширует разные типы

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

int — тождество

Для маленьких целых чисел hash(n) == n. Это самая дешёвая операция в Python:

print(hash(0))         # 0
print(hash(1))         # 1
print(hash(42))        # 42
print(hash(-1))        # -2 — единственное исключение, -1 зарезервировано
print(hash(2**62))     # 4611686018427387904

Для больших чисел (выше 2^61 - 1) применяется деление по модулю простого числа PyHASH_MODULUS = 2^61 - 1. То есть хеш всегда укладывается в 61 бит, а -1 зарезервирован как маркер ошибки.

str и bytes — SipHash 1-3

Строки хешируются за O(длины) — это занимает примерно 1 наносекунду на байт. SipHash рандомизируется при старте процесса:

# в одном запуске
print(hash("data"))    # например, 3741209284756723
print(hash("data"))    # то же самое в этом запуске

# в новом запуске python — другое число

Эту рандомизацию можно отключить через PYTHONHASHSEED=0, но в продакшене не надо — это снимает защиту от hash-flooding.

tuple — рекурсивный комбинатор

Кортеж хешируется через комбинацию хешей элементов. Псевдокод:

def tuple_hash(t):
    h = INITIAL_VALUE
    for x in t:
        h = combine(h, hash(x))   # XOR + умножение + сдвиг
    return h

Это означает: tuple из неhashable элементов (list, dict) сам не hashable. И стоимость хеширования tuple = сумма стоимостей хеширования его элементов плюс константа на каждый.

print(hash((1, 2, 3)))           # OK
print(hash((1, [2, 3])))         # TypeError: unhashable type: 'list'

Что не хешируется

Любой mutable тип в Python неhashable: list, dict, set. Логика проста: если объект можно изменить после вставки в dict, его хеш изменится, и dict потеряет ключ. Чтобы такого не было, hash() для mutable типов поднимает TypeError.

try:
    hash([1, 2, 3])
except TypeError as e:
    print(e)   # unhashable type: 'list'
Стоимость hash для базовых типов

Числа — измеренные на CPython 3.13, M-class CPU, в наносекундах. Порядок величин — самое важное.

hash тип
hash(int)~5 nshash(n) == n для маленьких — фактически no-op
hash(short str, 10 байт)~20 nsSipHash setup + 10 итераций
hash(long str, 1KB)~700 nsO(длины) — линейно по байтам
hash(tuple of 3 int)~30 ns3 hash(int) + combine
hash(float)~15 nsспециальная обработка NaN/Inf
hash(custom class)зависитесли __hash__ не определён — id(obj), что почти бесплатно

Измерим — посчитаем хеши

Откройте python -m timeit и сравните стоимость хеширования разных типов:

python -m timeit -s "x = 42" "hash(x)"
# 50 000 000 loops, best of 5: 6.8 nsec per loop

python -m timeit -s "x = 'hello world'" "hash(x)"
# 20 000 000 loops, best of 5: 18.4 nsec per loop

python -m timeit -s "x = 'a' * 10000" "hash(x)"
# 50 000 loops, best of 5: 6.7 usec per loop

python -m timeit -s "x = (1, 2, 3)" "hash(x)"
# 10 000 000 loops, best of 5: 32.1 nsec per loop

Тут видно: для int хеш почти бесплатный, для строки 10KB — 6.7 микросекунды (один lookup в dict — миллион таких операций в секунду максимум).

Практический вывод: не делайте длинные строки ключами dict, если можно сделать короткие. UUID длиной 36 символов хешируется в 5 раз медленнее, чем int.

Деревьеподобный хеш для составных ключей

В реальной работе DE часто хешируют составные ключи — (user_id, event_type, date). Это всегда tuple, не строка с конкатенацией:

# плохо — лишний alloc + длинная строка для хеширования
key = f"{user_id}|{event_type}|{date}"

# хорошо — рекурсивный хеш по элементам, без alloc промежуточной строки
key = (user_id, event_type, date)

На миллионах операций разница достигает секунд. Tuple-ключи быстрее, чем строки-ключи, и читаемее в коде.

Попробуй сам

Откройте REPL (uv run python или python) и проверьте:

# 1. Стабильность hash для int в разных запусках
# Запустите python дважды и проверьте, одинаков ли hash(123)
hash(123)

# 2. Рандомизация для str
# Запустите python дважды и проверьте hash("data") — разные числа
hash("data")

# 3. Tuple из mixed типов — работает, если все элементы hashable
hash((1, "two", 3.0, (4, 5)))

# 4. Хеш float — попробуйте NaN
import math
hash(math.nan)   # 0 — спецзначение
hash(math.inf)   # 314159

# 5. Хеш списка — TypeError
try:
    hash([1, 2, 3])
except TypeError as e:
    print(e)

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

  • Почему hash(1) == hash(True) (оба = 1)? Подсказка — True это подкласс int.
  • Почему hash(1.0) == hash(1)? Подсказка — для dict «1.0 и 1 — один ключ», иначе сломались бы инварианты.
  • Почему hash([1, 2, 3]) бросает TypeError, а hash((1, 2, 3)) — нет?
NOTE

Запомните: hash() — это детерминированная функция, превращающая вход в фикс-длины число. Хорошая hash-функция должна быть detereministic, uniform и fast. Python для разных типов использует разные алгоритмы — int сам себе хеш, str через SipHash, tuple через рекурсивную комбинацию.

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

Dict: hash function, open addressing и perturbation probe Hash Join в PostgreSQL: build, probe и батчи
Проверка знанийKnowledge check
Почему hash([1, 2, 3]) в Python бросает TypeError, а hash((1, 2, 3)) — нет?
ОтветAnswer
list — mutable: его содержимое можно изменить после вставки в dict (например, list.append). Если хеш зависит от содержимого, он изменится после мутации, и dict потеряет ключ — будет искать его в новом слоте, а лежит он в старом. Чтобы такого не было, mutable типы (list, dict, set) сделаны неhashable: их __hash__ намеренно поднимает TypeError. tuple — immutable: после создания изменить нельзя, поэтому хеш стабилен на всём времени жизни объекта. Это инвариант: hashable объект обязан быть immutable (или, точнее, его хеш должен быть стабилен).

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 6. Какое утверждение о свойствах хорошей хеш-функции является НЕВЕРНЫМ?

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

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

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

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