Skip to content

Instantly share code, notes, and snippets.

@nrinaudo
Last active November 9, 2023 09:32
Show Gist options
  • Star 49 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save nrinaudo/b02d0d17f62b6babea60cb0b52ded287 to your computer and use it in GitHub Desktop.
Save nrinaudo/b02d0d17f62b6babea60cb0b52ded287 to your computer and use it in GitHub Desktop.

Introduction

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.

Type classes

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.

Superfluous dependencies

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 on core
  • one api executable for the web API, with a dependency on core

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?

  1. newtype Document in the api module - boilerplate galore for something that is arguably just a matter of principle
  2. declare the instances in the core module - bringing the entirely unrelated aeson dependency in the cli module

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 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.

Global coherence

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.

Monads

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.

@reverofevil
Copy link

As for Monads: they indeed don't companies very well. Try Applicative Functors or Freer Monads.

Or ditch category theory in favor of final tagless. Things that are very natural and basic in mathematic sense do not necessarily have a good interface or performant implementation.

@eric-corumdigital
Copy link

Type classes: I agree, type classes are not working well there; so do not use them that way. Not every package is great, or great for your use case.

Unchecked exceptions: I agree, programmers need to care more about the unhappy paths. Typically, when arguments boil down, what I find left is a simple unwillingness to think about errors.

Monads: mtl is not an ultimate conclusion of Monad. It is an example and it is opinionated. Monad does not attempt to solve anything, it just is. If there is a more suitable abstraction for your problem then use it instead. Consider Applicative, Category, Arrow, Functor and extensible free monads, or something entirely new of your own design.

The Haskell community is not a hive mind. People will disagree and that is okay.

@friedbrice
Copy link

friedbrice commented Sep 2, 2019

@jessechalken

"Haskell code, when it compiles, just works."

Ignore anyone who says this.

Martin Odersky has a version of this saying for Scala. He says, it's easy to write a program hundreds of lines long and find that it works as expected the first time you run it. I can, in fact corroborate his claim, but for programs into the thousands of line, not just hundreds. If you stick to writing referrentially-transparent Scala, then I see no reason why this should not scale to tens or hundreds of thousands of lines, since referential transparency makes it possible to completely encapsulates all program behavior, in turn making it possible to easily confidently compose programs.

To "ignore anyone who says this" would mean ignoring Martin Odersky :-)

@sjakobi
Copy link

sjakobi commented Sep 2, 2019

Thanks for your critique @nrinaudo! You might be interested in the discussion about your post at https://www.reddit.com/r/haskell/comments/cyfs94/someones_haskell_disappointment_gist_i_came_across/.

@jesseschalken
Copy link

jesseschalken commented Sep 3, 2019

@friedbrice "Haskell code, when it compiles, just works" means that the program specification is checked by the type system, which is something only achieved by a complete proof system (ie. not Haskell).

The fact that it is subjectively "easy" for a program to work the first time it compiles is irrelevant. It's also easy for a program to not work the first time it compiles by accidentally writing sin instead of cos, or + instead of - etc etc.

@friedbrice
Copy link

friedbrice commented Sep 3, 2019

@jessechalken

I know what a proof system is, and I didn't mean to start a flame war. You're coming down hard on Haskellers, and I just wanted to point out that (1) the sentiment is not unique to Haskell, you also find it from the creator of Scala, that (2) there's a grain of truth to the hyperbole [and yes, anyone who says this--ever--means it in hyperbole, not literally ;-) ], and that (3) the reason there's a grain of truth to it is because such code is written in a referentially transparent style.

No offense meant. And no hard feelings whatsoever :-)

Cheers :-)
Daniel

@ivanperez-keera
Copy link

ivanperez-keera commented Sep 3, 2019

I mainly agree with the fact that the points you are criticizing should be criticized.

Type classes and orphan instances: I use orphan instances. I take the warning as a warning. For separation of concerns, it's sometimes a good idea. For reference, the work described by a just recently released paper has many orphan instances: they types are described in graphics libraries, the classes in generic libraries, and the orphan instances in backend-specific libraries. The way that was built you should only ever have (link!) one instance of each class at any given point, but this is enforced by developers, not the language.

Runtime errors: I absolutely agree with you that this is bad. There is one solution that we could implement right now, but it would require indexed monads and potentially make error messages uglier (and I suspect, type inference, harder). I wrote about this for Fault Tolerant FRP in an ICFP paper in 2018, where the faults are explicitly listed in informative types (polymorphic dependent types that are the identity type function at value level) and set union is used to list all unhandled faults (read exceptions).

