Новости

29.11.2022

Книга «Python. Исчерпывающее руководство»

5.16. ПЕРЕДАЧА АРГУМЕНТОВ ФУНКЦИЯМ ОБРАТНОГО ВЫЗОВА


Одна серьезная проблема с функциями обратного вызова связана с передачей аргументов передаваемой функции. Возьмем написанную ранее функцию after():

import time

def after(seconds, func):
     time.sleep(seconds)
     func()


В этом коде func() фиксируется для вызова без аргументов. Если вы захотите передать дополнительные аргументы, можно попытаться сделать так:

def add(x, y):
     print(f'{x} + {y} -> {x+y}')
     return x + y

after(10, add(2, 3)) # Ошибка: add() вызывается немедленно


Здесь функция add(2, 3) выполняется немедленно, возвращая 5. Потом after() вызывает ошибку, пытаясь выполнить 5(). Это определенно не то, чего вы ожидали. Но на первый взгляд нет очевидного способа заставить программу работать при вызове add() с нужными аргументами.

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

В нашем случае одно из возможных решений основано на упаковке вычисления в функцию с нулем аргументов при помощи lambda:

after(10, lambda: add(2, 3))


Такие маленькие функции с нулем аргументов иногда называют преобразователями (thunk). Это выражение, которое будет вычислено позднее, когда будет вызвано как функция с нулем аргументов. Этот способ может стать приемом общего назначения, позволяющим отложить вычисление любого выражения на будущее. Поместите выражение в lambda и вызовите функцию, когда вам понадобится значение.

Вместо использования лямбда-функции можно воспользоваться вызовом functools.partial() для создания частично вычисленной функции:

from functools import partial

after(10, partial(add, 2, 3))


partial() создает вызываемый объект, один или несколько аргументов которого уже были заданы и кешированы. Это может быть удобным способом привести неподходящие функции в соответствие с ожидаемыми сигнатурами в обратных вызовах и других возможных применениях. Несколько примеров использования partial():

def func(a, b, c, d):
     print(a, b, c, d)

f = partial(func, 1, 2)          # Зафиксировать a=1, b=2
f(3, 4) # func(1, 2, 3, 4)
f(10, 20) # func(1, 2, 10, 20)

g = partial(func, 1, 2, d=4)     # Зафиксировать a=1, b=2, d=4
g(3) # func(1, 2, 3, 4)
g(10) # func(1, 2, 10, 4)


partial() и lambda могут использоваться для похожих целей. Но между этими двумя решениями есть важные семантические различия. С partial() вычисление и связывание аргументов происходит при первом определении частичной функции. При использовании лямбда-функции с нулем аргументов вычисление и связывание аргументов выполняется во время фактического выполнения лямбда-функции (все вычисления откладываются):

>>> def func(x, y):
... return x + y
...
>>> a = 2
>>> b = 3
>>> f = lambda: func(a, b)
>>> g = partial(func, a, b)
>>> a = 10
>>> b = 20
>>> f() # Использует текущие значения a, b
30
>>> g() # Использует текущие значения a, b
5
>>>


Частичные выражения вычисляются полностью, поэтому вызов partial() создает объекты, способные сериализоваться в последовательности байтов, сохраняться в файлах и даже передаваться по сети (например, средствами модуля стандартной библиотеки pickle). С лямбда-функциями это невозможно. Поэтому в приложениях с передачей функций (возможно, с интерпретаторами Python, работающими в разных процессах или на разных компьютерах) решение c partial() оказывается более гибким.

Замечу, что применение частичных функций тесно связано с концепцией, называемой каррированием (currying). Это прием функционального программирования, где функция с несколькими аргументами выражается цепочкой вложенных функций с одним аргументом:

# Функция с тремя аргументами
def f(x, y, z):
     return x + y + z

# Каррированная версия
def fc(x):
     return lambda y: (lambda z: x + y + z)

# Пример использования
a = f(2, 3, 4)      # Функция с тремя аргументами
b = fc(2)(3)(4)     # Каррированная версия


Этот прием не относится к общепринятому стилю программирования Python, и причины для его практического применения встречаются редко. Но иногда слово «каррирование» мелькает в разговорах с программистами, которые провели много времени, разбираясь в таких вещах, как лямбда-счисление. Этот метод обработки нескольких аргументов был назван в честь знаменитого логика Хаскелла Карри. Полезно знать, что это такое, например, если вы столкнетесь с группой функциональных программистов, ожесточенно спорящих на каком-нибудь светском мероприятии.

Вернемся к исходной проблеме передачи аргументов. Другой вариант передачи аргументов функции обратного вызова основан на их передаче в отдельных аргументах внешней вызывающей функции. Рассмотрим следующую версию функции after():

def after(seconds, func, *args):
     time.sleep(seconds)
     func(*args)

after(10, add, 2, 3) # Вызывает add(2, 3) через 10 секунд


Заметьте, что передача ключевых аргументов func() не поддерживается. Это было сделано намеренно. Одна из проблем ключевых аргументов в том, что имена аргументов заданной функции могут конфликтовать с уже используемыми именами (то есть seconds и func). Ключевые аргументы могут быть зарезервированы для передачи параметров самой функции after():

def after(seconds, func, *args, debug=False):
     time.sleep(seconds)
     if debug:
          print('About to call', func, args)
func(*args)


Но не все потеряно. Задать ключевые аргументы для func() можно при помощи partial():

after(10, partial(add, y=3), 2)


Если вы хотите, чтобы функция after() получала ключевые аргументы, безопасным решением может стать использование только позиционных аргументов:

