Функциональное программирование. Много внимания уделяется рекурсии, почему?

Я познакомился с функциональным программированием [FP] (используя Scala). Одна вещь, которая выходит из моих первоначальных знаний, заключается в том, что FP сильно зависят от рекурсии. А также, кажется, в чистых FP единственный способ сделать итеративный материал - написать рекурсивные функции.

И из-за интенсивного использования рекурсии, кажется, следующая вещь, о которой должны были беспокоиться FP, были StackoverflowExceptions, как правило, из-за длительных рекурсивных вызовов. Это было решено путем введения некоторых оптимизаций (оптимизация хвостовой рекурсии в сопровождении стековых кадров и аннотации @tailrec от Scala v2.8 и далее)

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

PS: Обратите внимание, что я новичок в функциональном программировании, поэтому не стесняйтесь указывать мне существующие ресурсы, если они объяснят/отвечают на мой вопрос. Также я понимаю, что Scala, в частности, обеспечивает поддержку итеративного использования.

Ответ 1

Тестирование Церкви Тьюринга подчеркивает эквивалентность между различными моделями вычислимости.

Используя рекурсию, нам не нужно изменчивое состояние при решении какой-либо проблемы, и это позволяет определить семантику в более простых терминах. Таким образом, решения могут быть проще в формальном смысле.

Я думаю, что Prolog показывает лучше, чем функциональные языки, эффективность рекурсии (у нее нет итерации) и практические ограничения, с которыми мы сталкиваемся при ее использовании.

Ответ 2

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

Программа, которая не делает ничего тривиального, должна будет что-то перебирать в какой-то момент. Для функционального программирования это означает, что программа должна использовать рекурсивные функции.

Ответ 3

Функция, которая вызывает требование, что вы делаете что-то рекурсивно, является неизменяемыми переменными.

Рассмотрим простую функцию для вычисления суммы списка (в псевдокоде):

fun calculateSum(list):
    sum = 0
    for each element in list: # dubious
        sum = sum + element # impossible!
    return sum

Теперь element в каждой итерации списка отличается, но мы можем переписать это, чтобы использовать функцию foreach с аргументом лямбда, чтобы избавиться от этой проблемы:

fun calculateSum(list):
    sum = 0
    foreach(list, lambda element:
        sum = sum + element # impossible!
    )
    return sum

Тем не менее, значение переменной sum должно быть изменено в каждом прогоне лямбда. Это незаконно на языке с неизменяемыми переменными, поэтому вам нужно переписать его так, чтобы он не изменял состояние:

fun calculateSum([H|T]):
    return H + calculateSum(T)

fun calculateSum([]):
    return 0

Теперь эта реализация потребует много нажатий и выталкивания из стека вызовов, а программа, в которой все небольшие операции будут делать это, не будет работать очень быстро. Поэтому мы переписываем его как хвост рекурсивный, поэтому компилятор может оптимизировать хвостовой вызов:

fun calculateSum([H|T], partialSum):
    return calculateSum(T, H + partialSum)

fun calculateSum([], partialSum):
    return partialSum

fun calculateSum(list):
    return calculateSum(list, 0)

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

Аннотация @tailrec в Scala - это инструмент, который поможет вам проанализировать, какие функции являются хвостовыми рекурсивными. Вы утверждаете: "Эта функция рекурсивна на хвосте", а затем компилятор может сказать вам, ошибаетесь ли вы. Это особенно важно в Scala по сравнению с другими функциональными языками, потому что машина, на которой он работает, JVM, не поддерживает оптимизацию хвостовых вызовов, поэтому невозможно оптимизировать оптимизацию хвоста в Scala обстоятельства, которые вы получили бы на других функциональных языках.

Ответ 4

TL; DR: рекурсия используется для обработки индуктивно определенных данных, которые являются вездесущими.

Рекурсия естественна, когда вы работаете на более высоких уровнях абстракции. Функциональное программирование связано не только с кодированием функций; речь идет о работе на более высоких уровнях абстракции, где вы, естественно, используете функции. Используя функции, вполне естественно повторно использовать одну и ту же функцию (вызвать ее снова), из любого контекста, где это имеет смысл.

Мир построен путем повторения похожих/одинаковых строительных блоков. Если вы разрезаете кусок ткани пополам, у вас есть два куска ткани. Математическая индукция лежит в основе математики. Мы, люди, считаем (как, 1,2,3...). Любая индуктивно определенная вещь (например, {числа из 1} являются {1 и номерами из 2}) естественным образом обрабатывается/анализируется рекурсивным функции, в соответствии с теми же случаями, в которых эта вещь определена/построена.

Рекурсия везде. Любой итеративный цикл все равно переопределен для рекурсии, потому что, когда вы входите в этот цикл, вы снова вводите этот же цикл (только с разными переменными цикла). Таким образом, это не похоже на изобретение новых концепций в области вычислений, это больше похоже на обнаружение основ и делает его явным.


