Словари: dict, методы, get, comprehension, упорядоченность

dict - вторая по важности коллекция после list. Это hash-таблица для пар ключ-значение, с амортизированным O(1) для доступа, вставки и удаления. В Python 3.7+ dict сохраняет порядок вставки. В этом уроке - все основные операции, идиомы и подводные камни.

Создание

# Литерал
user = {"name": "Alice", "age": 30, "email": "a@b.c"}

# Пустой
empty = {}              # dict (не set!)
empty2 = dict()

# Из пар
data = dict([("a", 1), ("b", 2)])   # {'a': 1, 'b': 2}

# Из keyword-аргументов
config = dict(host="localhost", port=5432)

# Из zip
keys = ["name", "age"]
values = ["Alice", 30]
user = dict(zip(keys, values))

# Из comprehension
squares = {x: x ** 2 for x in range(5)}   # {0: 0, 1: 1, 2: 4, 3: 9, 4: 16}

# Через fromkeys - один и тот же дефолт для всех
flags = dict.fromkeys(["a", "b", "c"], False)   # {'a': False, 'b': False, 'c': False}
`dict.fromkeys(keys, [])` создаст словарь со ссылками на ОДИН и тот же список. Изменение через один ключ изменит все:
d = dict.fromkeys(["a", "b"], [])
d["a"].append(1)
print(d)   # {'a': [1], 'b': [1]} - shared!

Правильно через comprehension:

d = {k: [] for k in ["a", "b"]}

Доступ и модификация

user = {"name": "Alice", "age": 30}

# Чтение
user["name"]            # 'Alice'
user["missing"]         # KeyError

# Безопасное чтение
user.get("name")        # 'Alice'
user.get("missing")     # None
user.get("missing", "default")   # 'default'

# Запись
user["email"] = "a@b.c"
user.update({"age": 31, "city": "Moscow"})   # bulk update

# Удаление
del user["age"]
removed = user.pop("name")         # удалить и вернуть
removed = user.pop("missing", "default")
last_pair = user.popitem()          # последнюю пару (LIFO с Py 3.7)
user.clear()                        # очистить

get - идиоматичный безопасный доступ. Используй вместо try/except KeyError или if key in dict.

Проверка наличия

"name" in user        # True - проверка ключа
"Alice" in user       # False - проверяется ключ, не значение
"Alice" in user.values()   # True - значение

# Проверка пар
("name", "Alice") in user.items()   # True

in для dict работает за O(1). in user.values() - линейный O(n).

setdefault и defaultdict

Часто нужно: «если ключа нет, создай со значением по умолчанию, потом верни/измени».

# Через setdefault - один встроенный метод
data = {}
data.setdefault("users", []).append("Alice")
data.setdefault("users", []).append("Bob")
print(data)   # {'users': ['Alice', 'Bob']}

setdefault возвращает существующее значение или вставляет дефолтное.

Альтернатива - collections.defaultdict:

from collections import defaultdict

data = defaultdict(list)
data["users"].append("Alice")   # ключа нет - создаст []
data["users"].append("Bob")
data["roles"].append("admin")   # автоматически создан
print(dict(data))   # {'users': ['Alice', 'Bob'], 'roles': ['admin']}

Используй defaultdict когда нужно много ленивых вставок, setdefault для разовых случаев. Подробнее про этот и другие контейнеры - в уроке про collections.

Итерация

data = {"name": "Alice", "age": 30, "email": "a@b.c"}

# По ключам (по умолчанию)
for key in data:
    print(key)

# По значениям
for value in data.values():
    print(value)

# По парам
for key, value in data.items():
    print(f"{key} = {value}")

items() - идиоматичный способ когда нужны оба. Не используй for key in d: d[key] - это лишний lookup.

Объединение словарей

a = {"x": 1, "y": 2}
b = {"y": 20, "z": 30}

# update - изменяет первый
a.update(b)
print(a)   # {'x': 1, 'y': 20, 'z': 30}

# | operator (Python 3.9+) - новый dict
merged = a | b
print(merged)   # {'x': 1, 'y': 20, 'z': 30}

# unpacking - новый dict, любые версии Python
merged = {**a, **b}

При коллизии ключей позднейший выигрывает. b затирает значения из a.

Упорядоченность

С Python 3.7 dict гарантированно сохраняет порядок вставки. Это часть языковой спецификации, не деталь реализации.

d = {"first": 1, "second": 2, "third": 3}
list(d.keys())     # ['first', 'second', 'third'] - порядок гарантирован

Раньше для упорядоченности использовали collections.OrderedDict. Теперь обычно достаточно dict. OrderedDict остаётся полезен только для:

  • Сравнения с учётом порядка (OrderedDict == сравнивает и порядок)
  • Метода move_to_end()

Comprehensions для dict

# Обратная карта
data = {"a": 1, "b": 2, "c": 3}
reversed_map = {v: k for k, v in data.items()}
# {1: 'a', 2: 'b', 3: 'c'}

# Фильтрация
positive = {k: v for k, v in data.items() if v > 1}

# Трансформация значений
doubled = {k: v * 2 for k, v in data.items()}

# Группировка через словарь
words = ["apple", "ant", "banana", "berry"]
by_letter = {}
for word in words:
    by_letter.setdefault(word[0], []).append(word)
# {'a': ['apple', 'ant'], 'b': ['banana', 'berry']}

dict как замена elif

Если нужно выбрать значение по ключу:

