Перейти к основному содержимому

Функциональное программирование

Теория​

Функции - объекты первого класса​

В 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=...)

itertools​

Готовые итераторы для частых задач - ничего не хранят в памяти целиком, отдают элементы по одному

  • chain(*iterables) - склеивает несколько итерируемых объектов в один, один за другим
  • count(start=0, step=1) - бесконечный счётчик, обычно используется вместе с islice
  • cycle(iterable) - бесконечно повторяет конечную последовательность по кругу
  • islice(iterable, stop) (или islice(iterable, start, stop, step)) - "срез" для итератора, обычный [:] для итераторов не работает
  • groupby(iterable, key) - группирует ПОДРЯД идущие элементы с одинаковым ключом, подробности и пример - ниже
  • zip_longest(*iterables, fillvalue=...) - как zip, но продолжает до самого длинного итерируемого, а не обрывается по короткому (пример - в разделе "Итераторы" выше)
from itertools import chain, count, cycle, islice

list(chain([1, 2], [3, 4])) # [1, 2, 3, 4] - склеить несколько итерируемых подряд
list(islice(count(10), 3)) # [10, 11, 12] - взять первые 3 из бесконечного count()
list(islice(cycle([1, 2]), 5)) # [1, 2, 1, 2, 1] - зациклить конечную последовательность

groupby(iterable, key) группирует ПОДРЯД ИДУЩИЕ элементы с одинаковым ключом - если исходная последовательность не отсортирована по этому ключу, одна и та же группа может разбиться на несколько:

from itertools import groupby

students = [("Аня", "B"), ("Боря", "A"), ("Вика", "B")]

# неправильно - без сортировки "B" разобьётся на две группы
for group, items in groupby(students, key=lambda s: s[1]):
print(group, list(items))
# B [('Аня', 'B')]
# A [('Боря', 'A')]
# B [('Вика', 'B')]

# правильно - сначала sorted() по тому же ключу
for group, items in groupby(sorted(students, key=lambda s: s[1]), key=lambda s: s[1]):
print(group, list(items))
# A [('Боря', 'A')]
# B [('Аня', 'B'), ('Вика', 'B')]

Генераторы​

Генератор - функция с yield вместо (или вместе с) return
Вызов такой функции не выполняет её тело сразу - возвращает объект-генератор, который уже сам по себе является итератором (__iter__/__next__ не нужно писать руками)

from collections.abc import Iterator


def count_up(limit: int) -> Iterator[int]:
current = 0
while current < limit:
current += 1
yield current


list(count_up(3)) # [1, 2, 3] - тот же результат, что и CountUp выше, но без __iter__/__next__

Каждый next() возобновляет функцию с того места, где она остановилась на предыдущем yield, и выполняет код до следующего yield (или до конца функции - тогда автоматически поднимается StopIteration)

Генераторное выражение - как списковое включение, но ленивое:

squares_list = [x * x for x in range(1_000_000)] # считает и хранит в памяти сразу МИЛЛИОН чисел
squares_gen = (x * x for x in range(1_000_000)) # ничего не считает, пока не попросят элемент

next(squares_gen) # 0 - посчитался только первый элемент

yield from делегирует выдачу элементов вложенному итерируемому объекту - удобно для рекурсии:

from collections.abc import Iterator

type NestedList = list[int | NestedList]


def flatten(nested: NestedList) -> Iterator[int]:
for item in nested:
if isinstance(item, list):
yield from flatten(item)
else:
yield item


list(flatten([1, [2, 3, [4]], 5])) # [1, 2, 3, 4, 5]

NestedList - рекурсивный псевдоним типа (type - синтаксис Python 3.12+): список, где каждый элемент - либо int, либо снова NestedList

Примеры​

Разберём связку генератор → sorted → groupby на нейтральных данных, а не на модели курсов и студентов из недели 3 - именно такую связку придётся самостоятельно применить к этой модели в автогрейдер-задаче недели 4, и решение "один в один" тут не должно подойти

Дана история покупок в интернет-магазине - список пар (имя_клиента, сумма)
Нужно посчитать, сколько всего потратил каждый клиент, без промежуточного списка всех покупок

from collections.abc import Iterator
from itertools import groupby

purchases = [
("Аня", 500),
("Боря", 300),
("Аня", 150),
("Вика", 800),
("Боря", 200),
]


def iter_amounts(purchases: list[tuple[str, int]]) -> Iterator[tuple[str, int]]:
"""Генератор - лениво отдаёт пары (клиент, сумма) из истории покупок."""
yield from purchases


by_customer = sorted(iter_amounts(purchases), key=lambda pair: pair[0])

for customer, pairs in groupby(by_customer, key=lambda pair: pair[0]):
total = sum(amount for _, amount in pairs)
print(customer, total)
# Аня 650
# Боря 500
# Вика 800

iter_amounts здесь - просто yield from purchases (ruff сам подсказывает такое упрощение, правило UP028), но в реальности источником вместо списка в памяти чаще будет файл или курсор БД - тогда тело меняется, а интерфейс (генератор пар "клиент, сумма") остаётся тем же
iter_amounts не строит список заранее - при большом числе записей это экономит память, а sorted(...) перед groupby - обязательный шаг, без него группировка была бы случайной
В автогрейдер-задаче та же связка применяется к Course/Student/Registrar из недели 3, но поля и структура там другие - копировать этот код один в один не получится

