Уважаемые пользователи Голос!
Сайт доступен в режиме «чтение» до сентября 2020 года. Операции с токенами Golos, Cyber можно проводить, используя альтернативные клиенты или через эксплорер Cyberway. Подробности здесь: https://golos.io/@goloscore/operacii-s-tokenami-golos-cyber-1594822432061
С уважением, команда “Голос”
GOLOS
RU
EN
UA
ninjas
7 лет назад

Академия: Введение в функциональное программирование. Конспект шестой недели курса: рекурсия и стек

Всем привет. Я участвую в проекте Академия от @ontofractal и публикую конспекты курса  Введение в функциональное программирование от Delft University of Technology. Сегодня я хотел бы рассказать о простом и очень выразительном средстве, позволяющем создавать циклические конструкции - рекурсивных функциях. Обычно эта тема воспринимается как некое эзотерическое знание, доступное лишь избранным ФП-ниндзям, но смею вас уверить, что это не так, и понять, как работает рекурсия, может каждый

Рекурсивные функции

Рекурсия - это прием, при котором функция в качестве результата вычисления возвращает саму себя. В качестве иллюстрации напишем функцию, вычисляющую степень заданного числа:

prod :: [Int] -> Int
prod [] = 0
prod [x] = x
prod (x:xs) = x * prod xs 

Первая строка описывает случай, когда мы передаем функции пустой список. Вторая строка - это базовый случай; именно он определяет, когда прерывается цепочка вызовов функции. Ну и третья строка - это само рекурсивное определение функции. Обращу внимания на новый для нас элемент синтаксиса: двоеточие. Этот инфиксный оператор используется так: аргумент с левой стороны становится головой списка, правый - остатком. Запись 5 : [4,3,2] даста результат [5,4,3,2]

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

 

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

Элиминация хвостового вызова

Модель вычислений, изображенная выше, проста для понимания, но в ней есть серьезный изъян: она предполагает выделение памяти под отложенные вычисления. Остановимся на этом моменте подробнее

Как известно, при выполении программ в памяти создается стек вызовов. Это специальная структура, которая хранит информация о выполняемых функциях. В упрощенном виде его работу можно представить так:

 

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

Теперь представим, что первая функция - рекурсивная, и все последующие вызовы, обозначенные цифрами 2-4 - это вызовы самой себя с новыми аргументами (как в нашей подстановочной модели). Тогда получится, что каждая итерация будет создавать новый стек, и, соответсвенно, занимать место в памяти. В таком случае, если мы дадим функции prod достаточно длинный список, это может привести к переполнению стека. К счастью, в реальности вычисление рекурсивных функций происходит совершенно иначе, благодаря технике, названной элиминация хвостового вызова

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

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

Несколько аргументов

В работе с рекурсивными функциями никто не ограничивает нас использованием только одного аргумента. Как пример предлагаю написать собственную реализацию стандартной функции zip, которая берет два списка и превращает его в один, состоящий из пар элементов исходных списков:

customZip :: [a] -> [b] -> [(a, b)]
customZip [] _ = []
customZip _ [] = []
customZip (x:xs) (y:ys) = (x, y) : customZip (xs) (ys) 

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

Быстрая (на самом деле нет) сортировка

Если помните, в самом первом конспекте мы рассматривали пример реализации быстрой сортировки при помощи рекурсивной функции. Тогда он выглядел как китайская грамота, но теперь мы наконец можем понять все детали его реализации

quickSort :: Ord a => [a] -> [a]
quickSort [] = []
quickSort (x:xs) = quickSort ys ++ [x] ++ quickSort zs
  where 
    ys = [y | y <- xs, y <= x]
    zs = [z | z <- xs, z > x]

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

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

Что показалось самым важным на этой неделе?

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

Анонс следующей части

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

2
256.100 GOLOS
На Golos с May 2017
Комментарии (2)
Сортировать по:
Сначала старые