Функции высшего порядка в python

Функции высшего порядка – это один из мощнейших инструментов функционального подхода к программированию. Эта идея прекрасно реализована в Python, как на уровне самого языка, так и в его экосистеме: в модулях functoolsoperator и itertools.

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

Объекты первого класса

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

Напишем пример, чтобы посмотреть, как функции используются в качестве объекта первого класса:

def hello(name):
    print(f"Hello, {name}!")

greeting_function = hello
greeting_function("Hexlet")
# "Hello, Hexlet!"

В этом примере мы определяем функцию hello, которая принимает один аргумент и печатает приветствие. Затем мы присваиваем функцию переменной greeting_function и вызываем ее с аргументом.

Рассмотрим другой пример с передачей функции в качестве аргумента другой функции:

def apply_function(numbers, function):
    results = []
    for number in numbers:
        result = function(number)
        results.append(result)
    return results

def square(number):
    return number ** 2

numbers = [1, 2, 3, 4, 5]
squared_numbers = apply_function(numbers, square)
print(squared_numbers)
# [1, 4, 9, 16, 25]

В этом примере функция apply_function принимает в качестве аргументов список чисел и функцию. Функция apply_function применяет переданную функцию к каждому числу в списке и возвращает новый измененный список. Функция square возводит число в квадрат, и используется в качестве аргумента функции apply_function.

Объекты первого класса также позволяют возвращать функции из другой функции:

def make_multiplier(n):
    def multiplier(x):
        return x * n
    return multiplier

times_2 = make_multiplier(2)
times_3 = make_multiplier(3)

print(times_2(5)) # 10
print(times_3(5)) # 15

В этом примере функция make_multiplier принимает число n и возвращает новую функцию, которая умножает свой аргумент на n. Затем эта функция применяется, чтобы создать две новые функции times_2 и times_3, которые умножают свой аргумент на 2 и 3 соответственно. В итоге вызывается функция с аргументом 5, чтобы увидеть их результаты.

Функции высшего порядка

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

Например, встроенные функции map и filter в Python являются функциями высшего порядка, которые работают с итерируемыми объектами и применяют функцию к каждому элементу итерируемого объекта.

Рассмотрим следующий пример функции высшего порядка:

def repeat(func, n):
    for i in range(n):
        func()

def hello():
    print("Hello, world!")

repeat(hello, 3)

Вывод будет следующим:

Hello, world!
Hello, world!
Hello, world!

Этот код использует функцию repeat() для многократного вызова функции hello().

Рассмотрим другой пример с возвратом функции:

def double(function):
    def inner(argument):
        return function(function(argument))
    return inner

def multiply_by_five(x):
    return x * 5

double(multiply_by_five)(3)
# 75

В этом примере в теле функции double создается функция inner и возвращается в роли результата. Так как вызов double возвращает функцию, мы можем сразу сделать второй вызов ((3)). Он уже даст результат двойного применения исходной функции к аргументу.

Имя inner довольно часто используется, чтобы называть функции, которые создаются на лету внутри внешней функции.

Но мы могли бы и не вызвать функцию-значение сразу, а вместо этого сохранить в переменную:

multiply_by_25 = double(multiply_by_five)
multiply_by_25
# <function double.<locals>.inner at 0x7fd1975c58c8>
multiply_by_25(1)
# 25
multiply_by_625 = double(multiply_by_25)
multiply_by_625
# <function double.<locals>.inner at 0x7fd1968f41e0>
multiply_by_625(1)
# 625

При выводе значения ссылки multiply_by_25 отображается double.<locals>.inner — та самая созданная на лету функция inner.

И в случае multiply_by_625 функция называется inner, но адрес в памяти другой — большое шестнадцатеричное число после “at”.

Встроенные функции высших параметров

В Python есть функции, одним из аргументов которых являются другие функции: map()filter()reduce()apply().

Три наиболее общих функций высшего порядка встроены в Python: map (), reduce () и filter (). Эти функции используют в качестве (некоторых) своих параметров другие функции — вот почему мы называем их функциями высшего порядка.

map()

Функция map() позволяет обрабатывать одну или несколько последовательностей с помощью заданной функции:

>>> list1 = [7, 2, 3, 10, 12]
>>> list2 = [-1, 1, -5, 4, 6]
>>> map(lambda x, y: x*y, list1, list2)
[-7, 2, -15, 40, 72]

Аналогичного (только при одинаковой длине списков) результата можно добиться с помощью списочных выражений:

>>> [x*y for x, y in zip(list1, list2)]
[-7, 2, -15, 40, 72]

