I was recently asked to explain why I felt disappointed by Haskell, as a language. And, well. Crucified for crucified, I might as well criticise Haskell publicly.
First though, I need to make it explicit that I claim no particular skill with the language - I will in fact vehemently (and convincingly!) argue that I'm a terrible Haskell programmer. And what I'm about to explain is not meant as The Truth, but my current understanding, potentially flawed, incomplete, or flat out incorrect. I welcome any attempt at proving me wrong, because when I dislike something that so many clever people worship, it's usually because I missed an important detail.
Another important point is that this is not meant to convey the idea that Haskell is a bad language. I do feel, however, that the vocal, and sometimes aggressive, reverence in which it's held might lead people to have unreasonable expectations. It certainly was my case, and the reason I'm writing this.
I love the concept of type classes. I gave talks about type classes on multiple occasions, and encourage everyone I work with to use them more. But I don't like Haskell's.
The main reason for that dislike is simple: the community, language, toolchain... all try to convince you that orphan instances are evil. The one person who doesn't seem to hate them is Simon Peyton Jones, magnificent man that he is.
Imagine a project which must process documents of some kind. It could, say, count the number of words in these documents. The actual processing is irrelevant to my point.
This project must have two user interfaces: a CLI, and a web API.
I tend to structure such projects intuitively:
- one
core
module with all the business types and functions - one
cli
executable for the CLI specific part, with a dependency oncore
- one
api
executable for the web API, with a dependency oncore
The problem arises when I decide to represent Document
as JSON. I need to provide the correct aeson
instances for Document
- but these would be orphaned instances, and orphaned instances are bad.
What are my options, then?
- newtype
Document
in theapi
module - boilerplate galore for something that is arguably just a matter of principle - declare the instances in the
core
module - bringing the entirely unrelatedaeson
dependency in thecli
module
This is not a huge problem, but it's frustrating.
One of the main arguments for type classes, and their superiority to inheritance, is that you can add new behaviours to data types you don't own. But isn't that a bit of a lie, if you disallow orphaned instances?
You can add an instance of a type class you own to a type you don't, or of a type class you don't own to a type you do. And that's it. A truer argument would be that you can add behaviours to types you only partially own, but that sounds far less exciting.
The thing is though, given this constraint, type classes are not much better than nominal subtyping (when it comes to adding behaviours to types). If I can't modify Document
and want to give it an instance of, say, Show
, I must create a new type that wraps a value of type Document
(albeit at no runtime cost). Just like you would if you wanted to make an existing type Comparable
- create a wrapper that extends Comparable
.
And, of course, when you newtype Document
, you lose of all its other instances. And yes, I'm aware that Haskell has very clever, very well designed mechanisms to re-create these instances at very little human cost - but then, what's to prevent Java 28 from defining an annotation of some sort to automatically extend the wrapped type's superclasses, and proxy all calls to the wrapped value? Wouldn't that make inheritance just as flexible as Haskell's type classes?
And yes, I'm aware that type classes have other advantages - implicit composition, for instance. But I can't help but feel that the usual Type classes allow you to add behaviours to types you don't own spiel is a bit of a fib, if you're not going to allow orphan instances.
Yes, I understand that global coherence is nice. Yes, orphaned instances make it much harder to guarantee global coherence.
But I feel local coherence is not worse than global coherence, provided it's well enforced.
The usual example against locally coherent type class instances is sets implemented as binary sorted trees. How do you merge two sets when you can't know if they're using the same notion of ordering? Global coherence sorts that out - you can't possibly have different notions, because you can't possibly have more than one type class intance for that type in the entire system.
It seems to me that, in some languages (by which I mean Scala), that notion of ordering is materialised as a value. And, in languages supporting the popular definition of dependent types (by which I mean, not Scala), you could bring that value to the type level - naively, and without any real skill in that domain, I feel that if you can have the compiler refuse to zip two lists with different lengths, you should be able to have it refuse two sets with different orderings.
I don't know of a language that does that. Maybe it doesn't exist. Maybe it's a terrible idea. Maybe there are very good reasons for this to be silly. I just haven't found one yet.
Haskell has exceptions. Unchecked exceptions. And people feel it's a good thing. I don't even.
One of the main Haskell selling points (and rightly so!) is that its powerful type system allows you to write code that, if it compiles, works. And yet, unchecked exceptions make it impossible for the compiler to guarantee that you've dealt with all possible error cases - it'll cheerfully accept code that will crash, and let you find out about your mistakes at runtime. That's exactly what I'd like a type system to not do.
I honestly don't understand how you can both state that Haskell code, when it compiles, just works and Unchecked decisions are a very good point in the error handling design space. The two seem at odds to me.
I've tried to read arguments justifying the existence of exceptions in Haskell. I've asked people that are actually proficient with the language. All I've got so far is it's no worse than Java! which, granted, but that's not exactly how Haskell is sold, is it?
As a small aside, I'm perfectly happy with exceptions for exceptional circumstances - cpu not found, oom... On the other hand, Invalid credentials, connection loss, invalid request... are not exceptions. Those are regular, normal circumstances that must be dealt with before a program is considered complete. And yet a lot of libraries I've tried deal with them as exceptions, expecting me to somehow know all the error case I must deal with and how they've been modeled.
Ah, yes. This is what will get people really riled up. I think Haskell's fetishism of monads is unhealthy. I don't like monads. The fact that different monads don't compose unless you know exactly what monads they are and have written or obtained the glue code is, to me, proof that we have better abstractions to discover.
I know I'll get shot for this, but I think mtl
(what little I understand of it) is bad. Well. It's a really good implementation of a bad solution. The fact that it's essentially boilerplate central, writing all that mind numbing code for all possible combinations of monads so that users don't have to, is to me a very strong hint that something's not quite right.
See for yourself:
instance MonadError e m => MonadError e (IdentityT m) where
throwError = lift . throwError
catchError = Identity.liftCatch catchError
instance MonadError e m => MonadError e (ListT m) where
throwError = lift . throwError
catchError = List.liftCatch catchError
instance MonadError e m => MonadError e (MaybeT m) where
throwError = lift . throwError
catchError = Maybe.liftCatch catchError
instance MonadError e m => MonadError e (ReaderT r m) where
throwError = lift . throwError
catchError = Reader.liftCatch catchError
instance (Monoid w, MonadError e m) => MonadError e (LazyRWS.RWST r w s m) where
throwError = lift . throwError
catchError = LazyRWS.liftCatch catchError
instance (Monoid w, MonadError e m) => MonadError e (StrictRWS.RWST r w s m) where
throwError = lift . throwError
catchError = StrictRWS.liftCatch catchError
instance MonadError e m => MonadError e (LazyState.StateT s m) where
throwError = lift . throwError
catchError = LazyState.liftCatch catchError
instance MonadError e m => MonadError e (StrictState.StateT s m) where
throwError = lift . throwError
catchError = StrictState.liftCatch catchError
instance (Monoid w, MonadError e m) => MonadError e (LazyWriter.WriterT w m) where
throwError = lift . throwError
catchError = LazyWriter.liftCatch catchError
instance (Monoid w, MonadError e m) => MonadError e (StrictWriter.WriterT w m) where
throwError = lift . throwError
catchError = StrictWriter.liftCatch catchError
If a Java library were to copy / paste quite that much code, just changing the types and a few different details here and there, it'd be mocked relentlessly. But since it's Haskell, and those are monads, then clearly it must be good. To me, however, this looks a lot like the 27 implementations golang of a linked list I had to write because of lack of parametric polymorphism. There might be a subtle distinction, I've just not yet seen it.
What little I understand of algebraic effects make me feel that they're a much better solution to most (all?) the problems that monads try to solve. And yes, they're hard to add support for, but clearly impossible - it has been done.
I mostly agree: the majority of your criticism is spot on. Since I agree on most points, please allow me to bring up a few where I differ:
Global vs Local coherence
In a turing complete language, you can't, in general, check equality of two arbitrary functions. In that sense, global coherence is necessary. This sounds like a problem unique to Haskell (since other languages allow values to store their own individual dictionaries--i am of course reffering to "objects"), but I think of it as a solution unique to Haskell. You have the exact same problem of dictionary coherence in any language that allows locally overriding the dictionary (whether through OOP inheritance or Scala implicits), so Haskell simply disallows local overrides.
Monads
I don't know anything about algebraic effects, so maybe they solve everything I'm about to bring up, but I doubt it. When I talk to someone who doesn't like monads, I like to dig a little deeper. Does this person think referential transparency is important? I tend to find that dislike of monads typically goes with a general feeling that referential transparency is not all that important. And that's fine. If someone doesn't really care about referential transparency, then they will never see the point of monads and think it's just math fetishism.
(Correct me if wrong) Haskell and its followers (Idris, Elm, Purescript, Eta, etc) are the only general-purpose languages that are meant to be referentially transparent as a design goal (modulo escape hatches like
unsafeInterleaveIO
). They do this by encoding as first class values what in other languages would be statements and side-effects. People tend to say Haskell decorates side-effects with the typeIO
, but this is incorrect. Haskell has no observable side effects: it's referentially transparent. ThatIO ()
you have there is a first-class value. The encoding is necessarily a monad in a deep and essential way. Noticing that fact, and exploiting it, is what makes the API not completely unbearable to use. Contrast that with another monad: async style in early node.js. The API is terrible and requires you to have global variables and mutate them, simply because nobody realize that async style is a monad.What I find interesting is that whether or not some construct is a monad is not a design choice. An encoding or pattern either is or isn't a monad: the programer doesn't get to choose (http://mail.openjdk.java.net/pipermail/lambda-dev/2013-February/008314.html). Like it or not, monads are here to stay, and your language is full of them. I like it, as identifying and abstracting the pattern means you get to avoid writing 27 identical implementations of
when
.The criticisms of MTL are completely fair, and part of the reason is that the set of abstractions they chose are not necessarily the right fit for every application. But the implicit composition via type class constraints is rather handy. As such, here are my thoughts: if you can, write everything in concrete
IO
; if that doesn't cut it for you, come up with some focussed I/O monad type classes tailored for your application; write your whole app using those classes; inMain
write a bunch of (orphan) instances of your type classes for the concreteIO
type; that's it. If using type classes in such a way is distasteful to your coworkers, Matt Parsons describes a similar architecture using anewtype
directly above your finalMain
(https://www.parsonsmatt.org/2018/03/22/three_layer_haskell_cake.html), and Fommil describes a similar architecture using implicit parameters (https://discourse.haskell.org/t/records-of-functions-and-implicit-parameters/747).Haskell is a Trailblazer
Haskell is indeed a trailblazer, but it's not the pinnacle of language design (if there is such a thing). It's not the best possible language simply because it is a trailblazer, and to be both requires an insane amount of luck. What do I mean? Take, for example, non-strict semantics. Haskell was not the first language to have such a paradigm-shifting feature. Haskell benefited from lessons learned by other trailblazers, such as Hope and Miranda, and a large body of academic work. But when I say Haskell itself is a trailblazer, I'm talking about the novel languages features introduced by Haskell, and two in particular: (1) uncompromising referential transparency, and (2) type classes. Both are genuinely new features, and both, despite being worth it, have induced a lot of growing pain.
I'd like to say that most of the growing pains with Feature (1) were solved by providing a monadic interface for reified I/O. (Haskell didn't always have the
IO
type or theMonad
class, and--yes--it could still do I/O, but--yes--it was a clusterfuck. Writing a program in Haskell was like writing a program in Assembly: it was awful.) Feature (2) has required a lot more time and consideration to iron out the ergonomics, and work is still going on today. I don't think that means type classes are bad, they're still worth the bad ergonomics, but they can probably be made better. Cut the Haskell team a little bit of slack, as it's the first language to have such a feature.Along those lines, I can't really say that Haskell is the ultimate programming language: it's more like the prototype. The ultimate programming language will be lazy, will be referentially transparent, and will have something very much like type classes with global coherence, though the ergonomics will be significantly improved. It'll also have other features that I've never thought of, but it won't be the trailblazer for those features. It'll have to benefit from the lessons learned by others.