Monads: I agree with you. Monads and monad transfomers are by far not the end. We are learning a lot with Dunai, which, although incredibly fast and versatile FRP, is also sometimes harder than necessary due to frequent use of transformers. (We create higher level languages on top of it, alleviating the problem for our users, but, more generally, it would be best not to aim to "hide" transformers but embrace them.)

I think it's wonderful that you are raising these points. I would not think of them as catastrophic. We, as a community, have homework to do, and that's how we become better.

@yawaramin
Copy link

yawaramin commented Sep 4, 2019

Haskell's problems with orphan instances, and loss of global coherence, point to a more fundamental issue: lack of modularity of instances. I know I'm not saying anything new here, this is all well known. I'm just interested to hear what people are doing to more tightly control this modularity (I mean, short of defining newtypes all over the place).

Edit: great discussion here: https://pchiusano.github.io/2018-02-13/typeclasses.html

@rpeszek
Copy link

rpeszek commented Feb 20, 2021

I am clearly late to comment on this. I think this is somewhat different from other comments so I will still post it.
FYI, I am using Haskell professionally. I have used Java (you have mentioned it) professionally as well,

The main question that pops to my mind is this: So which language is better? I have not found one that is ready for industrial use.

Runtime Errors: The argument can be made that there are too many libraries on Hackage that abuse non-termination.
Good example is the dreaded decodeUtf8 which, IMO, just should not be there.
I would not criticize the language for this. To avoid untyped errors you would need dependent types or something equally powerful. Consider a / b computation where a and b are ints. Do you want a checked exception on division?
You can argue that Haskell's IO monad should explicitly type exceptions like IOError and I agree that it would be nice to see IOError's and such in type signatures. This is not how Haskell or similar languages have been designed.

Orphan Instances: People do use them. newtype does not cause "boilerplate galore", there is -XGeneralizedNewtypeDeriving which will create instances for you. In fact, automatic boiler plate code creation is where Haskell is simply second to none. You typically get these for free: Eq, Show, Functor, Functor, Foldable, Traversable, ToJSON, FromJSON and more.

Monads simply are. Criticizing the concept is like criticizing multiplication. It is a fair game, IMO, to criticize application of mathematical abstractions to certain types of computing, not the abstractions themselves. Such critique would be hard to do with Monads, they are really a very good polymorphic abstraction over a lot of different types of computations.

About transformers vs extensible effects (e.g. polysemy), this is not one vs the other. They work well in tandem.
This aside, learning transformers is really a great way to expand understanding of computations. You learn how computations compose as types. E.g. how (a -> _) composes with (a,_) and you start to see the properties of such compositions.
You can think about a monad as a mini-embedded language with protection of his boundaries. transformers explicitly compose such embedded mini-languages.

Since you have mentioned Java, I would challenge you to look at vavr which has to deal with a lot of duplicate code because it cannot express certain polymorphic expressions which are trivially implemented using the Monad (mapM, sequence, etc) and has to re-implement them for every type. About the code duplication example you have shown: try to do that in Java!
You see Java (and golang) have you stuck with: SomeType <T>, what you need it T R where T is a type variable parameterized by R, very few languages can do that!
With that in mind, notice Functor in the above Haskell free boilerplate list. Functor is now a familiar (even if very underused) thing in mainstream languages. Mainstream langs (like Java) would have to implement map separately on each type, separately for list, map, option, binary tree, rose tree, function, tuple, ... there are very many functors and the lang typically settles on just one (list) or a very few.
You also see equals boilerplate, toString boilerplate in mainstream language code.

It is also good to try to understand what the code you presented does, e.g.

catchError = LazyRWS.liftCatch catchError

mtl provide polymorphic versions of transformers , this code simply delegates to transformers non-polymorphic liftCatch. Delegating to other implementation is what you see in all languages. The technique is familiar, but what this code actually achieves is mind-blowing if you come from a different language.

Coherence Haskell is, I believe, unique in having this property.
Edward Kmett's presentation about it: https://www.youtube.com/watch?v=hIZxTQP1ifo

With all of this said, I think this was a well written piece. I would like to commend you for writing it.
I hope my comment is helpful in filling out some blanks which make Haskell look bad if they are left bank and make Haskell look quite good if you fill them up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment