Уроки курса
1 Введение в Python и философия дзен
30 мин
2 Переменные и динамическая типизация
30 мин
3 Базовые типы данных: числа, строки и булевы значения
30 мин
4 Изменяемые и неизменяемые объекты (Mutable vs Immutable)
30 мин
5 Форматирование строк и f-строки
30 мин
6 Углубленная работа со списками
30 мин
7 Кортежи и их особенности
30 мин
8 Словари под капотом
30 мин
9 Множества и математические операции
30 мин
10 Генераторы списков (List Comprehensions)
30 мин
11 Генераторы словарей и множеств
30 мин
12 Встроенные функции для коллекций
30 мин
13 Условные операторы и логические выражения
30 мин
14 Циклы while и управление потоком
30 мин
15 Итерация с циклом for
30 мин
16 Конструкции for...else и while...else
30 мин
17 Функции enumerate и zip
30 мин
18 Создание собственных функций (def)
30 мин
19 Позиционные и именованные аргументы
30 мин
20 Проблема изменяемых аргументов по умолчанию
30 мин
21 Произвольное число аргументов (*args и **kwargs)
30 мин
22 Область видимости переменных (LEGB)
30 мин
23 Анонимные функции (lambda)
30 мин
24 Функции высшего порядка
30 мин
25 Замыкания (Closures)
30 мин
26 Введение в объектно-ориентированное программирование
30 мин
27 Атрибуты классов и экземпляров
30 мин
28 Магический метод __init__
30 мин
29 Методы экземпляра
30 мин
30 Инкапсуляция и сокрытие данных
30 мин
31 Декоратор @property
30 мин
32 Наследование классов
30 мин
33 Переопределение методов и функция super()
30 мин
34 Полиморфизм в Python
30 мин
35 Магические методы строк (__str__ и __repr__)
30 мин
36 Обработка исключений (try-except)
30 мин
37 Блоки else и finally
30 мин
38 Генерация собственных исключений (raise)
30 мин
39 Открытие и чтение файлов
30 мин
40 Запись данных в файлы
30 мин
41 Контекстные менеджеры (with)
30 мин
42 Работа с форматом JSON
30 мин
43 Модули и импорты
30 мин
44 Полезные модули стандартной библиотеки
30 мин
45 Модуль datetime
30 мин
46 Модуль collections
30 мин
47 Виртуальные окружения (venv)
30 мин
48 Установка сторонних пакетов через pip
30 мин
49 Организация структуры Python-проекта
30 мин
50 Финальный проект: создание приложения
30 мин

Словари под капотом

Освоение работы с парами ключ-значение, безопасного извлечения данных через get и метода setdefault.

Прогресс урока: 0%

Введение: От списков к хэш-таблицам. Добро пожаловать на уровень Intermediate!

До сих пор мы с вами активно работали со списками (lists) и кортежами (tuples). Это отличные структуры данных, когда нам важен порядок элементов. Однако, представьте себе ситуацию: вы работаете в библиотеке с фондом в миллион книг. Если книги стоят на полках просто в порядке их поступления (как в списке), то чтобы найти книгу 'Мастер и Маргарита', вам придется просматривать каждую книгу от первой до миллионной. В худшем случае вы сделаете миллион проверок. В информатике это называется линейным временем поиска — O(n), где 'n' — количество элементов. Для современных высоконагруженных систем, обрабатывающих терабайты данных, такой подход абсолютно неприемлем. Скорость — это критический фактор.

Именно здесь на сцену выходят словари (dictionaries, dict). Словарь в Python — это реализация структуры данных, известной в Computer Science как 'хэш-таблица' (hash table) или 'ассоциативный массив'. Словарь позволяет находить нужный элемент не путем перебора, а практически мгновенно, независимо от того, сколько элементов хранится в словаре: десять или десять миллионов. Время поиска в словаре составляет O(1) — константное время. Это похоже на то, как если бы у вас был магический каталог: вы называете название книги, и каталог моментально выдает вам номер полки и место. Вам больше не нужно ничего перебирать.

В этом уроке мы перестанем воспринимать словари просто как синтаксический сахар с фигурными скобками {}. Мы заглянем под капот и разберемся, почему они такие быстрые, что такое хэшируемость (hashability), почему списки не могут быть ключами словаря, и как правильно (в стиле 'Pythonic way') извлекать и обновлять данные, избегая ненавистной ошибки KeyError. Мы освоим методы .get() и .setdefault(), а в конце урока создадим систему управления библиотекой в рамках методологии Project-Based Learning, применив все полученные знания на практике. Приготовьтесь, этот урок кардинально изменит ваше понимание работы с данными в Python!

python
# Сравнение поиска в списке и словаре
import time

# Создаем список и словарь из 10 миллионов элементов
my_list = list(range(10000000))
my_dict = {i: f'value_{i}' for i in range(10000000)}

target = 9999999

# Поиск в списке (O(n))
start_time = time.time()
target in my_list
print(f'Поиск в списке занял: {time.time() - start_time:.6f} секунд')

# Поиск в словаре (O(1))
start_time = time.time()
target in my_dict
print(f'Поиск в словаре занял: {time.time() - start_time:.6f} секунд')

Какова алгоритмическая сложность (Big O) поиска значения по ключу в словаре Python в среднем случае?

Как работает магия O(1)? Знакомство с хэш-функцией

