К содержанию

Модуль 8 · Урок 8.2

Дополнительные механизмы языка

Замыкания, собственные декораторы, контекстные менеджеры, match, байты и асинхронность.

4 задачи

Дополнительный урок — для продвинутых. Он для тех, кто хочет понимать устройство библиотек: в темах ЕГЭ из этих механизмов встречается только готовый декоратор @cache. Задачи основных уроков решаются без него.

Декоратор @cache из урока о рекурсии запоминает ответы, хотя код функции ты не менял. А with open(…) закрывает файл сам, даже при ошибке. Как это устроено? Функция в Python — такое же значение, как число: её создают внутри другой функции, возвращают и оборачивают; на этом держатся замыкания, декораторы и контекстные менеджеры.

Разберём эти три механизма, а в конце — ещё три полезные вещи: match, байты и асинхронное ожидание.

Замыкания

Внутренняя функция помнит имена внешней — это замыкание. Функцию можно определить внутри другой функции, и она видит локальные имена внешней. Если внутренняя функция пережила вызов внешней и всё ещё пользуется её именами, это замыкание:

def make_multiplier(factor):    def multiply(number):        return number * factor    return multiply triple = make_multiplier(3)double = make_multiplier(2)print(triple(7), double(7))
21 14

Возвращается сама функция multiply, не её вызов. Она сохраняет доступ к factor этого конкретного вызова make_multiplier: у triple это 3, у double — 2.

Два вызова make_multiplier — две функции, у каждой свой запомненный factor.

Замыкание помнит саму переменную, а не её значение в момент создания. Поэтому функции, созданные в цикле, видят одну переменную цикла — и после цикла все получают её последнее значение:

late = [lambda: i for i in range(3)]snapshot = [lambda i=i: i for i in range(3)]print([f() for f in late], [f() for f in snapshot])
[2, 2, 2] [0, 1, 2]

Если нужен снимок, часто используют параметр по умолчанию, как во втором списке, или отдельный вызов создающей функции.

Декораторы

Декоратор получает функцию и возвращает обёртку с дополнительным поведением. Запись @announce над def add означает add = announce(add): имя add теперь указывает на то, что вернул декоратор.

from functools import wraps def announce(function):    @wraps(function)    def wrapper(*args, **kwargs):        print("Вызов", function.__name__)        result = function(*args, **kwargs)        return result    return wrapper @announcedef add(a, b):    return a + b print(add(2, 5))
Вызов add 7

Сначала сообщение о вызове, затем 7. wrapper — замыкание: он помнит исходную function. *args и **kwargs из урока об аргументах принимают любые аргументы и передают их дальше без изменений:

После @announce вызов add(2, 5) попадает в обёртку, она печатает сообщение и вызывает исходную функцию.
Тонкости:зачем wraps

Без @wraps(function) у обёрнутой функции было бы имя wrapper: атрибут __name__ и описание взялись бы от обёртки. wraps копирует их с исходной функции.

Если забыть return result, обёртка будет всегда возвращать None, даже если исходная функция вычисляла число. Сохранение поведения — часть договорённости декоратора. Готовый @cache из урока о рекурсии устроен так же: обёртка сначала ищет ответ в словаре и вызывает исходную функцию, только если его там нет.

Контекстные менеджеры

Контекстный менеджер задаёт, что делать при входе в блок with и при выходе из него. Проще всего написать его генератором с @contextmanager: код до yield — вход, после — выход:

from contextlib import contextmanager @contextmanagerdef section(title):    print("начало:", title)    try:        yield    finally:        print("конец:", title) with section("подсчёт"):    print(sum(range(10)))
начало: подсчёт 45 конец: подсчёт

При входе в with выполняется код до yield, затем блок, затем часть после yield. finally из урока об исключениях гарантирует выход и при ошибке в блоке. Так же можно замерить время: perf_counter() до yield и после. И так же with open(…) закрывает файл.

Тонкости:контекстный менеджер классом

Без декоратора контекстный менеджер — класс с методами __enter__ (вход) и __exit__ (выход). Генератор с @contextmanager должен выполнить yield ровно один раз.

match

match проверяет форму значения и раздаёт его части по именам. Она удобна для разбора небольших команд:

for line in ["move 3 5", "quit", "jump"]:    command = line.split()    match command:        case ["move", x, y]:            print(int(x), int(y))        case ["quit"]:            print("Завершение")        case _:            print("Неизвестная команда")
3 5 Завершение Неизвестная команда

Шаблон ["move", x, y] подходит для списка из трёх элементов, где первый — "move", и связывает два остальных с именами x и y. case _ ловит всё остальное. Шаблоны проверяются сверху вниз до первого подходящего:

Для "jump" проверены все три шаблона — подошёл только последний.

Имя в шаблоне не сравнивается с переменной, а захватывает значение. Если нужно просто сравнить с несколькими константами, понятнее обычный if.