Итак, рекурсия естественна. Мы просто записываем некоторые законы о нашей проблеме, некоторые уравнения с определяемой нами функцией, которые сохраняют некоторый инвариант (в предположении, что функция когерентно определена), переопределяя проблему в упрощенных терминах и вуаля! У нас есть решение.

Пример, функция для вычисления длины списка (индуктивно определенный рекурсивный тип данных). Предположим, что он определен и возвращает длину списка, что не удивительно. Какими законами он должен подчиняться? Какой инвариант сохраняется при каком упрощении задачи?

Самое непосредственное - перечислить список отдельно от его элемента head, а остальное - a.k.a. хвост списка (в соответствии с тем, как список определен/построен). Закон,

length (x:xs) = 1 + length xs

D'ээ! Но как насчет пустого списка? Должно быть, что

length [] = 0

Итак, как мы пишем такую ​​функцию?... Подождите... Мы уже писали это! (В Haskell, если вам интересно, где приложение функции выражается сопоставлением, скобки используются только для группировки, а (x:xs) - это список с x его первым элементом и xs остальные).

Все, что нам нужно для языка, допускающего такой стиль программирования, состоит в том, что он TCO (и, возможно, немного роскошно, TRMCO), поэтому нет раздувания стека, и мы настроены.


Еще одна вещь - чистота - неизменность переменных кода и/или структуры данных (поля записей и т.д.).

Что это делает, помимо освобождения наших умов от необходимости отслеживать то, что меняется когда-либо, это делает время явно очевидным в нашем коде, вместо того, чтобы скрываться в наших "изменяющихся" изменяемых переменных/данных. Мы можем только "изменить" в императивном коде значение переменной с этого момента - мы не можем очень хорошо изменить его значение в прошлом, можем ли мы?

И поэтому мы заканчиваем списками записанной истории изменений, с явным явным выражением в коде: вместо x := x + 2 пишем let x2 = x1 + 2. Это упрощает рассуждение о коде.


Чтобы устранить неизменность в контексте хвостовой рекурсии с помощью TCO, рассмотрите эту хвостовую рекурсивную перезапись вышеуказанной функции length под аккумулятором парадигма аргументов:

length xs = length2 0 xs              -- the invariant: 
length2 a []     = a                  --     1st arg plus
length2 a (x:xs) = length2 (a+1) xs   --     length of 2nd arg

Здесь TCO означает повторное использование переадресации вызовов в дополнение к прямому переходу, и поэтому цепочка вызовов для length [1,2,3] может рассматриваться как фактически мутирующая записи кадра стека вызовов, соответствующие параметрам функции:

length [1,2,3]
length2 0 [1,2,3]       -- a=0  (x:xs)=[1,2,3]
length2 1 [2,3]         -- a=1  (x:xs)=[2,3]
length2 2 [3]           -- a=2  (x:xs)=[3]
length2 3 []            -- a=3  (x:xs)=[]
3

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


И следующее приводит к тому, что вся история вычисления длины списка аргументов явна (и доступна для повторного использования, если это необходимо):

length xs = last results
  where
     results = length3 0 xs
     length3 a []     = [a]
     length3 a (x:xs) = a : length3 (a+1) xs

В Haskell это известно как охраняемая рекурсия, или corecursion (по крайней мере, я думаю, что это так).

Ответ 5

Есть два свойства, которые я считаю существенными для функционального программирования:

  • Функции - это первые члены класса (только релевантные, потому что для того, чтобы сделать это полезным, требуется второе свойство)

  • Функции являются чистыми, т.е. функция, вызываемая с теми же аргументами, возвращает одно и то же значение.

Теперь, если вы программируете в императивном стиле, вам нужно использовать назначение.

Рассмотрим цикл for. Он имеет индекс, и на каждой итерации индекс имеет другое значение. Таким образом, вы можете определить функцию, которая возвращает этот индекс. Если вы дважды вызываете эту функцию, вы можете получить разные результаты. Таким образом, нарушая принцип № 2.

Если вы нарушаете принцип № 2. Прохождение вокруг функций (принцип № 1) становится чем-то чрезвычайно опасным, потому что теперь результат функции может зависеть от того, когда и как часто вызывается функция.

Ответ 6

В рекурсии нет ничего особенного. Это широко распространенный инструмент программирования и математики, и не более того. Однако функциональные языки обычно минималистичны. Они вводят много причудливых концепций, таких как сопоставление образцов, система типов, понимание списка и т.д., Но это не более чем синтаксический сахар для очень общих и очень мощных, но простых и примитивных инструментов. Этими инструментами являются: функция абстракции и функциональное приложение. Это осознанный выбор, поскольку простота ядро ​​языка значительно упрощает рассуждения. Это также упрощает составление компиляторов. Единственный способ описать цикл в терминах этих инструментов - использовать рекурсию, поэтому повелительные программисты могут подумать, что функциональное программирование - это рекурсия. Это не так, просто нужно подражать этим фантазийным петлям для бедных, которые не могут отказаться от синтаксического сахара над выражением goto, и поэтому это одна из первых вещей, которые они застряли.