Чтобы понять, почему словари такие быстрые, нам нужно разобраться с концепцией хэширования. Когда вы пишете my_dict['apple'] = 100, интерпретатор Python не просто кладет строку 'apple' и число 100 куда-то в память. Сначала он берет ключ ('apple') и передает его во внутреннюю хэш-функцию. Хэш-функция — это математический алгоритм, который принимает данные любого размера (строку, число, кортеж) и возвращает число фиксированной длины — хэш (hash value).

Давайте разберем этот процесс по шагам. Шаг 1: Вычисление хэша. Python вызывает встроенную функцию hash('apple'). Допустим, она возвращает огромное целое число, например, -5872937583726. Шаг 2: Вычисление индекса. Это огромное число слишком велико для индекса массива в оперативной памяти. Поэтому Python берет остаток от деления этого хэша на текущий размер таблицы (под капотом словарь — это разреженный массив). Получается небольшой индекс, скажем, индекс 4. Шаг 3: Размещение в памяти. Python идет в ячейку памяти с индексом 4 и сохраняет там сам ключ ('apple') и его значение (100).

Теперь, когда вы запрашиваете значение через print(my_dict['apple']), происходит та же самая магия. Python снова вычисляет хэш от 'apple' (хэш для одного и того же объекта всегда одинаков на протяжении жизни программы), снова находит индекс 4, сразу идет в ячейку номер 4 и мгновенно достает оттуда число 100. Ему не нужно проверять ячейки 0, 1, 2 и 3. Он точно знает адрес! Именно эта математическая точность и обеспечивает сложность O(1). Однако, у этой магии есть строгие ограничения, которые вытекают из философии Python: 'Явное лучше, чем неявное'. Если ключ изменится после того, как он был помещен в словарь, его хэш тоже изменится. Следовательно, мы больше никогда не сможем найти его по старому адресу. Из этого вытекает главное правило ключей словаря, которое мы изучим в следующем блоке.

python
# Демонстрация работы хэш-функции
key1 = 'apple'
key2 = 'banana'
key3 = 42
key4 = (1, 2, 3) # Кортеж

print(f"Хэш ключа '{key1}': {hash(key1)}")
print(f"Хэш ключа '{key2}': {hash(key2)}")
print(f"Хэш числа {key3}: {hash(key3)}")
print(f"Хэш кортежа {key4}: {hash(key4)}")

# В рамках одного запуска скрипта хэши неизменны
assert hash('apple') == hash('apple')

Какая встроенная функция в Python используется для получения целого числа на основе переданного объекта (если объект поддерживает эту операцию)?

Хэшируемость и неизменяемость (Mutable vs Immutable)

Вспомните прошлые уроки (Модуль 'Изменяемые и неизменяемые объекты'). Мы говорили, что числа (int, float), строки (str) и кортежи (tuple) являются неизменяемыми (immutable), а списки (list), множества (set) и сами словари (dict) — изменяемыми (mutable). Это разделение критически важно для работы словарей.

Главное правило словарей: Ключом словаря может быть только хэшируемый (hashable) объект. В Python по умолчанию все неизменяемые объекты являются хэшируемыми. Почему? Вернемся к механике из предыдущего блока. Если бы мы могли использовать список my_list = [1, 2] в качестве ключа, Python вычислил бы его хэш (допустим, индекс 5) и положил бы туда значение. А что, если на следующей строке мы сделаем my_list.append(3)? Содержимое списка изменилось. Если бы мы снова попытались найти его хэш, алгоритм выдал бы совершенно другой индекс (например, 12). Но наши данные все еще лежат в ячейке 5! Мы бы навсегда потеряли доступ к этому значению, образовалась бы утечка памяти, и словарь перестал бы работать корректно.

Именно поэтому создатели Python запретили использовать изменяемые типы данных в качестве ключей. Если вы попытаетесь сделать my_dict = {[1, 2]: 'value'}, интерпретатор мгновенно выбросит ошибку TypeError: unhashable type: 'list'. Это отличный пример принципа 'Ошибки не должны замалчиваться' из Дзена Python. Если вам нужно использовать коллекцию элементов в качестве ключа (например, координаты X и Y), используйте неизменяемый кортеж: points = {(10, 20): 'Enemy', (0, 0): 'Player'}. Понимание разницы между хэшируемыми и нехэшируемыми объектами — это отличительная черта уверенного Intermediate разработчика. Значения же словаря (values) могут быть абсолютно любыми объектами: числами, строками, списками, функциями, другими словарями (вложенные структуры) и даже классами.

Какой из перечисленных объектов вызовет ошибку 'TypeError: unhashable type', если попытаться сделать его ключом словаря?

Анатомия коллизий: Что, если хэши совпадают?

Давайте копнем еще глубже в теорию Computer Science, которая применима к Python. Мы знаем, что хэш-функция возвращает число, и на основе этого числа мы вычисляем индекс ячейки (корзины или 'bucket' в терминологии хэш-таблиц). Но оперативная память не бесконечна, размер хэш-таблицы ограничен (изначально в Python словарь создается с 8 пустыми ячейками и динамически расширяется при заполнении на 2/3).

Так как возможных объектов (строк, чисел) бесконечное множество, а ячеек в словаре конечное количество, математически неизбежна ситуация, когда два совершенно разных ключа дадут один и тот же индекс после вычисления остатка от деления. Эта ситуация называется коллизией хэшей (hash collision). Разрушается ли от этого магия O(1)? Нет! Разработчики ядра CPython (основная реализация Python) предусмотрели элегантное решение.

