Рекурсия: let rec и хвостовые вызовы

Осваиваем главный инструмент повторения в функциональном языке — рекурсию, и её эффективную форму с хвостовыми вызовами.

Хвостовая рекурсия — рекурсия, где рекурсивный вызов является самым последним действием функции, что позволяет компилятору выполнить её без роста стека.

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

Ключевое слово rec

let rec factorial n =
  if n <= 1 then 1
  else n * factorial (n - 1)
(* factorial 5 = 120 *)

Без rec имя factorial внутри тела ещё не было бы видно. Это сознательное решение языка: по умолчанию let не рекурсивен.

Проблема глубокой рекурсии

В factorial выше после рекурсивного вызова остаётся работа — умножение на n. Значит, каждый вызов держит на стеке свой кадр. Для больших n это переполнит стек. Решение — аккумулятор: накапливаем результат в дополнительном параметре, и рекурсивный вызов становится последним действием.

let factorial n =
  let rec loop acc i =
    if i <= 1 then acc
    else loop (acc * i) (i - 1)   (* хвостовой вызов *)
  in
  loop 1 n

Здесь loop (acc * i) (i - 1) — последнее, что делает функция. Это и есть хвостовой вызов.

Как работает под капотом

Когда рекурсивный вызов стоит в хвостовой позиции, текущий кадр стека больше не нужен. Компилятор OCaml выполняет оптимизацию хвостового вызова: переиспользует текущий кадр, превращая рекурсию в цикл. Поэтому хвостовая версия работает с любым n в постоянной памяти стека, тогда как нехвостовая «съест» стек на больших значениях с исключением Stack_overflow. Различить легко: если после рекурсивного вызова есть хоть одна операция, вызов не хвостовой.

(* Нехвостовая: после вызова есть «1 +»  *)
let rec length = function
  | [] -> 0
  | _ :: t -> 1 + length t

(* Хвостовая: вызов — последнее действие *)
let length lst =
  let rec loop acc = function
    | [] -> acc
    | _ :: t -> loop (acc + 1) t
  in loop 0 lst

Взаимная рекурсия

let rec is_even n = if n = 0 then true else is_odd (n - 1)
and is_odd n = if n = 0 then false else is_even (n - 1)

Частые ошибки

  • Забыть rec. Тогда компилятор скажет, что имя функции не определено внутри тела.
  • Считать любую рекурсию хвостовой. Если результат вызова ещё участвует в вычислении — она не хвостовая.
  • Бояться рекурсии из-за производительности. Хвостовая рекурсия компилируется в цикл.

Итоги

  • Рекурсивные функции требуют let rec; взаимная рекурсия — let rec ... and ....
  • Хвостовой вызов — последнее действие функции; такую рекурсию компилятор превращает в цикл.
  • Аккумулятор делает рекурсию хвостовой и безопасной для больших данных.
Проверьте себя
1. Зачем нужно ключевое слово `rec` в `let rec`?
AДля ускорения функции
BЧтобы функция могла ссылаться на саму себя внутри тела
CЧтобы сделать функцию хвостовой
DЧтобы разрешить мутацию
2. Какой вызов считается хвостовым?
AЛюбой рекурсивный вызов
BТот, что является самым последним действием функции
CВызов с аккумулятором всегда
DВызов внутри умножения
3. Почему хвостовая рекурсия не переполняет стек?
AОна использует кучу вместо стека
BКомпилятор переиспользует текущий кадр стека, превращая её в цикл
COCaml увеличивает стек динамически
DХвостовая рекурсия запрещена