Почему GHC делает исправление настолько запутанным?
Глядя на исходный код GHC, я вижу, что определение fix:
fix :: (a -> a) -> a
fix f = let x = f x in x
В примере исправление используется следующим образом:
fix (\f x -> let x' = x+1 in x:f x')
Это в основном дает последовательность чисел, увеличивающихся на единицу до бесконечности. Чтобы это произошло, fix должен выполнять функцию, которую он получает обратно в эту самую функцию в качестве первого параметра. Мне непонятно, как это может сделать определение fix, перечисленное выше.
Это определение, как я понял, как работает fix:
fix :: (a -> a) -> a
fix f = f (fix f)
Итак, у меня есть два вопроса:
- Как x когда-либо понимается исправление x в первом определении?
- Есть ли преимущество в использовании первого определения над вторым?
Ответы
Ответ 1
Легко понять, как это определение работает, применяя эквациональные рассуждения.
fix :: (a -> a) -> a
fix f = let x = f x in x
Что будет оценивать x
при попытке оценить fix f
? Он определяется как f x
, поэтому fix f = f x
. Но что здесь x
? Это f x
, как и раньше. Итак, вы получите fix f = f x = f (f x)
. Рассуждая таким образом, вы получаете бесконечную цепочку приложений f
: fix f
= f (f (f (f ...)))
.
Теперь, заменив (\f x -> let x' = x+1 in x:f x')
на f
, вы получите
fix (\f x -> let x' = x+1 in x:f x')
= (\f x -> let x' = x+1 in x:f x') (f ...)
= (\x -> let x' = x+1 in x:((f ...) x'))
= (\x -> x:((f ...) x + 1))
= (\x -> x:((\x -> let x' = x+1 in x:(f ...) x') x + 1))
= (\x -> x:((\x -> x:(f ...) x + 1) x + 1))
= (\x -> x:(x + 1):((f ...) x + 1))
= ...
Изменить. Что касается вашего второго вопроса, @is7s указал в комментариях, что первое определение предпочтительнее, потому что оно более эффективно.
Чтобы узнать, почему, давайте посмотрим на Core для fix1 (:1) !! 10^8
:
a_r1Ko :: Type.Integer
a_r1Ko = __integer 1
main_x :: [Type.Integer]
main_x =
: @ Type.Integer a_r1Ko main_x
main3 :: Type.Integer
main3 =
!!_sub @ Type.Integer main_x 100000000
Как вы можете видеть, после преобразований fix1 (1:)
по существу стало main_x = 1 : main_x
. Обратите внимание, как это определение относится к самому себе - вот что означает "связывание узла". Эта самореклама представляется как простая указательная ссылка во время выполнения:
![fix1]()
Теперь посмотрим на fix2 (1:) !! 100000000
:
main6 :: Type.Integer
main6 = __integer 1
main5
:: [Type.Integer] -> [Type.Integer]
main5 = : @ Type.Integer main6
main4 :: [Type.Integer]
main4 = fix2 @ [Type.Integer] main5
main3 :: Type.Integer
main3 =
!!_sub @ Type.Integer main4 100000000
Здесь приложение fix2
фактически сохраняется:
![fix2]()
В результате вторая программа должна выполнять распределение для каждого элемента списка (но поскольку список сразу же потребляется, программа по-прежнему эффективно работает в постоянном пространстве):
$ ./Test2 +RTS -s
2,400,047,200 bytes allocated in the heap
133,012 bytes copied during GC
27,040 bytes maximum residency (1 sample(s))
17,688 bytes maximum slop
1 MB total memory in use (0 MB lost due to fragmentation)
[...]
Сравните это с поведением первой программы:
$ ./Test1 +RTS -s
47,168 bytes allocated in the heap
1,756 bytes copied during GC
42,632 bytes maximum residency (1 sample(s))
18,808 bytes maximum slop
1 MB total memory in use (0 MB lost due to fragmentation)
[...]
Ответ 2
Как x когда-либо понимается как fix x в первом определении?
fix f = let x = f x in x
Пусть привязки в Haskell рекурсивные
Прежде всего, поймите, что Haskell позволяет рекурсивно связывать привязки. Что Haskell называет "let", некоторые другие языки называют "letrec". Это нормально для определения функций. Например:
ghci> let fac n = if n == 0 then 1 else n * fac (n - 1) in fac 5
120
Но это может показаться странным для определения значений. Тем не менее, значения могут быть рекурсивно определены из-за неустойчивости Haskell.
ghci> take 5 (let ones = 1 : ones in ones)
[1,1,1,1,1]
См. Нежное введение в Haskell в разделах 3.3 и 3.4 для более подробной информации о ленивости Haskell.
Thunks в GHC
В GHC пока еще не оцененное выражение завернуто в "thunk": обещание выполнить вычисление. Тонки оцениваются только тогда, когда они абсолютно необходимы. Предположим, что мы хотим fix someFunction
. Согласно определению fix
, это
let x = someFunction x in x
Теперь, что видит GHC, это что-то вроде этого.
let x = MAKE A THUNK in x
Таким образом, он счастливо делает для вас удар и движется прямо до тех пор, пока вы не спросите, что на самом деле x
.
Оценка образца
Это выражение thunk просто имеет отношение к самому себе. Возьмем пример ones
и перепишем его для использования fix
.
ghci> take 5 (let ones recur = 1 : recur in fix ones)
[1,1,1,1,1]
Итак, как будет выглядеть этот трюк?
Мы можем встроить ones
в качестве анонимной функции \recur -> 1 : recur
для более четкой демонстрации.
take 5 (fix (\recur -> 1 : recur))
-- expand definition of fix
take 5 (let x = (\recur -> 1 : recur) x in x)
Итак, что такое x
? Ну, хотя мы не совсем уверены в том, что x
, мы все равно можем воспользоваться приложением функции:
take 5 (let x = 1 : x in x)
Эй, посмотрим, мы вернулись к определению, которое у нас было раньше.
take 5 (let ones = 1 : ones in ones)
Итак, если вы считаете, что понимаете, как это работает, тогда вы хорошо чувствуете, как работает fix
.
Есть ли какое-либо преимущество в использовании первого определения над вторым?
Да. Проблема в том, что вторая версия может вызвать утечку пространства даже при оптимизации. См. билет GHC traС# 5205, для аналогичной проблемы с определением forever
. Вот почему я упомянул thunks: потому что let x = f x in x
выделяет только один thunk: x
thunk.
Ответ 3
Разница заключается в совместном использовании и копировании. 1
fix1 f = x where x = f x -- more visually apparent way to write the same thing
fix2 f = f (fix2 f)
Если мы подставим определение в себя, то оба будут уменьшены как одна и та же бесконечная цепочка приложений f (f (f (f (f ...))))
. Но первое определение использует явное именование; в Haskell (как и в большинстве других языков) совместное использование разрешено возможностью назвать вещи: одно имя более или менее гарантировано относится к одному "сущности" (здесь x
). Второе определение не гарантирует совместного использования - результат вызова fix2 f
заменяется на выражение, поэтому его можно также заменить на значение.
Но данный компилятор теоретически может быть умным и использовать совместное использование во втором случае.
Связанная проблема: " Y комбинатор". В нетипизированном лямбда-исчислении, где нет конструкций именования (и, следовательно, нет самореференции), комбинатор Y эмулирует самооценку, организуя определение, которое нужно скопировать, поэтому обращение к копии self становится возможным, Но в реализациях, которые используют модель среды для обозначения названных объектов на языке, становится возможной прямая ссылка по имени.
Чтобы увидеть более резкое различие между двумя определениями, сравните
fibs1 = fix1 ( (0:) . (1:) . g ) where g (a:[email protected](b:_)) = (a+b):g t
fibs2 = fix2 ( (0:) . (1:) . g ) where g (a:[email protected](b:_)) = (a+b):g t
См. также:
(особенно попытайтесь выработать последние два определения в предыдущей ссылке выше).
1 Используя определения, для вашего примера fix (\g x -> let x2 = x+1 in x : g x2)
получаем
fix1 (\g x -> let x2 = x+1 in x : g x2)
= fix1 (\g x -> x : g (x+1))
= fix1 f where {f = \g x -> x : g (x+1)}
= fix1 f where {f g x = x : g (x+1)}
= x where {x = f x ; f g x = x : g (x+1)}
= g where {g = f g ; f g x = x : g (x+1)} -- both g in {g = f g} are the same g
= g where {g = \x -> x : g (x+1)} -- and so, here as well
= g where {g x = x : g (x+1)}
и, таким образом, собственно рекурсивное определение для g
фактически создано. (в приведенном выше случае мы пишем ....x.... where {x = ...}
для let {x = ...} in ....x....
, для удобочитаемости).
Но второй вывод происходит с критическим различием подстановки значения назад, а не имени, как
fix2 (\g x -> x : g (x+1))
= fix2 f where {f g x = x : g (x+1)}
= f (fix2 f) where {f g x = x : g (x+1)}
= (\x-> x : g (x+1)) where {g = fix2 f ; f g x = x : g (x+1)}
= h where {h x = x : g (x+1) ; g = fix2 f ; f g x = x : g (x+1)}
чтобы фактический вызов продолжался, например,
take 3 $ fix2 (\g x -> x : g (x+1)) 10
= take 3 (h 10) where {h x = x : g (x+1) ; g = fix2 f ; f g x = x : g (x+1)}
= take 3 (x:g (x+1)) where {x = 10 ; g = fix2 f ; f g x = x : g (x+1)}
= x:take 2 (g x2) where {x2 = x+1 ; x = 10 ; g = fix2 f ; f g x = x : g (x+1)}
= x:take 2 (g x2) where {x2 = x+1 ; x = 10 ; g = f (fix2 f) ; f g x = x : g (x+1)}
= x:take 2 (x2 : g2 (x2+1)) where { g2 = fix2 f ;
x2 = x+1 ; x = 10 ; f g x = x : g (x+1)}
= ......
и мы видим, что здесь устанавливается новая привязка (для g2
) вместо предыдущей (для g
), которая используется повторно как определение fix1
.
Ответ 4
У меня есть, возможно, немного упрощенное объяснение, которое исходит из inlining оптимизации. Если мы имеем
fix :: (a -> a) -> a
fix f = f (fix f)
то fix
является рекурсивной функцией, и это означает, что она не может быть встроена в те места, где она используется (правая часть INLINE
будет проигнорирована, если задана).
Однако
fix' f = let x = f x in x
не является рекурсивной функцией - она никогда не называет себя. Только x
внутри является рекурсивным. Поэтому при вызове
fix' (\r x -> let x' = x+1 in x:r x')
компилятор может встроить его в
(\f -> (let y = f y in y)) (\r x -> let x' = x+1 in x:r x')
а затем продолжить его упрощение, например
let y = (\r x -> let x' = x+1 in x:r x') y in y
let y = (\ x -> let x' = x+1 in x:y x') in y
как будто функция была определена с использованием стандартного рекурсивного обозначения без fix
:
y x = let x' = x+1 in x:y x'