Текст и байты

Текст и байты — разные данные, между ними стоит кодировка. Строка str — символы. bytes — последовательность целых значений от 0 до 255, представляющих сырые байты. Для перевода между ними нужна кодировка:

text = "Привет"data = text.encode("utf-8")restored = data.decode("utf-8")print(restored == text)print(len(text), len(data))
True 6 12

Количество байтов и количество символов не обязаны совпадать: в UTF-8 каждая русская буква занимает два байта. Чтение файла в режиме rb даёт байты. Преобразование str(data) не заменяет декодирование: оно создаёт текстовое представление байтового объекта.

Асинхронность

Асинхронность позволяет ждать несколько внешних операций сразу, но не ускоряет вычисления. Когда программа ждёт сеть или другой внешний источник, она могла бы в это время обслужить другую задачу. Асинхронное выполнение позволяет описать такие ожидания и передавать управление между задачами. Это не обязательно одновременные вычисления на разных ядрах.

import asyncio async def report(name, delay):    await asyncio.sleep(delay)    return name async def main():    results = await asyncio.gather(        report("A", 0.02),        report("B", 0.01),    )    print(results) if __name__ == "__main__":    asyncio.run(main())
['A', 'B']

async def создаёт асинхронную функцию; её вызов создаёт объект корутины, выполнение которого надо организовать. await ожидает результат, позволяя циклу событий выполнять другую готовую работу. gather организует совместное выполнение переданных операций и возвращает результаты в порядке аргументов, а не порядке завершения: B закончилась раньше, но стоит второй. asyncio.run запускает всё это в обычном скрипте.

Пока A ждёт, B тоже ждёт: общее время — самое долгое ожидание, 0,02 с, а не сумма 0,03 с.

Обычное тяжёлое вычисление без точек ожидания блокирует такой цикл. Замена def на async def сама по себе не ускоряет перебор делителей.

Тонкости:ноутбуки, потоки и процессы

В средах, где цикл событий уже запущен (например, некоторые ноутбуки), вместо asyncio.run(main()) пишут await main(). Потоки и процессы — другие способы выполнять работу одновременно, со своими правилами общих данных.

Итог

Функция — значение: её создают, возвращают и оборачивают. На этом стоят замыкания, декораторы и контекстные менеджеры. Вот что понадобится дальше.

  • ЗамыканиеВнутренняя функция помнит переменные внешней — саму переменную, а не снимок.
  • Декоратор@d над функцией — f = d(f); обёртка должна вернуть результат.
  • withВход — до yield, выход — после; finally выполняет выход и при ошибке.
  • matchПроверяет форму значения сверху вниз; encode и decode — текст и байты.
  • asyncОжидания идут вместе, вычисления быстрее не становятся.

Проверьте себя

Ответь сначала сам, потом открой ответ.

Вопрос 1

Что вернёт double(triple(2)) для функций из урока?

ОтветСкрыть ответ
Ответ

12: triple(2) = 6, затем double(6) = 12.

Вопрос 2

Что напечатает with section("x"): print(1)?

ОтветСкрыть ответ
Ответ

«начало: x», 1 и «конец: x» — вход, блок и выход.

Вопрос 3

Почему список late из урока даёт [2, 2, 2], а не [0, 1, 2]?

ОтветСкрыть ответ
Ответ

Все три функции обращаются к одной переменной i, а к моменту вызова цикл закончился и i равно 2.

Вопрос 4

Что выведет print(mul(3, 4)), если mul умножает два числа и обёрнута @announce?

ОтветСкрыть ответ
Ответ

Сначала «Вызов mul», затем 12.

Вопрос 5

Что напечатает разбор команд из урока для строк "move 1 2" и "move 1"?

ОтветСкрыть ответ
Ответ

1 2 и «Неизвестная команда»: во второй строке два элемента, а шаблон требует три.

Практика

Для проверки ответов нужен JavaScript. Условия, подсказки и решения доступны ниже.

Что напечатает программа?

def make_multiplier(factor):    def multiply(number):        return number * factor    return multiply print(make_multiplier(5)(4))

Введите число.

Что напечатает программа?

def announce(function):    def wrapper(*args, **kwargs):        print(function.__name__)        function(*args, **kwargs)    return wrapper @announcedef add(a, b):    return a + b print(add(2, 5))

Запишите напечатанные строки через пробел в порядке вывода.

Что напечатает программа?

print(len("Hi".encode("utf-8")), len("Пока".encode("utf-8")))

Запишите напечатанные числа через пробел в порядке вывода.

Что напечатает программа?

import asyncio async def report(name, delay):    await asyncio.sleep(delay)    return name async def main():    results = await asyncio.gather(        report("A", 0.03),        report("B", 0.01),        report("C", 0.02),    )    print(results) asyncio.run(main())

Запишите список так, как его печатает Python.