Под капотом 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. Дальше есть несколько вариантов развития событий:
Если исключение не генерируется, то интерпретатор запустит операторы после блока 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. Актуальная документация предлагает такой код:
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():
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. Разрабатываем облачные решения, чтобы вы предоставляли клиентам лучшие сервисы.