Главная / Курсы / Python / Контекстные менеджеры
# Глава 21. Контекстные менеджеры Обсудим удобную конструкцию для совершения парных операций над объектом, таких как захват и освобождение блокировки, подключение к бд и корректное отключение. Контекстные менеджеры позволяют это организовывать удобно, безопасно и эффективно. ## Мотивация Допустим, нам потребовалось открыть файл и дописать в него строку. Как будет выглядеть наивное решение? С помощью встроенной функции `open()` получить объект файла в режиме на дозапись (append), использовать его и закрыть: ```python f = open("/mnt/data/useful_urls.txt", "a") f.write("https://senjun.ru/courses") f.close() ``` В чем же наивность такого решения? Оно не безопасно. Может не хватить прав на работу с файлом, на жестком диске кончится место, возникнут другие обстоятельства непреодолимой силы... И в строках 1-2 сгенерируется исключение. Мы никогда не доберемся до вызова `close()`, не закроем файл. А это грозит утечкой файловых дескрипторов. Ведь `open()` — всего лишь обертка над системным вызовом, скрывающая получение файлового дескриптора от ОС. Из питона к нему даже можно получить доступ: ```python f.fileno() ``` `fileno()` возвращает целое число — файловый дескриптор. Все операции с открытым файлом через функции объекта файла (`write()`, `read()` и т.д.) инкапсулируют работу с этим дескриптором. А как известно, ОС выставляет лимит на количество дескрипторов, которые процессу разрешено единовременно держать открытыми. Поэтому отсутствие `close()` при активной работе с файлами рано или поздно приведет к ошибке: ``` OSError: [Errno 24] Too many open files: 'useful_urls.txt' ``` Помимо нехватки дескрипторов есть и вторая причина вовремя закрывать файлы: риск потери или повреждения их содержимого. В случае креша программы или экстренного выключения компьютера данные не успеют записаться на жесткий диск. Чтобы всего этого избежать, достаточно обернуть код в `try/finally`. Тогда `close()` отработает при любом сценарии: ```python try: f = open("/mnt/data/urls.txt", "a") f.write("https://senjun.ru/courses") finally: f.close() ``` Наше решение из наивного превратилось во вполне рабочее... но лишенное гибкости и лаконичности. В мире питона желаемого результата можно добиться гораздо проще. ## Контекстные менеджеры и оператор with Мы обсудили нюансы работы с объектами файлов. Но кроме файловых дескрипторов есть еще сокеты, блокировки, подключения к бд и другие объекты, закрытие которых необходимо контролировать. Работа с ними подразумевает такие парные операции как: - Заблокировать -> отпустить. - Модифицировать -> откатить изменения. - Запустить -> остановить. - Подключиться -> отключиться. В питоне для всех этих паттернов припасен инструмент, заслуженно завоевавший народную любовь: контекстные менеджеры. **Контекстные менеджеры** — это конструкции, выделяющие и освобождающие ресурсы строго по необходимости. Используются они при помощи выражения `with ... as ...`, которое выглядит более удобным, чем оборачивание кода в `try/finally`. Вернемся к нашему примеру. Откажемся от ловли исключений в пользу контекстного менеджера: ```python with open("/mnt/data/urls.txt", "a") as f: f.write("https://senjun.ru/courses") ``` Явного вызова `close()` больше нет. Вместо него `with` дает гарантию, что по достижении конца блока файл будет закрыт. Оператор `with` создает блок кода, используя функционал контекстного менеджера. Который в нашем примере спрятан внутри функции `open()`. Как и следует из названия, контекстный менеджер управляет контекстом: выполняет некие действия при входе в блок и при выходе из блока. Что нам это дало? Код примера упростился, а контроль своевременного освобождения ресурса делегирован менеджеру контекста. Сделайте этот код более безопасным с помощью `with`: отдельно для записи и для чтения из файла. Обратите внимание: в метод `write()` объекта `file` передается url с символом переноса строки. Это нужно, чтобы разные url находились на разных строках и не были склеены в одну. {.task_text} ```python {.task_source #python_chapter_0210_task_0010} FILEPATH = "urls.txt" file = open(FILEPATH, "w") file.write("https://www.python.org\n") file.write("https://senjun.ru\n") file.close() file = open(FILEPATH, "r") for i, line in enumerate(file): print(f"{i}: {line.strip()}") ``` Воспользуйтесь конструкцией `with open(...) as ...: ...`. {.task_hint} ```python {.task_answer} FILEPATH = "urls.txt" with open(FILEPATH, "w") as file: file.write("https://www.python.org\n") file.write("https://senjun.ru\n") with open(FILEPATH, "r") as file: for i, line in enumerate(file): print(f"{i}: {line.strip()}") ``` А как быть, если требуется открыть сразу несколько файлов? В принципе никто не запрещает добавить вложенных блоков `with`. Но иногда проще их скомбинировать: ```python with open(path_a) as f_a, open(path_b) as f_b, open(path_c) as f_c: handle(f_a, f_b, f_c) ``` Замените вложенные `with` на объединенный. {.task_text} ```python {.task_source #python_chapter_0210_task_0020} messages = ["msg1", "msg2"] with open("messages.txt", "w") as f_out: with open("messages_uppercase.txt", "w") as f_out_upper: for msg in messages: f_out.write(f"{msg}\n") f_out_upper.write(f"{msg.upper()}\n") ``` Для комбинирования нескольких блоков `with` в один через запятую перечислите открываемые файлы. {.task_hint} ```python {.task_answer} messages = ["msg1", "msg2"] with open("messages.txt", "w") as f_out, open("messages_uppercase.txt", "w") as f_out_upper: for msg in messages: f_out.write(f"{msg}\n") f_out_upper.write(f"{msg.upper()}\n") ``` ## Протокол контекстного менеджера Мы выяснили, что контекстный менеджер должен выполнять некоторые действия при входе в блок `with` и при выходе из блока. В этом и состоит его протокол. Он реализован внутри множества встроенных в питон функций (например, `open()`), и его нужно поддерживать при разработке кастомных контекстных менеджеров. Обычный класс имплементирует протокол и превращается в контекстный менеджер с помощью всего двух dunder-методов: `__enter__()` и `__exit__()`. И вот как это выглядит на примере самописного класса для работы с файлом: ```python class SenjunFile: def __init__(self, filepath, mode): self.filepath = filepath self.mode = mode def __enter__(self): print(f"Opening {self.filepath}") self.__file = open(self.filepath, self.mode) return self.__file def __exit__(self, exc_type, exc_value, exc_traceback): print(f"Closing {self.filepath}") if not self.__file.closed: self.__file.close() return False with SenjunFile("useful_urls.txt", "r") as file: for line in file: print(line.strip()) file.close() ``` Из примера видно, как распределены роли между `__enter__()` и `__exit__()`. `__enter__()` **устанавливает контекст.** Возвращает объект, который становится доступен пользователю класса через оператор `as`. Например, этим объектом может быть `self`. Или `None`. При этом `with` гарантирует, что если `__enter__()` вернулся без ошибки, то по окончанию блока вызовется `__exit__()`. А что будет, если ошибка возникнет во время присвоения значения через оператор `as`? Она обработается точно так же, как и любая другая ошибка внутри `with`! `__exit__()` корректно **закрывает контекст.** Возвращает флаг о том, нужно ли подавлять исключение в случае его возникновения. Если код внутри блока `with` был прерван из-за исключения, а `__exit__()` вернул `False`, то исключение бросится повторно. Но если `__exit__()` вернет `True`, исключение будет подавлено. При возникновении исключения в аргумент `exc_type` метода `__exit__()` сохраняется тип исключения, в `exc_value` — его значение, а в `exc_traceback` — трассировка. Если же исключения не было, то все три поля хранят `None`. Если говорить про особенности реализации `__enter__()` и `__exit__()` в нашем примере, то `__enter__()` вместо `self` возвращает поле объекта — объект файла. Чтобы пользователь класса `SenjunFile` мог работать с файлом напрямую, а не искать его среди полей. `__exit__()` для простоты всегда возвращает `False`, поэтому возникнувшие внутри `with` исключения будут брошены повторно. Что произойдет, если пользователь внутри блока `with` сам вызовет `close()` для объекта `file`? Ничего страшного. Считается допустимым вызывать `close()` более одного раза, даже для закрытого файла. В таком случае `close()` просто ничего не сделает. Но это не помешало нам в методе `__exit__()` организовать проверку, действительно ли файл открыт. Второй пример реализации контекстного менеджера: временное перенаправление стандартного вывода однопоточной программы. ```python {.example_for_playground} import sys class StdoutRedirected: def __init__(self, stdout_replacement): self.__stdout = sys.stdout self.__stdout_replacement = stdout_replacement def __enter__(self): sys.stdout = self.__stdout_replacement def __exit__(self, exc_type, exc_value, exc_traceback): sys.stdout = self.__stdout return False print("---> stdout") with open("output.txt", "w") as f: with StdoutRedirected(f): print("---> file") print("---> stdout again") ``` В данном примере видно, что из метода `__enter__()` не требуется возвращать какой-либо объект для последующего доступа к нему через оператор `as`. Метод `__enter__()` неявно возвращает `None`, и этого достаточно для корректной работы в связке с `with-as`. Напишите свой контекстный менеджер `TimeIt` для подсчета времени выполнения блока кода. Пусть при установке контекста он выводит в консоль строку `"Started measuring execution time..."`, а при выходе из контекста — `"Execution time: N seconds. Catched exception: FLAG"`, где `N` - целое число (секунды выполнения кода), а `FLAG` принимает значение `True` либо `False` в зависимости от того, было ли перехвачено внутри `with` исключение. {.task_text} Контекстный менеджер `TimeIt` должен подавлять исключение в случае его возникновения. {.task_text} Для замера времени выполнения используйте функцию `default_timer()` из модуля `timeit`. Она возвращает float-значение секунд в формате Unix timestamp. {.task_text} ```python {.task_source #python_chapter_0210_task_0030} ``` Для реализации протокола контекстного менеджера не забудьте имплементировать dunder-методы `__enter__()` и `__exit__()`. Секунды не забудьте привести к целому числу. {.task_hint} ```python {.task_answer} import timeit class TimeIt: def __enter__(self): self._start = timeit.default_timer() print("Started measuring execution time...") def __exit__(self, exc_type, exc_value, exc_traceback): delta = timeit.default_timer() - self._start print(f"Execution time: {int(delta)} seconds. Catched exception: {exc_type is not None}") return True ``` Кстати, в контекстный менеджер можно превратить не только класс, но и функцию. Но для этого нужно понимать, что такое генераторы и декораторы. Их мы рассмотрим в следующих главах. ## Резюмируем - При работе с файлом не забывайте закрывать его через `close()`. Чтобы избежать утечки файловых дескрипторов и обеспечить корректное сохранение данных в файле, открытом на запись. - Контекстные менеджеры — это конструкции, незаменимые в сценариях "подготовить объект/завершить работу с объектом": открыть и закрыть сокет, запустить и остановить таймер, и т.д. - Контекстные менеджеры — более идиоматичная альтернатива для управления ресурсами, чем `try/finally`. - Контекстные менеджеры используются с помощью блока `with ... as ...`, внутри которого безопасно размещается бизнес-логика. При входе в блок контекстный менеджер подготавливает контекст, при выходе из блока — корректно его завершает. - В контекстный менеджер можно превратить обычный класс: для этого нужно имплементировать dunder-методы `__enter__()` и `__exit__()`.
Отправка...
Наша группа в telegram. Здесь можно задавать вопросы и общаться.
Задонатить. Если вам нравится курс, вы можете поддержать развитие площадки!