Под капотом Python. Тонкости популярных конструкций with и contextmanager
Привет, Хабр! Меня зовут Павел Корсаков, я python-разработчик, backend-developer в облачном провайдере beeline cloud.
Язык программирования Python задумывался своим автором Гвидо ван Россумом как ориентированный на повышение производительности разработчика, читаемости кода и его качества. Как следствие, в нем много синтаксического сахара, за который мы его любим. А к какому-то сахару мы так привыкаем, что используем и не задумываемся, что происходит под капотом в этот момент. В статье я хочу разобрать конструкции языка with и contextmanager, рассказать, как они устроены, какие задачи решают и как развивались от истоков Python до наших дней.
В этой статье оглянемся в прошлое языка, ответим на вопросы, как написать менеджер контекста, как создать функцию генератор для декоратора contextmanager. Опытные разработчики могут узнать что-то новенькое или дополнить статью ценными комментариями.
Как это было
Если исходить из того, что делает with и contextmanager, то можно найти что-то общее с try … except, и это неспроста, так как именно эта конструкция лежит в основе.
Сама конструкция появилась задолго до Python и использовалась в языках, оказавших на него влияние таких, как Ada, C++, Java. Они имеют свои механизмы обработки исключений, похожие на Python.
with Ada.Text_IO; use Ada.Text_IO; with Ada.Integer_Text_IO; use Ada.Integer_Text_IO; procedure main is x, y, z : Integer; begin Put("X = "); Get(x); Put("Y = "); Get(y); z := x / y; Put_Line(Integer'Image(x) & " / " & Integer'Image(y) & " Цифирь давай, цифирь!"); when Constraint_Error => Put_Line("Я такое не ем!"); when others => Put_Line("Что-то пошло не так!"); end main;
double divide(int a, int b) < if (b) return a / b; throw "Division by zero!"; >int main() < int x; int y<>; try < double z; std::cout catch (const char* error_message) < std::cout std::cout
public static void main(String[] args) throws IOException < try < BufferedReader reader = new BufferedReader(new FileReader("C:\\Users\\Username\\Desktop\\test.txt")); String firstString = reader.readLine(); System.out.println(firstString); >catch (ArithmeticException e) < System.out.println("Ошибка! Файл не найден!"); >>
На сайте https://docs.python.org самое первое упоминание, которое удалось найти в официальной документации, — 1.4 от 25 октября 1996 года.
Оно говорит, что существует две формы выражения try:
Эти формы нельзя смешивать, но они могут быть вложены друг в друга. Первый вариант — обработчик исключений, а второй нужен для гарантированного выполнения раздела finally.
С небольшими изменениями есть и сейчас:
def foo(): try: a = 5 / 0 except Exception as e: print(e)
Что здесь происходит? Когда интерпретатор доходит до строчки try, он запоминает текущий контекст программы.
Что же такое контекст
Для лучшего понимания заглянем в теоретическую часть. Контекст задачи — это минимальный набор данных, которые необходимо сохранить, чтобы была возможность прервать выполнение задачи, а после продолжить с того же места. Концепция контекста приобретает значение в случае прерываемых задач, когда после прерывания процессор сохраняет контекст и продолжает выполнять процедуру обслуживания прерываний. Термин часто встречается в асинхронном программировании в Python.
Сохранив контекст, интерпретатор может продолжить выполнение кода с того же места. После этого интерпретатор выполняет операторы, вложенные внутрь заголовка try. Дальше есть несколько вариантов развития событий:
- Если исключение не генерируется, то интерпретатор запустит операторы после блока except.
- Если генерируется исключение и оно указано в except, происходит откат до сохраненного контекста и выполняются операторы под except.
- Если генерируется исключение и оно не указано в except, тогда исключение распространяется до самого последнего введенного оператора try, который дает совпадения с исключением (try могут быть вложенными).
- Если найти оператор try не удается и поиск достигает верхнего уровня процесса, то интерпретатор Python уничтожит программу и выведет стандартное сообщение об ошибке.
Другой вариант это finally.
def foo(): try: a = 5 / 0 finally: print('finally 1') print('finally 2')
Здесь так же: когда интерпретатор доходит до строчки try, он запоминает текущий контекст программы. Выполняет операторы, вложенные внутрь заголовка try. Если исключение не генерируется, то интерпретатор запустит операторы в блоке finally и продолжит выполнение кода. Если генерируется исключение — интерпретатор запустит операторы в блоке finally и, так как ошибка не отработана, программа завершится. В выводе будет «finally 1» и сообщение об ошибке. Без вывода «finally 2». В этой конструкции finally выполняется всегда. Таким образом, в нашем случае, контекст — это набор данных, которые сохраняются программой перед выполнением try.
10 лет спустя: встреча с Python 2.5
Так жили 10 лет, пока 19 сентября 2006 не появился Python 2.5. С этого момента стало возможным совместное использование else, except и finally. А строковые исключения были признаны устаревшими.
С тех пор ничего не поменялось. Разве что ошибки теперь обязательно наследуются от класса Exception. Актуальная документация предлагает такой код:
def divide(x, y): try: result = x / y except ZeroDivisionError: print("division by zero!") else: print("result is", result) finally: print("executing finally clause")
Если несколько ошибок обрабатываются одинаково, их можно завернуть в кортеж:
except (ZeroDivisionError, ValueError):
И в одном try может быть несколько except:
try: result = x / y except ZeroDivisionError: print("division by zero!") except Exception: print("unexpected error!")
try … except — штука отличная. В Python использование исключения не приводит к значительным накладным расходам. Так же в Python — с версии 3.11 реализованы исключения «Zero-cost». Теперь, если исключение не вызывается, накладные расходы не увеличиваются, также как в C++ и Java. Ссылка на документацию.
Поэтому try … except широко используется. Срабатывание исключения всё же увеличивает накладные расходы. Блок try / except чрезвычайно эффективен, если не создаются исключения. На самом деле перехват исключения обходится дорого. Ссылка на документацию.
Оператор with — альтернатива try . finally
В Python 2.5 впервые появился новый оператор, связанный с исключениями with. Он задумывался как альтернатива try . finally. Оператор with предназначен для работы с контекстными менеджерами.
Тут я сделаю небольшое отступление и определимся с понятиями. Контекстный менеджер, или как у Марка Луца, диспетчер контекста — это интерфейс к объектам Python для определения нового контекста запуска. Контекстные менеджеры позволяют объекту определять код, который выполняется в начале и конце блока кода. Это реализовано с использованием пары методов, которые позволяют определяемым пользователем классам определять контекст времени выполнения, который вводится перед выполнением тела инструкции и завершается после завершения инструкции. Ссылка на документацию.
Пользовательский класс через метод __enter__ и __exit__ переключает состояние контекста. Поэтому он и называется менеджер контекста. В примере ниже – это EXPRESSION.
- Если стало понятнее, то отлично. Если нет, то разбираемся, что такое интерфейс.
- Если у класса реализованы методы __enter__ и __exit__ ,то он удовлетворяет интерфейсу менеджера контекста (или реализует интерфейс менеджера контекста).
- И совсем по-простому. Если у класса реализованы методы __enter__ и __exit__ , то он — менеджер контекста.
- Если посмотреть, что о нем сказано в документации, — магия улетучивается в один момент. Этот код:
with EXPRESSION as TARGET: SUITE
manager = (EXPRESSION) enter = type(manager).__enter__ exit = type(manager).__exit__ value = enter(manager) hit_except = False try: TARGET = value SUITE except: hit_except = True if not exit(manager, *sys.exc_info()): raise finally: if not hit_except: exit(manager, None, None, None)
Поскольку здесь except поднимает ту же самую ошибку, которую получает, он не обязателен. Такое поведение будет и без except. А если мы хотим поменять ошибку на свою, он нам может пригодиться. Чтобы было легче понять магию, его можно упростить:
manager = (EXPRESSION) enter = type(manager).__enter__ exit = type(manager).__exit__ value = enter(manager) try: TARGET = value SUITE finally: exit(manager, *sys.exc_info())
Что происходит? У экземпляра класса вызываем метод enter. В конструкции try выполняем код, который находится под with. Как бы ни отработал код, всегда вызываем метод exit из finally. И райзим ошибку, если генерируется исключение. Всё, никакой магии. Но кода с with стало в пять раз меньше.
Эта концепция прижилась и уже 17 лет является стандартом. Чтобы with мог работать с нашим классом, нужно реализовать два метода enter и exit, для асинхронной версии async with — aenter и aexit.
Как дела с contextmanager
Функция contextmanager из библиотеки contextlib, как и with, впервые появилась в Python 2.5. Ссылка на документацию.
В 2006 году в ней было три метода. С тех пор она значительно разрослась, и все ее методы помогают работать с менеджером контекста.
Сontextmanager — это еще один синтаксический сахар для работы с контекстом. У него есть асинхронная версия, и она практически ничем не отличается.
Если посмотреть код, вы увидите краткий и понятный докстринг:
def contextmanager(func): """@contextmanager decorator. Typical usage: @contextmanager def some_generator(): try: yield finally: This makes this: with some_generator() as : equivalent to this: try: = finally: """ @wraps(func) def helper(*args, **kwds): return _GeneratorContextManager(func, args, kwds) return helper
Таким образом, contextmanager — это функция декоратор, который параметром получает генератор. В нашем случае эта функция для возврата значения используется yield вместо return. И генератор возвращает только одно значение. Функция contextmanager возвращает объект класса GeneratorContextManager. У этого объекта реализованы методы enter и exit. И его можно использовать как менеджер контекста с with.
Рекомендую посмотреть код класса _GeneratorContextManager и классов, от которых он был наследован. Код сложноват для новичка в программировании, но содержит много комментариев, поэтому разобраться в нем все же возможно.
От себя добавлю, что собачка в декораторе — это тоже питоновский синтаксический сахар. И иногда удобнее декорировать функцию не там, где мы ее определяем, а там, где ее вызываем. И тогда докстринг можно немного поправить:
def contextmanager(func): """@contextmanager decorator. Typical usage: def some_generator(): try: yield finally: This makes this: with contextmanager(some_generator)() as : equivalent to this: try: = finally: """
Подведем итоги
Почти всегда синтаксический сахар или какая-нибудь магия под капотом имеет обычный и прагматичный код, который используется для повышения производительности разработчика, читаемости и качества кода. Лишний раз загляните в документацию или исходный код, чтобы понять, что делает та или иная конструкция. Знание таких вещей может пригодиться на собеседовании и поможет в поиске багов в коде.
Разработчики, которые используют синтаксический сахар, но не задумываются или не знают, как он работает, будут проигрывать в гибкости написания кода. В одних случаях использование синтаксического сахара будет обоснованно, в других использование измененного под свои нужды кода может дать необходимую гибкость.
beeline cloud — secure cloud provider. Разрабатываем облачные решения, чтобы вы предоставляли клиентам лучшие сервисы.