Navigation Menu

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.

@soft-copy
Copy link

I suggest
"And yes, they're hard to add support for, but clearly impossible - it has been done."
to
"And yes, they're hard to add support for, but clearly possible - it has been done."
or
"And yes, they're hard to add support for, but clearly not impossible - it has been done."

@friedbrice
Copy link

friedbrice commented Sep 2, 2019

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 type IO, but this is incorrect. Haskell has no observable side effects: it's referentially transparent. That IO () 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; in Main write a bunch of (orphan) instances of your type classes for the concrete IO type; that's it. If using type classes in such a way is distasteful to your coworkers, Matt Parsons describes a similar architecture using a newtype directly above your final Main (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 the Monad 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.

@matthiasgoergens
Copy link

If you'd like to see what a language without global cohenerce can look like, try OCaml.

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

@jesseschalken
Copy link

jesseschalken commented Sep 2, 2019

"Haskell code, when it compiles, just works"

Ignore anyone who says this.

That can only be said of a program written in a proof language where the complete specification for the program behavior is captured and proven by the type system. That is not Haskell. (Although GHC is gaining dependent typing features you are by no means required to use them.)

Even in a proof language, you still need to write the program specification correctly. An empty file may compile, but that doesn't mean it does what you want.

Haskell has exceptions. Unchecked exceptions. And people feel it's a good thing. I don't even.

So does Rust with panic!. Without a complete proof system, there are always going to be invariants in the code which are not proved by the type system, and so the developer is always going need an "escape hatch" to say to the compiler "I know I haven't proven to you this is true, but I'm telling you it is, and if it turns out it's not, the code is broken".

Indexing into an array is a good example. You could say it should always produce a Maybe because the index might be out of bounds, but if the indexes are never out of bounds because otherwise the code is broken then everything ends up in the Maybe monad for no reason.

The right approach here is "error when it's your fault, MonadError otherwise".

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?

Orphaned instances are instances for types of type classes where you own neither. If you ban orphaned instances, you can still write instances for types you don't own, provided you own the type class. That's what you gain, and what Java, C++ etc don't allow.

@shmish111
Copy link

@jesseschalken Elm doesn't have unchecked exceptions, it avoids the need by removing powerful tools. Personally I prefer to have those tools but it shows it is possible (and indeed very nice)

@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