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:
coremodule with all the business types and functions
cliexecutable for the CLI specific part, with a dependency on
apiexecutable for the web API, with a dependency on
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?
apimodule - boilerplate galore for something that is arguably just a matter of principle
- declare the instances in the
coremodule - bringing the entirely unrelated
aesondependency in the
This is not a huge problem, but it's frustrating.
Lies and damned lies
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
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.
Runtime type errors
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.