Следующий пример:

Принимает функцию-аргумент и применяет её ко всем элементам входящей последовательности.

# напечатаем квадраты чисел от 1 до 5
my_list = list(map(lambda x: x**2, [1, 2, 3, 4, 5]))

print(my_list)
> [1, 4, 9, 16, 25]

filter()

Функция filter() позволяет фильтровать значения последовательности. В результирующем списке только те значения, для которых значение функции для элемента истинно:

>>> numbers = [10, 4, 2, -1, 6]
>>> filter(lambda x: x < 5, numbers)     # В результат попадают только те элементы x, для которых x < 5 истинно
[4, 2, -1]

То же самое с помощью списковых выражений:

>>> numbers = [10, 4, 2, -1, 6]
>>> [x for x in numbers if x < 5]
[4, 2, -1]

Следующий пример:
Как следует из названия, 
filter()
 фильтрует последовательность по заданному условию.

# отфильтруем список с целью получить только чётные значения
my_list = list(filter(lambda x: x % 2 == 0, [11, 22, 33, 44, 55, 66]))

print(my_list)
> [22, 44, 66]

zip()

Упаковывает итерируемые объекты в один список кортежей. При работе ориентируется на объект меньшей длины:

word_list = ['Elf', 'Dwarf', 'Human']
digit_tuple = (3, 7, 9, 1)
ring_list = ['ring', 'ring', 'ring', 'ring', 'ring']

my_list = list(zip(word_list, digit_tuple, ring_list))

print(my_list)
> [('Elf', 3, 'ring'), ('Dwarf', 7, 'ring'), ('Human', 9, 'ring')]

apply()

Функция для применения другой функции к позиционным и именованным аргументам, заданным списком и словарем соответственно (Python 2):

>>> def f(x, y, z, a=None, b=None):
...     print x, y, z, a, b
...
>>> apply(f, [1, 2, 3], {'a': 4, 'b': 5})
1 2 3 4 5

В Python 3 вместо функции apply() следует использовать специальный синтаксис:

>>> def f(x, y, z, a=None, b=None):
...     print(x, y, z, a, b)
...
>>> f(*[1, 2, 3], **{'a': 4, 'b': 5})
1 2 3 4 5

Модуль functools

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

reduce()

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

>>>from functools import reduce
>>>numbers = [2, 3, 4, 5, 6]
>>> reduce(lambda res, x: res*x, numbers, 1)
720

Вычисления происходят в следующем порядке:

(((2*3)*4)*5)*6

Цепочка вызовов связывается с помощью промежуточного результата (res). Если список пустой, просто используется третий параметр (в случае произведения нуля множителей это 1):

>>> reduce(lambda res, x: res*x, [], 1)
1

Разумеется, промежуточный результат необязательно число. Это может быть любой другой тип данных, в том числе и список. Следующий пример показывает реверс списка:

>>> reduce(lambda res, x: [x]+res, [1, 2, 3, 4], [])
[4, 3, 2, 1]

Для наиболее распространенных операций в Python есть встроенные функции:

>>> numbers = [1, 2, 3, 4, 5]
>>> sum(numbers)
15
>>> list(reversed(numbers))
[5, 4, 3, 2, 1]

Принимает функцию и последовательность. Запускает цепь вычислений, применяя функцию ко всем элементам последовательности. Сводит набор к единственному значению.

from functools import reduce

# посчитаем сумму элементов списка
res = reduce(lambda sum, x: sum + x, [0.1, 0.3, 0.6])

print(res)
> 1.0

С помощью reduce() можно делать так:

import functools
# посчитаем с reduce количество вхождений строки в список
uncle_ben = ['С большой силой', 'приходит', 'большая ответственность']
complete = functools.reduce(lambda a, x: a + x.count('ответственность'), uncle_ben, 0)

print(complete)
> 1

partial()

Функция служит для частичного назначения аргументов. На входе и на выходе — тоже функции.

import functools

# допустим у нас есть какая-то функция
def set_stars(amount, type):
    print(type, amount)

# уменьшаем число аргументов set_stars с partial
print_neutron_star = functools.partial(set_stars, type='Neutron stars: ')
print_neutron_star(1434)

> Neutron stars:  1434

cmp_to_key()

Возвращает ключевую функцию для компарации объектов.

# сортировка по убыванию
def num_compare(x, y):
    return y - x

print(sorted([4, 43, 1, 22], key=functools.cmp_to_key(num_compare)))

> [43, 22, 4, 1]

update_wrapper()

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

Модуль 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]