# Глава 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. Здесь можно задавать вопросы и общаться.
Задонатить. Если вам нравится курс, вы можете поддержать развитие площадки!