Если вы новичок в функциональном программировании, вам может быть интересно: «Как я могу создать цикл в функциональном языке программирования?»
Предположим, мы хотим вычислить сумму 1 + 2 + 3 + … + n
для заданного n
натурального числа. В нефункциональных языках программирования мы обычно используем цикл for
или while
:
// Imperative style in Java int sum (int n) { int s = 0; for (int i = 1; i <= n; i++) { s = s + i; } return s; }
Большинство инженеров-программистов настолько знакомы с этими конструкциями циклов, что они кажутся неотъемлемой частью любого языка программирования. Тем не менее, если вы начнете изучать функциональный язык программирования — особенно такой чистый, как Haskell, — вы можете быть удивлены, узнав, что в нем нет таких вещей, как for/while
циклов.
Почему так? Потому что циклические конструкции по своей природе императивны. В частности, это утверждения, которые говорят: «Выполните это выражение/команду x раз или выполните это выражение/команду, пока это условие истинно». Эти циклы for
и while
не являются выражениями, потому что они не возвращают значения. Приведенный выше код является типичным примером императивного стиля программирования: цикл многократно обновляет переменную s
посредством присваивания переменной.
Однако в функциональной парадигме все является выражением. В результате нет операторов и, следовательно, нет конструкций цикла. Вместо этого функциональная парадигма смещает мышление с Как я могу использовать цикл для вычисления этого? на Какова рекурсивная структура выражения, которое я пытаюсь сформулировать?
Функций достаточно для повторных вычислений
Как мы можем сформулировать sum
как выражение? Мы можем сделать это, опираясь на математическое определение sum
следующим образом:
Мы можем перевести это определение непосредственно в рекурсивную функцию — функцию, которая вызывает сама себя.
(* Functional style in OCaml or Haskell *) let rec sum n = if n <= 0 then 0 else n + sum (n-1)
Если подумать, императивное решение довольно низкоуровневое, потому что оно просто состоит из набора последовательных команд, которые обновляют переменную. С другой стороны, рекурсивная версия демонстрирует более высокий уровень абстракции. Действительно, рекурсивное решение формулирует sum n
как выражение, которое составлено из двух других выражений.
Увлекательный вывод из функционального программирования заключается в том, что возможности определять функции в терминах самих себя, т. е. рекурсивно, уже достаточно для формулирования повторяющихся вычислений. Никаких специальных циклов не требуется.
Переполнение стека
Какой бы мощной ни была рекурсия, она связана с печально известной проблемой — «переполнением стека». Если мы применим рекурсивное sum
из предыдущего раздела к большому числу, выполнение функции может в конечном итоге достичь предела стека. Если мы посчитаем sum 1000000
, мы увидим исключение переполнения стека.
let rec sum n = if n <= 0 then 0 else n + sum (n-1) sum 1000000 (* Result: exception Stack_overflow *)
Чтобы понять, почему происходит переполнение стека, давайте посмотрим на выполнение sum
применительно к 5
.
sum 5 5 + sum 4 5 + (4 + sum 3) 5 + (4 + (3 + sum 2)) 5 + (4 + (3 + (2 + sum 1))) 5 + (4 + (3 + (2 + (1 + sum 0)))) 5 + (4 + (3 + (2 + (1 + 0)))) 5 + (4 + (3 + (2 + 1))) 5 + (4 + (3 + 3)) 5 + (4 + 6) 5 + 10 15
Обратите внимание, что цепочка отложенных вычислений вызвана тем, что рекурсивный вызов является частью операции n + sum (n — 1)
. Среда выполнения использует стек вызовов, чтобы помнить, куда вернуться после разрешения отложенных вычислений. Мы ясно видим, что чем больше n
, тем длиннее цепочка расширяется вправо. Точнее, количество кадров стека, используемых во время выполнения, растет линейно с n
. Если это число превышает определенный порог, мы получаем ошибку переполнения стека. Но не все рекурсивные функции вызывают переполнение стека.
Хвост-рекурсивные функции
Давайте еще раз посмотрим на функцию суммы для вычисления 1 + 2 + 3 + … + n
, но на этот раз по-другому. Мы поддерживаем текущий sum
и счетчик c
. Изначально sum
равно 0, а c
равно 1. На каждом шаге sum
и c
обновляются в соответствии со следующими правилами.
Процесс итерации завершается, когда c
больше n
, и в этом случае sum
содержит окончательный результат.
На первый взгляд может показаться, что мы отошли от функционального программирования и вернулись к императивной парадигме. В конце концов, это именно то, что мы сделали в императивной реализации в начале этой статьи. Там мы также поддерживали счетчик и текущую сумму в цикле for
. Тем не менее, мы сохраняем мышление, основанное на выражениях, формулируя приведенный выше алгоритм как функцию.
Здесь аргумент s
представляет текущую сумму, c
представляет счетчик, а n
представляет количество натуральных чисел, которое мы хотим сложить. Мы можем перевести эту функцию с тремя аргументами в рекурсивную функцию.
let rec sum_iter s c n = if c > n then s else sum_iter (s + c) (c + 1) n sum_iter 0 1 3 (* Result: 6 *)
В отличие от sum
из предыдущего раздела, sum_iter
не вызывает ошибки переполнения стека даже в случае большого аргумента. Например, sum_iter 0 1 1000000
без проблем возвращает результат.
let rec sum_iter s c n = if c > n then s else sum_iter (s + c) (c + 1) n sum_iter 0 1 1000000 (* Result: 500000500000 *)
Переполнения стека не происходит, потому что sum_iter
— это особый тип рекурсивной функции, называемой функция хвостовой рекурсии. Он вызывает сам себя напрямую без дополнительных операций. Ниже показано выполнение sum_iter 0 1 5
.
let rec sum_iter s c n = if c > n then s else sum_iter (s + c) (c + 1) n sum_iter 0 1 5 sum_iter 1 2 5 sum_iter 3 3 5 sum_iter 6 4 5 sum_iter 10 5 5 sum_iter 15 6 5
Как мы видим, выполнение хвостовой рекурсии сильно отличается от выполнения нехвостовой рекурсии. В хвостово-рекурсивном варианте цепочка отложенных вычислений не выстраивается. Каким бы большим ни было n
, форма исполнения остается неизменной. В результате компилятор функциональных языков программирования при компиляции хвостовой рекурсивной функции в машинный или байтовый код не использует никакого стека вызовов для выполнения хвостовой рекурсивной функции. Эта оптимизация называется устранением хвостовой рекурсии. Благодаря этой оптимизации версия с хвостовой рекурсией не вызывает проблемы переполнения стека, несмотря на то, что является рекурсивной функцией.
Таким образом, есть три основные причины, по которым функциональные языки не нуждаются в специальных конструкциях циклов for
и while
. Во-первых, в функциональных языках программирования все рассматривается как выражение, тогда как циклы for
и while
являются операторами, а не выражениями. Во-вторых, функции, если они определены сами по себе, достаточны для формулировки повторяющихся вычислений. Наконец, функциональные языки поддерживают оптимизацию хвостовой рекурсии. Это означает, что хвостовая рекурсия использует тот же объем памяти, что и эквивалентный цикл, и, следовательно, не вызывает проблем с переполнением стека.
Если вам понравилась эта статья, вы можете прочитать мою книгу Искусство функционального программирования, в которой я углубляюсь в функциональный образ мышления и попутно сравниваю его с парадигмой императивного программирования.