def after(seconds, func, debug=False, /, *args, **kwargs):
     time.sleep(seconds)
     if debug:
          print('About to call', func, args, kwargs)
     func(*args, **kwargs)

after(10, add, 2, y=3)


Есть и другой настораживающий факт: after() представляет два разных вызова функций, объединенных вместе. Возможно, проблема передачи аргументов может быть решена декомпозицией на две функции:

def after(seconds, func, debug=False):
     def call(*args, **kwargs):
          time.sleep(seconds)
          if debug:
               print('About to call', func, args, kwargs)
          func(*args, **kwargs)
     return call

after(10, add)(2, y=3)


Теперь конфликты между аргументами after() и func полностью исключены. Но такие решения могут породить конфликты с вашими коллегами, которые будут читать ваш код.

5.17. ВОЗВРАЩЕНИЕ РЕЗУЛЬТАТОВ ИЗ ОБРАТНЫХ ВЫЗОВОВ


В прошлом разделе не упоминалась еще одна проблема: возвращение результатов вычислений. Рассмотрим измененную функцию after():

def after(seconds, func, *args):
     time.sleep(seconds)
     return func(*args)


Она работает, но есть неочевидные граничные случаи, возникающие из-за того, что в ней задействованы две разные функции: сама функция after() и переданный обратный вызов func.
Одна из сложностей связана с обработкой исключений. Опробуйте следующие два примера:

after("1", add, 2, 3)            # Ошибка: TypeError (ожидается целое число)
after(1, add, "2", 3)            # Ошибка: TypeError (конкатенация int
                                 # со str невозможна)


В обоих случаях выдается ошибка TypeError, но по разным причинам и в разных функциях. Первая ошибка обусловлена проблемой в самой функции after(): функции time.sleep() передается неправильный аргумент. Вторая ошибка возникает из-за проблемы с выполнением функции обратного вызова func(*args).

Есть несколько вариантов, чтобы различить эти два случая. В одном из них используются цепочки исключений. Идея в том, чтобы упаковать ошибки из обратного вызова особым способом, позволяющим обрабатывать их отдельно от других ошибок:

class CallbackError(Exception):
     pass

def after(seconds, func, *args):
     time.sleep(seconds)
     try:
          return func(*args)
     except Exception as err:
          raise CallbackError('Callback function failed') from err


Измененный код отделяет ошибки от переданного обратного вызова в отдельную категорию исключений. Он используется примерно так:

try:
     r = after(delay, add, x, y)
except CallbackError as err:
     print("It failed. Reason", err.__cause__)


При возникновении проблемы с выполнением самой функции after() это исключение будет распространяться наружу без перехвата. С другой стороны, проблемы, связанные с выполнением переданной функции обратного вызова, будут перехватываться, и программа будет сообщать о них исключением CallbackError.

Вся эта схема неочевидна. Но на практике обработка ошибок — достаточно сложная тема. Такой подход позволяет точнее управлять распределением ответственности и упрощает документирование поведения after(). Если с обратным вызовом возникают проблемы, программа всегда сообщает о ней в виде CallbackError.

Другой вариант — упаковка результата функции обратного вызова в экземпляр-результат, содержащий и значение, и ошибку. Например, класс можно определить так:

class Result:
     def __init__(self, value=None, exc=None):
          self._value = value
          self._exc = exc
     def result(self):
          if self._exc:
               raise self._exc
          else:
               return self._value


Далее используйте этот класс для возвращения результатов из функции after():

def after(seconds, func, *args):
     time.sleep(seconds)
     try:
          return Result(value=func(*args))
     except Exception as err:
          return Result(exc=err)

# Пример использования:

r = after(1, add, 2, 3)
print(r.result())              # Выводит 5
s = after("1", add, 2, 3)      # Немедленно выдает TypeError -
                               # недопустимый аргумент sleep().

t = after(1, add, "2", 3)      # Возвращает "Result"
print(t.result())              # Выдает TypeError


Второй способ основан на выделении выдачи результата функции обратного вызова в отдельный шаг. При возникновении проблемы с after() о ней будет сообщено немедленно. Если возникнет проблема с обратным вызовом func(), уведомление о ней будет отправлено при попытке пользователя получить результат вызовом метода result().

Этот стиль упаковки результата в специальный экземпляр для распаковки в будущем все чаще встречается в современных языках программирования. Одна из причин в том, что он упрощает проверку типов. Если вам понадобится включить аннотацию типа в after(), ее поведение полностью определено — она всегда возвращает Result и ничего другого:

def after(seconds, func, *args) -> Result:
     ...


Этот паттерн еще не так часто встречается в коде Python, но он регулярно возникает при работе с примитивами синхронизации (потоками и процессами). Например, экземпляры Future ведут себя так при работе с пулами потоков:

from concurrent.futures import ThreadPoolExecutor

pool = ThreadPoolExecutor(16)
r = pool.submit(add, 2, 3)            # Возвращает Future
print(r.result())                     # Распаковывает результат Future

 

Об авторе
Дэвид Бизли — автор книг Python Essential Reference, 4-е издание (Addison-Wesley, 2010) и Python Cookbook, 3-е издание (O’Reilly, 2013). Сейчас ведет учебные курсы повышения квалификации в своей компании Dabeaz LLC (www.dabeaz.com). Он пишет на Python и преподает его с 1996 года.


Подробнее с книгой можно ознакомиться в нашем каталоге


Комментарии: 0

Пока нет комментариев


Оставить комментарий






CAPTCHAОбновить изображение

Наберите текст, изображённый на картинке

Все поля обязательны к заполнению.

Перед публикацией комментарии проходят модерацию.