Существует несколько методов разрешения коллизий. Наиболее популярные: 'Метод цепочек' (Chaining) — когда в одну ячейку помещается связный список всех конфликтующих элементов, и 'Открытая адресация' (Open Addressing). В Python исторически используется разновидность открытой адресации, которая называется пробирование (probing). Если Python вычисляет индекс для ключа 'A', но ячейка уже занята ключом 'B', он использует специальную математическую формулу (которая учитывает биты самого хэша), чтобы вычислить следующий 'псевдослучайный' индекс. Он проверяет следующую ячейку. Если она тоже занята, он прыгает дальше, пока не найдет свободное место (для вставки) или пока не найдет нужный ключ (для поиска). Этот процесс прыжков происходит молниеносно внутри кода на языке C. Именно поэтому словари в Python занимают довольно много памяти (overhead) — они всегда должны иметь свободные 'пустые' ячейки, чтобы избегать длинных цепочек коллизий и сохранять скорость O(1). Если таблица заполняется слишком сильно (load factor > 2/3), Python автоматически создает новую, более просторную таблицу, и перехэширует (rehash) все элементы. Это занимает время O(n), но происходит крайне редко. В повседневной разработке вы не замечаете коллизий, но знание о них делает вас инженером, понимающим цену скорости и памяти.

python
# Имитация коллизии на уровне логики (только для демонстрации концепции)
class BadHashString:
    def __init__(self, value):
        self.value = value
        
    def __hash__(self):
        return 1 # Ужасная хэш-функция! Всегда возвращает 1 (100% коллизия)
        
    def __eq__(self, other):
        return self.value == other.value

# Создаем словарь
my_dict = {}

obj1 = BadHashString('apple')
obj2 = BadHashString('banana')

# Python все равно справится благодаря открытой адресации
my_dict[obj1] = 100
my_dict[obj2] = 200

print(my_dict[obj1]) # Выведет 100
print(my_dict[obj2]) # Выведет 200
# Но скорость работы такого словаря упадет до O(n)

Задание

Мысленный эксперимент: Анализ памяти

  • Представьте пустой словарь в Python. Под капотом он сразу резервирует память под 8 ячеек (buckets).
  • Мы начинаем добавлять ключи: 1, 2, 3, 4, 5.
  • Когда словарь заполняется на 2/3 (около 5 элементов), он понимает, что риск коллизий возрастает.
  • Он динамически выделяет новый, больший блок памяти (обычно увеличивая размер в 2-4 раза) и пересчитывает индексы (rehash) для всех старых ключей.
10 баллов

Базовые операции и итерация. Динамические представления (Views)

Прежде чем перейти к продвинутым методам, давайте закрепим базовый синтаксис (Active Recall). Создание словаря: user = {'name': 'Alice', 'age': 30, 'role': 'admin'}. Добавление или изменение элемента: user['age'] = 31 (если ключ существует, значение перезапишется; если нет — создастся новая пара). Удаление элемента: del user['role'].

Но самое интересное начинается при итерации (переборе) словаря. Новички часто пишут код так: for key in user: print(user[key]). Это работает, но это не 'Pythonic way'. У словарей есть три мощных метода, которые возвращают так называемые 'представления' (views): .keys(), .values() и .items().

  • dict.keys(): Возвращает представление всех ключей. Поведение по умолчанию при итерации словаря: for k in user: эквивалентно for k in user.keys():.
  • dict.values(): Возвращает представление всех значений. Полезно, если вам нужно найти сумму всех цен или собрать все имена: total = sum(prices.values()).
  • dict.items(): Самый важный метод. Он возвращает пары (tuple) из ключа и значения. Используя распаковку (unpacking), мы можем элегантно перебрать весь словарь: for key, value in user.items(): print(f'{key}: {value}').

Важно понимать термин 'динамические представления' (dynamic views). В Python 3 методы .keys(), .values() и .items() не создают новые списки в памяти (в отличие от Python 2). Они создают легковесные 'окна', через которые вы смотрите на словарь. Если словарь изменится, эти представления мгновенно отразят изменения. Это экономит огромное количество памяти при работе с большими объемами данных. Однако из-за этого нельзя изменять размер словаря (добавлять или удалять ключи) прямо во время итерации по нему — вы получите ошибку RuntimeError: dictionary changed size during iteration.

python
user = {'name': 'Alice', 'age': 30, 'role': 'admin'}

# 1. Плохой стиль (Антипаттерн)
for key in user:
    print(f"Key: {key}, Value: {user[key]}")

print('-' * 20)

# 2. Pythonic way (Использование .items())
for key, value in user.items():
    print(f"{key} = {value}")

print('-' * 20)

# Демонстрация динамического представления (View)
keys_view = user.keys()
print("До:", keys_view)
user['location'] = 'London' # Изменяем оригинальный словарь
print("После:", keys_view) # Представление изменилось автоматически!

Какой метод словаря возвращает динамическое представление, состоящее из кортежей (ключ, значение), что идеально подходит для распаковки в цикле for?

Проблема отсутствующего ключа: Бой с KeyError

Мы подошли к одной из самых частых ошибок начинающих Python-разработчиков. Предположим, вы получаете данные от внешнего API (например, информацию о пользователе из базы данных). Вы ожидаете, что там есть возраст пользователя: user = {'name': 'Bob', 'role': 'user'}. Вы пишете код: age = user['age']. И ваша программа мгновенно падает (крашится) с ошибкой KeyError: 'age'.

