Way back, about three years ago, I started writing a Haskell tutorial as a series of posts on this blog. After getting to monads, I moved on to other things. But based on some recent philosophizing, I think I’m going to come back to it. I’ll start by explaining why, and then over the next few days, I’ll re-run revised versions of old tutorial posts, and then start new material dealing with the more advanced topics that I didn’t get to before.
To start with, why am I coming back to Haskell? What changed since the last time I wrote about it?
I last wrote about Haskell three years ago, when I was still working for IBM. In the time since then, I’ve been working for Google. It’s been a very enlightening couple of years. Instead of working on isolated research code-bases, I’m working in a truly massive code-base. I’ve regularly written code that will be read by at least dozens of other engineers, and I regularly read code written by hundreds of other people.
At Google, we generally program in three languages: C++, Java, and Python. None of them are functional languages: are all state-heavy, imperative, object-oriented languages. But the more I’ve read and written code in this code-base, the more I’ve found that functional code is the best way of building large things. When I look at a piece of code, if the code is basically functional, I’ve found that it’s much easier to understand, much easier to test, and much less likely to produce painful bugs. It’s gotten to the point where when I see code that isn’t functional, I cringe a little. Almost everything that I write ends up being at least mostly functional – the places where I use non-functional code, it’s because the language and compiler aren’t up to the task of keeping the code efficient.
Writing functional code in non-functional languages is, obviously, possible. I do it pretty much every day. But it’s not easy. And it’s far less clear than it would be in a real proper functional language. As I said above, I sometimes need to compromise for efficiency; and sometimes, the language just isn’t expressive in the right way to let me do things in the way that a functional programmer really would.
Back when I started the original Haskell tutorial, I was rather skeptical about Haskell. Functional languages have not, traditionally, been used for large, complex systems. There were lots of claims about functional systems, but no real strong evidence for those claims.
My experiences over the last few years have convinced me that the functional approach is, really, the correct one. But why Haskell? As I’ve mentioned before, I’m an obsessive programming language guy. I know way the hell too many programming languages. In the functional realm, I’ve learned not just Haskell – but the strict typed family (like SML and OCaml); the Lisp family (CommonLisp, Scheme), other lazy languages (Clean, Miranda, Hope), and hybrid functional languages (Erlang, Clojure). And in all of those languages, I haven’t seen any that were both as clear as Haskell, and as good at managing complexity. I’m really convinced that for an awful lot of complex applications, Haskell is really right.
This is, in many ways, a direct contradiction of what I said when introducing Haskell the first time around. Back then, I said that the fact that Haskell was referentially transparent wasn’t important. Referential transparency is another way of saying that the language is mathematically functional: that every expression in the language is a function from its inputs to its outputs, with no hidden parameters, no hidden mutable state that can change the result of a call.
At the time, I said that I thought that the most common argument in favor of referential transparency was silly. You see, people talk about referential transparency being good because it allows you to do formal reasoning about programs. It’s close to impossible to reason about programs in languages like C++, where you’ve got things like mutable pointers to functions that contain implicit, persistent mutable state. But a lazy functional language like Haskell, you can reason about. At the time, my argument was that people don’t really reason about real, non-trivial programs, and that real complex systems would still be impossible to reason about if they were written in Haskell.
I was wrong. Since then, I’ve done rather a lot of reasoning about programs. Sometimes that’s been in the context of dealing with concurrency, when I’ve got a strange, intermittent bug which I can’t reliably reproduce, and so formally reasoning about the possible behaviors of the system was the only way to figure out what was going on. Other times, I’ve been working on things that are just too expensive to debug – once they’ve shown that they can fail, you can’t deploy test runs on a thousand machines to see if, maybe, you can reproduce the problem and generate a useful stack trace. Even if the cost of deploying a known buggy program weren’t too expensive, sorting through stacks from a thousand machines to figure out what was going on isn’t feasible. So I’ve wound up coming back to formal reasoning.
You can do formal reasoning about programs written in non-functional languages. But you’ve got to start by making assumptions – and if those assumptions are wrong, you end up wasting a huge amount of time. The style of the program has a huge impact on that: in general, the more functional the programming style, the easier it is to work out a valid set of assumptions to allow you to analyze the program. But no matter what, if the language itself is hostile to that kind of reasoning, you’re going to have a much harder time of it than if you were using a language that was designed for reasoning.
Languages like Haskell, which have referential transparency, were designed for being analyzable and reasonable. What referential transparency does is buy you the ability to make very strong basic assertions about your system: assertions, not assumptions. In a functional language, you know that certain axioms are true. For example, you know that no one could have spawned a thread in the wrong place, because you can only create a thread in a threading monad; code that doesn’t have access to that monad can’t acquire locks, send messages, or spawn threads. If you use something like software transactional memory, you know that no one could have accidentally mutated something outside of a transaction – because it’s impossible.
I’ve still got some qualms about Haskell. On one hand, it’s a very elegant language, and the functional nature of it makes it a beautiful glue language. Some of the most beautiful, clear, elegant code I’ve ever seen is written in Haskell – and that’s not because it was written by exceptional programmers, but because the nature of Haskell as a language can make things clearer than many other programming languages.
But it’s not all good. Haskell has some serious problems. In particular, it’s got two issues that worry me enough that I’m still a bit hesitant to recommend it for a lot of applications. Those two are what I call lazy confusion, and monad complexity.
By lazy confusion, I mean that it’s often extremely difficult to predict what’s going to happen in what order in a Haskell program. You can say what the result will be, but you can’t necessarily say what order the steps will happen in. That’s because Haskell uses lazy evaluation, which means that no computation in Haskell is really evaluated until its result is used. You can write Haskell programs that generate infinitely long lists – but it’s not a problem, because no element of the list is ever evaluated until you try to use it, and you’ll never use more that a finite number of elements. But lazy evaluation can be very confusing: even Haskell experts – even people who’ve implemented Haskell compilers! – sometimes have trouble predicting what code will be executed in what order. In order to figure out the computational complexity of algorithms or operations on data structures, people often wind up basically treating the program as if it were going to be evaluated eagerly – because analyzing the laziness is just too difficult. Laziness is not a bad thing; in fact, I’m pretty convinced that very frequently, it’s a good thing, which can make code much cleaner and clearer. But the difficulty of analyzing it is a major concern.
Monad complexity is a very different problem. In Haskell, most code is completely stateless. It’s a pure functional language, so most code can’t possibly have side effects. There’s no assignments, no I/O, nothing but pure functions in most Haskell code. But state is absolutely essential. To quote Simon Peyton-Jones, one of the designers of Haskell: “In the end, any program must manipulate state. A program that has no side effects whatsoever is a kindd of black box. All you can tell is that the box gets hotter.” The way that Haskell gets around that is with a very elegant concept called a monad. A monad is a construct in the program that allows you to create an element of state, and transparently pass it through a sequence of computations. This gives you functional semantics for a stateful computation, without having to write tons of code to pass the state around. So, for example, it lets you write code like:
>fancyHello :: IO () >fancyHello = > do > print "What is your name?" > x print (concat ["Hello ", x])
Great, huh? But there’s a problem: there is an object that conceptually contains the state being passed between the steps of the “do” construct.
The reason that that’s a problem is that there are multiple different monads, to represent different kinds of state. There are monads for mutable arrays – so that you can write efficient matrix code. There are monads for parsing, so that you can write beautiful parsers. There are monads for IO, so that you can interact with the outside world. There are monads for interacting with external libraries written in non-functional libraries. There are monads for building graphical UIs. But each of them has a packet of state that needs to be passed between the steps. So if you want to be able to do more than one monadic thing – like, say, write a program with a GUI that can also read and write files – you need to be able to combine monads. And the more monads you need to combine, the more complicated and confusing things can get.
I’ll come back to those two problems in more detail in both the revised tutorial posts, and the new posts that I’ll be writing.