# elif
def get_label(grade):
    if grade == "A":
        return "Отлично"
    elif grade == "B":
        return "Хорошо"
    elif grade == "C":
        return "Удовлетворительно"
    else:
        return "Неизвестно"

# dict
def get_label(grade):
    labels = {"A": "Отлично", "B": "Хорошо", "C": "Удовлетворительно"}
    return labels.get(grade, "Неизвестно")

Декларативнее и легче расширять, чем цепочка elif. Особенно при множестве веток или динамически меняющемся списке.

То же для dispatch на функции:

def handle_create(data): ...
def handle_update(data): ...
def handle_delete(data): ...

handlers = {
    "create": handle_create,
    "update": handle_update,
    "delete": handle_delete,
}

def dispatch(action, data):
    handler = handlers.get(action)
    if handler is None:
        raise ValueError(f"Unknown action: {action}")
    return handler(data)

Что может быть ключом

Только hashable объекты:

  • Числа (int, float, complex)
  • Строки (str, bytes)
  • Tuple из hashable
  • frozenset
  • Большинство пользовательских классов (по умолчанию)

Mutable не подходят:

data = {}
data[[1, 2]] = "value"   # TypeError - list unhashable
data[{1, 2}] = "value"   # TypeError - set unhashable
data[(1, 2)] = "value"   # OK - tuple hashable

Это и в уроке про изменчивость разбирали. Для составных ключей - tuple или frozenset.

Performance

ОперацияСложность
d[k] доступO(1) средняя
d[k] = v вставкаO(1) средняя
del d[k]O(1) средняя
k in dO(1) средняя
len(d)O(1)
d.values() поискO(n)
ИтерацияO(n)

«Средняя» означает амортизированную - редко O(n) при коллизиях или resize hash-таблицы. Как устроена сама хеш-таблица, разбирается в уроке «Map изнутри». Для backend-задач можно считать что dict-операции константные.

Идиомы

Подсчёт частот:

from collections import Counter

words = ["apple", "banana", "apple", "cherry", "banana", "apple"]
counts = Counter(words)
print(counts)   # Counter({'apple': 3, 'banana': 2, 'cherry': 1})
print(counts.most_common(2))   # [('apple', 3), ('banana', 2)]

Counter подробно в уроке 26.

Merge без перезатирания:

defaults = {"timeout": 30, "retries": 3}
user_config = {"timeout": 60}

# user_config приоритет
final = {**defaults, **user_config}
# {'timeout': 60, 'retries': 3}

Конвертация в dict:

# Из строки key=value
"a=1,b=2".split(",")             # ['a=1', 'b=2']
dict(item.split("=") for item in "a=1,b=2".split(","))   # {'a': '1', 'b': '2'}

Подводные камни

1. {} это dict, не set

empty_dict = {}
empty_set = set()    # для пустого set нужен явный конструктор

2. Изменение dict во время итерации

for k in data:
    if data[k] < 0:
        del data[k]   # RuntimeError: dictionary changed size during iteration

Используй копию ключей:

for k in list(data.keys()):
    if data[k] < 0:
        del data[k]

Или dict comprehension:

data = {k: v for k, v in data.items() if v >= 0}

3. Mutable default

def add_to_group(item, groups={}):
    groups.setdefault("default", []).append(item)
    return groups

# Те же проблемы что с list - shared между вызовами

Используй None и инициализируй внутри.

4. get vs []

data = {"key": None}

data["key"]              # None - есть ключ со значением None
data["missing"]          # KeyError - нет ключа

data.get("key")          # None - есть ключ
data.get("missing")      # None - нет ключа

# Различить через `in`
"missing" in data        # False

get не различает «ключа нет» и «значение None». Если важно - используй in или try/except KeyError.

Сравнение с Go и PHP

В Go map[string]int{"a": 1, "b": 2} - похоже на Python dict, но требует объявления типов ключа и значения. Также не сохраняет порядок:

m := map[string]int{"a": 1, "b": 2}
v, ok := m["a"]   // ok - false если нет ключа

В PHP ассоциативные массивы это и dict, и list одновременно. Сохраняют порядок:

$data = ['name' => 'Alice', 'age' => 30];
$data['email'] = 'a@b.c';

Python dict ближе к PHP по гибкости (сохраняет порядок, не требует типизации) и к Go по семантике (отдельный тип от list).

Мини-задание

  1. Безопасный доступ через get:
config = {"host": "localhost"}

host = config.get("host", "0.0.0.0")
port = config.get("port", 5432)
debug = config.get("debug", False)
print(host, port, debug)   # localhost 5432 False
  1. Группировка через defaultdict:
from collections import defaultdict

orders = [
    {"user": "Alice", "total": 100},
    {"user": "Bob", "total": 50},
    {"user": "Alice", "total": 200},
    {"user": "Bob", "total": 75},
]

by_user = defaultdict(list)
for order in orders:
    by_user[order["user"]].append(order["total"])

print(dict(by_user))
# {'Alice': [100, 200], 'Bob': [50, 75]}
  1. Dict comprehension для конвертации:
# Перевернуть отображение
ru_en = {"один": "one", "два": "two", "три": "three"}
en_ru = {v: k for k, v in ru_en.items()}
print(en_ru)
# {'one': 'один', 'two': 'два', 'three': 'три'}

Что дальше

Освоили dict. В следующем уроке - множества (set): коллекции уникальных элементов с быстрым поиском и операциями объединения, пересечения, разности.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.