В Python прямой доступ по ключу через квадратные скобки [] является строгим. Философия гласит: 'Явное лучше, чем неявное'. Если вы просите ключ 'age', а его нет, Python не вернет вам None (как это делает JavaScript возвращая undefined). Python громко кричит: 'ОШИБКА! Такого ключа не существует!'. Это защищает нас от непредсказуемого поведения программы, но заставляет писать дополнительный код.

Как новички (Juniors) решают эту проблему? Они используют проверку вхождения (оператор in):
if 'age' in user:
    age = user['age']
else:
    age = 0

Или используют конструкцию обработки исключений try/except:
try:
    age = user['age']
except KeyError:
    age = 0

Оба этих способа абсолютно рабочие и иногда оправданы. Однако, они занимают 4-5 строк кода для простейшей операции. Если вам нужно извлечь 10 разных полей из JSON-ответа, ваш код превратится в нечитаемую 'лапшу' из проверок `if/else`. Разработчики Python, следуя принципу 'Простое лучше, чем сложное', внедрили в класс `dict` специальный метод для изящного и безопасного решения этой задачи — метод .get(). В следующем блоке мы разберем его магию.

python
# Имитация ответа от сервера (некоторые поля отсутствуют)
server_response = {
    'id': 101,
    'username': 'john_doe',
    'status': 'active'
    # Поле 'email' отсутствует!
}

# Прямой доступ - Опасность!
# print(server_response['email'])  # Раскомментируйте, чтобы увидеть KeyError

# Подход Junior (работает, но длинно)
if 'email' in server_response:
    email = server_response['email']
else:
    email = 'no-reply@domain.com'
print(f"Email (if/else): {email}")

Какую ошибку (Exception) выбросит Python при попытке выполнить код `val = my_dict['missing_key']`, если ключа 'missing_key' нет в словаре?

Безопасное извлечение: Элегантность метода .get()

Знакомьтесь: метод dict.get(key, default=None). Это ваш лучший друг при работе с внешними данными, JSON-файлами и любыми словарями, где вы не уверены на 100% в наличии ключа. Этот метод позволяет запросить значение, а если ключа нет — вернуть безопасное значение по умолчанию (fallback value), не прерывая работу программы ошибкой KeyError.

Синтаксис прост: первый аргумент — это ключ, который мы ищем. Второй (необязательный) аргумент — это значение, которое нужно вернуть, если ключ не найден. Если второй аргумент не указан, Python вернет объект None.

Давайте перепишем пример из предыдущего блока (где мы использовали 4 строки с if/else) с помощью .get():
age = user.get('age', 0). Всё! Одна строка вместо четырех. Читается как обычный английский язык: 'Получи (get) возраст из пользователя, а если его там нет, верни 0'.

Практический кейс (Code Review Simulation):
Контекст: Вы пишете парсер для конфигурационного файла. Конфиг — это словарь. В нем могут быть настройки `timeout` (таймаут соединения). Если пользователь не указал `timeout` в файле, система должна использовать стандартное значение — 30 секунд.
Junior код:
try:
    timeout = config['timeout']
except KeyError:
    timeout = 30

Senior (ваш) код:
timeout = config.get('timeout', 30)
Выглядит профессионально, читается мгновенно, работает быстрее, так как не включает в себя накладные расходы на обработку исключений (Exception handling overhead). Используйте .get() всегда, когда логика программы допускает отсутствие ключа.

python
config = {
    'host': '127.0.0.1',
    'port': 8080
    # 'timeout' и 'debug' отсутствуют
}

# Безопасное извлечение с None по умолчанию
debug_mode = config.get('debug')
print(f"Debug mode is: {debug_mode}") # Выведет: Debug mode is: None

# Безопасное извлечение с кастомным значением по умолчанию (fallback)
timeout = config.get('timeout', 30)
print(f"Timeout is set to: {timeout} seconds") # Выведет: Timeout is set to: 30 seconds

# Если ключ существует, .get() возвращает его значение (игнорируя default)
port = config.get('port', 80)
print(f"Port is: {port}") # Выведет: Port is: 8080 (а не 80)

Какое значение вернет выражение `my_dict.get('unknown_key')`, если второй аргумент (default) не был передан явно?

Проблема инициализации: Как сгруппировать данные?

Мы научились безопасно извлекать данные с помощью .get(). Но давайте рассмотрим другую, более сложную задачу. Допустим, мы анализируем логи сервера, и нам нужно сгруппировать ошибки по их типам. У нас есть список кортежей: logs = [('404', 'Page not found'), ('500', 'Server error'), ('404', 'Image not found')]. Мы хотим получить словарь, где ключом будет код ошибки (например, '404'), а значением — список (list) всех сообщений с этим кодом: {'404': ['Page not found', 'Image not found'], '500': ['Server error']}.

Как мы будем это делать через цикл? Если мы просто напишем result['404'].append('msg'), мы снова получим KeyError, потому что при первой встрече кода '404' в словаре result еще нет такого ключа, и мы пытаемся вызвать метод .append() у несуществующего списка.

Нам нужно сначала инициализировать список. Логика Junior-разработчика:
for code, msg in logs:
    if code not in result:
        result[code] = [] # Создаем пустой список для нового ключа
    result[code].append(msg) # Теперь смело добавляем

Код работает отлично. Но опять же, мы делаем двойную работу. Сначала Python ищет ключ в словаре для проверки (if code not in result). Затем он снова ищет этот же ключ, чтобы присвоить пустой список. А потом ищет его третий раз, чтобы сделать .append(). Это неэффективно и многословно. Для таких задач 'инициализации ключа со значением по умолчанию' в Python существует специальный метод, который часто недооценивают или не понимают новички — метод .setdefault(). В следующем блоке мы разберем его механику работы, которая сначала может показаться контринтуитивной, но является мощнейшим инструментом в арсенале Intermediate разработчика.