Другой момент, когда требуется (может быть косвенная) рекурсия, - это обработка рекурсивно определенных структур данных. Наиболее распространенным примером является list ADT. В FP обычно определяется как data List a = Nil | Branch a (List a). Поскольку определение ADT здесь является рекурсивным, функция обработки для него также должна быть рекурсивной. Опять же, рекурсия здесь в любом случае не является особой: обработка такого ADT рекурсивным образом выглядит естественно как на императивном, так и на функциональном языках. Ну, в случае, когда логические циклы ADT-типа по-прежнему могут быть приняты, но в случае разных древовидных структур они не могут.

Таким образом, в рекурсии нет ничего особенного. Это просто другое приложение функций. Однако из-за ограничений современных вычислительных систем (которые исходят из плохо сделанных дизайнерских решений на языке C, который является фактически стандартным кросс-платформенным ассемблером), вызовы функций не могут быть вложены бесконечно, даже если они являются хвостовыми вызовами. Из-за этого разработчики функциональных языков программирования должны либо ограничить разрешенные хвостовые вызовы хвостовой рекурсией (scala), либо использовать сложные методы, такие как trampoling (old ghc codegen) или скомпилировать непосредственно в asm (современный ghc codegen).

TL; DR: В FP нет ничего особенного в рекурсии, но не меньше, чем в IP, однако рекурсия хвоста является единственным типом хвостовых вызовов, разрешенных в scala из-за ограничений JVM.

Ответ 7

Избегание побочных эффектов является одним из столпов функционального программирования (другое использует функции более высокого порядка).

Представьте себе, как вы можете использовать императивный контроль потока, не полагаясь на мутацию. Возможно ли это?

Конечно, for (var i = 0; i < 10; i++) ... зависит от мутации (i++). На самом деле любая условная конструкция цикла. В while (something) ... значение something будет зависеть от некоторого изменчивого состояния. Конечно, while (true) ... не использует мутацию. Но если вы когда-нибудь захотите выйти из этого цикла, вам понадобится if (something) break. Действительно, попробуйте подумать о (без бесконечности) методе цикла, отличном от рекурсии, которая не полагается на мутацию.

Как насчет for (var x in someCollection) ...? Теперь мы приближаемся к функциональному программированию. x можно рассматривать как параметр для тела цикла. Повторное использование имени не совпадает с переопределением значения. Возможно, в теле цикла вы yield return значения в качестве выражения в терминах x (без мутации).

Точно так же вы можете переместить тело цикла for в тело функции foo (x) ..., а затем сопоставить это над someCollection с помощью функции более высокого порядка - заменив вашу конструкцию цикла чем-то вроде Map(foo, someCollection).

Но тогда как реализована библиотечная функция Map без мутации? Ну, используя рекурсию, конечно! Это сделано для вас. Менее часто приходится внедрять рекурсивные функции самостоятельно, как только вы начнете использовать вторую часть функций более высокого порядка, чтобы заменить ваши петлевые конструкции.

Кроме того, при оптимизации хвостового вызова рекурсивное определение эквивалентно итеративному процессу. Вам также может понравиться это сообщение в блоге: http://blogs.msdn.com/b/ashleyf/archive/2010/02/06/recursion-is-the-new-iteration.aspx

Ответ 8

В прошлый раз я использовал Функциональный язык (Clojure). Я никогда не испытывал соблазна использовать рекурсию. Все можно было рассматривать как набор вещей, к которым была применена функция, чтобы получить часть продуктов, к которым была применена другая функция, до тех пор, пока не был достигнут окончательный результат.

Рекурсия - это только один способ и не обязательно самый ясный способ обработки нескольких элементов, которые вы обычно должны обрабатывать, чтобы иметь дело с любым g

Ответ 9

Для новых учеников FP я хотел бы добавить свои 2 цента. Как упоминалось в некоторых ответах, рекурсия состоит в том, чтобы использовать неизменяемые переменные, но почему мы должны это делать? потому что он позволяет легко запускать программу на нескольких ядрах параллельно, но почему мы этого хотим? Разве мы не можем запустить его в одном ядре и быть счастливым, как всегда, мы были? Нет, потому что контент для обработки увеличивается с каждым днем, а тактовый цикл ЦП не может быть значительно увеличен, чем добавление большего количества ядер. С прошлого десятилетия тактовая частота была примерно до 2,7 ГГц до 3,0 ГГц для бытовых компьютеров, а разработчики чипов столкнулись с проблемами в установке все большего количества транзисторов в своих. Также FP был их очень долгое время, но не забирал поскольку он использовал рекурсию, и память была очень дорогой в те дни, но поскольку тактовые частоты росли год за годом, поэтому сообщество решило продолжить работу с ООП Редактировать: это было довольно быстро, у меня было всего пару минут