For the last couple of months, I’ve been working on-and-off in Haskell for my programming languages class. I’ve written previously about my preference for declarative functional syntax, but I’d never really spent the time to learn a pure Functional Programming™️ language.
tl;dr: I’m glad I learned Haskell. It’s great at expressing some programs that are difficult (or practically impossible) to express in other languages. However, this necessarily makes other types of programs difficult to write in Haskell.
Keep in mind that I’ve learned just enough Haskell to “be dangerous” (both in the sense of being comfortable enough to write moderately complex programs and in the sense that I’m naive enough to be potentially misguided à la Dunning-Kruger). Take this with a grain of salt.
The Entry Point: Higher-Order Functions
I was thinking about the first time I encountered anything that looked remotely
like Functional Programming. I think that first interaction was probably when I
learned Javascript’s Array.{map, reduce, filter}
. These higher-order
functions, so-called because they take a function as an argument, make you think
about the execution of a program in a radically different way.
Internalizing the concept of a higher-order function requires a similar type of mental leap as that of learning recursion. If you follow the “traditional” imperative path of learning to program, recursion is a common stumbling block because it forces you to think about functions not merely as procedures, but as abstracted procedures that can be called within themselves. Higher-order functions impose a similar stumbling block: you have to think of functions as data. Just as you manipulate and pass variables, you can combine and pass functions.
Once comfortable with map
, reduce
, filter
, and the like, you can express a
crazy amount of functionality using HOFs. Some of my favorite parts of Ruby and
Rust are in its support for just this small set of HOFs.
Haskell is not Array.map
, but “more”
From the above section, you can see that my initial mental model of functional programming was fairly narrow. It’s that thing that allows you to move around functions like you would variables… Well, kinda. Of course, thoughts about functional programming also elicited the obligatory thoughts of “there are no side effects” and “everything is a function”
Haskell does make generous use of HOFs. It uses a lot of functions (though
not everything is a function)
and, indeed, it’s pretty difficult to have unintentional side-effects in Haskell
programs. But, to sell Haskell as “what if we took Array.map
and made a
language around that” would be missing the point.
I’d argue that what makes Haskell unique and useful is an altogether different set of features:
- Pattern Matching
- Immutability
- Algebraic Data Types
These three features alone (as well as some necessaries like functional composition) already make a compelling case for giving Haskell a try.
Pattern Matching
First-class pattern matching might be my favorite thing in Haskell. Take the following example:
mySearch :: Eq t => [t] -> t -> Bool
mySearch (x:xs) target = (x == target) || mySearch xs target
mySearch [] _ = False
>>> mySearch [1,2,3] 2
True
>>> mySearch [1,2,3] 4
False
>>> mySearch [] 10
False
mySearch
checks if an element (target
) is present in the list (which is the
first argument). The first line pattern matches on a non-empty list. If the list
is non-empty, x
is the first element in the list, and xs
is the rest of the
list. If the list is empty, the second case will be used ([]
pattern matches
on the empty list), and we return False
.
Pattern matching on lists is awesome in itself. You can also pattern match on specific values and on algebraic data types.
isMeaningOfLife :: Maybe Int -> Bool
isMeaningOfLife (Just 42) = True
isMeaningOfLife _ = False
>>> isMeaningOfLife Nothing
False
>>> isMeaningOfLife (Just 42)
True
This, of course, just scratches the surface of what’s possible. You can pattern match each variable independently and have tons of different cases for a given function. This becomes super powerful in more complicated programs and is a good shorthand for decomposing complicated functions into their simpler cases. (Many recursive functions written in Haskell feel like writing mathematical proofs; “there are 3 cases to consider…”)
My pet theory on why pattern matching is so great in Haskell is that for purely functional programs, a function’s output is much more tightly bound to its input. As side-effects are prohibited, the only way a function can actually do anything is by returning something. Similarly, functional programming is syntactically more declarative. Pattern matching simply makes it a lot easier to write declarative pure functions. Patterns allow you to describe behaviors of a function under different forms of input without much syntactic boilerplate.
Immutability
Haskell expressions are immutable. Once you construct a piece of data, it cannot be changed. To change data (i.e. appending to a list, adding a key to a dictionary, etc.) a new data structure is created. This prevents large classes of bugs. You never have to care about some other thread or function changing the internal state of some data structure because, well, they can’t.
Other language communities have picked up on immutability too, either as libraries (notably Google’s Guava for Java and immutable-js for Javascript) or as language defaults (like in Rust).
Immutability can be a real pain to deal with. (i.e. “I just want to do in-place array operations!”) However, I think Haskell mostly gets away with it because it uses immutability everywhere. When mutating internal state isn’t an option, it becomes less of a temptation — you start to think in ways that don’t necessitate mutability.
Algebraic Data Types
“Algebraic Data Type” is one of those scary-sounding phrases that end up being pretty harmless when explained.
Put simply, there are product types (which are analogous to tuples or structs)
and sum types (which are kinda like enum
s with an associated value.
-- Example Product Type
data Point = Point Int Int
-- Example Sum Type
data MyMaybe a = Nothing
| Just a
The product type Point
is a type that contains 2 integers. For example, we
could represent the origin as (Point 0 0)
.
Sum types are like enums, in that there is a set of valid symbolic forms for the
given type. MyMaybe
is parametrized over type a
(like List<T>
in an OOP
language). It can have the form Nothing
or Just a
. This is a useful way to
describe optional values. For example, if we wanted to describe an optional
integer, we could have a function that returns Just 42
if the operation is
successful and Nothing
otherwise.
Sum types are especially powerful, as they can easily be used to represent syntax trees:
data Exp = IntExp Int
| PlusExp Expression Expression
| IfExp Exp Exp Exp
| GreaterThanExp Exp Exp
>>> parse "if 1 > 0 then 2 else 3"
(IfExp (GreaterThanExp (IntExp 1) (IntExp 0)) (IntExp 2) (IntExp 3))
Note that sum types can be recursive. In PlusExp
there are 2 subexpressions,
each of which have their own internal syntax tree.
Recall that we can pattern match on algebraic data types, which means that if we wanted to write an evaluator for a syntax tree like the one presented above, we could do so pretty easily:
eval :: Expression -> Int
eval (IntExp i) = i
eval (PlusExp e1 e2) = v1 + v2 where
e1 = eval e1
e2 = eval e2
…
I really enjoy using ADTs (especially in combination with pattern matching) in Haskell. They’re a surprisingly good way of representing certain types of data, and it allows the type-checker to do a lot of reasoning on your behalf about the correctness of programs.
I’m encouraged by Rust’s typed enums and Swift’s associated value enums, both of which give similar functionality in a non-functional setting.
<*>
, <$>
, >>=
, Oh My!
So with all that gushing about Haskell out of the way, I should mention some areas that frustrated me.
Haskell syntax is definitively not C-derived, so there’s a bit of a burden to relearn basic syntax. However, the real weirdness comes in when you start going a bit deeper into Haskell’s language features.
The basics of Haskell’s type system will be familiar for programmers of other languages. Functions take typed inputs and produce typed outputs. However, there are also type classes, which are kinda like protocols or traits for types.
Common type classes include
Functor/Applicative,
Foldable, and
Monad. As these type classes are used so often
(and have associated operations that are used frequently), Haskell has a number
of syntactic operators that allow you to invoke type class functions easily.
<$>
is identical to Functor’s fmap
, >>=
is Monad’s bind
. There are a
bunch more operators,
including a few variations of each of the ones I’ve mentioned so far. The result
can be a bit of symbol soup, which is difficult to read at a glance.
I mean, there’s the very real question (if posed somewhat in jest) of
“how do you pronounce <*>
?”
To its credit, Haskell has a really excellent REPL, and you can ask it the type signature of an operator when you get confused.
>>> :t (>>=)
(>>=) :: Monad m => m a -> (a -> m b) -> m b
There’s also the concern that some types of programs just don’t lend themselves to being written in a functional way. For anything involving super heavy system programming, file IO, or GUI interactions, I don’t think I’d want to use Haskell.
I was also a bit surprised about the story for
debugging in Haskell. It’s… not amazing
(in my opinion). You can still do printf
debugging with
Debug.Trace
,
but putting trace
as a shim in between function calls feels more obtrusive
than a printf
statement. To its credit, Haskell’s REPL is great, so you can
debug smaller components there easily. GHCI (Haskell’s REPL) also has breakpoint
support. Safe to say though, just as you’ll need to relearn parts of programming
when picking up FP, you also need to relearn parts of debugging.
Aside: Category/Type Theory
As an aside, one can’t help but notice that the average Haskell programmer seems to be more interested in Category and/or Type theory (also formal methods) than, say, the average Python developer.
Type theory has always been something that I “feel” like I should be interested in, but more from an intellectual perspective than for any practical reason.
At the risk of sounding dismissive, I think you can probably ignore most of the discussions about “a monad being just a monoid in the category of endofunctors” and still get along just fine in Haskell.
Conclusions
logether, I find the programming experience in Haskell to be really enjoyable. When working in a domain well-suited to Haskell’s strengths, I find it pretty easy to get into a flow state. I don’t feel like I’m fighting the type-checker; rather, it feels like it’s keeping me honest.
I’m sure this doesn’t generalize, but when working in Haskell I get the feeling that if my code passes the type-checker, I’m reasonably confident that I’ve written a “correct” program. Many people also feel this about Rust’s type-checker. It’s a refreshing feeling. When working in Go or Python (or Javascript, for that matter), writing code feels like just the first half of the battle — you need to exercise it at runtime to build confidence in its correctness. Of course, semantic bugs are easy to commit in Haskell. But, just as a baseline, I feel more confident in “onced-over” Haskell code than I would code written in other languages.
I’m glad I took the time to learn Haskell. It comes at writing programs from a very different perspective than languages I’ve used before, and it’s made me more attuned to aspects of type systems that aren’t as easily explored in non-functional languages.