Декоратор singledispatch модуля functools

Курс по стандартной библиотеке: https://stepik.org/a/259466?utm_source=proproprogs

Смотреть материал на YouTube | RuTube

На этом занятии мы с вами познакомимся с декоратором singledispatch модуля functools, который позволяет специализировать функции в зависимости от типа первого аргумента. Давайте рассмотрим работу этого декоратора на простом примере.

Пусть имеется функция с одним параметром, которая вычисляет сумму для переданной ей итерируемой последовательности:

import functools
 
 
@functools.singledispatch
def my_sum(arg):
    print("Вызов общей функции")
    return sum(arg)

Эта функция сразу декорирована с помощью singledispatch. Сам по себе декоратор пока никак не меняет поведение этой функции. Она будет корректно работать с любыми итерируемыми числовыми последовательностями:

res1 = my_sum([1, 2, 3])
print(res1)

Однако если ей передать одно число или словарь:

res1 = my_sum([1, 2, 3])
res2 = my_sum({'a': 1, 'b': 2})  # ошибка
res3 = my_sum(1)  # ошибка
print(res1, res2, res3)

то получим ошибки применения функции sum() к таким данным. Выйти из этой ситуации можно разными способами. Один из них, как раз предоставляет декоратор singledispatch. Мы можем описать разные реализации функции my_sum() в зависимости от типа первого аргумента. Например, так:

@my_sum.register(int)
def _(arg):
    print("Сумма для типа int")
    return arg
 
 
@my_sum.register(dict)
def _(arg):
    print("Сумма для типа dict")
    return sum(arg.values())

Метод register() регистрирует объекты-функции для указанных типов данных первого аргумента. Имена регистрируемых функций не имеют значения, они могут быть любыми. Поэтому я просто записал символ подчеркивания. В результате, если переданное значение будет целым числом, то оно будет просто возвращаться функцией. А если передается словарь, то суммируются его значения, а не ключи. Теперь вызовы функции:

res1 = my_sum([1, 2, 3])
res2 = my_sum({'a': 1, 'b': 2})
res3 = my_sum(1)
print(res1, res2, res3)

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

После выполнения программы в консоли увидим строчки:

Вызов общей функции
Сумма для типа dict
Сумма для типа int
6 3 1

Как видите, теперь никаких ошибок нет и суммы подсчитаны верно.

Давайте детальнее посмотрим на работу декоратора singledispatch. При первом вызове он формирует объект со словарем dispatch_cache, в который по ключу object добавляет ссылку на функцию my_sum(). Почему именно такой ключ для общей функции, вы сейчас поймете. Два последующих вызова функции register() добавляют в этот словарь еще две записи с ключами типов int и dict и соответствующими ссылками на функции. После этого, при вызове функции:

res1 = my_sum([1, 2, 3])

начинается поиск по словарю dispatch_cache на соответствие типа первого аргумента, зарегистрированным типам. Вначале поиск осуществляется по специализированным типам int и dict и проверяется с помощью функции isinstance(). Если она возвращает True, соответствие найдено, иначе поиск продолжается. В данном случае значение True будет получено для типа object, который соответствует начальной, общей функции. Поэтому она и вызывается для списка.

Я напомню, что функция isinstance() проверяет типы с учетом их иерархии наследования. И, так как, все типы неявно наследуются от object, то это есть общий универсальный тип данных в языке Python.

Когда вызывается функция:

res2 = my_sum({'a': 1, 'b': 2})

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

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

@functools.singledispatch
def my_per(a, b):
    print("Вызов общей функции")
    return a + b

Зарегистрируем для нее специализацию сразу для двух типов list и set:

@my_per.register(list | set)
def _(arg):
    print("Периметр для типов list и set")
    return sum(arg)

И еще для типа float:

@my_per.register(float)
def _(a, b):
    print("Периметр для типа float")
    return a + b

Теперь, если вызвать эти функции командами:

res1 = my_per(1, 2.5)
res2 = my_per([1, 2, 3])
res3 = my_per({10, 20})

то в первом случае будет вызвана общая функция, а не специализированная для float, несмотря на то, что второй аргумент имеет этот тип данных. А вот если первый аргумент будет иметь тип float:

res1 = my_per(1.5, 2)

то вызовется специализация для этого типа данных. Это следует иметь в виду, используя декоратор singledispatch.

Помимо стандартных (встроенных) типов данных, можно использовать свои собственные, например, в виде объявленных классов. Давайте, объявим два класса Perimetr и Area для двух разных типов, следующим образом:

class Perimetr:
    def __init__(self, a, b):
        self.a = a
        self.b = b
 
 
class Area:
    def __init__(self, a, b):
        self.a = a
        self.b = b

А следом определим специализации функции, используя эти классы:

@functools.singledispatch
def my_calc_fig(arg):
    print("Вызов общей функции")
    return arg
 
 
@my_calc_fig.register(Perimetr)
def _(a):
    print("Периметр, тип Perimetr")
    return (a.a + a.b) * 2
 
 
@my_calc_fig.register(Area)
def _(a):
    print("Площадь, тип Area")
    return a.a * a.b

Если теперь вызвать функцию my_calc_fig() командами:

res1 = my_calc_fig(Perimetr(2, 6))
res2 = my_calc_fig(Area(2, 6))

то в первом случае будет вычислен периметр, а во втором – площадь прямоугольника.

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

Курс по стандартной библиотеке: https://stepik.org/a/259466?utm_source=proproprogs

Видео по теме