Под капотом Python. Тонкости популярных конструкций with и contextmanager

от автора

Привет, Хабр! Меня зовут Павел Корсаков, я python-разработчик, backend-developer в облачном провайдере beeline cloud.

Язык программирования Python задумывался своим автором Гвидо ван Россумом как ориентированный на повышение производительности разработчика, читаемости кода и его качества. Как следствие, в нем много синтаксического сахара, за который мы его любим. А к какому-то сахару мы так привыкаем, что используем и не задумываемся, что происходит под капотом в этот момент. В статье я хочу разобрать конструкции языка with и contextmanager, рассказать, как они устроены, какие задачи решают и как развивались от истоков Python до наших дней. 

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

Как это было

Если исходить из того, что делает with и contextmanager, то можно найти что-то общее с try … except, и это неспроста, так как именно эта конструкция лежит в основе.

Сама конструкция появилась задолго до Python и использовалась в языках, оказавших на него влияние таких, как Ada, C++, Java. Они имеют свои механизмы обработки исключений, похожие на Python.

Ada
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) & " = " & Integer'Image(z));       exception         when Data_Error => Put_Line("Цифирь давай, цифирь!");         when Constraint_Error => Put_Line("Я такое не ем!");         when others => Put_Line("Что-то пошло не так!"); end main; 

C++
double divide(int a, int b) {     if (b)         return a / b;     throw "Division by zero!"; }    int main() {     int x{500};     int y{};           try     {         double z {divide(x, y)};         std::cout << z << std::endl;     }     catch (const char* error_message)     {         std::cout << error_message << std::endl;     }     std::cout << "The End..." << std::endl; } 

JAVA
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 года. 

https://docs.python.org/release/1.4/ref/ref7.html#REF13811 

Оно говорит, что существует две формы выражения try: 

  • try…except [else]; 

  • try…finally.

Эти формы нельзя смешивать, но они могут быть вложены друг в друга. Первый вариант — обработчик исключений, а второй нужен для гарантированного выполнения раздела finally.

С небольшими изменениями есть и сейчас:

def foo():     try:         a = 5 / 0     except Exception as e:         print(e)

Что здесь происходит? Когда интерпретатор доходит до строчки try, он запоминает текущий контекст программы.

Что же такое контекст

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

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

  1. Если исключение не генерируется, то интерпретатор запустит операторы после блока except.

  2. Если генерируется исключение и оно указано в except, происходит откат до сохраненного контекста и выполняются операторы под except.

  3. Если генерируется исключение и оно не указано в except, тогда исключение распространяется до самого последнего введенного оператора try, который дает совпадения с исключением (try могут быть вложенными).

  4. Если найти оператор 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. Актуальная документация предлагает такой код:

https://docs.python.org/3/tutorial/errors.html#errors-and-exceptions

def divide(x, y):     try:         result = x / y     except ZeroDivisionError:         print("division by zero!")     else:         print("result is", result)     finally:         print("executing finally clause")
Вариант с несколькими ошибками

Если несколько ошибок обрабатываются одинаково, их можно завернуть в кортеж:

https://docs.python.org/3/tutorial/errors.html#handling-exceptions

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(<arguments>):             <setup>             try:                 yield <value>             finally:                 <cleanup>      This makes this:          with some_generator(<arguments>) as <variable>:             <body>      equivalent to this:          <setup>         try:             <variable> = <value>             <body>         finally:             <cleanup>     """     @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(<arguments>):             <setup>             try:                 yield <value>             finally:                 <cleanup>      This makes this:          with contextmanager(some_generator)(<arguments>) as <variable>:             <body>      equivalent to this:          <setup>         try:             <variable> = <value>             <body>         finally:             <cleanup>     """

Подведем итоги

Почти всегда синтаксический сахар или какая-нибудь магия под капотом имеет обычный и прагматичный код, который используется для повышения производительности разработчика, читаемости и качества кода. Лишний раз загляните в документацию или исходный код, чтобы понять, что делает та или иная конструкция. Знание таких вещей может пригодиться на собеседовании и поможет в поиске багов в коде.

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

beeline cloud — secure cloud provider. Разрабатываем облачные решения, чтобы вы предоставляли клиентам лучшие сервисы.


ссылка на оригинал статьи https://habr.com/ru/companies/beeline_cloud/articles/749580/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *