Python: углубленный курс

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

1. Продвинутый синтаксис и модель данных Python

Продвинутый синтаксис и модель данных Python

Эта статья открывает курс Python: углубленный курс и задаёт фундамент: как читать и писать современный Python-код и как интерпретатор на самом деле работает с вашими объектами. Дальше в курсе мы будем опираться на эти знания при проектировании API, написании библиотечного кода, оптимизации, тестировании и работе с асинхронностью.

Ключевая мысль: в Python почти всё — это объект, а поведение объектов описывается моделью данных через специальные методы (их ещё называют dunder-методы от double underscore, например __len__).

Продвинутый синтаксис, который встречается в промышленном коде

Распаковка и расширенная распаковка

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

Частые применения:

  • возвращение нескольких значений из функции
  • разделение «первого/последнего» элемента и «середины»
  • слияние последовательностей
  • Сопоставление с образцом (structural pattern matching)

    Начиная с Python 3.10 появился оператор match, который делает ветвление по форме данных.

    Важно:

  • match не заменяет полностью if/elif, но делает код чище, когда проверяется структура данных
  • можно сопоставлять словари, последовательности, а также пользовательские классы
  • Источник: Документация Python: match statement

    Выражение присваивания (оператор :=)

    Оператор морж := позволяет присвоить значение прямо внутри выражения. Это полезно, когда значение нужно и для проверки, и для использования.

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

    Источник: PEP 572 — Assignment Expressions

    F-строки и отладочное представление

    F-строки — стандарт де-факто для форматирования строк в современном Python.

    Полезные приёмы:

  • !r — использовать repr() (удобно для отладки)
  • формат-спецификаторы, например :.2f для чисел с плавающей точкой
  • Источник: Документация Python: f-strings

    Модель данных Python: как язык «вызывает» ваш код

    Что такое модель данных

    Модель данных Python — это набор правил, описывающих, как объекты ведут себя в базовых операциях:

  • арифметика (+, *)
  • сравнения (==, <)
  • доступ к атрибутам (obj.x)
  • контейнеры (in, индексация)
  • итерация (for)
  • печать и отладка (str(), repr())
  • Главный практический вывод: большинство возможностей языка — это синтаксический сахар над вызовами специальных методов.

    Источник: Документация Python: Data model

    !Схема показывает, какие специальные методы стоят за привычным синтаксисом

    Специальные методы: читаем код через «перевод» в dunder

    Некоторые соответствия:

    | Синтаксис | Что фактически происходит | |---|---| | len(x) | x.__len__() | | x[i] | x.__getitem__(i) | | x[i] = v | x.__setitem__(i, v) | | v in x | x.__contains__(v) (или перебор через итератор) | | for v in x | iter(x)x.__iter__()__next__() | | x + y | x.__add__(y) (или обратный y.__radd__(x)) | | x == y | x.__eq__(y) | | with cm: | cm.__enter__() / cm.__exit__(...) |

    Это важно для:

  • проектирования собственных классов
  • предсказуемого поведения операторов
  • корректной интеграции с экосистемой (коллекции, сортировки, сериализация)
  • Атрибуты, методы и протокол доступа: где Python ищет obj.x

    Когда вы пишете obj.x, Python не просто берёт поле из объекта. Он следует правилам поиска атрибутов.

    Упрощённо:

  • Сначала Python смотрит, нет ли на классе специальных объектов, управляющих доступом к атрибуту (например, property). Такие объекты называют дескрипторами.
  • Затем ищет значение в словаре объекта (обычно это obj.__dict__).
  • Затем ищет атрибут на классе и базовых классах.
  • Если не нашёл — вызывает __getattr__ (если определён).
  • __getattribute__ и __getattr__

  • __getattribute__(self, name) вызывается всегда при доступе к атрибуту. Ошибка в нём легко приводит к бесконечной рекурсии.
  • __getattr__(self, name) вызывается только если обычный поиск атрибута не нашёл значение.
  • Дескрипторы и property: управляем доступом к полям

    Дескриптор — это объект, который реализует один или несколько методов __get__, __set__, __delete__. Если такой объект лежит в атрибутах класса, Python использует его для управления доступом.

    Самый распространённый дескриптор — property, с которым вы создаёте вычисляемые атрибуты и валидацию.

    Источник: Документация Python: Descriptors

    Итераторы и генераторы: протокол итерации

    Итераторный протокол

    Чтобы объект был итерируемым, обычно достаточно:

  • __iter__, который возвращает итератор
  • у итератора должен быть __next__, который возвращает следующий элемент или выбрасывает StopIteration
  • Пример собственного итератора:

    Генераторы

    Генератор — это функция с yield, которая автоматически реализует протокол итератора.

    Практический критерий:

  • если нужно «лениво» производить значения и не хранить всё в памяти — используйте генераторы
  • Источник: Документация Python: Generators

    Контекстные менеджеры: протокол with

    Контекстный менеджер гарантирует корректную настройку и освобождение ресурса.

  • __enter__ выполняется при входе в with
  • __exit__ выполняется при выходе (даже при исключении)
  • На практике чаще используют стандартную библиотеку contextlib.

    Источник: Документация Python: contextlib

    dataclass: меньше шаблонного кода, больше смысла

    dataclasses (Python 3.7+) позволяют объявлять классы-«контейнеры данных» с автогенерацией __init__, __repr__, сравнений.

    Что здесь происходит:

  • frozen=True делает объект неизменяемым (попытка присвоить p.x = ... приведёт к ошибке)
  • slots=True убирает обычный __dict__ у экземпляра и экономит память, а также ускоряет доступ к атрибутам в некоторых сценариях
  • Источник: Документация Python: dataclasses

    __repr__ и __str__: интерфейс для людей и отладки

  • __str__ — «человекочитаемая» строка (используется print())
  • __repr__ — отладочное представление (используется в REPL и repr()), по хорошей практике максимально однозначное
  • Практика для библиотечного кода: делайте хороший __repr__, потому что он экономит часы отладки.

    Сравнение, хеширование и неизменяемость

    Сравнения и хеширование важны для корректной работы:

  • set и ключей dict
  • сортировок
  • кэширования
  • Базовые правила:

  • если объект изменяемый, обычно не стоит делать его хешируемым
  • если вы определяете __eq__, подумайте, должен ли объект быть хешируемым (тогда нужен согласованный __hash__)
  • В dataclass это частично решается опциями frozen=True и настройками генерации сравнений.

    Частые ошибки при работе с моделью данных

  • Переопределять __getattribute__ без крайней необходимости
  • Возвращать True из __exit__ «на всякий случай» и тем самым скрывать ошибки
  • Делать у изменяемых объектов нестабильный __hash__
  • Писать «тяжёлые» __repr__, которые могут выполняться очень часто (например, при логировании)
  • Смешивать побочные эффекты с протоколами (например, итерация должна быть предсказуемой)
  • Итоги

    В этой статье вы:

  • разобрали современный синтаксис, который часто встречается в коде (распаковка, match, :=, f-строки)
  • поняли, что синтаксис Python опирается на протоколы и специальные методы модели данных
  • увидели, как устроен доступ к атрибутам, зачем нужны дескрипторы и property
  • освежили протокол итерации, генераторы и контекстные менеджеры
  • познакомились с dataclass, а также с идеями неизменяемости и slots
  • Дальше по курсу мы будем использовать эту базу для проектирования классов и API, написания надёжного кода и осмысленной оптимизации.

    2. Функциональные техники и метапрограммирование

    Функциональные техники и метапрограммирование

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

  • функциональные техники (композиция функций, итераторные конвейеры, замыкания, декораторы)
  • метапрограммирование (код, который создаёт или модифицирует другой код и классы)
  • Ключевая связка между темами: в Python функции и классы — это объекты, а значит их можно передавать, оборачивать, генерировать и анализировать так же, как любые другие значения.

    Функции как объекты и callable

    В Python функция — объект, который можно:

  • присвоить переменной
  • положить в контейнер
  • передать как аргумент
  • вернуть из другой функции
  • Любой объект, который можно вызвать как obj(...), называется callable. Это либо функция/метод, либо объект, реализующий __call__.

    Связь с моделью данных: выражение g("Ada") приводит к вызову g.__call__("Ada").

    Функции высшего порядка и композиция

    Функция высшего порядка — функция, которая принимает другую функцию или возвращает функцию.

    Самые частые места, где это проявляется в промышленном Python:

  • sorted(..., key=...)
  • min/max(..., key=...)
  • map, filter (используются, но часто читаемость лучше у генераторов)
  • колбэки (обработчики событий)
  • key= и модуль operator

    Вместо lambda часто удобнее использовать готовые функции из operator:

    Для объектов:

    Источник: Документация Python: operator

    Частичное применение: functools.partial

    partial фиксирует часть аргументов, превращая функцию в новую функцию с меньшим числом параметров.

    Это полезно, когда API требует колбэк определённой формы, а у вас есть функция более общего вида.

    Источник: Документация Python: functools.partial

    map и filter против генераторов

    map и filter ленивы (возвращают итераторы), но генераторные выражения часто читаются проще:

    Практическое правило:

  • если преобразование короткое и линейное — генераторы и списковые включения обычно читаются лучше
  • если хочется переиспользовать шаги конвейера — выносите шаги в функции
  • Итераторные конвейеры и itertools

    Функциональный стиль в Python почти всегда опирается на итераторы и ленивые вычисления.

    Модуль itertools даёт строительные блоки для конвейеров:

  • chain склеивает несколько последовательностей
  • islice берёт срез у итератора
  • takewhile/dropwhile отбирает по условию
  • groupby группирует подряд идущие элементы с одинаковым ключом
  • Пример с groupby и важной оговоркой:

    groupby не «собирает все одинаковые элементы из списка», а группирует только последовательные блоки. Если нужно группировать независимо от порядка, обычно применяют сортировку по ключу или словарь накопления.

    Источник: Документация Python: itertools

    Замыкания: состояние без класса

    Замыкание возникает, когда внутренняя функция захватывает переменные из внешней области видимости.

    nonlocal для изменения захваченной переменной

    Если нужно изменять значение внешней переменной, используйте nonlocal:

    Частая ловушка: позднее связывание в цикле

    Здесь lambda захватывает переменную i, а не её значение на момент создания функции. Распространённое исправление — фиксировать значение через аргумент по умолчанию:

    Декораторы: управляем поведением функций

    Декоратор — это callable, который принимает функцию и возвращает другую callable (обычно обёртку).

    Источник: PEP 318 — Decorators for Functions and Methods

    Базовый декоратор

    Почему важен functools.wraps

    Без дополнительных действий обёртка теряет метаданные исходной функции (имя, докстринг, аннотации). Это ломает логирование, автогенерацию документации и интроспекцию.

    Источник: Документация Python: functools.wraps

    !Как декоратор превращает функцию в обёртку и где тут замыкание и functools.wraps

    Параметризованные декораторы

    Если декоратору нужны настройки, делают функцию, которая возвращает декоратор:

    Декоратор как объект с __call__

    Иногда удобно хранить состояние в экземпляре класса:

    Связь с моделью данных: такой декоратор работает потому, что экземпляр класса является callable через __call__.

    functools для библиотечного кода

    Кэширование: lru_cache

    lru_cache запоминает результаты вызовов и переиспользует их при повторных вызовах с теми же аргументами.

    Важные ограничения:

  • аргументы должны быть хешируемыми (как ключи dict)
  • кэширование уместно для детерминированных вычислений без побочных эффектов
  • Источник: Документация Python: functools.lru_cache

    Обобщённые функции: singledispatch

    singledispatch позволяет делать перегрузку по типу первого аргумента.

    Это удобно для расширяемых API, где обработчики регистрируются постепенно.

    Источник: PEP 443 — Single-dispatch generic functions

    Метапрограммирование: управление созданием и описанием кода

    Метапрограммирование в Python обычно решает одну из задач:

  • автоматизировать шаблонный код (регистрация плагинов, валидация, сериализация)
  • обеспечить ограничения на уровне классов (контракты, протоколы)
  • анализировать объекты (интроспекция) для фреймворков и инструментов
  • Главная идея из модели данных: класс создаётся вызовом метакласса, а по умолчанию метакласс — type.

    Источник: Документация Python: Customizing class creation

    Интроспекция: узнаём, что за объект перед нами

    Модуль inspect помогает извлекать сигнатуры, аннотации и другую метаинформацию.

    Практический смысл:

  • CLI-утилиты и DI-контейнеры связывают параметры по имени
  • тестовые фреймворки и валидаторы читают аннотации
  • декораторы могут аккуратно обрабатывать args, *kwargs, не ломая контракт
  • Источник: Документация Python: inspect

    Дескрипторы и __set_name__: метапрограммирование на уровне атрибутов

    В прошлой статье мы познакомились с дескрипторами (__get__, __set__, __delete__). Для библиотечного кода особенно полезен __set_name__: он вызывается при создании класса и сообщает дескриптору имя атрибута, под которым он установлен.

    Это типичный приём для валидации, ORM и сериализации.

    Источник: Документация Python: Descriptor HowTo Guide

    __init_subclass__: настройка наследников без метаклассов

    __init_subclass__ вызывается автоматически, когда от класса наследуются. Это удобный хук для:

  • регистрации плагинов
  • автопроверок
  • накопления метаданных
  • Этот инструмент часто проще и безопаснее, чем метакласс.

    Источник: Документация Python: object.__init_subclass__

    Метаклассы: мощно, но используйте экономно

    Метакласс управляет тем, как создаются классы. В отличие от __init_subclass__, метакласс может:

  • менять словарь атрибутов до создания класса
  • контролировать создание через __new__ и __init__ метакласса
  • Минимальный пример: требуем, чтобы у каждого наследника был атрибут name.

    Когда метакласс оправдан:

  • вы пишете фреймворк, который строит классы по декларативному описанию (ORM, схемы)
  • нужно вмешаться до создания класса (например, переписать namespace)
  • Когда лучше не использовать:

  • если задачу решают декоратор класса, __init_subclass__ или дескрипторы
  • Источник: PEP 3115 — Metaclasses in Python 3000

    Декораторы классов и динамическое создание классов

    Декоратор класса похож на декоратор функции: принимает класс и возвращает класс (возможно, модифицированный). Декораторы классов стандартизованы в Python.

    Источник: PEP 3129 — Class Decorators

    Также класс можно создать динамически через type(name, bases, namespace):

    Это полезно для генерации классов из конфигураций, схем или описаний протоколов.

    Итоги

  • Функции в Python — объекты, а callable определяется через протокол __call__.
  • Функциональный стиль в промышленном Python чаще всего строится на итераторах, конвейерах и композиции через key=, partial, генераторы и itertools.
  • Замыкания дают «состояние без класса», но требуют аккуратности с поздним связыванием в циклах.
  • Декораторы — центральная техника расширения поведения, и functools.wraps критичен для сохранения метаданных.
  • Метапрограммирование начинается с интроспекции (inspect), продолжается дескрипторами и __init_subclass__, а метаклассы стоит применять только когда нужны возможности уровня создания класса.
  • 3. Типизация, протоколы и качество кода

    Типизация, протоколы и качество кода

    В предыдущих статьях мы разобрали, как Python работает на уровне модели данных (протоколы через dunder-методы, дескрипторы, итераторы, контекстные менеджеры) и как строить библиоточный код через функциональные техники и метапрограммирование (декораторы, интроспекция, __init_subclass__, метаклассы). Эта статья соединяет эти идеи с практикой индустриального Python: как делать API предсказуемыми, проверяемыми и поддерживаемыми с помощью типизации, протоколов и инструментов качества кода.

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

    Что такое аннотации типов и зачем они нужны

    Python остаётся динамически типизированным: интерпретатор не запрещает вам передать в функцию значение другого типа. Аннотации типов существуют для:

  • читаемости: по сигнатуре понятно, что ожидается и что возвращается
  • статической проверки: тип-чекер найдёт ошибки до запуска
  • поддержки IDE: автодополнение, навигация, рефакторинг
  • контрактов библиотек: понятные, расширяемые интерфейсы
  • Аннотации задаются по PEP 526 — Syntax for Variable Annotations и общей системе типов по PEP 484 — Type Hints.

    Пример базовой аннотации:

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

    Базовые строительные блоки typing

    Официальный справочник: Документация Python: typing.

    Any и стоимость отсутствия типов

    Any означает разрешено всё. Это удобно при миграции, но отключает пользу типизации: тип-чекер перестаёт защищать.

    Практика: избегайте Any в публичных интерфейсах; допустимо внутри адаптеров и на границах системы.

    None, Optional и объединения

    Если значение может быть None, это часть контракта.

    Современный синтаксис объединений по PEP 604 — Allow writing union types as X | Y:

    Эквивалент через Optional:

    Обобщённые контейнеры

    Для стандартных коллекций используйте параметризацию:

    Это стало возможным благодаря PEP 585 — Type Hinting Generics In Standard Collections.

    Literal, Final, Annotated

  • Literal фиксирует конкретные допустимые значения
  • Final сигнализирует, что значение не должно переопределяться
  • Annotated позволяет добавлять метаданные к типу (часто для валидаторов и генераторов схем)
  • Уточнение типов по потоку выполнения

    Тип-чекеры умеют сужать тип после проверок:

    Практика:

  • предпочитайте явные проверки is None
  • если у значения несколько форм, делайте ветвления, где каждая ветка возвращает конкретный тип
  • Пользовательские типы для доменной модели

    NewType для логических различий

    Если в системе есть разные идентификаторы, которые все являются int, можно отделить их логически:

    Тип-чекер не даст случайно перепутать UserId и OrderId, хотя в рантайме это всё ещё int.

    TypedDict для словарей фиксированной структуры

    Когда данные приходят как словарь с заданными ключами (например, JSON), используйте TypedDict:

    Это делает контракт явным и уменьшает количество ошибок с опечатками ключей.

    Протоколы: структурная типизация поверх модели данных

    Мы уже видели, что многие возможности языка реализованы через протоколы в смысле модели данных: если объект реализует __iter__, он итерируем; если есть __enter__ и __exit__, он контекстный менеджер. В типизации это выражается как структурная совместимость: важно не имя класса, а наличие нужных методов.

    Это формализовано в PEP 544 — Protocols: Structural subtyping (static duck typing).

    !Сравнение номинальной и структурной типизации и роль Protocol

    Простой Protocol

    Socket не обязан наследоваться от SupportsClose. Достаточно совпадающей структуры.

    Протоколы с дженериками

    Это особенно полезно для интерфейсов адаптеров, клиентов, репозиториев.

    runtime_checkable и границы рантайм-проверок

    Иногда нужно проверить соответствие протоколу в рантайме:

    Важно понимать ограничения: такие проверки обычно смотрят на наличие атрибутов/методов, а не на точные сигнатуры и не на семантику.

    collections.abc как источник готовых протоколов

    Многие интерфейсы уже описаны в стандартной библиотеке в виде абстрактных базовых классов:

  • Iterable, Iterator
  • Sequence, Mapping
  • Callable
  • Справочник: Документация Python: collections.abc.

    Практическое правило:

  • в аргументах функций предпочитайте интерфейсы вроде Sequence[str] или Mapping[str, int], а не конкретные list[str] или dict[str, int], если вам не важна конкретная реализация
  • Перегрузки, колбэки и точные сигнатуры

    Callable и читаемые колбэки

    overload для разной логики возвращаемого типа

    Если возвращаемый тип зависит от аргументов, используйте @overload:

    Перегрузки помогают IDE и тип-чекеру, даже если реализация одна.

    Качество кода: типизация как часть инженерного конвейера

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

    Инструменты и их роли

    | Инструментальный слой | Что ловит | Пример пользы | |---|---|---| | Форматтер | единый стиль | меньше шума в диффах | | Линтер | ошибки и плохие практики | неиспользуемые переменные, подозрительные конструкции | | Тип-чекер | несоответствие контрактам типов | перепутанные аргументы, неверные возвращаемые типы | | Тесты | логические ошибки и регрессии | проверка сценариев и границ | | Ревью | архитектура и читаемость | качество API, поддерживаемость |

    Полезные источники:

  • PEP 8 — Style Guide for Python Code
  • Документация Python: unittest
  • Практика: делайте контракты проверяемыми

  • Аннотируйте публичные функции и классы
  • В аргументах принимайте абстракции (Sequence, Mapping, Protocol), а не конкретные реализации
  • Не злоупотребляйте Any
  • Разделяйте данные и поведение: dataclass с типами для данных, отдельные сервисы для логики
  • Для гибких расширяемых API используйте Protocol и/или singledispatch (мы обсуждали его в прошлой статье)
  • Как связываются типизация и метапрограммирование

    Типизация усиливает техники из предыдущей статьи:

  • декораторы должны сохранять сигнатуру, иначе ухудшится и интроспекция, и подсказки IDE; поэтому functools.wraps важен не только для метаданных, но и для поддерживаемости типов
  • интроспекция через inspect может читать аннотации и строить автоматическую валидацию или CLI
  • дескрипторы и __init_subclass__ удобно сочетать с Protocol: типы задают контракт, а метапрограммирование автоматизирует шаблонный код
  • !Как аннотации типов работают вместе с тестами и линтерами

    Частые ошибки и анти-паттерны

  • Аннотировать всё Any: создаётся иллюзия типизации без реальной пользы
  • Считать типы заменой тестов: типы проверяют форму, тесты проверяют поведение
  • Делать рантайм-проверки типов повсюду: это может замедлять код и усложнять дизайн; лучше валидировать на границах системы
  • Аннотировать слишком конкретно: list вместо Sequence, dict вместо Mapping, из-за чего снижается переиспользуемость
  • Путать протоколы модели данных и typing.Protocol: первое влияет на поведение в рантайме, второе описывает контракт для статической проверки
  • Итоги

  • Аннотации типов в Python задают контракты, которые проверяются инструментами, но не ограничивают интерпретатор.
  • Protocol реализует структурную типизацию и напрямую продолжает идею протоколов из модели данных Python.
  • Готовые интерфейсы часто уже есть в collections.abc, а TypedDict и NewType помогают описывать реальные доменные структуры.
  • Типизация приносит максимальную пользу как часть конвейера качества: форматирование, линтинг, статическая проверка, тесты и ревью.
  • Дальше по курсу эти принципы пригодятся при проектировании API, написании библиотек и инфраструктурного кода, где ошибки особенно дороги.

    4. Асинхронность, параллелизм и работа с I/O

    Асинхронность, параллелизм и работа с I/O

    В предыдущих статьях курса мы смотрели на Python как на систему протоколов и контрактов:

  • в модели данных вы видели, что синтаксис опирается на специальные методы (например, итерация и контекстные менеджеры)
  • в функциональных техниках вы строили конвейеры и обёртки (декораторы), которые особенно полезны в инфраструктурном коде
  • в типизации вы формализовали контракты через Protocol, Callable, обобщения и точные сигнатуры
  • Теперь мы добавим ещё один слой: как писать код, который эффективно работает с I/O (сеть, диски, сокеты, subprocess), и как правильно выбирать между асинхронностью, потоками и процессами.

    Термины: параллелизм и конкурентность

  • Конкурентность: несколько задач продвигаются во времени, но не обязательно одновременно. Пример: один поток исполнения переключается между задачами во время ожидания сети.
  • Параллелизм: задачи реально исполняются одновременно (например, на разных ядрах CPU).
  • В Python эти понятия часто соответствуют разным инструментам:

  • asyncio даёт конкурентность в одном потоке, особенно эффективную для I/O
  • threading даёт конкурентность (и иногда параллелизм для I/O), но CPU-код упирается в GIL
  • multiprocessing даёт параллелизм для CPU
  • !Диаграмма показывает, чем отличается конкурентность (переключение) от параллелизма (одновременное выполнение)

    Ключевой критерий выбора: CPU-bound против I/O-bound

  • I/O-bound задачи тратят основное время на ожидание: сеть, база данных, файловая система, subprocess.
  • CPU-bound задачи тратят основное время на вычисления: криптография, обработка изображений, большие расчёты.
  • Важно помнить про GIL (Global Interpreter Lock): в CPython только один поток выполняет байткод Python в каждый момент времени. Это означает:

  • потоки отлично помогают скрывать ожидание I/O
  • потоки почти не ускоряют чистый CPU-код на Python
  • процессы позволяют масштабировать CPU-нагрузку по ядрам
  • Полезные ссылки:

  • Документация Python: threading
  • Документация Python: multiprocessing
  • Документация Python: concurrent.futures
  • Что такое I/O и почему оно блокирует

    Под I/O обычно понимают:

  • сетевые запросы (TCP/UDP, HTTP)
  • чтение и запись файлов
  • работа с subprocess (чтение stdout/stderr, ожидание завершения)
  • Блокирующий вызов — это вызов, который останавливает текущий поток до завершения операции. Например, обычный socket.recv() или file.read().

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

    Модели конкурентности в Python

    Потоки: threading и пул потоков

    Плюсы:

  • просто интегрировать с блокирующими библиотеками
  • удобно для I/O
  • Минусы:

  • переключение потоков и синхронизация усложняют отладку
  • для CPU-кода ускорение ограничено GIL
  • Частый промышленный паттерн: использовать ThreadPoolExecutor для вызова блокирующего I/O или библиотек, которые не имеют async API.

    Процессы: multiprocessing и пул процессов

    Плюсы:

  • настоящий параллелизм для CPU
  • Минусы:

  • дороже по памяти и запуску
  • нужно сериализовать данные между процессами
  • Асинхронность: asyncio

    asyncio — стандартная библиотека для конкурентного I/O в одном потоке через event loop.

    Ссылки:

  • Документация Python: asyncio
  • Документация Python: Developing with asyncio
  • Как работает asyncio: event loop, корутины, задачи

    Event loop

    Event loop (цикл событий) управляет:

  • готовыми к выполнению задачами
  • ожиданием событий I/O (сокеты, таймеры)
  • планированием продолжения корутин после await
  • !Схема объясняет, как await отдаёт управление циклу событий и как задача возобновляется после I/O

    Корутина и await

    Корутина — это функция, объявленная через async def. Внутри неё await означает: “пока операция не готова, отдай управление event loop”.

    Критически важно: корутина сама по себе не запускается “в фоне”, она должна быть либо await-нута, либо превращена в задачу.

    Awaitable, Task и Future

    В asyncio вы встретите три близких понятия:

  • корутина (объект, возвращаемый async def)
  • Task — обёртка вокруг корутины, которую event loop планирует к выполнению
  • Future — объект “обещание результата”, низкоуровневый примитив, на котором построены Task
  • Практическое правило:

  • если нужно дождаться результата прямо здесь — используйте await
  • если нужно запустить параллельно (конкурентно) — используйте asyncio.create_task
  • Ссылки:

  • Документация Python: Creating Tasks
  • Отмена, таймауты и корректное завершение

    Отмена задач

    Отмена в asyncio реализована через исключение asyncio.CancelledError, которое “вбрасывается” в корутину в точке ожидания.

    Практика:

  • отмена должна быть ожидаемой частью дизайна, особенно для серверов
  • не “глотайте” CancelledError, если вы действительно отменяете задачу
  • Таймауты

    Современный вариант: asyncio.timeout() (Python 3.11+).

    Ссылка:

  • Документация Python: asyncio.timeout
  • Структурированная конкурентность: TaskGroup

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

    asyncio.TaskGroup (Python 3.11+) реализует структурированную конкурентность: задачи живут внутри блока, и при выходе из блока гарантируется, что:

  • все задачи завершены
  • исключения корректно собраны
  • при исключении остальные задачи будут отменены
  • Ссылка:

  • Документация Python: asyncio.TaskGroup
  • Async-версии протоколов из модели данных

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

    Асинхронный контекстный менеджер

    Чтобы работал async with, объект должен реализовать:

  • __aenter__
  • __aexit__
  • Ссылка:

  • Документация Python: Asynchronous context managers
  • Асинхронная итерация

    Чтобы работал async for, объект должен реализовать:

  • __aiter__
  • __anext__ (должен возвращать awaitable и завершать итерацию через StopAsyncIteration)
  • Ссылка:

  • Документация Python: Asynchronous iterators
  • Асинхронные генераторы

    Асинхронный генератор — это async def с yield, полезен для потоков данных, где каждый элемент может требовать I/O.

    Ссылка:

  • Документация Python: Asynchronous generators
  • Сетевой I/O в asyncio: Streams

    Высокоуровневый способ работать с TCP в asyncioпотоки (Streams): asyncio.open_connection и asyncio.start_server.

    Клиент

    Сервер (эхо)

    Ссылка:

  • Документация Python: Streams
  • Subprocess и пайпы без блокировки

    asyncio умеет запускать subprocess так, чтобы чтение stdout/stderr не блокировало event loop.

    Ссылка:

  • Документация Python: asyncio subprocesses
  • Файлы и asyncio: важная граница

    Файлы на локальной файловой системе в стандартной библиотеке обычно читаются блокирующе. Поэтому типичная стратегия:

  • для сетевого I/O использовать asyncio
  • для файлового I/O использовать отдельные потоки (через asyncio.to_thread) или процессы при необходимости
  • Ссылка:

  • Документация Python: asyncio.to_thread
  • Синхронизация в asyncio

    Даже в одном потоке нужны примитивы синхронизации, потому что задачи переключаются в точках await.

    Основные инструменты:

  • asyncio.Lock для взаимного исключения
  • asyncio.Semaphore для ограничения параллелизма (например, не более 10 запросов одновременно)
  • asyncio.Queue для конвейеров producer-consumer
  • Ссылка:

  • Документация Python: Synchronization primitives
  • Типизация для async-кода

    Из третьей статьи курса важны два практических направления.

    Аннотации awaitable-объектов

  • collections.abc.Awaitable[T] описывает то, что можно await-ить и что даёт результат типа T
  • collections.abc.AsyncIterator[T] и collections.abc.AsyncIterable[T] описывают async for
  • Ссылка:

  • Документация Python: collections.abc
  • Protocol для асинхронных интерфейсов

    Можно описывать “утиный тип” для асинхронного клиента, не привязываясь к реализации.

    Такой контракт хорошо сочетается с DI, тестовыми дублями и модульной архитектурой.

    Типичные ошибки и как их избегать

  • Создавать корутину и забывать её await-нуть или превратить в задачу: это приводит к предупреждениям и незапущенной логике.
  • Делать CPU-тяжёлую работу внутри async def без вынесения в процесс или поток: event loop “зависает”, остальные задачи не выполняются.
  • Использовать блокирующий I/O в async-коде без to_thread или executor: эффект тот же, что и выше.
  • Не продумывать отмену и таймауты: без них серверы и воркеры часто “висят” на плохих соединениях.
  • Запускать фоновые задачи без структуры: предпочтительнее TaskGroup, чтобы жизненный цикл задач был ограничен блоком.
  • Итоги

  • asyncio даёт конкурентность в одном потоке и особенно эффективен для сетевого I/O.
  • Потоки удобны для интеграции с блокирующими библиотеками и файловым I/O, но не ускоряют CPU-код из-за GIL.
  • Процессы дают параллелизм для CPU-задач ценой межпроцессного обмена.
  • async with и async for напрямую продолжают идею протоколов модели данных через __aenter__, __aexit__, __aiter__, __anext__.
  • Для управляемой конкурентности в современном Python стоит опираться на TaskGroup, а для стабильности системного кода обязательно учитывать отмену и таймауты.
  • 5. Тестирование, профилирование и оптимизация производительности

    Тестирование, профилирование и оптимизация производительности

    Эта статья связывает все предыдущие части курса в единую инженерную практику.

  • Из модели данных Python вы уже знаете, что «магия» почти всегда сводится к протоколам и специальным методам.
  • Из функциональных техник и метапрограммирования вы взяли декораторы, интроспекцию и способы строить расширяемые API.
  • Из типизации вы получили контракты, которые можно проверять статически.
  • Из асинхронности и I/O вы понимаете, где выигрывает asyncio, а где нужны потоки или процессы.
  • Теперь добавим два критически важных навыка, без которых «углублённый Python» в промышленности не работает:

  • как доказывать корректность кода тестами
  • как находить и исправлять узкие места производительности
  • Главный принцип статьи: сначала делаем корректно и измеряемо, потом ускоряем только то, что реально тормозит.

    Тестирование как контракт поведения

    Тесты фиксируют поведение системы. В идеале они дополняют типизацию:

  • типы защищают форму взаимодействия (сигнатуры, протоколы)
  • тесты защищают смысл (семантику, бизнес-правила, крайние случаи)
  • !Пирамида тестов и компромиссы между скоростью и реализмом

    Виды тестов и когда они нужны

  • Unit-тесты проверяют функцию или класс в изоляции.
  • Integration-тесты проверяют взаимодействие нескольких компонентов (например, клиент + БД, обработчик + очередь).
  • End-to-end проверяют сценарий целиком, максимально близко к продакшену.
  • Практическое правило:

  • чем ближе тест к реальности, тем он дороже и медленнее
  • основу набора тестов почти всегда составляют unit-тесты
  • Что считать хорошим тестом

  • один тест проверяет одну идею
  • тест детерминирован (не зависит от времени, сети и случайности без контроля)
  • при падении теста по сообщению и данным легко понять причину
  • тест проверяет наблюдаемое поведение, а не внутреннюю реализацию
  • Инструменты: unittest и pytest

    unittest: стандартная библиотека

    unittest удобен там, где важны встроенные механизмы Python и минимум зависимостей.

  • базовый фреймворк: Документация unittest
  • мокинг и патчинг: Документация unittest.mock
  • Минимальный пример:

    pytest: де-факто стандарт индустрии

    pytest ценят за лаконичность, фикстуры, параметризацию и богатую экосистему.

  • основной сайт: Документация pytest
  • Пример:

    Фикстуры и изоляция: управляем окружением

    Фикстуры позволяют описывать окружение теста декларативно: подготовить данные, ресурс, клиент, временную директорию.

    Пример фикстуры в pytest:

    Практика:

  • делайте фикстуры маленькими и переиспользуемыми
  • не прячьте в фикстурах сложную логику без необходимости
  • Параметризация: тестируем таблицу случаев

    Параметризация помогает покрывать крайние случаи без копипаста.

    Моки, стабы и патчи: тестируем без реальных внешних систем

    Когда код взаимодействует с сетью, БД или временем, на уровне unit-тестов это почти всегда нужно подменять.

    Три близких понятия:

  • stub возвращает заранее заданные данные
  • mock дополнительно позволяет проверять, как именно его вызывали
  • patch временно подменяет объект в нужном модуле
  • Пример с unittest.mock:

    Важная связка с предыдущими статьями:

  • если вы проектировали API через Protocol, то в тестах легко подставлять фейковую реализацию «по утиному типу»
  • Тестирование асинхронного кода

    Из статьи про asyncio вы знаете, что корутина должна быть await-нута, а конкуретность удобнее структурировать через TaskGroup. Эти же идеи отражаются в тестировании.

    Варианты:

  • в unittest есть IsolatedAsyncioTestCase: Документация IsolatedAsyncioTestCase
  • в экосистеме pytest часто используют плагин pytest-asyncio: Документация pytest-asyncio
  • Пример через unittest:

    Практика для async-тестов:

  • не используйте реальные таймауты и sleep, если можно смоделировать событие
  • обязательно проверяйте отмену и таймауты, если ваш код их поддерживает
  • Property-based тестирование: проверяем свойства, а не примеры

    Если у функции есть общее свойство, полезно генерировать множество входов автоматически.

    Инструмент: Документация Hypothesis

    Идея:

  • вы описываете свойство (инвариант)
  • библиотека генерирует входы и пытается найти контрпример
  • Производительность: измеряем, затем оптимизируем

    Оптимизация без измерений почти всегда приводит к двум проблемам:

  • ускорили не то
  • усложнили код без пользы
  • Надёжный процесс:

  • Зафиксируйте корректность тестами.
  • Измерьте базовую производительность (время, память).
  • Найдите узкое место профилированием.
  • Внесите изменение.
  • Снова измерьте и убедитесь, что тесты проходят.
  • !Цикл: тесты + измерения + профилирование + итерации

    Бенчмаркинг: timeit и дисциплина эксперимента

    Для микробенчмарков используйте timeit, он уменьшает шум и прогревает интерпретатор.

  • Документация timeit
  • Пример:

    Правила качественного измерения:

  • сравнивайте варианты на одной машине и в одинаковых условиях
  • следите, что измеряете одну и ту же работу
  • не делайте выводов по одному прогону
  • Для проектов часто используют бенчмарки в pytest через плагин pytest-benchmark: Документация pytest-benchmark

    Профилирование CPU: cProfile, pstats, внешние профайлеры

    Встроенный профайлер cProfile

    cProfile даёт статистику по функциям: сколько раз вызывались и сколько времени заняли.

  • Документация cProfile
  • Документация pstats
  • Запуск скрипта под профайлером:

    Анализ:

    Интерпретация ключевых колонок:

  • ncalls: сколько раз вызвали
  • tottime: время внутри функции без подфункций
  • cumtime: время функции вместе с вызовами подфункций
  • Практика:

  • сначала смотрите cumtime, чтобы найти «воронку»
  • затем уточняйте по tottime, чтобы понять, где именно дорогая работа
  • Внешние профайлеры

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

    Пример: py-spy.

  • py-spy (GitHub)
  • Профилирование памяти: tracemalloc

    Если проблема в росте памяти или «утечках» (обычно это удержание ссылок), используйте tracemalloc.

  • Документация tracemalloc
  • Базовый паттерн:

    Что вы получаете:

  • какие строки кода сделали больше всего аллокаций
  • сравнение снапшотов «до» и «после»
  • Оптимизация: от больших выигрышей к меньшим

    Сначала алгоритм и структуры данных

    Самые большие выигрыши обычно не в микротрюках, а в правильной модели данных:

  • вместо линейного поиска по списку используйте set или dict
  • избегайте лишних проходов по данным
  • группируйте работу пакетами
  • Пример замены:

    Затем уменьшайте количество работы и аллокаций

    Практические приёмы:

  • используйте генераторы вместо промежуточных списков, если данные можно обрабатывать потоком
  • избегайте конкатенации строк в цикле, используйте "".join(...)
  • выносите инварианты из циклов
  • Используйте кэширование там, где это корректно

    Вы уже видели lru_cache в теме функциональных техник. Это частый способ ускорить чистые функции.

  • Документация functools.lru_cache
  • Критерии корректности:

  • результат зависит только от аргументов
  • нет побочных эффектов
  • аргументы хешируемы
  • Учитывайте модель исполнения: CPU-bound против I/O-bound

    Связка с статьёй про конкурентность:

  • если задача I/O-bound, ускорение часто даёт asyncio или пул потоков
  • если задача CPU-bound, ускорение часто даёт алгоритм или пул процессов
  • Типичная ошибка: делать тяжёлые вычисления внутри async def, блокируя event loop. В таких случаях выносите CPU-часть:

  • в процесс через concurrent.futures.ProcessPoolExecutor
  • или в нативный код, если это библиотечный уровень
  • Оптимизация объектов и атрибутов: dataclass(slots=True) и __slots__

    Из первой статьи вы знаете, что у обычных объектов часто есть __dict__. Для больших объёмов однотипных объектов это может быть дорого по памяти.

    Подходы:

  • dataclass(slots=True)
  • ручное __slots__
  • Ссылка: Документация dataclasses

    Микрооптимизации: только после профилирования

    Примеры микрооптимизаций, которые иногда помогают, но редко являются главным тормозом:

  • замена list.append в локальную переменную в горячем цикле
  • уменьшение количества обращений к атрибутам
  • выбор более дешёвого способа форматирования, если он в горячем месте
  • Правило: если профайлер не показал участок как узкое место, его ускорение почти никогда не меняет общую картину.

    Регрессия производительности: делаем скорость проверяемой

    Чтобы ускорение не пропало через неделю:

  • добавляйте бенчмарки на критичные сценарии
  • фиксируйте бюджеты по времени или по числу аллокаций (где уместно)
  • запускайте бенчмарки в CI хотя бы периодически, понимая, что шум у измерений выше, чем у unit-тестов
  • Частые ошибки

  • тестировать реализацию вместо поведения, из-за чего рефакторинг становится дорогим
  • мокать всё подряд и получать тесты, которые ничего не гарантируют
  • оптимизировать до того, как есть тесты и измерения
  • путать CPU-проблему и I/O-проблему, выбирая не тот инструмент конкурентности
  • делать выводы по микробенчмаркам без понимания реальной нагрузки
  • Итоги

  • Тесты и типы дополняют друг друга: типы дают контракт формы, тесты фиксируют смысл.
  • Для измерений используйте timeit и проектные бенчмарки, а для поиска узких мест профайлеры (cProfile, pstats, внешние инструменты).
  • Для памяти используйте tracemalloc и сравнение снапшотов.
  • Оптимизация почти всегда начинается с алгоритмов и структуры данных, а микрооптимизации делаются только после профилирования.
  • Выбор между asyncio, потоками и процессами должен следовать из природы нагрузки: I/O-bound или CPU-bound.