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

Комментарии (7)


  1. vda19999
    21.07.2023 12:30

    А зачем интерпретатор что-то сохраняет когда доходит до try? Он же в except вам даст состояние программы не до try, а до исключения


    1. pavelkpv Автор
      21.07.2023 12:30

      Хороший вопрос, и у меня нет на него ответа. Но чтоб было о чем подискутировать я приведу несколько цитат из Луца, которые засели у меня в голове при повторном его прочтении.

      Как работают операторы try

      С точки зрения работы ниже описано, как выполняются операторы try. При входе в оператор try интерпретатор Python запоминает текущий контекст программы, чтобы он мог возвратиться к нему, если возникнет исключение. Первыми выполняются операторы, вложенные внутрь заголовка try. То, что происходит следующим, зависит от того, генерировались ли исключения во время выполнения операторов блока try, и соответствуют ли они тем, которые отслеживает try.

      • Если исключение происходит во время выполнения операторов блока try и оно соответствует одному из перечисленных в операторе, тогда интерпретатор Python переходит обратно на try и запускает операторы под первой конструкцией except, дающей совпадение со сгенерированным исключением. Затем объект сгенерированного исключения присваивается переменной, указанной после ключевого слова as в конструкции (при его наличии). После выполнения блока except поток управления возобновляется ниже полного оператора try (если только сам блок except не сгенерирует еще одно исключение, в случае чего процесс начинается заново с этой точки в коде).

      5 издание том 2 Часть VII глава 34 стр 328

      Обратите внимание, что в Python отсутствует способ возвратиться обратно к коду, который сгенерировал исключение (конечно, не считая повторного запуска кода, достигнувшего данной точки). Как только вы перехватили исключение, поток управления продолжается после полного оператора try, перехватившего исключение, но не после оператора, его инициировавшего. На самом деле Python очищает память от любых функций, которые завершили работу в результате возникновения исключения, подобных функции fetcher в нашем примере; они не возобновляемы. Оператор try перехватывает исключения и является тем местом, где программа возобновляет выполнение.

      5 издание том 2 Часть VII глава 33 стр 320

      Однако подлинный возврат к предыдущему состоянию не является частью языка Python. Возврат к предыдущему состоянию перед переходом отменяет все вычисления, но исключения Python этого не делают: переменные, которым присваивались значения между моментом входа в оператор try и моментом генерации исключения, не переустанавливаются в свои предыдущие значения. Даже генераторные функции и выражения, обсуждаемые в главе 20 первого тома, не делают полный возврат к предыдущему состоянию — они реагируют на запросы next(G) просто восстановлением состояния и возобновлением выполнения.

      5 издание том 2 Часть VII глава 33 стр 318

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


  1. tenzink
    21.07.2023 12:30

    В контексте C++ уместно было бы упомянуть идиому Resource Acquisition Is Initialization (RAII). Мне кажется, что идеологически это ближе к context managers, чем try....catch. Идея в том, что ресурс (динамическия память, открытый файл и т.п.) привязывается к объекту, который корректно освобождает ресурс при выходе из области видимости

    Тогда пример на python:

    with open('a.txt', 'r') as f:
      # do something with the file f

    Аналогичен такому коду на C++:

    std::ifstream f('a.txt');
    // do something with the file f

    При выходе из области видимости файл будет закрыт при вызове деструктора объёкта std::ifstream


    1. slonopotamus
      21.07.2023 12:30

      Ну только вот из деструктора исключениями не покидаться.


      1. pavelkpv Автор
        21.07.2023 12:30

        А без исключений он гарантированно закроет файл?


        1. slonopotamus
          21.07.2023 12:30

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

          Я имел в виду что в общем случае RAII из C++ не позволяет делать то что можно делать через with. Ну допустим мы хотим через with начать транзакцию в БД, что-то там поделать и попытаться её закоммитить при выходе из with. И это совершенно нормально что коммит может не удаться и мы выкинем наружу исключение. Но в случае C++ и RAII, мы не можем из деструктора ни кидать исключения, ни возвращать значения. В результате у нас нет никакого способа сообщить вызывающей стороне что коммит не удался.


    1. pavelkpv Автор
      21.07.2023 12:30

      В принципе добавить можно. Но в С++ мой уровень компетенции ниже чем в Python. Поэтому стараюсь добавлять только то что верефицировано.