Как работает лавка 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

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