python
logs = [
    ('404', 'user_profile.jpg'),
    ('200', 'index.html'),
    ('404', 'style.css'),
    ('500', 'database.db')
]

grouped_logs = {}

# Решение в 'лоб' (работает, но не оптимально)
for code, file in logs:
    if code not in grouped_logs:
        grouped_logs[code] = [] # Инициализация
    grouped_logs[code].append(file) # Обновление

print("Сгруппировано (if/else):\n", grouped_logs)
# Вывод:
# {'404': ['user_profile.jpg', 'style.css'], '200': ['index.html'], '500': ['database.db']}

Почему код `my_dict['new_key'].append(1)` вызовет ошибку KeyError (предполагается, что my_dict изначально пуст)?

Группировка в одну строку: Магия .setdefault()

Метод dict.setdefault(key, default=None) — это 'швейцарский нож' для группировки данных. Его название может немного сбивать с толку. Слово 'set' подразумевает установку значения. На самом деле этот метод делает две вещи одновременно: возвращает и устанавливает. Давайте расшифруем его алгоритм по шагам.

Когда вы вызываете my_dict.setdefault(key, default), внутри словаря происходит следующее:
1. Python проверяет, есть ли key в словаре.
2. Если ключ УЖЕ ЕСТЬ: метод ничего не меняет в словаре и просто возвращает текущее значение по этому ключу (работает точно так же, как .get()). Значение default игнорируется.
3. Если ключа НЕТ: метод создает этот ключ, присваивает ему значение default (добавляет новую пару в словарь) и возвращает это самое значение default.

Поскольку метод в любом случае возвращает значение (существующее или только что созданное дефолтное), мы можем сразу применить к этому значению методы (например, .append()). Возвращаясь к нашей задаче с логами, мы можем переписать 4 строки кода всего в одну:
grouped_logs.setdefault(code, []).append(msg)

Разбор полетов:
При первой встрече кода '404' (ключа нет): setdefault создает пару {'404': []}, возвращает этот новый пустой список, и метод .append('msg') добавляет строку в этот список.
При второй встрече кода '404' (ключ уже есть): setdefault находит ключ '404', видит, что там уже лежит список ['msg_1'], и просто возвращает ссылку на этот список (не создавая новый). Метод .append('msg_2') добавляет вторую строку в тот же самый список.

Это невероятно элегантно! Мы избавляемся от дублирования поиска ключа, наш код становится быстрее (за счет внутренней оптимизации CPython) и намного короче. Метод setdefault — это признак того, что вы мыслите категориями структур данных и пишете идиоматичный Python код (Pythonic way).

python
logs = [
    ('ERROR', 'System crash'),
    ('INFO', 'User logged in'),
    ('ERROR', 'Disk full'),
    ('WARNING', 'Memory high')
]

# Изящное решение с setdefault (Pythonic way)
grouped_logs = {}
for level, message in logs:
    # Читается так: "Получи список для level. Если его нет, создай пустой [], и сразу сделай append(message)"
    grouped_logs.setdefault(level, []).append(message)

import pprint
pprint.pprint(grouped_logs)
# Вывод:
# {'ERROR': ['System crash', 'Disk full'],
#  'INFO': ['User logged in'],
#  'WARNING': ['Memory high']}

В чем главное отличие метода .setdefault() от .get()?

Задание

Практическое применение: Подсчет элементов

  • У вас есть строка: text = 'abracadabra'
  • Создайте пустой словарь char_count = {}
  • В цикле for пройдитесь по каждому символу строки (for char in text).
  • Подумайте, как с помощью setdefault() (или get()) увеличить счетчик для каждого символа на 1.
  • Подсказка: char_count[char] = char_count.get(char, 0) + 1
10 баллов

Генераторы словарей (Dict Comprehensions): Красота и скорость

Мы вспомнили концепцию `list comprehensions` (генераторы списков) из архивных материалов курса, которая позволяет создавать списки одной строкой кода: [x**2 for x in range(5)]. Python предоставляет точно такой же мощный инструмент для словарей — Dict Comprehensions. Это синтаксис, позволяющий создавать новые словари из итерируемых объектов (списков, строк, других словарей) кратко, декларативно и очень быстро, избегая использования громоздких циклов for и метода .update().

Базовый синтаксис выглядит так: {ключ: значение for переменная in итерируемый_объект}. В отличие от генератора списков (где используются квадратные скобки []), генератор словаря использует фигурные скобки {} и обязательно требует пару ключ: значение, разделенную двоеточием, перед циклом for.

Рассмотрим классический Use Case: У вас есть список имен пользователей users = ['alice', 'bob', 'charlie']. Вам нужно создать словарь, где ключом будет имя пользователя, а значением — длина его имени. Вместо написания цикла:
lengths = {}
for u in users:
    lengths[u] = len(u)

Вы можете написать элегантный dict comprehension:
lengths = {u: len(u) for u in users}. Результат: {'alice': 5, 'bob': 3, 'charlie': 7}.

Но мощь dict comprehensions раскрывается при добавлении условий (фильтрации). Вы можете добавить блок if в конец выражения. Например, мы хотим создать словарь только с теми именами, длина которых больше 4 символов: {u: len(u) for u in users if len(u) > 4}. Результатом будет {'alice': 5, 'charlie': 7}. Боб (3 буквы) был отфильтрован на этапе генерации. Этот подход работает быстрее традиционных циклов for, так как генераторы оптимизированы на уровне языка C и не вызывают накладных расходов на постоянный вызов метода .update() или присваивание по индексу в области видимости Python. Это воплощение принципа 'Красивое лучше, чем уродливое'.

