Как работает лавка Haskell?
Рассмотрим эту функцию, которая удваивает все элементы в списке:
doubleMe [] = []
doubleMe (x:xs) = (2*x):(doubleMe xs)
Затем рассмотрим выражение
doubleMe (doubleMe [a,b,c])
Кажется очевидным, что во время выполнения это первое расширение:
doubleMe ( (2*a):(doubleMe [b,c]) )
(Это очевидно, потому что никаких других возможностей не существует, насколько я вижу).
Но мой вопрос таков: Почему именно это теперь расширяется до
2*(2*a) : doubleMe( doubleMe [b,c] )
вместо
doubleMe( (2*a):( (2*b) : doubleMe [c] ) )
?
Интуитивно, я знаю ответ: Потому что Хаскелл ленив. Но может ли кто-нибудь дать мне более точный ответ?
Есть ли что-то особенное в списках, которые вызывают это, или идея более общая, чем просто списки?
Ответы
Ответ 1
doubleMe (doubleMe [a,b,c])
не расширяется до doubleMe ( (2*a):(doubleMe [b,c]) )
. Он расширяется до:
case doubleMe [a,b,c] of
[] -> []
(x:xs) -> (2*x):(doubleMe xs)
Это внешний вызов функции сначала расширяется. Главное различие между ленивым языком и строгим: при расширении вызова функции вы не сначала оцениваете аргумент - вместо этого вы заменяете вызов функции своим телом и оставляете аргумент as-is на данный момент.
Теперь doubleMe
нужно развернуть, потому что сопоставление шаблонов должно знать структуру его операнда, прежде чем его можно оценить, поэтому мы получаем:
case (2*a):(doubleMe [b,c]) of
[] -> []
(x:xs) -> (2*x):(doubleMe xs)
Теперь сопоставление шаблонов можно заменить телом второй ветки, потому что теперь мы знаем, что вторая ветвь соответствует той, которая соответствует. Поэтому мы подставляем (2*a)
для x
и doubleMe [b, c]
для xs
, давая нам:
(2*(2*a)):(doubleMe (doubleMe [b,c]))
Итак, как мы достигнем этого результата.
Ответ 2
Ваш "очевидный" первый шаг на самом деле не настолько очевиден. Фактически, что происходит, это примерно так:
doubleMe (...)
doubleMe ( { [] | (_:_) }? )
doubleMe ( doubleMe (...)! )
и только в этой точке действительно ли это "enter" внутренняя функция. Так оно продолжается
doubleMe ( doubleMe (...) )
doubleMe ( doubleMe( { [] | (_:_) }? ) )
doubleMe ( doubleMe( a:_ ! ) )
doubleMe ( (2*a) : doubleMe(_) )
doubleMe ( (2*a):_ ! )
теперь здесь внешняя функция doubleMe
имеет "ответ" на вопрос [] | (_:_)
, что было единственной причиной того, что что-либо во внутренней функции было вообще оценено.
На самом деле, следующий шаг также не обязательно то, что вы, хотя: это зависит от того, как вы оцениваете внешний результат! Например, если бы все выражение было tail $ doubleMe ( doubleMe [a,b,c] )
, то оно фактически больше расширялось бы как
tail( { [] | (_:_) }? )
tail( doubleMe(...)! )
tail( doubleMe ( { [] | (_:_) }? ) )
...
tail( doubleMe ( doubleMe( a:_ ! ) ) )
tail( doubleMe ( _:_ ) )
tail( _ : doubleMe ( _ ) )
doubleMe ( ... )
то есть. на самом деле он никогда не достигнет 2*a
!
Ответ 3
Другие ответили на общий вопрос. Позвольте мне добавить что-то по этому конкретному вопросу:
Есть ли что-то особенное в списках, вызывающих это, или идея более общая, чем просто списки?
Нет, списки не являются особыми. Каждый тип data
в Haskell имеет ленивую семантику. Попробуйте простой пример, используя тип пары для целых чисел (Int, Int)
.
let pair :: (Int,Int)
pair = (1, fst pair)
in snd pair
Выше, fst,snd
- это пары проекций, возвращающих первый/второй компонент пары. Также обратите внимание, что pair
- рекурсивно определенная пара. Да, в Haskell вы можете рекурсивно определять все, а не только функции.
Под ленивой семантикой приведенное выше выражение грубо оценивается следующим образом:
snd pair
= -- definition of pair
snd (1, fst pair)
= -- application of snd
fst pair
= -- definition of pair
fst (1, fst pair)
= -- application of fst
1
Для сравнения, используя надежную семантику, мы бы оценили ее так:
snd pair
= -- definition of pair
snd (1, fst pair)
= -- must evaluate arguments before application, expand pair again
snd (1, fst (1, fst pair))
= -- must evaluate arguments
snd (1, fst (1, fst (1, fst pair)))
= -- must evaluate arguments
...
В ожидаемой оценке мы настаиваем на оценке аргументов перед применением fst/snd
, и мы получаем бесконечно циклическую программу. На некоторых языках это приведет к ошибке "переполнение стека".
В ленивой оценке мы скоро применим функции, даже если аргумент не полностью оценен. Это приводит к тому, что snd (1, infiniteLoop)
возвращает 1
.
Таким образом, ленивая оценка не относится к спискам. В Haskell все лениво: деревья, функции, кортежи, записи, пользовательские типы data
и т.д.
(Nitpick: если программист действительно запрашивает их, можно определить типы, имеющие строгие/нетерпеливые компоненты. Это можно сделать с помощью аннотаций строгости или с помощью расширений, таких как unboxed types. Иногда они используют их, они обычно не встречаются в программах Haskell.)
Ответ 4
Это подходящее время для вытягивания эквациональных рассуждений, что означает, что мы можем подставить функцию для ее определения (поменять местами, чтобы не иметь столкновений). Я собираюсь переименовать doubleMe
в d
для краткости, хотя:
d [] = [] -- Rule 1
d (x:xs) = (2*x) : d xs -- Rule 2
d [1, 2, 3] = d (1:2:3:[])
= (2*1) : d (2:3:[]) -- Rule 2
= 2 : d (2:3:[]) -- Reduce
= 2 : (2*2) : d (3:[]) -- Rule 2
= 2 : 4 : d (3:[]) -- Reduce
= 2 : 4 : (2*3) : d [] -- Rule 2
= 2 : 4 : 6 : d [] -- Reduce
= 2 : 4 : 6 : [] -- Rule 1
= [2, 4, 6]
Итак, если бы мы выполнили это с 2 слоями doubleMe
/d
:
d (d [1, 2, 3]) = d (d (1:2:3:[]))
= d ((2*1) : d (2:3:[])) -- Rule 2 (inner)
= d (2 : d (2:3:[])) -- Reduce
= (2*2) : d (d (2:3:[])) -- Rule 2 (outer)
= 4 : d (d (2:3:[])) -- Reduce
= 4 : d ((2*2) : d (3:[])) -- Rule 2 (inner)
= 4 : d (4 : d (3:[])) -- Reduce
= 4 : 8 : d (d (3:[])) -- Rule 2 (outer) / Reduce
= 4 : 8 : d (6 : d []) -- Rule 2 (inner) / Reduce
= 4 : 8 : 12 : d (d []) -- Rule 2 (outer) / Reduce
= 4 : 8 : 12 : d [] -- Rule 1 (inner)
= 4 : 8 : 12 : [] -- Rule 1 (outer)
= [4, 8, 12]
В качестве альтернативы вы можете сократить время в разные моменты времени, в результате чего
d (d [1, 2, 3]) = d (d (1:2:3:[]))
= d ((2*1) : d (2:3:[]))
= (2*(2*1)) : d (d (2:3:[]))
= -- Rest of the steps left as an exercise for the reader
= (2*(2*1)) : (2*(2*2)) : (2*(2*3)) : []
= (2*2) : (2*4) : (2*6) : []
= 4 : 6 : 12 : []
= [4, 6, 12]
Это два возможных расширения для этого вычисления, но это не относится к спискам. Вы можете применить его к типу дерева:
data Tree a = Leaf a | Node a (Tree a) (Tree a)
Если соответствие шаблонов на Leaf
и Node
было бы сродни соответствию на []
и :
соответственно, если вы рассмотрите определение списка
data [] a = [] | a : [a]
Причина, по которой я говорю, что это два возможных расширения, состоит в том, что порядок, в котором он был расширен, зависит от конкретной среды выполнения и оптимизации для используемого вами компилятора. Если он видит оптимизацию, которая сделает вашу программу намного быстрее, она может выбрать эту оптимизацию. Вот почему лень часто бывает благом, вам не нужно думать о порядке, в котором все происходит так, потому что компилятор делает это за вас. Это было бы невозможно на языке без чистоты, например С#/Java/Python/etc. Вы не можете переупорядочить вычисления, поскольку эти вычисления могут иметь побочные эффекты, зависящие от порядка. Но при выполнении чистых вычислений у вас нет побочных эффектов, поэтому у компилятора есть более легкая работа по оптимизации вашего кода.
Ответ 5
doubleMe [] = []
doubleMe (x:xs) = (2*x):(doubleMe xs)
doubleMe (doubleMe [a,b,c])
Я думаю, что разные люди расширяют их по-разному. Я не имел в виду, что они производят разные результаты или что-то еще, только среди людей, которые делают это правильно, на самом деле нет стандартного обозначения. Вот как я это сделаю:
-- Let manually compute the result of *forcing* the following expression.
-- ("Forcing" = demanding that the expression be evaluated only just enough
-- to pattern match on its data constructor.)
doubleMe (doubleMe [a,b,c])
-- The argument to the outer `doubleMe` is not headed by a constructor,
-- so we must force the inner application of `doubleMe`. To do that,
-- first force its argument to make it explicitly headed by a
-- constructor.
= doubleMe (doubleMe (a:[b,c]))
-- Now that the argument has been forced we can tell which of the two
-- `doubleMe` equations applies to it: the second one. So we use that
-- to rewrite it.
= doubleMe (2*a : doubleMe [b,c])
-- Since the argument to the outer `doubleMe` in the previous expression
-- is headed by the list constructor `:`, we're done with forcing it.
-- Now we use the second `doubleMe` equation to rewrite the outer
-- function application.
= 2*2*a : doubleMe (doubleMe [b, c])
-- And now we've arrived at an expression whose outermost operator
-- is a data constructor (`:`). This means that we've successfully
-- forced the expression, and can stop here. There wouldn't be any
-- further evaluation unless some consumer tried to match either of
-- the two subexpressions of this result.
Это то же самое, что и ответы sepp2k и leftaroundabout, просто они пишут это смешно. Ответ sepp2k имеет выражение case
, кажущееся из ниоткуда - многоэксклюзивное определение doubleMe
получило неявное переписывание как одно выражение case
. leftaroundabout ответ имеет { [] | (_:_) }?
вещь в нем, которая, по-видимому, является обозначением "Я должен заставить аргумент, пока он не будет выглядеть как []
или (_:_)
".
Ответ bhelkir похож на мой, но он рекурсивно заставляет все подвыражения результата также, что не произойдет, если у вас нет потребителя, который его требует.
Так что никакого неуважения к кому-либо, но мне нравится мое лучше.:-P
Ответ 6
Напишите\lambda y.m для обозначения абстрактной версии doubleMe и t для списка [a, b, c]. Тогда термин, который вы хотите уменьшить,
\y.m (\y.m t)
Другими словами, существует два redex. Haskell предпочитает сначала запускать внешние редексы, так как это нормальный порядок-иш-язык. Однако это не совсем так. doubleMe не действительно \y.m, и только на самом деле имеет redex, когда его аргумент имеет правильную форму (список). Так как это еще не redex, и нет никаких переделов внутри (\ y.m), мы перемещаемся справа от приложения. Так как Haskell также предпочтет сначала оценить самые последние перестановки. Теперь у меня действительно есть форма списка, поэтому срабатывает redex (\ y.m t).
\y.m (a : (\y.m t'))
И затем мы вернемся к вершине и снова сделаем все. За исключением этого времени, внешний край имеет redex.
Ответ 7
Он делает это из-за того, как определяются списки и лень. Когда вы попросите руководителя списка, он оценивает тот первый элемент, который вы попросили, и сохранит остальное на потом. Все операции обработки списка построены на основе концепции "голова: отдых", поэтому промежуточные результаты никогда не появляются.