4.3. Рекурсия. Декораторы. Генераторы
- Что такое рекурсивные функции и как их правильно составлять
- Чем отличаются императивный и декларативный стили программирования
- Почему рекурсивные алгоритмы могут быть медленными — и как это исправить
- Как работает кеширование с помощью lru_cache
- Что такое декораторы и как они помогают расширять поведение функций
- Что такое генераторы и чем они полезны при работе с большими объёмами данных
- Ещё по теме
- Что дальше
В этой статье вы познакомитесь с продвинутыми приёмами работы с функциями. Мы разберём, как устроены рекурсивные функции и чем они отличаются от привычных императивных решений. Вы научитесь ускорять рекурсивные вычисления с помощью кеширования и декораторов, а также узнаете, как писать функции, которые возвращают значения по мере необходимости, — с помощью генераторов и оператора yield.
Ключевые вопросы статьи
- Что такое рекурсивные функции и как их правильно составлять?
- Чем отличаются императивный и декларативный стили программирования?
- Почему рекурсивные алгоритмы могут быть медленными — и как это исправить?
- Как работает кеширование с помощью
lru_cache?- Что такое декораторы и как они помогают расширять поведение функций?
- Что такое генераторы и чем они полезны при работе с большими объёмами данных?
Что такое рекурсивные функции и как их правильно составлять
Рассмотрим классическую задачу — вычисление факториала числа. В математике факториал обозначается знаком! и определяется так:
Сначала реализуем функцию, которая вычисляет факториал числа n с помощью цикла:
def fact(n):
factorial = 1
for i in range(2, n + 1):
factorial *= i
return factorial
print(fact(5))
# Вывод программы:
# 120
Теперь перепишем определение факториала немного по-другому:
Это значит, что, чтобы найти n!, мы можем сначала вычислить (n - 1)!, а потом умножить результат на n. Такой способ удобно реализовать с помощью рекурсии.
Рекурсивной называется функция, которая в процессе своей работы вызывает саму себя. Такие функции особенно полезны при работе с задачами, где решение строится на основе более простых подзадач того же типа.
Чтобы правильно составить рекурсивную функцию, нужно выполнить два шага:
- Задать базовый случай — то, что функция должна вернуть при простейшем значении аргумента.
- Задать рекурсивное правило — то, как вычислить результат на основе значения функции от меньшего аргумента.
Вот как будет выглядеть рекурсивная реализация вычисления факториала:
def fact(n):
if n == 0: # 0! = 1
return 1
return fact(n - 1) * n # n! = (n - 1)! * n
print(fact(5))
# Вывод программы:
# 120
Чем отличаются императивный и декларативный стили программирования
Примечание
Рекурсивная версия функции факториала буквально повторяет математическое определение: n! = (n - 1)! × n.
В ней не описан пошаговый процесс вычислений, а задано само правило. Такой подход называется декларативным.
Важно
Декларативный стиль программирования описывает, что должно быть получено, а не как этого достичь.
Для сравнения, первая версия функции с циклом (for i in range...) относится к императивному стилю. В ней явно указано, как именно нужно последовательно выполнить действия, чтобы получить результат.
Важно
Императивный стиль фокусируется на процессе: он отвечает на вопрос «как это сделать?», тогда как декларативный — на «что именно нужно получить?».
Рекурсивные функции ближе к декларативному стилю: они делают код более выразительным и компактным. Однако, как вы увидите дальше, у такого подхода есть свои ограничения — особенно когда речь идёт о производительности.
Почему рекурсивные алгоритмы могут быть медленными — и как это исправить
Рассмотрим применение рекурсивной функции для вычисления n-го числа последовательности Фибоначчи. Это последовательность, в которой:
- первые два числа равны 1;
- каждое последующее — сумма двух предыдущих.
Иными словами:
fib(0) = 1
fib(1) = 1
fib(n) = fib(n - 1) + fib(n - 2), если n > 1
На языке Python рекурсивная функция для вычисления n-го числа Фибоначчи может выглядеть так:
def fib(n):
if n in (0, 1):
return 1
return fib(n - 1) + fib(n - 2)
print(fib(35))
# Вывод программы:
# 14930352
Однако при запуске программы вы можете заметить, что вычисление происходит с ощутимой задержкой. Чтобы это измерить, воспользуемся модулем timeit, который позволяет узнать среднее время выполнения фрагмента кода:
from timeit import timeit
def fib(n):
if n in (0, 1):
return 1
return fib(n - 1) + fib(n - 2)
print(f"Среднее время вычисления: "
f"{round(timeit('fib(35)', number=10, globals=globals()) / 10, 3)} с.")
# Вывод программы:
# Среднее время вычисления: 2.924 с.
А теперь сравним с итеративной (императивной) версией той же функции:
from timeit import timeit
def fib(n):
f_1, f = 1, 1
for i in range(n - 1):
f_1, f = f, f_1 + f
return f
print(f"Среднее время вычисления: "
f"{round(timeit('fib(35)', number=10, globals=globals()) / 10, 3)} с.")
# Вывод программы:
# Среднее время вычисления: 2e-06 с.
Разница огромная: две микросекунды против почти трёх секунд. Почему же рекурсивная функция настолько медленнее?
Причина в том, что при каждом вызове fib(n) функция вызывает саму себя дважды: один раз для fib(n - 1) и один раз для fib(n - 2). Это приводит к тому, что одни и те же значения пересчитываются много раз. Получается ветвящееся рекурсивное дерево, в котором одни и те же подзадачи повторяются снова и снова.
Чтобы наглядно это увидеть, добавим счётчик вызовов функции:
def fib(n):
global count
count += 1
if n in (0, 1):
return 1
return fib(n - 1) + fib(n - 2)
count = 0
print(f"35-е число Фибоначчи равно: {fib(35)}.")
print(f"Количество вызовов рекурсивной функции равно: {count}.")
# Вывод программы:
# 35-е число Фибоначчи равно: 14930352.
# Количество вызовов рекурсивной функции равно: 29860703.
Решение — запоминать уже вычисленные значения, чтобы не считать их повторно. Такой приём называется кешированием или мемоизацией.
Один из способов — сохранять промежуточные результаты в словаре:
def fib(n):
global count
count += 1
if n not in cache:
cache[n] = fib(n - 1) + fib(n - 2)
return cache[n]
count = 0
cache = {0: 1, 1: 1}
print(f"35-е число Фибоначчи равно: {fib(35)}.")
print(f"Количество вызовов рекурсивной функции равно: {count}.")
# Вывод программы:
# 35-е число Фибоначчи равно: 14930352.
# Количество вызовов рекурсивной функции равно: 69.
Теперь попробуем измерить скорость:
from timeit import timeit
def fib(n):
global count
count += 1
if n not in cache:
cache[n] = fib(n - 1) + fib(n - 2)
return cache[n]
count = 0
cache = {0: 1, 1: 1}
print(f"Среднее время вычисления: "
f"{round(timeit('fib(35)', number=10, globals=globals()) / 10, 6)} с.")
# Вывод программы:
# Среднее время вычисления: 2e-06 с.
Функция работает почти так же быстро, как итеративная, — но при этом остаётся декларативной.
Попробуем теперь вычислить большее число, например fib(1000):
from timeit import timeit
def fib(n):
if n not in cache:
cache[n] = fib(n - 1) + fib(n - 2)
return cache[n]
cache = {0: 1, 1: 1}
print(f"Среднее время вычисления: "
f"{round(timeit('fib(1000)', number=10, globals=globals()) / 10, 6)} с.")
Программа завершится ошибкой:
RecursionError: maximum recursion depth exceeded
В Python по умолчанию глубина рекурсии ограничена (обычно до 1000). Чтобы увеличить лимит, используйте setrecursionlimit из модуля sys:
from timeit import timeit
from sys import setrecursionlimit
def fib(n):
if n not in cache:
cache[n] = fib(n - 1) + fib(n - 2)
return cache[n]
setrecursionlimit(2000)
cache = {0: 1, 1: 1}
print(f"Среднее время вычисления: "
f"{round(timeit('fib(1000)', number=10, globals=globals()) / 10, 6)} с.")
# Вывод программы:
# Среднее время вычисления: 0.000132 с.
Примечание
Максимально допустимая глубина рекурсии зависит от операционной системы. Увеличивать её можно, но не бесконечно.
Итак, мы ускорили работу рекурсивного алгоритма с помощью кеширования. Но при этом код стал менее читаемым — появились глобальные переменные и дополнительные проверки. Как сделать то же самое, но проще? В следующем разделе вы узнаете, как поручить кеширование интерпретатору Python с помощью встроенного инструмента — декоратора.
Как работает кеширование с помощью lru_cache
Чтобы не писать дополнительный код вручную, можно поручить кеширование самому интерпретатору. В стандартной библиотеке Python для этого предусмотрен удобный инструмент — декоратор lru_cache из модуля functools.
Он позволяет автоматически запоминать результаты предыдущих вызовов функции и использовать их повторно. Это особенно полезно для рекурсивных функций, где часто повторяются одни и те же вычисления с одинаковыми аргументами.
Вот пример использования:
from timeit import timeit
from functools import lru_cache
@lru_cache(maxsize=1000)
def fib(n):
if n in (0, 1):
return 1
return fib(n - 1) + fib(n - 2)
print(f"Среднее время вычисления: "
f"{round(timeit('fib(35)', number=10, globals=globals()) / 10, 6)} с.")
# Вывод программы:
# Среднее время вычисления: 2e-06 с.
Как видите, результат сопоставим с кешированием через словарь — но при этом код стал намного чище и короче.
Примечание
Благодаря @lru_cache функция вновь принимает декларативную форму: она просто описывает правило вычисления, а управление памятью и ускорение берёт на себя интерпретатор.
Если вы хотите сбросить кеш или узнать, сколько значений в нём хранится, у lru_cache есть методы .cache_clear() и .cache_info(). Подробнее о них — в документации.
Что такое декораторы и как они помогают расширять поведение функций
Иногда нужно изменить поведение функции — например, добавить логирование, измерение времени выполнения или кеширование результатов. Но менять сам код функции при этом не хочется (или нельзя). В таких случаях удобно использовать декораторы.
Декоратор — это специальная конструкция, которая оборачивает функцию и может изменить её поведение до, после или вместо обычного вызова.
В Python уже есть готовые декораторы. Один из них — lru_cache из модуля functools. Он автоматически сохраняет (кеширует) результаты вызова функции, чтобы при повторном вызове с теми же аргументами не пересчитывать результат заново.
Рассмотрим базовый пример:
def func():
...
def decorator(old_func):
def new_func():
return old_func()
return new_func
print(func)
func = decorator(func)
print(func)
Если вы выполните этот код, то увидите, что func превратилась в другую функцию — new_func, обёрнутую в дополнительную логику.
Чтобы не вызывать декоратор вручную, Python предлагает синтаксический сахар — специальную конструкцию с символом @. С её помощью пример выше можно записать так:
def decorator(old_func):
def new_func():
return old_func()
return new_func
@decorator
def func():
...
Теперь напишем декоратор, который будет считать, сколько раз была вызвана функция, и возвращать это количество вместе с результатом. Такой приём может пригодиться, если вы хотите отладить код или проанализировать его поведение:
# Декоратор принимает функцию f как аргумент
def count(f):
total = 0
# Объявляем функцию, которая расширяет функционал f
def decorated(*args, **kwargs):
# Переменная total объявлена нелокальной для доступа из внутренней функции
nonlocal total
total += 1
# Возвращаем значение исходной функции и дополнительно total
return f(*args, **kwargs), total
# Возвращаем новую функцию как объект
return decorated
Теперь применим этот декоратор к простой функции hello, которая приветствует пользователя по имени:
@count
def hello(name):
return f"Привет, {name}!"
print(hello("Пользователь_1"))
print(hello("Пользователь_2"))
Что здесь важно:
- Мы используем ключевое слово nonlocal, чтобы переменная total сохраняла своё значение между вызовами.
- Аргументы
*argsи**kwargsпозволяют оборачивать любую функцию — с любым числом позиционных и именованных параметров. - Декоратор возвращает новую функцию, которая сначала выполняет дополнительную логику, а затем вызывает оригинальную.
Таким образом, декораторы позволяют расширять поведение функций, не трогая их определение. Это мощный инструмент, особенно когда нужен переиспользуемый и чистый код.
Что такое генераторы и чем они полезны при работе с большими объёмами данных
В Python функции могут возвращать не только готовое значение, но и генератор — специальный объект, который выдаёт элементы по одному, по запросу. Это особенно удобно, когда вы работаете с большим количеством данных и нет смысла загружать всё в память сразу.
С генераторами вы уже сталкивались, даже если не замечали этого. Например, в выражениях вроде:
squares = (i ** 2 for i in range(10))
print(squares)
# Вывод программы:
# <generator object <genexpr> at 0x000001C225EFC9E0>
Такой объект не содержит все значения сразу — он выдаёт их по одному при каждой итерации. Чтобы получить значение, вызывается метод __next__() (обычно неявно — например, в цикле for).
Чтобы написать свою собственную функцию-генератор, вместо оператора return нужно использовать yield. Этот оператор:
- приостанавливает выполнение функции;
- возвращает текущее значение;
- позволяет продолжить выполнение с того места, где функция остановилась.
Рассмотрим генераторную версию функции Фибоначчи:
def fib(n):
n_1, n_2 = 1, 1
for i in range(n):
yield n_1
n_1, n_2 = n_2, n_1 + n_2
print(", ".join(str(x) for x in fib(10)))
# Вывод программы:
# 1, 1, 2, 3, 5, 8, 13, 21, 34, 55
Примечание
Внутри функции fib() есть цикл на n итераций, но выполняются они не все сразу. Генератор работает лениво — значения вычисляются и выдаются только по мере запроса. Если вы в цикле прерываете выполнение, недостающие значения так и не будут вычислены. Это особенно важно при работе с большими или бесконечными последовательностями — генераторы экономят память и ускоряют работу.
Также учтите, что в одной функции-генераторе можно использовать несколько операторов yield, как в обычной функции допускается несколько return.
Кроме того, генераторы отлично сочетаются с циклами, выражениями-генераторами и конструкциями вроде map() или filter().
Ещё по теме
Подробнее о декораторах можно почитать в этом материале.
Что дальше
Теперь вы умеете создавать рекурсивные функции и понимаете, как они позволяют описывать алгоритмы в декларативном стиле. Вы увидели, почему рекурсивные решения могут быть медленными и как избежать этого с помощью кеширования.
Вы научились использовать декораторы, чтобы расширять поведение функций без изменения их кода. А ещё — познакомились с генераторами и оператором yield, которые позволяют работать с большими объёмами данных эффективно и элегантно.
Дальше мы кратко подведём итоги, вспомним ключевые приёмы работы с функциями и подготовимся к следующей теме: объектной модели Python.
Ключевые выводы статьи
- Рекурсия — это техника, при которой функция вызывает саму себя. Она подходит для задач, определяемых через более простые подзадачи.
- Рекурсивные решения часто наглядны, но могут быть медленными без кеширования.
- Кеширование (мемоизация) позволяет сохранять промежуточные результаты. В Python удобно использовать
lru_cacheиз модуляfunctools.- Декораторы позволяют оборачивать функции, добавляя к ним новый функционал — без изменения исходного кода.
- Генераторы создают значения по мере необходимости. Это позволяет экономить память и ускоряет работу с большими объёмами данных.
- Оператор
yieldделает функцию ленивой: она «замораживается» до следующего вызова и продолжает выполнение с того места, где остановилась.