python
# Пример 1: Извлечение данных из списка кортежей
user_data = [('id1', 'Alice'), ('id2', 'Bob'), ('id3', 'Charlie')]
user_dict = {user_id: name for user_id, name in user_data}
print(f"Users: {user_dict}")

# Пример 2: Преобразование (трансформация) существующего словаря
prices_rub = {'apple': 100, 'banana': 150, 'orange': 120}
EXCHANGE_RATE = 100

# Создаем новый словарь, где цены переведены в доллары
prices_usd = {product: price / EXCHANGE_RATE for product, price in prices_rub.items()}
print(f"Prices in USD: {prices_usd}")

# Пример 3: Фильтрация с помощью if
# Оставляем только товары, которые стоят дороже 1.2$
expensive_products = {k: v for k, v in prices_usd.items() if v > 1.2}
print(f"Expensive: {expensive_products}")

Какой символ обязательно должен разделять выражения для ключа и значения внутри генератора словаря (dict comprehension) перед ключевым словом for?

Эволюция словарей: Упорядоченность в Python 3.6+

Если вы будете читать старые книги или статьи (написанные до 2016 года), вы обязательно встретите фразу: 'Словари в Python не упорядочены (unordered)'. Это означало, что если вы добавили ключи в порядке 'A', 'B', 'C', при итерации по словарю с помощью for k in my_dict: они могли вывестись в случайном порядке, например, 'B', 'C', 'A'. Это происходило из-за того, как работала хэш-таблица под капотом (ключи размещались в случайных ячейках памяти на основе их хэшей).

Из-за этой 'случайности' разработчикам приходилось использовать специальный класс collections.OrderedDict, если им было критически важно сохранить порядок добавления элементов (например, при парсинге XML-документов или созданииOrdered JSON). Это создавало дополнительные сложности и увеличивало потребление памяти.

Однако, начиная с версии Python 3.6 (и официально стандартизировано в Python 3.7), произошло архитектурное чудо. Разработчик Raymond Hettinger предложил новую реализацию словарей (Compact Dict). Теперь словарь внутри состоит из двух массивов: один плотный массив, который хранит сами пары ключ-значение в строгом порядке их добавления, и второй разреженный массив (собственно хэш-таблица), который хранит только индексы, указывающие на первый массив. Результат? Словари стали занимать на 20-25% меньше оперативной памяти, и, что самое главное, стандартные словари (dict) стали упорядоченными по умолчанию!

Теперь, в современном Python, порядок, в котором вы вставляете ключи в словарь, гарантированно сохраняется при итерации. {'a': 1, 'b': 2, 'c': 3} всегда будет итерироваться как 'a', затем 'b', затем 'c'. Класс OrderedDict потерял свою актуальность для 99% задач (он используется только если вам нужны специфические методы, вроде перемещения элемента в конец словаря move_to_end, или если для вас важно, чтобы равенство словарей учитывало порядок: {'a':1, 'b':2} == {'b':2, 'a':1} вернет True для стандартных dict, но False для OrderedDict). Понимание этой эволюции показывает ваш глубокий уровень знаний экосистемы Python.

Слияние словарей: От update() до оператора '|'

Частая задача при работе с данными — объединить два словаря в один. Например, у вас есть словарь со стандартными настройками default_config и словарь с пользовательскими настройками user_config. Вам нужно получить финальный конфиг, где пользовательские настройки переопределяют стандартные, если ключи совпадают.

Исторически (и до сих пор очень часто) для этого используется метод .update(). Он работает 'на месте' (in-place), мутируя оригинальный словарь: default_config.update(user_config). Это эффективно по памяти, но иногда нам нужен новый словарь, чтобы не портить исходные данные. В Python 3.5 появился красивый синтаксис распаковки словарей с помощью двух звездочек **. Мы могли написать: final_config = {**default_config, **user_config}. Python распаковывает оба словаря внутрь новых фигурных скобок. Так как user_config идет вторым, его значения перетрут значения default_config при совпадении ключей.

Но Python продолжает развиваться (Дзен: 'Красивое лучше, чем уродливое'). В версии Python 3.9 был добавлен долгожданный оператор слияния (Merge Operator) — вертикальная черта |. Теперь слияние словарей выглядит так же интуитивно понятно, как сложение чисел или объединение множеств: final_config = default_config | user_config. Этот оператор создает совершенно новый словарь. Более того, появился оператор |= (Merge and Update Operator), который работает аналогично методу .update(), обновляя словарь 'на месте': default_config |= user_config. Знание таких современных конструкций (ES6+ в мире JS или Python 3.9+ в нашем случае) делает ваш код лаконичным, современным и очень легко читаемым на Code Review. Выбирайте инструмент под задачу: нужен новый словарь — используйте |, нужно обновить существующий — |= или .update().

python
default_settings = {'theme': 'light', 'font_size': 14, 'show_images': True}
user_settings = {'theme': 'dark', 'show_images': False}

# Способ 1: метод .update() (Изменяет оригинал!)
# copy_settings = default_settings.copy()
# copy_settings.update(user_settings)

# Способ 2: Распаковка kwargs (Python 3.5+)
merged_35 = {**default_settings, **user_settings}
print("Python 3.5+:", merged_35)

