Функциональное программирование
Теория
Функции - объекты первого класса
В Python функция - обычное значение, как число или строка: её можно присвоить переменной, положить в список/словарь, передать в другую функцию или вернуть из функции
def greet(name: str) -> str:
return f"Привет, {name}!"
say_hello = greet # функцию можно присвоить переменной
say_hello("Аня") # "Привет, Аня!"
operations = {"greet": greet} # и положить в словарь
operations["greet"]("Боря") # "Привет, Боря!"
Именно это делает возможными функции высшего порядка и декораторы (о них - в следующей статье)
Замыкания
Замыкание - вложенная функция, которая "запоминает" переменные из внешней функции, даже после того как внешняя функция уже отработала
from collections.abc import Callable
def make_counter() -> Callable[[], int]:
count = 0
def increment() -> int:
nonlocal count # без nonlocal Python создал бы НОВУЮ локальную count, а не изменил внешнюю
count += 1
return count
return increment
counter = make_counter()
counter() # 1
counter() # 2 - count не сбросился, увеличивается тот же самый счётчик
nonlocal нужен именно для ИЗМЕНЕНИЯ переменной из внешней функции - читать её можно и без
nonlocal, а вот присвоить новое значение (count += 1) без него нельзя
Функции высшего порядка
Функция высшего порядка (HOF) - функция, которая принимает другую функцию как аргумент или возвращает функцию
Самые частые встроенные HOF:
map(func, iterable)- применяетfuncк каждому элементу, возвращает итератор с результатамиfilter(func, iterable)- оставляет только элементы, для которыхfuncвернула истинное значениеsorted(iterable, key=func)- сортирует элементы,funcопределяет, по какому значению сравниватьmin(iterable, key=func)/max(iterable, key=func)- то же самое для поиска минимума/максимума
Отдельно стоят any(iterable) и all(iterable) - формально это не HOF, они не принимают
функцию: any возвращает True, если среди элементов есть хотя бы один истинный, all -
если истинны все
Но в функциональном стиле их почти всегда комбинируют с генераторным выражением или map,
получая предикат по условию, без ручного for с break
grades = [5, 4, 5]
any(grade < 3 for grade in grades) # False - двоек нет
all(grade >= 4 for grade in grades) # True - все оценки не ниже 4
Обе функции ленивые и останавливаются досрочно: any - на первом истинном элементе, all -
на первом ложном
Поэтому генераторное выражение внутри any/all эффективнее спискового включения - на большом
объёме данных не обязательно проверять все элементы, если ответ уже понятен (подробнее про
лень - в разделе "Генераторы" ниже)
В HOF часто передают не отдельно объявленную функцию, а lambda - анонимную функцию из одного
выражения
Синтаксис - lambda аргументы: выражение, return внутри писать не нужно и нельзя - результат
выражения возвращается неявно
Внутри lambda нельзя писать несколько строк или операторы (if/for как отдельные строки,
присваивания) - только одно выражение, для более сложной логики пишут обычную def
square = lambda x: x * x
square(5) # 25
# то же самое обычной функцией
def square_fn(x: int) -> int:
return x * x
Кстати, square = lambda x: x * x - ровно то, от чего предостерегает ruff (правило E731):
если лямбда получает имя, это уже полноценная функция, и правильнее сразу писать def
lambda предназначен для мест, где функция нужна мельком и без имени - аргумент key= в
sorted/filter, а не самостоятельное присваивание переменной
students = ["Аня", "Боря", "Вика"]
list(map(str.upper, students)) # ['АНЯ', 'БОРЯ', 'ВИКА']
list(filter(lambda s: len(s) > 3, students)) # ['Боря', 'Вика'] - у "Аня" длина 3
sorted(students, key=len) # ['Аня', 'Боря', 'Вика']
min(students, key=len) # 'Аня' - самая короткая строка
max(students, key=len) # 'Боря' - первая из строк максимальной длины
На практике map/filter часто заменяют списковыми включениями (list comprehension) - многие
считают их более читаемыми:
[s.upper() for s in students] # то же, что map(str.upper, students)
[s for s in students if len(s) > 3] # то же, что filter(lambda s: len(s) > 3, students)
Оба варианта рабочие, map/filter иногда удобнее для уже существующей функции без lambda,
единого правила нет - выбирайте то, что читается яснее в конкретном месте
Своя HOF-функция:
from collections.abc import Callable
def apply_twice(func: Callable[[int], int], value: int) -> int:
return func(func(value))
apply_twice(lambda x: x * 2, 3) # 12
functools
reduce(func, iterable, initial)- последовательно "сворачивает" итерируемое в одно значениеpartial(func, *args)- заранее подставляет часть аргументов, возвращает новую функциюlru_cache(maxsize=...)- декоратор, кэширует результаты функции по аргументам, при повторном вызове с теми же аргументами отдаёт готовый ответ без пересчётаcache- то же самое сmaxsize=None(безлимитный кэш), короткая запись специально для этого частого случая
from functools import partial, reduce
reduce(lambda acc, x: acc + x, [1, 2, 3, 4], 0) # 10
def add(a: int, b: int) -> int:
return a + b
add_ten = partial(add, 10) # "заморозили" первый аргумент
add_ten(5) # 15
Особенно полезен для рекурсивных функций с повторяющимися подзадачами - без кэша сложность растёт экспоненциально:
from functools import cache
@cache
def fib(n: int) -> int:
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
fib(30) # посчитается мгновенно - без кэша рекурсивный fib(30) делает больше миллиона вызовов
@cache - это ровно @lru_cache(maxsize=None), ruff даже подсказывает такую замену (правило
UP033) там, где maxsize=None указан явно
lru_cache(maxsize=N) со своим числом по-прежнему нужен, если кэш должен быть ограничен по
размеру
Итераторы
Iterable (итерируемый объект) - у него есть __iter__, который отдаёт итератор
Iterator (итератор) - у него есть и __iter__ (обычно возвращает сам себя), и __next__,
который отдаёт следующий элемент или поднимает StopIteration, когда элементы кончились
numbers = [1, 2, 3] # list - iterable, но не iterator
it = iter(numbers) # iter() достаёт итератор из итерируемого объекта
next(it) # 1
next(it) # 2
next(it) # 3
next(it) # StopIteration
for x in numbers делает под капотом ровно это - вызывает iter() один раз, потом next() по
кругу, пока не поймает StopIteration
Свой итератор пишется через __iter__/__next__:
from typing import Self
class CountUp:
def __init__(self, limit: int) -> None:
self.limit = limit
self.current = 0
def __iter__(self) -> Self:
return self # итератор возвращает сам себя
def __next__(self) -> int:
if self.current >= self.limit:
raise StopIteration
self.current += 1
return self.current
list(CountUp(3)) # [1, 2, 3]
enumerate(iterable) и zip(*iterables) - тоже итераторы, просто под более узкую задачу, чем
писать __iter__/__next__ руками:
students = ["Аня", "Боря", "Вика"]
for i, name in enumerate(students, start=1):
print(i, name)
# 1 Аня
# 2 Боря
# 3 Вика
grades = [5, 4, 5]
list(zip(students, grades, strict=True)) # [('Аня', 5), ('Боря', 4), ('Вика', 5)]
strict=True - просьба к zip поднять ValueError, если длины не совпали, а не молча
обрезать по короткой (реальная авария, которую иначе легко не заметить)
zip останавливается по самому короткому итерируемому объекту, если strict не указан
Если нужно не терять "хвост" более длинного, есть itertools.zip_longest(*iterables, fillvalue=...)