💾 Archived View for jun.skein.city › journal › software › griping-with-haskell.gmi captured on 2024-12-17 at 09:49:50. Gemini links have been rewritten to link to archived content
-=-=-=-=-=-=-
Over the last couple months, I've taken some time to finally learn and use Haskell. I chose this because after using OCaml for a little bit I realized I finally felt ready to approach a purely functional programming language. It took a little bit, but after the first few weeks I actually felt pretty happy in Haskell. It is a very elogent and pleasant language to write, that allows you to represent concepts in very ingenious ways. Lazy evaluation is also something that I will now miss whenever I am not writing Haskell. I like Haskell a lot, and will be continuing to use it.
However, the language is bloated with warts. Whether they be tooling related, standard library related, or language construct related. While it is very elogant to write Haskell, it is absolutely horrible to build an application in it. So this log will be a little ramble of my experience.
Module structure in something like OCaml is glorious, eloquent, and makes for a very easy way to bind code. Interface files, while sometimes a little frustrating when doubling type signatures, are a godsend for self-documenting code and even API level documentation. Haskell on the other hand, by its own admission, has a terribly under implemented module system.
While a file represents a module, like OCaml, submodules are not permitted inside of a file. Furthermore, the only way to define an interface is through blind symbol/name dumps in a parenthesized segment in the module declaration. I cannot stress enough how immediately horrible this is, and while reading the module code directly is not the end of the world (I can do it in Java, I can do it in Haskell), it is woefully lacking. I understand why OCaml's more advanced module features are omitted, as they are better implemented via type classes, but Haskell's module system is lacking to a point that it almost feels malicious.
I know a lot of people point this out when deriding Haskell, but it doesn't make it any less true. Stack relies on YAML, one of the worst configuration languages to smear across the programming landscape. Cabal meanwhile has its own syntax that appears to try and look like the table of contents for a dissertation. Neither are fast, particularly well documented, or easy to quickly determine information from. I'm sure plenty of people point out how Maven, Gradle, SBT, NPM, Yarn are all similarly full of warts; But I'm sorry to inform them that all of those build tools are also horrible.
The critical flaw that Cabal and Stack share, in my view, is the conflation of responsibilities. Why does my build tool manage my dependencies, and why does it not directly manage my language version? I see a library that requires "base >= 4.10 && < 5" and yet I cannot seem to quickly identify what my base library version is. This is nonsense, and even trying to setup a basic suite of libraries in Haskell is a chore as a result. This doesn't even begin to approach the issues with ghc-pkg and package environments.
Build tools should simply build, and references packages from a separate package tool. I am even harsh on OCaml's dune for conflating the two responsibilities into one tool, but at least dune will pretty quickly get out of your way. I wish Haskell had something analogous to OCaml's opam + ocamlbuild, that I could neatly wrap in my own Makefile for ease of use.
I don't think Haskell users understand how devastatingly bad this looks for their language of choice. The idea that one of the first pieces of advice newcomers hear is "download this Data.Text library, don't use Strings" should alarm you. Strings carry so much overhead, 5 architecture width integers per character of text, that they are unusable for anything outside of toys. This is absolutely, fundamentally insane. A 1 KiB, UTF-8 encoded text file will take up 40 KiB of RAM on just the data type alone.
Some people will defend this, as it allows Strings to leverage the swath of list functions that the Haskell standard library provides. I think this is a horrible way to excuse an even bigger problem:
Haskell, the great magician of monads and type classes it is, has a phenomenal type class: Traversable. It represents anything that can be traversed over, such as a list. Instead of using something like this for representing any list-like data structures and therefore letting any programmer bring their own implementation, they decided to require the use of lists. This design choice, that I believe happened due to the functions predating the Traversable type class entirely, is catastrophic across the language. Firstly, you have "map" for lists and "fmap" for the Functor type class. Secondly, this breeds confusion as lists also implement Traversable themselves, allowing them to use their own specialized functions as well as anything provided by the type class.
This is not convenience, nor is it an eloquent way to represent concepts. The fact that fundamental names are reserved for a list type that is so memory hungry that people actively push people away from it for things like Strings is a problem. No benefit can outweigh how easily and quickly this pushes people away.
The lazy evaluation of Haskell is amazing, incredible, and I love it. That said, thunk accumulation and general ways they evaluate leads to some gotchas. These are able to be worked around, and certainly not something I would recommend changing. But they need to me be mentioned.
As a result of thunks only evaluating as needed, but existing in memory regardless, some weird behavior exists. For example, "foldl" is tail-recursive but will still crash due to thunk accumulation exhausting stack memory. This is the opposite of a strict evaluation (which can be used with "foldl'"), which is entirely safe to use. Furthermore, this just makes it difficult to predict what the memory usage of your Haskell application will be.
People love to claim how Haskell can be "as fast as C++", and then magically vanish when told to give a general use case that holds that claim. Haskell is not a particularly fast language, and certainly does not match a direct representation language such as that of C. But it is not slow either, especially after it warms up. Haskell excels at making predictable outcomes very, very fast. It does this by abusing the fact that since functions are pure, the result can just replace the program after it is evaluated the first time. Secondarily, the fact that everything is lazily evaluated means it will only evaluate what is needed to complete an operation.
This is all extremely clever, and powerful. But to take advantage of this you need to deeply, and painfully consider data shapes and how to represent your system. This is very hard for complex subject matters, much harder than people would want you to believe, and I have infinite respect for those who can pull it off routinely. But in my experience it feels like the pain points of OOP with the overhead of mathematical constructs. Which leads into my final section.
For the final section of this diatribe I must compare Haskell to that of class-based, statically typed OOP. This is in contrast to the OOP of Smalltalk and it's derivatives, of whom I love dearly. Haskell, as a result of it's heavily reliance on type theory, monadic composition, and pure constructs, suffers from the paralysis of options and design decisions that make it difficult to be productive. Some may decry that such decisions are much easier with experience, and certainly more valuable than the decisions in OOP solutions! I would agree entirely with both statements, but it doesn't negate the issue.
Languages should operate to ease decision fatigue, not overload you with it. OCaml provides you with just enough to shape your data, operate upon your data, and bind all of this into a neat module (I am ignoring objects, as you should too). C provides you with a little too sparse of options, but it too allows you to shape your data (mostly) and act across it. Even Go, a language I detest and would like to see expunged from the records, does this well. Haskell reminds me of trying to design an elegant inheritance tree in Java, just with actual benefits to successfully doing so instead of more pain.
Thank you for coming along with me on my hastily written barrage of issues I have with Haskell. I still do like the language a lot, believe it or not. But I also have come to accept that it's a toy language for me, and notably not a very productive one. I am comfortable to continue my programming adventures in OCaml instead, infinitely glad I took this voyage.