Everyone makes mistakes. One of the reasons I tend to like languages like Haskell is that they help me catch more of my mistakes up front, at compile time. This can be frustrating, but it also gives me a somewhat greater sense of security when my code compiles correctly. Though it’s not a total guarantee, it’s a decent indicator that I haven’t made one of at least a restricted class of possible mistakes.
A friend who’s currently learning Haskell asked me about the ways using it might help us avoid errors that we see in other languages. This post is an attempt to clarify some of the main ways we can use the features available to us (especially the type system) to cut back on bugs.
This post is aimed at people somewhat new to learning Haskell. They probably know about pattern matching, pure functions, strong static types, and what laziness is. They might kinda-sorta know about functors, applicatives, and monads but don’t have much experience with them in practice (and I won’t be using any of those words again in this post).
One note about the examples given: it’s hard to present non-trivial examples in a blog post. It’s also hard to really see the value of certain kinds of error reduction in a trivial example. I’ve tried to make sure these examples generalize fairly naturally, but they’re also all relatively short, so may fall prey to either of these issues.
There are some obvious ways Haskell reduces certain classes of errors, but they’re so common in most programmers’ experience that I’ll only mention them in passing here. This includes things like garbage collection, which helps us reduce memory-management-related errors. Unless you’re commonly working in C, C++, or similar, you’re probably used to this already. I’ve tried to make the majority of this post about what Haskell does differently from at least a sizeable chunk of mainstream languages.
Of course, although we might dream about it, we can’t eliminate bugs entirely. Nothing in the language itself stops you from writing this kind of code:
isThree :: Int -> Bool
4 = True
isThree = False isThree _
This would best be caught by tests or careful code review. The type system does stop us from making the following kind of mistake, though:
isThree :: Int -> Bool
"three" = True
isThree = False isThree _
Even without the type signature, the way we use a function elsewhere can cause the compiler to reject bad code, thanks to type inference
goodOrBad :: Int -> String
= if isThree (sum [1..n]) then "good" else "bad"
goodOrBad n
"three" = True
isThree = False isThree _
We can eliminate another set of possible typos (and therefore
possible lurking bugs) by moving from String
to a specific
type. We’ve gotten rid of the possibility that "Bad"
and
"bad"
were used interchangeably because the programmer
wasn’t sure about the convention. We’ve also changed the type of
goodOrBad
to better let other functions know about what’s
going on in it.
data Quality = Good | Bad
goodOrBad :: Int -> Quality
= if isThree (sum [1..n]) then Good else Bad goodOrBad n
Note that Quality
is isomorphic to Bool
.
That is, it has the same “shape”, just different names.
data Quality = Good | Bad
data Bool = True | False
In many other languages, because creating new types is somewhat
difficult, there would be a strong temptation here to use
Bool
instead of something more accurate like
Quality
. However, if we do this we’re losing some
information (at the level of the programmer, not at the level of the
type system). If a function we have takes a Quality
, we
know something more about it than if it takes a Bool
. For
this purpose, using newtype
is often the way to go. It lets
us add information to an underlying type, with zero runtime
cost.
newtype Quality = Quality Bool
goodOrBad :: Int -> Quality
=
goodOrBad n if isThree (sum [1..10]) then Quality True else Quality False
Things we encode in the type system are often safer (meaning more
error-free) than conventions, or things programmers are supposed to
remember. At least there’s some process checking for mistakes
(typechecking during compilation). We want to offload as much boring
bookkeeping to the computer as possible, so we can think about the
harder parts of the problem undisturbed by having to remember whether
it’s spelled "Good"
or "good"
.
The above reduces certain kinds of errors we see in dynamically-typed
languages (JavaScript’s {} + [] === 0
comes to mind), but
there are definitely many mainstream languages with static types that
avoid these pitfalls. Where does Haskell differ from those
languages?
One answer is that Haskell eliminates side effects. If you have a
function f :: Int -> Int
, there is no way for it to
print to the screen, or write to a database, or start a global
thermonuclear war. All it can do is transform an Int
into
another Int
. This is a guarantee not often seen, even in
many statically typed languages.
This is also where the idea that Haskell is useless comes from. The worst version of this is when somebody says that you can’t do anything at all with Haskell, because it has no side effects! You might think this doesn’t really happen, but when I was in school the professor of my “Advanced Object-Oriented Programming” class was asked what he thought about pure functional programming, and he said something like “I don’t see how it could be useful, since the end goal of any program is to have an effect on the world”.
Clearly, Haskell is able to have effects in the real world (or it would indeed be useless). For example, this blog post was served to you by a Haskell program. The HTML in the post was generated from a different format by using another Haskell program.
The key here is that although Haskell does have effects, those effects are limited. Just as types limit the possible values that a term in our program can take on, purity limits the kinds of effects that a function in our program can have.
Every function starts with no effects. The way to introduce effects
into a program that you’ve probably heard the most about is using
IO
. The main
function has type
IO ()
, which indicates to us that main
can
have input and output effects.
There are other kinds of effects we can add to our functions though.
For example, State
is a way to hold onto some stateful
value, and transform that state in various ways. If we add the effects
of Writer
, we can do things like maintain a persistent log
that we write to throughout the function. Adding Reader
to
the mix lets us have an environment that we can read from. In fact,
these three are so commonly used together that there’s something called
RWS
which combines the effects of reading, writing, and
state.
Because we might want more than one type of effect per function, we
want some way to mix and match the effects that we need in each
function. What if we don’t need to read anything, but we want both
writing and state? We can combine Writer
and
State
into a new thing (call it WS
) and use
them together. In this way we can select exactly the effects that a
function is able to have, and no more. If we see a function
f :: Int -> WS Int
, we know that f
transforms an Int
, and that it can write or use state.
What’s more valuable for error reduction is what we know
f
can’t do. We know it can’t read. We know it
can’t perform arbitrary IO
. It can only perform the effects
that it has in its type signature, in stark contrast to most languages
where you can perform any effect at any time!
Laziness is a surprising feature to include on a list of “things that reduce bug rates”, but it does have a few properties that help us do so. Imagine something like this:
=
complicatedFunc x error "the complicated function ran into an error"
= const 0 (complicatedFunc x) f x
Because of laziness, f
never even needs
complicatedFunc
to run before it returns the result 0. If
there’s some complex logic that can produce bad results like this,
laziness can skip right past it by simply not doing any more work than
it has to. Laziness ensures that we only do work that’s needed later on,
which helps us not only with errors but with performance. (Imagine
complicatedFunc
just took a really long time rather than
erroring out.) It’s a small gain, but it’s helpful nonetheless.
Let’s examine this type for a binary tree:
data Tree a = Leaf | Node a (Tree a) (Tree a)
This is “correct by construction”. There is no way to create an
object of type Tree
that is an invalid binary tree.
Contrast this with creating trees via pointer manipulation, or with
creating trees in a dynamic environment where we can set one node to
7
and another node to ["hello", "goodbye"]
,
and another node to point to itself.
There are many types that provide similar kinds of properties. For
example, NonEmpty
is a list that is guaranteed to always
have at least one element.
This leads me to a small point about library quality. Any language with high-quality libraries can easily provide correct trees, or correct nonempty lists. Haskell’s advantage is when you want to create your own types, and you want to reason about them. Because of various features of how the language works, it’s a lot easier to be safe not just with libraries that have been looked over by hundreds of people, but also with code that maybe only a couple people will ever see.
The final piece I want to say in this post is about types like
Maybe
or Either
. If we see the type
Maybe
, we know we either have a Just
value, or
we have the special indicator Nothing
. In this way,
Maybe
is sort of like having nullable values, but it
doesn’t infect every object like null pointers do! While it’s true that
Nothing
is like a better replacement for null
,
the real power of Maybe
comes from its ability to fade into
the background.
Consider the following few “safe” functions:
= Nothing
maybeHead [] :_) = Just x
maybeHead (x
0 = Nothing
maybeDiv a = Just (a `div` b)
maybeDiv a b
lookup key [] = Nothing
lookup key ((k, v):xs) =
if k == key then Just v else lookup key xs
When used individually, they’re nice because they give us a typesafe
result every time. Imagine if lookup
threw an exception
every time it failed to find the thing you were looking for! Instead, we
just get back the encoded value that it wasn’t found.
lookup 3 [(x, x*2) | x <- [1..5]]
Just 6
lookup 9 [(x, x*2) | x <- [1..5]]
Nothing
However, when used together in a block of do notation, these
functions take on an even nicer character. You know the callback hell
you run into when you have to do one thing, then another, then another
in JavaScript (later fixed by async/await)? Do notation is sort of the
fix for callback hell in Haskell, except it’s more general. It correctly
handles what happens when we get a Nothing
result from any
piece of our computation.
testMaybe :: Int -> Int -> [Int] -> Maybe Int
= do
testMaybe a b c <- lookup a [1..b]
found <- maybeDiv found 2
divided head <- maybeHead c
return (divided + head)
If any piece of testMaybe
fails, the whole thing returns
a Nothing
. It doesn’t try to compute using a bad value, it
essentially just skips over remaining computations. If the whole thing
succeeds, it returns a Just x
where x is the result of all
the computation.
The reason this can be seen as an error reduction strategy is that it
massively reduces the boilerplate a programmer might otherwise have to
write. If you make one mistake in handling any of the individual
Nothing
values, then the whole thing could be operating
under erroneous assumptions. Because of the way Maybe
works, do notation is able to handle all that logic for us “under the
hood”. It’s like a fairy godmother came down from the sky and, at the
end of each line, did a little work for us to make sure that our
function would operate properly.
A general takeaway here is that automatic processes and restrictions
help us eliminate errors. The type system checks for us, automatically,
that we haven’t done something like add a string to a number. Because
the default is “no effects”, when we have effects in a function, someone
had to put them in on purpose—the automatic default is “this function
has no side effects”. Laziness automatically keeps us from doing
unnecessary work. If our types are correct by construction, we are
automatically kept from creating bad values of those types.
Maybe
’s interaction with do notation automatically takes
care of Nothing
s, keeping us from accidentally doing the
wrong thing with them.
I think all of this is just fantastic. The more that can be automatically handled by the computer, and the less that I have to manually handle (and make mistakes with), the better! The ideal programming language would be one where I state what I want in a few English sentences, and the computer automatically interprets it the way I meant, without any errors. Until that day comes, I’ll prefer languages that do more of the error-checking and bookkeeping work for me, so I can focus on what’s interesting.