# Способ 3: Оператор слияния | (Python 3.9+)
# Значения словаря справа (user_settings) имеют приоритет при совпадении ключей!
final_settings = default_settings | user_settings
print("Python 3.9+:", final_settings)
# Результат: {'theme': 'dark', 'font_size': 14, 'show_images': False}

Какой результат выполнения кода в Python 3.9+: `dict1 = {'a': 1, 'b': 2}; dict2 = {'b': 3, 'c': 4}; result = dict1 | dict2`?

collections.defaultdict: Фабрика значений по умолчанию

Мы подробно разобрали метод .setdefault(), который решает проблему отсутствующих ключей при группировке данных. Но в стандартной библиотеке Python (модуль collections) есть еще один мощный инструмент, созданный специально для этой задачи — класс defaultdict. Если .setdefault() мы применяем разово на обычном словаре, то defaultdict меняет само поведение словаря на уровне его архитектуры.

Вспомним нашу задачу с логами сервера: мы хотели сгруппировать сообщения по коду ошибки. Если мы создадим наш словарь как from collections import defaultdict
grouped_logs = defaultdict(list)
, мы навсегда забудем про KeyError или необходимость писать .setdefault(). Почему? Потому что при создании defaultdict мы передаем ему в скобках фабричную функцию (в данном случае list — это класс/функция, которая при вызове без аргументов list() возвращает пустой список []).

Теперь, когда мы обращаемся к несуществующему ключу grouped_logs['404'].append('msg'), происходит магия, встроенная в класс: defaultdict видит, что ключа '404' нет. Он автоматически вызывает фабричную функцию list(), получает пустой список [], кладет его в словарь под ключом '404' и возвращает его нам. И мы тут же делаем в него .append()! Наш цикл группировки сокращается до одной, абсолютно понятной строки кода: for code, msg in logs: grouped_logs[code].append(msg). Никаких проверок if/else, никаких setdefault. Код кристально чист. defaultdict можно инициализировать функцией int (тогда по умолчанию новые ключи будут получать значение int(), то есть 0 — идеально для счетчиков), set (значение по умолчанию пустое множество set()) или любой вашей собственной функцией (lambda-функцией), которая не принимает аргументов.

python
from collections import defaultdict

# Пример 1: Группировка данных (фабрика - list)
logs = [('ERROR', 'Crash'), ('INFO', 'Login'), ('ERROR', 'Timeout')]
log_dict = defaultdict(list) # Все новые ключи будут инициализироваться []

for level, msg in logs:
    log_dict[level].append(msg) # Работает без setdefault!
print("Logs:", dict(log_dict))

# Пример 2: Подсчет элементов (фабрика - int)
text = "hello world"
char_counts = defaultdict(int) # int() возвращает 0

for char in text:
    char_counts[char] += 1 # char_counts[char] изначально 0, затем +1
print("Counts:", dict(char_counts))
# Результат: {'h': 1, 'e': 1, 'l': 3, 'o': 2, ' ': 1, 'w': 1, 'r': 1, 'd': 1}

Какую фабричную функцию нужно передать в defaultdict, чтобы новые отсутствующие ключи автоматически инициализировались числом 0 (например, для создания словаря-счетчика)?

Project-Based Learning: Система управления каталогом

Пришло время объединить все концепции: хэш-таблицы, безопасное извлечение .get(), инициализацию с .setdefault() и генераторы словарей. В рамках Project-Based Learning (PBL) мы напишем прототип системы обработки данных для интернет-магазина (Inventory Management System). У нас есть 'грязные' данные (raw data), приходящие от поставщиков в виде списка словарей. В этих данных могут отсутствовать некоторые поля, категории могут быть не указаны.

Наша бизнес-задача (Requirement Specification):
1. Очистить данные: убрать товары без цены. Для этого мы применим фильтрацию (из архива 'Продвинутая фильтрация списков/словарей').
2. Нормализовать данные: если у товара нет поля 'stock' (остаток на складе), мы должны безопасно присвоить ему значение 0 (используя метод .get()).
3. Структурировать данные: мы хотим создать сложный индекс (каталог), где ключом будет название 'Категории' (category), а значением — вложенный словарь (или список), содержащий названия товаров этой категории. Здесь нам поможет .setdefault() или defaultdict.

Этот процесс называется ETL-пайплайном (Extract, Transform, Load — Извлечение, Преобразование, Загрузка). Это фундамент Data Engineering и Backend-разработки. На уровне Junior разработчики часто пишут 'лапша-код' с огромным количеством вложенных `if/else`, проверяя ключи вручную. Вы же, как Intermediate специалист, напишете элегантный пайплайн. Внимательно изучите код в следующем блоке. Обратите внимание, как методы работы со словарями избавляют нас от обработки исключений и делают код читаемым, как открытая книга. Мы используем словарные включения (dict comprehensions) для создания индекса цен и setdefault для категоризации.

Задание

Разработка модуля Catalog Processor (ETL Pipeline)

  • Изучите входящие 'грязные' данные (raw_data) в следующем блоке кода.
  • Отследите, как метод .get() предотвращает падение программы на товаре 'Keyboard' (нет поля stock) и 'Mouse' (нет категории).
  • Обратите внимание на использование .setdefault() для создания структуры 'Категория -> Список товаров'.
  • Проанализируйте dict comprehension, создающий быстрый индекс поиска цен (name -> price).
