Сообщить об ошибке.

Обзор модуля functools Python

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

Содержание:


Кэширование значений.

Кэширование результатов представлено тремя функциями (можно использовать в качестве декоратора)- functools.lru_cache(),- functools.cache(),- functools.cached_property().

Первая из них - @functools.lru_cache() запоминает возвращаемый результат в соответствии с переданными аргументами. Такое поведение может сэкономить время и ресурсы:

from functools import lru_cache
import requests

@lru_cache(maxsize=32)
def get_with_cache(url):
    try:
        r = requests.get(url)
        return r.text
    except:
        return "Not Found"

for url in ["https://google.com/",
            "https://yandex.ru/",
            "https://mail.ru/",
            "https://google.com/",
            "https://yandex.ru/",
            "https://google.com/"]:
    get_with_cache(url)

print(get_with_cache.cache_info())
# CacheInfo(hits=3, misses=3, maxsize=32, currsize=3)
print(get_with_cache.cache_parameters())
# {'maxsize': 32, 'typed': False}

В этом примере, с помощью декоратора @lru_cache выполняются GET-запросы и кэшируются их результаты (до 32 кэшированных результатов). Чтобы убедиться, что кэширование действительно работает, можно проверить информацию о кэше, используя метод .cache_info(), который показывает количество попаданий и промахов в кэш. Декоратор также предоставляет методы .clear_cache() и .cache_parameters() для аннулирования кэшированных результатов и проверки параметров соответственно.

Если необходимо иметь немного более продвинутое кэширование, то можно включить необязательный аргумент typed=True, который заставляет аргументы разных типов кэшироваться отдельно.

Еще одним декоратором кэширования в functools является функция, называемая просто functools.cache(). Это простая обертка поверх functools.lru_cache(), в которой отсутствует аргумент max_size, т.е. она не ограничивает количество кэшированных значений.

Декоратор @functools.cached_property() используется для кэширования результатов атрибутов класса. Это очень полезно, если есть свойство, которое дорого вычислять, но при этом является неизменяемым. Работает аналогично встроенной функции property() с добавлением кэширования.

from functools import cached_property

class Page:

    @cached_property
    def render(self, value):
        # Что-то делается с аргументом `value`, что приводит к дорогому 
        # вычислению, и как следствие отображение HTML-страницы.
        # ...
        return html

Этот простой пример показывает, как можно использовать functools.cached_property(), например, для кэширования отрендеренной HTML-страницы, которая будет возвращаться пользователю снова и снова. То же самое можно сделать для определенных запросов к базе данных или длительных математических вычислений.

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

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

Автоматическая реализация операторов сравнения.

В Python можно реализовать операторы сравнения, такие как <, >= или ==, используя __lt__, __gt__ или __eq__. Однако реализация каждого из __eq__, __lt__, __le__, __gt__ или __ge__ может быть довольно раздражающей. Модуль functools включает в себя декоратор @functools.total_ordering(), который может помочь в этом - все, что нужно сделать, это реализовать __eq__ и один из оставшихся методов, а остальные будут автоматически созданы декоратором:

from functools import total_ordering

@total_ordering
class Number:
    def __init__(self, value):
        self.value = value

    def __lt__(self, other):
        return self.value < other.value

    def __eq__(self, other):
        return self.value == other.value

print(Number(20) > Number(3))
# True
print(Number(1) < Number(5))
# True
print(Number(15) >= Number(15))
# True
print(Number(10) <= Number(2))
# False

Код показывает, что, несмотря на то, что реализованы только __eq__ и __lt__, можно использовать все расширенные операции сравнения. Наиболее очевидным и важным здесь является сокращение кода и его читабельность.

Перегрузка методов/функций.

Всех учили, что перегрузка функций невозможна в Python, но на самом деле есть простой способ реализовать ее, используя две функции в модуле functools - это functools.singledispatch() и/или functools.singledispatchmethod(). Эти функции помогают реализовать то, что называется алгоритмом множественной отправки, который позволяет языкам программирования с динамической типизацией, таким как Python, различать типы во время выполнения.

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

Передача функций обратного вызова.

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

Рассмотрим несколько практических примеров:

def output_result(result, log=None):
    if log is not None:
        log.debug(f"Result is: {result}")

def concat(a, b):
    return a + b

