I'm confused by Haskell's thunks
Asked Answered
O

1

3

The wikibook says that: in this expression

let z = (length [1..5], reverse "olleh") in ...

z is a thunk.

But this stackoverflow post says that the outermost layer of z is the data constructor (,), which is in WHNF, thus z is not a thunk.

So which one is right? I tried this on GHCi

ghci> z = (length [1..5], reverse "olleh")
ghci> :sprint z
z = (_,_)

and see that z is not a thunk, only its components are.

Edit: The wikibook is saying that values are evaluated by layer. z is a outer layer of (,) and it is a thunk. After you deconstruct this layer by pattern matching, you get two sub-components, which are themself thunks. It makes sense to me, because it is in-line with the principle that if you do not refer to z later, then z needs not be evaluated. But GHCi does differently. So does it mean there is a gap between principles and practices or wikibook is simply wrong?

Orangewood answered 16/5, 2023 at 11:49 Comment(1)
Haskell-the-language (or -standard) doesn't really define what value is a thunk, what's in normal form, etc.. All it defines are strictness semantics of functions you might call on such values. In your examples, that doesn't happen so basically the compiler is free to do anything, possibly involving heuristics that for such and such type a thunk is likely to achieve nothing but higher memory overhead.Illustrational
M
3

Lazy evaluation lets us work with the value returned by a function before the call has actually been evaluated. We can pass it on to other callers and only trigger the evaluation later when something needs to look at the value in detail.

To do that, we create a thunk. All a thunk is is a little structure in memory that has fields to store the arguments.

But applying a data constructor (like (,)) also just produces a little structure in memory that the fields to store the arguments it was applied to. So it turns out there's really not any point in creating a thunk for the application of a data constructor.

If we make a thunk, we'll just store the arguments in the thunk without looking at them, and they'll be accessed later when something pattern matches on the value (triggering it to be evaluated). If we skip the thunk and really apply the data constructor, we'll just store the arguments without looking at them, and they'll be accessed later when something pattern matches on the value. The behaviour of the system is basically identical whether we use a thunk and delay the application or not.

So in trying to help learners build a conceptual model for how Haskell works, it makes sense to say that any time a variable is bound to the result of an application, it will create a thunk. This is fine as a conceptual model; it agrees with all of the normally-observable behaviour of the language, and it's simpler than worrying about special exceptions when the thing being applied is a data constructor. I suppose that is the perspective the wikibooks article is taking.

But remember that thunks aren't actually the goal. There's no hard specification for exactly when GHC must use a thunk to delay evaluation of a value. They're just a means to an end. In cases like this when the observable behaviour will be the same either way, GHC is free to create a thunk or not. It's always safe to skip the thunk for a data constructor application1, but GHC can also do this when strictness analysis reveals that the value was going to be forced soon anyway. GHC simply doesn't always do exactly what textbooks say; it does whatever it thinks will produce the "best" code while still behaving the same as the simpler model you'll get from textbooks.

It's important to remember that :sprint is not a tool for examining nice theoretical properties of Haskell. It's a debugging tool for peeking into the implementation that GHC uses. It specifically lets you see details that are not supposed to be observable from within Haskell itself, which is why it has to be a special GHCi command instead of a feature of the actual language. Sometimes :sprint will just reveal small details like this that are more to do with internal implementation details of GHC than they are to do with Haskell as a language.


1 Unless it has strict fields, but then you can think about it as syntactic sugar for a function that does examine its arguments before storing them. The "real" underlying constructor still doesn't need a thunk, but that is never actually applied in the user's code, only the wrapper function that does the extra evaluation.

Mcintire answered 16/5, 2023 at 13:59 Comment(2)
Thank you for your answer. It helps me understand this paragraph: Technically Haskell is only obligated to be nonstrict, not lazy. A truly lazy language memoizes, or holds in memory, the results of all the functions it does evaluate, and, outside of toy programs, this tends to use unacceptably large amounts of memory. Implementations of Haskell, such as GHC Haskell, are only obligated to be nonstrict such that they have the same behavior with respect to bottom; they are not required to take a particular approach to how the program executes or how efficiently it does so.Orangewood
@TrungDo Hmm, I think whoever wrote that is using a much more precise definition of what it means to be "truly lazy" than I would. But yes, the key point is that non-strictness is property of the language describing how the code behaves, while laziness is just one possible implementation strategy for achieving non-strictness. Non-strictness is the goal, laziness is the means. GHC must not deviate from non-strictness, but if it can produce better code by abandoning laziness in certain cases (while still being non-strict) then it can and should.Mcintire

© 2022 - 2024 — McMap. All rights reserved.