Как стать автором
Обновить
1123.59
OTUS
Цифровые навыки от ведущих экспертов

Functools – сила функций высшего порядка в Python

Время на прочтение8 мин
Количество просмотров32K
Автор оригинала: Martin Heinz

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

Кэширование

Давайте начнем с самых простых, но довольно мощных функций модуля functools. Начнем с функций кэширования (а также декораторов) - lru_cache, cache и cached_property. Первая из них - 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://martinheinz.dev/",
            "https://reddit.com/",
            "https://google.com/",
            "https://dev.to/martinheinz",
            "https://google.com/"]:
    get_with_cache(url)

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

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

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

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

Еще один декоратор, который вы можете использовать для кэширования – это cached_property. Как можно догадаться из названия, он используется для кэширования результатов атрибутов класса. Механика очень полезная, если у вас есть свойство, которое дорого вычислять, но оно при этом остается неизменным.

from functools import cached_property

class Page:

    @cached_property
    def render(self, value):
        # Do something with supplied value...
        # Long computation that renders HTML page...
        
        return html

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

Еще одна прелесть cached_property в том, что он запускается только при поиске, поэтому позволяет нам менять значение атрибута. После изменения атрибута закэшированное ранее значение меняться не будет, вместо этого будет вычислено и закэшировано новое значение. А еще кэш можно очистить, и все, что нужно для этого сделать – это удалить атрибут.

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

Сравнение и упорядочивание

Вероятно, вы уже знаете, что в Python можно реализовать операторы сравнения, такие как <, >= или ==, с помощью lt, gt или eq. Однако, может быть довольно неприятно реализовывать каждый из eq, lt, le, gt или ge. К счастью, в 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, а именно singledispatch и/или singledispatchmethod. Эти функции помогают нам реализовать то, что мы бы назвали алгоритмом множественной диспетчеризации, который позволяет динамически типизированным языкам программирования, таким как Python, различать типы во время выполнения.

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

Partial

Мы все работаем с различными внешними библиотеками или фреймворками, многие из которых предоставляют функции и интерфейсы, требующие от нас передачи коллбэков, например, для асинхронных операций или прослушивания событий. В этом нет ничего нового, но что, если нам нужно еще и передать некоторые аргументы вместе с коллбэком. Именно здесь и пригодится functools.partial. Можно использовать 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) в качестве коллбэка. В этом случае мы воспользуемся multiprocessing.apply_async, которая асинхронно вычисляет результат функции (concat) и возвращает результат коллбэка. Однако apply_async всегда будет передавать результат в качестве первого аргумента, и, если мы хотим включить какие-то дополнительные аргументы, как в случае с log=logger, нужно использовать partial.

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

import sys
from functools import partial

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

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

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

from functools import partial

RECORD_SIZE = 64

# Read binary file...
with open("file.data", "rb") as file:
    records = iter(partial(file.read, RECORD_SIZE), b'')
    for r in records:
        # Do something with the record...

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

Декораторы

Мы уже говорили о кое-каких декораторах в прошлых разделах, но не о декораторах для создания еще большего количества декораторов. Одним из таких декораторов является functools.wraps. Чтобы понять зачем он нужен, давайте просто посмотрим на пример:

def decorator(func):
    def actual_func(*args, **kwargs):
        """Inner function within decorator, which does the actual work"""
        print(f"Before Calling {func.__name__}")
        func(*args, **kwargs)
        print(f"After Calling {func.__name__}")

    return actual_func

@decorator
def greet(name):
    """Says hello to somebody"""
    print(f"Hello, {name}!")

greet("Martin")
# Before Calling greet
# Hello, Martin!
# After Calling greet

Этот пример показывает, как можно реализовать простой декоратор. Мы оборачиваем функцию, выполняющую определенную задачу (actual_func), внешним декоратором, и она сама становится декоратором, который затем можно применить к другим функциям, например, как в случае с greet. При вызове функции greet вы увидите, что она выводит сообщения как от actual_func, так и свои собственные. Вроде выглядит нормально, не так ли? Но что будет, если мы сделаем так:

print(greet.__name__)
# actual_func
print(greet.__doc__)
# Inner function within decorator, which does the actual work

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

from functools import wraps

def decorator(func):
    @wraps(func)
    def actual_func(*args, **kwargs):
        """Inner function within decorator, which does the actual work"""
        print(f"Before Calling {func.__name__}")
        func(*args, **kwargs)
        print(f"After Calling {func.__name__}")

    return actual_func

@decorator
def greet(name):
    """Says hello to somebody"""
    print(f"Hello, {name}!")

print(greet.__name__)
# greet
print(greet.__doc__)
# Says hello to somebody

Единственная задача функции wraps – это копирование имени, документации, списка аргументов и т.д., для предотвращения перезаписи. Если учесть, что wraps – это тоже декоратор, можно просто добавить его в нашу actual_func, и проблема решена! 

Reduce

Последнее, но не менее важное в модуле functools – это reduce. Возможно, из других языков, вы можете знать ее как fold (Haskell). Эта функция берет итерируемый объект и сворачивает (складывает) все его значения в одно. Применений этому множество, например:

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):  # Use `sum` function from standard library instead
    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

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

А если помнить, что reduce частенько сокращает все до одной строки, ее отлично можно скомбинировать с partial:

product = partial(reduce, operator.mul)

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

И, наконец, если вам нужен не только итоговый «свернутый» результат, то вы можете использовать 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]

Заключение

Как видите, в functools есть множество полезных функций и декораторов, которые могут облегчить вам жизнь, но это лишь верхушка айсберга. Как я говорил вначале, в стандартной библиотеке Python есть множество функций, которые помогают писать код лучше, поэтому, помимо функций, которые мы здесь рассмотрели, вы можете обратить внимание на другие модули, такие как operator или itertools (мою статью об этом модуле вы можете прочитать здесь или просто отправляйтесь в Python Module Index и изучите все, на что обратите внимание, и я просто уверен, что вы найдете там что-то полезное.


Материал подготовлен в рамках курса «Python Developer. Professional».

Всех желающих приглашаем на онлайн-интенсив «Быстрая разработка JSON API приложений на Flask». На этом уроке мы:
— Познакомимся со спецификацией JSON API;
— Узнаем, что такое сериализация/десериализация данных;
— Узнаем, что такое marshmallow и marshmallow-jsonapi;
— Познакомимся со Swagger;
— Посмотрим на обработку и выдачу связей.

→ РЕГИСТРАЦИЯ

Теги:
Хабы:
Всего голосов 9: ↑7 и ↓2+7
Комментарии0

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS