Teaching Haskell with Duet
Teaching Haskell to complete beginners is an enjoyable
experience. Haskell is foreign; many of its features are alien to
other programmers. It's purely functional. It's non-strict. Its type
system is among the more pervasive of other practical languages.
Simple at the core
Haskell's core language is simple, though. It shares this with Lisp.
Structured and Interpretation of Computer Programs by MIT
teaches Lisp beginning with a substitution model for function
application. This turns out to work well for Haskell too. This is how
I've been teaching Haskell to beginners at FP Complete for our
clients.
For example, in SICP, they use the example:
(+ (square 6) (square 10))
which reduces the function square
to
(+ (* 6 6) (* 10 10))
which reduces by multiplication to:
(+ 36 100)
and finally to
136
As they note in SICP,
The purpose of the substitution is to help us think about procedure
application, not to provide a description of how the interpreter
really works. Typical interpreters do not evaluate procedure
applications by manipulating the text of a procedure to substitute
values for the formal parameters.
That this, this is a model, it's not the real thing. In fact, if you
really eyeball the very first step, you might wonder which (square ..)
argument is evaluated first between the two. Scheme doesn't
specify an argument order; it varies. An implementation may even
inline the whole thing.
Rather, if we think about programs in terms of a simple sequence of
rewrites, we get a lot of bang for our buck in terms of reasoning and
understanding.
The right language to model
Motivated by this goal, I started thinking about automating this
process, so that students could use this model more readily, and see
the shape of functions and algorithms visually. The solution I came up
with was a new language that is a subset of Haskell, which I'll cover
in this post.
The reason that it's not full Haskell is that Haskell has a lot of
surface-level syntactic sugar. Evaluating the real language is
complicated and infeasible. The following contains too many things at
once to consider:
quicksort1 :: (Ord a) => [a] -> [a]
quicksort1 [] = []
quicksort1 (x:xs) =
let smallerSorted = quicksort1 [a | a <- xs, a <= x]
biggerSorted = quicksort1 [a | a <- xs, a > x]
in smallerSorted ++ [x] ++ biggerSorted
Pattern matches at the definition, list syntax, comprehensions,
lets. There's a lot going on here that makes a newbie's eyes glaze
over. We have to start simpler. But how simple?
GHC Haskell has a tiny language called Core to which all Haskell
programs compile down to. Its AST looks roughly like this:
data Expr
= App Expr Expr
| Var Var
| Lam Name Type Expr
| Case Expr [Alt]
| Let Bind Expr
| Lit Literal
Evaluation of Core is simple. However, Core is also a little too
low-level, because it puts polymorphic types and type-class
dictionaries as normal arguments, inlines a lot of things, looks
underneath boxed types like Int
(into I#
), and adds some extra
capabilities normal Haskell doesn't have that are only appropriate for
a compiler writer to see. The above function compiled to Core starts
like this:
quicksort1
= \ (@ a_a1Zd) ($dOrd_a1Zf :: Ord a_a1Zd) (ds_d22B :: [a_a1Zd]) ->
case ds_d22B of {
[] -> GHC.Types.[] @ a_a1Zd;
: x_a1sG xs_a1sH ->
++
@ a_a1Zd
(quicksort1
@ a_a1Zd
$dOrd_a1Zf
(letrec {
ds1_d22C [Occ=LoopBreaker] :: [a_a1Zd] -> [a_a1Zd]
...
We have to explain the special list syntax, module qualification,
polymorphic types, dictionaries, etc. all in one go, besides the
obvious challenge of the unreadable naming convention. Core is made
for compilers and compiler writers, not for humans.
Duet
Therefore I took a middle way. Last year I wrote
a language called Duet, which is
a Haskell subset made specifically for teaching at this period of
learning Haskell. Duet only has these language features: data types,
type-classes, top-level definitions, lambdas, case expressions, and
some literals (strings, integrals, rationals). Its main feature is
steppability; the ability to step through the code. Every step
produces a valid program.
Returning to the SICP example with our new tool, here's the same
program in Duet:
square = \x -> x * x
main = square 6 + square 10
chris@precision:~/Work/duet-lang/duet$ duet run examples/sicp.hs
(square 6) + (square 10)
((\x -> x * x) 6) + (square 10)
(6 * 6) + (square 10)
36 + (square 10)
36 + ((\x -> x * x) 10)
36 + (10 * 10)
36 + 100
136
Here we see a substitution model in action. Each line is a valid
program! You can take any line from the output and run it from that
point.
Unlike Scheme, Duet picks an argument order (left-to-right) for strict
functions like integer operations.
Note: You can follow along at home by creating a file and running
it using docker run
on Linux, OS X or Windows.
Folds
Let's turn our attention to the teaching of folds, which is a classic
hurdle to get newbies through, as it is a kind of forcing function for
a variety of topics.
The right fold is clasically defined like this:
foldr f z [] = z
foldr f z (x:xs) = f x (foldr f z xs)
This is not a valid Duet program, because (1) it uses list syntax
(lists aren't special), and (2) it uses case analysis at the
declaration level. If you try substitution stepping these, you quickly
arrive at an awkward conversation about the difference between the
seemingly three-argument function foldr
, and lambdas, partial
application, currying, and pattern matching, and whether we're
defining two functions or one. Here is the same program in Duet:
data List a = Nil | Cons a (List a)
foldr = \f -> \z -> \l ->
case l of
Nil -> z
Cons x xs -> f x (foldr f z xs)
At the end of teaching the substitution model, I cover that \x y z
is syntactic sugar for \x -> \y -> \z -> ...
, but only after the
intuition has been solidified that all Haskell functions take one
argument. They may return other functions. So the updated program is:
data List a = Nil | Cons a (List a)
foldr = \f z l ->
case l of
Nil -> z
Cons x xs -> f x (foldr f z xs)
Which is perfectly valid Haskell, and each part of it can be rewritten
predictably.
Let's look at comparing foldr
with foldl
.
data List a = Nil | Cons a (List a)
foldr = \f z l ->
case l of
Nil -> z
Cons x xs -> f x (foldr f z xs)
foldl = \f z l ->
case l of
Nil -> z
Cons x xs -> foldl f (f z x) xs
list = Cons 1 (Cons 2 Nil)
Folds at a glance
For a quick summary, we can use holes like in normal Haskell
indicated by _
or _foo
. In Duet, these are ignored by the type
system and the stepper, letting you run the stepper with holes in
too. They don't result in an error, so you can build up expressions
with them inside.
main_foldr = foldr _f _nil list
main_foldl = foldl _f _nil list
list = Cons 1 (Cons 2 (Cons 3 (Cons 3 Nil)))
(I increased the size of the list for a longer more compelling
output.)
We can pass --concise
which is a convenience flag to literally
filter out intermediate steps (cases, lambdas) which helps us see the
"high-level" recursion. This flag is still under evaluation (no pun
intended), but is useful here. Full output is worth studying with
students too, but is too long to fit in this blog post. I will include
a snippet from a non-concise example below.
The output looks like this:
$ duet run examples/folds-strictness.hs --main main_foldr --concise
foldr _f _nil list
_f 1 (foldr _f _nil (Cons 2 (Cons 3 (Cons 4 Nil))))
_f 1 (_f 2 (foldr _f _nil (Cons 3 (Cons 4 Nil))))
_f 1 (_f 2 (_f 3 (foldr _f _nil (Cons 4 Nil))))
_f 1 (_f 2 (_f 3 (_f 4 (foldr _f _nil Nil))))
_f 1 (_f 2 (_f 3 (_f 4 _nil)))
$ duet run examples/folds-strictness.hs --main main_foldl --concise
foldl _f _nil list
foldl _f (_f _nil 1) (Cons 2 (Cons 3 (Cons 4 Nil)))
foldl _f (_f (_f _nil 1) 2) (Cons 3 (Cons 4 Nil))
foldl _f (_f (_f (_f _nil 1) 2) 3) (Cons 4 Nil)
foldl _f (_f (_f (_f (_f _nil 1) 2) 3) 4) Nil
_f (_f (_f (_f _nil 1) 2) 3) 4
We can immediately see what the "right" part of foldr
means. Experienced Haskellers can already see the teaching
opportunities sprouting from the earth at this point. We're using O(n)
space here, building nested thunks, or using too much stack. Issues
abound.
Meanwhile, in foldl
, we've shifted accumulation of the nested thunks
to an argument of foldr
, but at the end, we still have a nested
thunk. Enter strict left fold!
We also see the argument order come into play: _f
is applied to 1
first in foldr
(_f 1 (foldr ...)
), but last in foldl
(_f (_f _nil 1) ...
, which is another important part of understanding the
distinction between the two.
Strict folds
To see the low-level mechanics, and as a precursor to teaching strict
fold, we ought to use an actual arithmetic operation (because you
can't strictly evaluate a _
hole, by definition, it's missing):
main_foldr = foldr (\x y -> x + y) 0 list
main_foldl = foldl (\x y -> x + y) 0 list
Both folds eventually yield:
1 + (2 + 0)
1 + 2
3
And:
((\x y -> x + y) 0 1) + 2
((\y -> 0 + y) 1) + 2
(0 + 1) + 2
1 + 2
3
(Here you can also easily see where the 0
lies in the tree.)
Which both have the built up thunk problem mentioned above.
Duet has bang patterns, so we can define a strict fold like this:
data List a = Nil | Cons a (List a)
foldr = \f z l ->
case l of
Nil -> z
Cons x xs -> f x (foldr f z xs)
foldl = \f z l ->
case l of
Nil -> z
Cons x xs -> foldl f (f z x) xs
foldl_ = \f z l ->
case l of
Nil -> z
Cons x xs ->
case f z x of
!z_ -> foldl_ f z_ xs
list = Cons 1 (Cons 2 Nil)
main_foldr = foldr (\x y -> x + y) 0 list
main_foldl = foldl (\x y -> x + y) 0 list
main_foldl_ = foldl_ (\x y -> x + y) 0 list
(We don't allow '
as part of a variable name, as it's not really
necessary and is confusing for non-Haskeller beginners. An undercore
suffices.)
Now, looking in detail without the --concise
arg, just before the
recursion, we see the force of the addition:
case Cons 1 (Cons 2 Nil) of
Nil -> 0
Cons x xs ->
case (\x y -> x + y) 0 x of
!z_ -> foldl_ (\x y -> x + y) z_ xs
case (\x y -> x + y) 0 1 of
!z_ -> foldl_ (\x y -> x + y) z_ (Cons 2 Nil)
case (\y -> 0 + y) 1 of
!z_ -> foldl_ (\x y -> x + y) z_ (Cons 2 Nil)
case 0 + 1 of
!z_ -> foldl_ (\x y -> x + y) z_ (Cons 2 Nil)
case 1 of
!z_ -> foldl_ (\x y -> x + y) z_ (Cons 2 Nil)
foldl_ (\x y -> x + y) 1 (Cons 2 Nil)
And finally, taking a glance with --concise
, we see:
$ duet run examples/folds-strictness.hs --main main_foldl_ --concise
foldl_ (\x y -> x + y) 0 list
foldl_ (\x y -> x + y) 1 (Cons 2 (Cons 3 (Cons 4 Nil)))
foldl_ (\x y -> x + y) 3 (Cons 3 (Cons 4 Nil))
foldl_ (\x y -> x + y) 6 (Cons 4 Nil)
foldl_ (\x y -> x + y) 10 Nil
10
Which spells out quite clearly that now we are: (1) doing direct
recursion, and (2) calculating the accumulator with each recursion
step (0
, 1
, 3
, 6
, 10
).
Concluding
This post serves as both knowledge sharing for our team and a public
post to show the kind of detailed level of training that we're doing
for our clients.
If you'd like Haskell training for your company,
contact us to arrange a meeting.
Want to read more about Haskell? Check out our blog and our Haskell
homepage.
Subscribe to our blog via email
Email subscriptions come from our Atom feed and are handled by Blogtrottr. You will only receive notifications of blog posts, and can unsubscribe any time.
Do you like this blog post and need help with Next Generation Software Engineering, Platform Engineering or Blockchain & Smart Contracts? Contact us.