Проверь себя​

1. Что значит "функция - объект первого класса" в Python?

Функцию можно присвоить переменной, положить в список/словарь, передать в другую функцию или вернуть из функции - она ничем не отличается от других значений

2. Зачем в make_counter нужен nonlocal, если count и так видна изнутри increment?

Читать count можно и без nonlocal
Но count += 1 - это присваивание, а без nonlocal присваивание создало бы новую локальную переменную count внутри increment, вместо изменения внешней

3. Что такое функция высшего порядка?

Функция, которая принимает другую функцию как аргумент или возвращает функцию - например, map, filter, sorted(key=...)

4. Чем итератор отличается от итерируемого объекта (iterable)?

У iterable есть только __iter__, который отдаёт итератор (например, list)
У итератора есть ещё и __next__, который отдаёт следующий элемент или поднимает StopIteration

5. Что происходит "под капотом", когда пишешь for x in obj?

Python один раз вызывает iter(obj), чтобы получить итератор, а потом вызывает next() по кругу, пока не поймает StopIteration

6. Чем генератор проще, чем класс с __iter__/__next__?

Не нужно вручную хранить состояние в атрибутах и вручную поднимать StopIteration - генератор сам запоминает, где остановился, между вызовами next(), и сам поднимает StopIteration, когда функция заканчивается

7. Чем генераторное выражение отличается от спискового включения?

Списковое включение ([x for x in ...]) сразу считает все элементы и хранит их в памяти
Генераторное выражение ((x for x in ...)) ничего не считает заранее - элементы считаются по одному, только когда их запрашивают

8. Почему itertools.groupby нужно применять к уже отсортированной последовательности?

groupby группирует только ПОДРЯД идущие элементы с одинаковым ключом
Если одинаковые по ключу элементы не идут подряд, они попадут в разные группы - поэтому перед groupby последовательность сортируют тем же ключом

9. Можно ли внутри lambda написать if как отдельную строку или несколько операций?

Нет - lambda может содержать только одно выражение, без отдельных операторов и без return
Тернарный x if cond else y - это выражение, поэтому подходит, а if/for как самостоятельные строки или присваивание - нет, для этого нужна обычная def

10. Почему functools.lru_cache особенно полезен для рекурсивного fib(n)?

Без кэша рекурсивный fib пересчитывает одни и те же подзадачи много раз, и сложность растёт экспоненциально
lru_cache запоминает результат по аргументам и при повторном вызове с тем же n отдаёт готовый ответ, не пересчитывая

11. Чем zip отличается от itertools.zip_longest?

zip останавливается, как только заканчивается самый короткий из итерируемых объектов - "хвост" более длинных потеряется
zip_longest продолжает до самого длинного, а недостающие места заполняет fillvalue

12. Что делает strict=True у zip и почему это стоит указывать явно?

Без strict zip молча обрезает результат по самому короткому итерируемому объекту - если длины должны совпадать, а разошлись из-за бага, это легко не заметить
strict=True вместо тихого обрезания поднимет ValueError

13. Почему any(x > 10 for x in huge_list) эффективнее, чем any([x > 10 for x in huge_list])?

Генераторное выражение не считает элементы заранее, а any останавливается на первом истинном
Если истинный элемент найдётся рано, до конца huge_list можно вообще не дойти
Список в квадратных скобках, наоборот, сначала полностью построится в памяти, даже если any потом остановится на первом же элементе

Итог / чек-лист​

  • Функции в Python - обычные значения: можно присвоить переменной, передать аргументом, вернуть из функции
  • Замыкание запоминает переменные внешней функции
    nonlocal нужен, только чтобы их ИЗМЕНЯТЬ, а не читать
  • HOF - функция, принимающая или возвращающая другую функцию (map, filter, sorted(key=), functools.reduce/partial/lru_cache)
  • any/all - формально не HOF (функцию не принимают), но с генераторным выражением вместо списка эффективно проверяют условие по всем элементам, останавливаясь досрочно
  • lambda - анонимная функция из одного выражения, без return, для более сложной логики - обычная def
  • map/filter не единственный вариант - списковые включения часто читаются понятнее
  • Iterable даёт итератор через __iter__, итератор дополнительно умеет __next__ и поднимает StopIteration, когда элементы кончились
  • enumerate/zip - тоже итераторы: нумеруют элементы и попарно объединяют несколько последовательностей (zip - по самой короткой, itertools.zip_longest - по самой длинной)
  • itertools - готовые итераторы (chain, count, cycle, islice, groupby), ничего не хранят в памяти целиком
  • groupby работает только с уже отсортированной по тому же ключу последовательностью
  • Генератор (yield) - самый простой способ написать итератор, не реализуя __iter__/__next__ руками
  • Генераторное выражение - ленивый аналог спискового включения, не считает элементы заранее
  • yield from делегирует выдачу элементов вложенному итерируемому объекту

Дополнительно​