Подтвердить что ты не робот

Рекурсия хвоста в Хаскелле

Я пытаюсь понять хвостовую рекурсию в Haskell. Я думаю, что понимаю, что это такое и как это работает, но я хотел бы убедиться, что я не испорчен.

Вот "стандартное" факториальное определение:

factorial 1 = 1
factorial k = k * factorial (k-1)

При запуске, например, factorial 3, моя функция вызовет себя 3 раза (дайте или возьмите). Это может создать проблему, если я захочу рассчитать фактор 99999999, поскольку у меня может быть переполнение стека. После того, как я доберусь до factorial 1 = 1, мне придется "вернуться" в стек и умножить все значения, поэтому у меня есть 6 операций (3 для вызова самой функции и 3 для умножения значений).

Теперь я представляю вам еще одну возможную факториальную реализацию:

factorial 1 c = c
factorial k c = factorial (k-1) (c*k)

Этот тоже рекурсивный. Он будет называть себя 3 раза. Но у него нет проблемы с тем, что все еще нужно "возвращаться" для вычисления умножений всех результатов, поскольку я уже передаю результат как аргумент функции.

Это то, что я понял, о том, что такое репликация хвоста. Теперь это кажется немного лучше первого, но вы все равно можете переполнять стек так же легко. Я слышал, что компилятор Haskell будет преобразовывать функции Tail-Recursive в циклы за кулисами. Наверное, это причина, по которой он рассчитывает выполнять хвостовые рекурсивные функции?

Если это причина, то абсолютно нет необходимости пытаться сделать функции tail рекурсивными, если компилятор не собирается делать этот умный трюк - я прав? Например, хотя теоретически компилятор С# мог обнаруживать и преобразовывать хвостовые рекурсивные функции в циклы, я знаю (по крайней мере, это то, что я слышал), что в настоящее время он этого не делает. Таким образом, в настоящее время нет абсолютно никакого смысла в создании рекурсивных функций. Это так?

Спасибо!

4b9b3361

Ответ 1

Здесь есть две проблемы. Одна из них - это рекурсия хвоста в целом, а другая - то, как Haskell обрабатывает вещи.

Что касается хвостовой рекурсии, у вас, похоже, правильное определение. Полезная часть состоит в том, что требуется только конечный результат каждого рекурсивного вызова, более ранние вызовы не должны храниться в стеке. Вместо того, чтобы "называть себя", функция делает что-то более близкое к "замене" самого себя, которое в конечном итоге выглядит как итеративный цикл. Это довольно простая оптимизация, которую обычно предоставляют достойные компиляторы.

Вторая проблема - ленивая оценка. Поскольку Haskell только оценивает выражение по мере необходимости, по умолчанию хвостовая рекурсия не совсем работает обычным способом. Вместо того чтобы заменять каждый вызов, он создает огромную вложенную кучу "thunks", то есть выражения, значение которых еще не запрошено. Если эта куча штыря становится достаточно большой, это действительно приведет к переполнению стека.

В Haskell на самом деле есть два решения, в зависимости от того, что вам нужно сделать:

  • Если результат состоит из вложенных конструкторов данных - например, для создания списка - тогда вы хотите избежать хвостовой рекурсии; вместо этого переместите рекурсию в одно из полей конструктора. Это также даст ленивый результат и не вызовет переполнение стека.

  • Если результат состоит из одного значения, вы хотите его строго оценить, чтобы каждый шаг рекурсии был принудительно, как только было необходимо окончательное значение. Это дает обычную псевдотерацию, которую вы ожидаете от хвостовой рекурсии.

Кроме того, имейте в виду, что GHC довольно умный и, если вы компилируете с оптимизацией, он часто будет определять места, где оценка должна быть строгой и заботиться о ней для вас. Однако это не будет работать в GHCi.

Ответ 2

Вы должны использовать встроенные механизмы, тогда вам не нужно думать о том, как сделать вашу функцию хвостовой рекурсивной

fac 0 = 1
fac n = product [1..n]

Или если продукт еще не определен:

fac n = foldl' (*) 1 [1..n]

(см. http://www.haskell.org/haskellwiki/Foldr_Foldl_Foldl%27, о том, какую версию... использовать)