10 баллов
python
raw_data = [
    {"name": "Laptop", "price": 1200, "category": "Electronics", "stock": 5},
    {"name": "Mouse", "price": 400, "stock": 10}, # Нет category!
    {"name": "Monitor", "price": 800, "category": "Electronics", "stock": 0},
    {"name": "Keyboard", "price": 600, "category": "Electronics"}, # Нет stock!
    {"name": "Defective Item", "category": "Misc"} # Нет price! (Бракованная запись)
]

# 1. Очистка и Нормализация (Трансформация списка словарей)
cleaned_catalog = []
for item in raw_data:
    if "price" not in item:
        continue # Пропускаем бракованные записи без цены
    
    # Нормализуем поля с помощью get()
    item['category'] = item.get('category', 'Uncategorized')
    item['stock'] = item.get('stock', 0)
    cleaned_catalog.append(item)

# 2. Группировка по категориям с помощью setdefault()
catalog_by_category = {}
for item in cleaned_catalog:
    cat = item['category']
    name = item['name']
    catalog_by_category.setdefault(cat, []).append(name)

# 3. Быстрый индекс цен с помощью dict comprehension
price_index = {item['name']: item['price'] for item in cleaned_catalog}

print("Сгруппированный каталог:", catalog_by_category)
print("Быстрый индекс цен (O(1) lookup):", price_index)

Глубокое понимание: Когда словарь — не лучший выбор?

До сих пор мы восхваляли словари, называя их самым мощным инструментом Python. Мы восхищались их сложностью O(1), методом get() и элегантностью setdefault(). Но 'Дзен Python' (наследие 1-го урока) и методика критического мышления требуют задать вопрос: 'В каких случаях нам НЕ стоит использовать словарь?' Выбор правильной структуры данных — это суть Software Architecture.

Во-первых, память (Memory Overhead). Как мы выяснили в блоке про хэш-таблицы и коллизии, словари (даже новые 'Compact Dict' в Python 3.6+) всегда резервируют память под пустые ячейки (spare capacity). Они должны быть заполнены максимум на 2/3, иначе перехэшируются и расширятся. Если вам нужно хранить огромный массив чисел (миллионы элементов), и ключами являются просто последовательные индексы 0, 1, 2... — использование словаря `dict[int, int]` будет катастрофической ошибкой. Обычный список (list) в Python, а лучше массив из библиотеки numpy, займет в десятки раз меньше памяти и будет работать быстрее, так как доступ по индексу в массиве тоже O(1), но без накладных расходов на вычисление хэшей.

Во-вторых, Сложные объекты с методами (OOP). Часто новички пытаются использовать словари для моделирования сложных объектов реального мира (вспомните архивный материал 'Часть 3. ООП: Система управления библиотекой'). Да, вы можете представить книгу как book = {'title': '1984', 'author': 'Orwell', 'is_borrowed': False}. Но что если у книги должно быть поведение? Метод borrow_book(), который меняет статус, проверяет резерв, отправляет уведомление? Хранить функции внутри словаря book['borrow'] = my_func можно, но это ужасный паттерн проектирования. В этом случае нужно переходить на Классы (Classes) и Объекты. Словари идеальны для 'тупых' данных (Data Transfer Objects, DTO, JSON payloads), но как только данным требуется 'поведение' или сложная валидация — пора создавать свой класс (или использовать dataclasses/pydantic). Знание границ применимости инструмента делает вас Senior-разработчиком.

Если вам нужно хранить последовательность из 10 миллионов целых чисел, и вам важен порядок и минимальное потребление оперативной памяти, какую базовую встроенную структуру данных Python вы выберете? (Введите одно слово на английском)

Подведение итогов: Путь просвещения (Active Recall Recap)

Наше путешествие 'под капот' словарей завершается. Вы проделали огромный путь от механического написания скобок {} до понимания работы C-структур памяти интерпретатора CPython. Давайте структурируем знания, чтобы переместить их из кратковременной памяти в долговременную (Active Recall Session).

1. Философия O(1): Вы знаете, что словари — это хэш-таблицы. Их скорость не зависит от размера. Вы вычисляете математический хэш ключа, получаете индекс и мгновенно достаете значение. Это сердце производительности Python.
2. Mutable vs Immutable: Вы понимаете, почему TypeError: unhashable type: 'list' — это не баг, а защита архитектуры. Ключи должны быть неизменяемыми (хэшируемыми), чтобы их 'адрес' в таблице никогда не изменился.
3. Безопасность (The .get() method): Вы навсегда прощаетесь с 'лапшой' из try/except KeyError при парсинге JSON-данных. Ваш код теперь изящен и безопасен: data.get('email', 'no_reply@test.com').
4. Инициализация (The .setdefault() & defaultdict): Сложные алгоритмы группировки данных (ETL) теперь занимают у вас одну строку. Вы понимаете разницу между разовой установкой через setdefault и изменением фабрики на уровне объекта через collections.defaultdict(list).
5. Современный синтаксис (Comprehensions & Merge): Вы используете генераторы словарей {k: v for...} для молниеносной фильтрации и трансформации, а также оператор | (Python 3.9+) для элегантного слияния конфигураций.

Но самое главное — вы усвоили инженерный принцип: 'Не существует идеального инструмента'. Словарь быстр, но жаден до памяти. Он идеален для JSON, но плох для бизнес-логики с поведением (тут правит ООП). Этот балансирующий подход (Scaffolding от теории к PBL) — ваш пропуск на уровень Confident Intermediate. В следующем модуле мы перейдем к функциям высшего порядка (Lambda, *args, **kwargs), но хэш-таблицы останутся вашим главным оружием в оптимизации кода. Успехов в программировании!