Почему Haskell foldr не выполняет stackoverflow, пока выполняется такая же реализация Scala?
Я читаю FP в Scala.
Упражнение 3.10 говорит, что переполнение foldRight
(см. изображения ниже).
Насколько я знаю, однако foldr
в Haskell этого не делает.
http://www.haskell.org/haskellwiki/
-- if the list is empty, the result is the initial value z; else
-- apply f to the first element and the result of folding the rest
foldr f z [] = z
foldr f z (x:xs) = f x (foldr f z xs)
-- if the list is empty, the result is the initial value; else
-- we recurse immediately, making the new initial value the result
-- of combining the old initial value with the first element.
foldl f z [] = z
foldl f z (x:xs) = foldl f (f z x) xs
Как такое поведение возможно?
В чем разница между двумя языками/компиляторами, которые вызывают это различное поведение?
Откуда эта разница? Платформа? Язык? Компилятор?
Можно ли написать безопасную для стека foldRight в Scala? Если да, то как?
![enter image description here]()
![enter image description here]()
Ответы
Ответ 1
Хаскелл ленив. Определение
foldr f z (x:xs) = f x (foldr f z xs)
сообщает нам, что поведение foldr f z xs
с непустым списком xs
определяется ленивом функции объединения f
.
В частности, вызов foldr f z (x:xs)
выделяет только один кусок в куче, {foldr f z xs}
(запись {...}
для thunk с выражением ...
) и вызывает f
с двумя аргументами - x
и удар. Что происходит дальше, это ответственность f
.
В частности, если это ленивый конструктор данных (например, (:)
), он сразу же будет возвращен вызывающему абоненту вызова foldr
(с конструктором два слота, заполненных (ссылками на) два значения).
И если f
требует своего значения справа, при минимальных оптимизациях компилятора не должно создаваться ни одного трюка (или одного, самое большее - текущего), так как значение foldr f z xs
сразу необходимо и можно использовать обычную оценку на основе стека:
foldr f z [a,b,c,....,n] ==
a `f` (b `f` (c `f` (... (n `f` z)...)))
Итак, foldr
действительно может вызывать SO, когда используется со строгой функцией объединения в чрезвычайно длинных списках входных данных. Но если функция объединения не требует сразу же ее значения справа или требует только ее части, оценка будет приостановлена в thunk, и частичный результат, созданный с помощью f
, будет немедленно возвращен. То же самое с аргументом слева, но они уже приходят как разрывы, потенциально, в список ввода.
Ответ 2
Хаскелл ленив. Поэтому foldr
выделяет кучу, а не стек. В зависимости от строгости функции аргумента он может выделять один (маленький) результат или большую структуру.
Вы по-прежнему теряете пространство по сравнению со строгой рекурсивной реализацией, но это выглядит не так очевидно, так как вы торгуете стек для кучи.
Ответ 3
Обратите внимание, что авторы здесь не ссылаются ни на одно определение foldRight в стандартной библиотеке scala, например на ту, что указана в списке. Они относятся к определению foldRight, которое они указали выше в разделе 3.4.
Стандартная библиотека scala определяет foldRight в терминах foldLeft путем изменения списка (что может быть сделано в пространстве с постоянным стеком), а затем вызывает foldLeft с аргументами переданной функции в обратном порядке. Это работает для списков, но не будет работать для структуры, которая не может быть безопасно отменена, например:
scala> Stream.continually(false)
res0: scala.collection.immutable.Stream[Boolean] = Stream(false, ?)
scala> res0.reverse
java.lang.OutOfMemoryError: GC overhead limit exceeded
Теперь давайте подумаем о том, что должно быть результатом этой операции:
Stream.continually(false).foldRight(true)(_ && _)
Ответ должен быть ложным, неважно, сколько ложных значений в потоке или если оно бесконечно, если мы собираемся объединить их с конъюнкцией, результат будет ложным.
haskell, конечно, получает это без проблем:
Prelude> foldr (&&) True (repeat False)
False
И это связано с двумя важными вещами: haskell foldr будет пересекать поток слева направо, а не справа налево, а haskell по умолчанию является ленивым. Первый пункт здесь, что foldr на самом деле пересекает список слева направо, может удивить или смутить некоторых людей, которые думают о правильной складке, начиная с правой стороны, но важная особенность правой складки - это не тот конец структуры, которую она запускает но в каком направлении ассоциативность. Поэтому дайте список [1,2,3,4] и op с именем op
, левая складка
((1 op 2) op 3) op 4)
и правая складка
(1 op (2 op (3 op 4)))
Но порядок оценки не должен иметь значения. Итак, что авторы сделали здесь в главе 3, это дать вам сгиб, который пересекает список слева направо, но поскольку scala по умолчанию является строгим, мы все равно не сможем пересечь поток бесконечных фальши, но имейте некоторое терпение, они доберутся до этого в главе 5:) Я дам вам быстрый взгляд, давайте посмотрим на разницу между foldRight, как он определен в стандартной библиотеке, и, как он определен в Складном typeclass в scalaz:
Здесь реализация из стандартной библиотеки scala:
def foldRight[B](z: B)(op: (A, B) => B): B
Здесь определение из scalaz Foldable:
def foldRight[B](z: => B)(f: (A, => B) => B): B
Разница в том, что Bs все ленивы, и теперь мы снова сворачиваем наш бесконечный поток, пока мы даем функцию, которая достаточно ленива по второму параметру:
scala> Foldable[Stream].foldRight(Stream.continually(false),true)(_ && _)
res0: Boolean = false
Ответ 4
Один простой способ продемонстрировать это в Haskell - использовать equational рассуждения, чтобы продемонстрировать ленивую оценку. Пусть записывается функция find
в терминах foldr
:
-- Return the first element of the list that satisfies the predicate, or `Nothing`.
find :: (a -> Bool) -> [a] -> Maybe a
find p = foldr (step p) Nothing
where step pred x next = if pred x then Just x else next
foldr :: (a -> b -> b) -> b -> [a] -> b
foldr f z [] = z
foldr f z (x:xs) = f x (foldr f z xs)
В нетерпеливом языке, если вы написали find
с foldr
, он пересечет весь список и будет использовать O (n). При ленивой оценке он останавливается на первом элементе, который удовлетворяет предикату, и использует только O (1) пространство (по модулю сбора мусора):
find odd [0..]
== foldr (step odd) Nothing [0..]
== step odd 0 (foldr (step odd) Nothing [1..])
== if odd 0 then Just 0 else (foldr (step odd) Nothing [1..])
== if False then Just 0 else (foldr (step odd) Nothing [1..])
== foldr (step odd) Nothing [1..]
== step odd 1 (foldr (step odd) Nothing [2..])
== if odd 1 then Just 1 else (foldr (step odd) Nothing [2..])
== if True then Just 1 else (foldr (step odd) Nothing [2..])
== Just 1
Эта оценка останавливается на конечном числе шагов, несмотря на то, что список [0..]
бесконечен, поэтому мы знаем, что мы не пересекаем весь список. Кроме того, существует верхняя граница сложности выражений на каждом шаге, которая переводится в постоянную верхнюю границу памяти, необходимую для ее оценки.
Ключ здесь в том, что функция step
, с которой мы складываемся, имеет это свойство: независимо от того, что значения x
и next
, это будет либо:
- Оцените
Just x
без вызова next
thunk или
- Хвост-вызов
next
thunk (фактически, если не буквально).