import logging
from multiprocessing import Pool
from functools import partial

logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger("default")

p = Pool()
p.apply_async(concat, ("Hello ", "World"), callback=partial(output_result, log=logger))
p.close()
p.join()

Приведенный выше фрагмент демонстрирует, как можно использовать функцию partial() для передачи функции output_result() ее аргумента log=logger в качестве функции обратного вызова callback. Здесь используется multiprocessing.apply_async(), которая асинхронно вычисляет результат предоставленной функции concat() и возвращает ее результат функции обратного вызова. Но, метод .apply_async() всегда будет передавать результат функции concat() в качестве первого аргумента, а если нужно включить какие-либо дополнительные аргументы, например, как в данном случае log=logger, то необходимо использовать functools.partial().

Это был довольно сложный вариант использования. Более простым примером может быть простое создание функции, которая вместо stdout печатает в stderr:

import sys
from functools import partial

print_stderr = partial(print, file=sys.stderr)
print_stderr("This goes to standard error output")

С помощью этого простого трюка создали новую вызываемую функцию, которая всегда будет передавать аргумент с ключевым словом file=sys.stderr для печати, что позволяет упростить код за счет отсутствия необходимости каждый раз указывать аргумент с ключевым словом.

Функция functools.partial() можно также применить к малоизвестной особенности функции iter(), например можно создать итератор, передав в iter() вызываемую функцию и дозорное значение, что может быть полезно в следующем приложении:

from functools import partial

RECORD_SIZE = 64

# Чтение двоичного файла...
with open("file.data", "rb") as file:
    records = iter(partial(file.read, RECORD_SIZE), b'')
    for r in records:
        # Сделаем что-нибудь с записью...

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

Сохранение имени и строк документации декорированной функции.

При инспектировании кода, когда проверяется имя и строка документации декорированной функции, часто обнаруживается, что они были заменены значениями функции-декоратора. Такое поведение нежелательно, так как невозможно каждый раз переписывать все имена функций и строки документации, когда используется какой-либо декоратор. Это проблема решается при помощи декоратора functools.wraps():

from functools import wraps

def decorator(func):
    @wraps(func)
    def actual_func(*args, **kwargs):
        """
        Внутренняя функция внутри декоратора, 
        которая выполняет фактическую работу
        """
        print(f"Перед вызовом {func.__name__}")
        func(*args, **kwargs)
        print(f"После вызова {func.__name__}")

    return actual_func

@decorator
def greet(name):
    """Здоровается с кем-нибудь"""
    print(f"Hello, {name}!")

print(greet.__name__)
# greet
print(greet.__doc__)
# Здоровается с кем-нибудь

Единственная задача функции functools.wraps() - скопировать имя, строку документа, список аргументов и т.д., для предотвращения их перезаписи. И учитывая, что functools.wraps() также является декоратором, можно просто поместить его перед actual_func, и проблема решена!

Функция functools.reduce().

Функция functools.reduce() берет итерируемый объект и уменьшает (или складывает) все его значения в одно. У этого есть много разных применений, вот некоторые из них:

from functools import reduce
import operator

def product(iterable):
    return reduce(operator.mul, iterable, 1)

def factorial(n):
    return reduce(operator.mul, range(1, n))

def sum(numbers):  
    """Внимание! Переопределяется встроенная функция `sum()`"""
    return reduce(operator.add, numbers, 1)

def reverse(iterable):
    return reduce(lambda x, y: y+x, iterable)

print(product([1, 2, 3]))
# 6
print(factorial(5))
# 24
print(sum([2, 6, 8, 3]))
# 20
print(reverse("hello"))
# olleh

Функция functools.reduce может упростить и уменьшить код в одну строку, который в противном случае был бы намного длиннее. С учетом сказанного, чрезмерное использование этой функции только ради сокращения кода обычно является плохой идеей.

Кроме того, учитывая, что использование сокращения обычно приводит к однострочникам, то это идеальный кандидат для functools.partial():

from functools import reduce, partial
import operator

product = partial(reduce, operator.mul)

print(product([1, 2, 3]))
# 6

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

from itertools import accumulate

data = [3, 4, 1, 3, 5, 6, 9, 0, 1]

print(list(accumulate(data, max)))
# [3, 4, 4, 4, 5, 6, 9, 9, 9]