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

41e45ed62b4b0a24df1814de72bdc02e.jpg

Привет, Хабр! Меня зовут Павел Корсаков, я 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():
            
            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. Разрабатываем облачные решения, чтобы вы предоставляли клиентам лучшие сервисы.

© Habrahabr.ru