Advent of Code usually seems to be a great way for me to learn a new programming language - something I’ve heard other developers do too. I had my first experiences with Go and Rust this way.
Given that I’d been working primarily with imperative languages for decades now, I thought I’d challenge myself to learn a functional programming language. Current and past colleagues and friends had spoken highly of Haskell, especially those who value the ideal of “only making valid program states representable in code”, so I thought I’d give it a try and document my experience.
TL;DR: I don’t think I’ll be using Haskell or other pure functional languages for building anything meaningful any time soon. I suspect that, in all the years of using imperative programming languages, my brain’s adapted to that paradigm of human-computer interaction and it’d be far too much effort, for little or uncertain reward, to really become productive in a pure functional paradigm. YMMV though, of course - this is just my personal experience. And, I still don’t fully understand what a monad is.
Setup
Being used to the simple process of installing Go (brew install go
) and Rust (via Rustup), I was a little taken aback at the complexity of the Haskell installation process. While it’s not insurmountable, and GHCup seems to help here, it’s still odd to me that one would need to manually select specific versions of all the relevant tools necessary to get started (as though I know what I’m doing at this point, I mean really 😄).
Setting up my coding environment was fairly simple, although this article sent me down a rabbit hole that made me realize I’d been using Neovim on hard mode for years now (I’ve been loving the simplicity of NvChad and configuring it in Lua, especially with the Lua language server’s help). Several hours later, I had a freshly revamped Neovim setup and the Haskell language server installed, and was ready to start coding.
Hello world
Haskell advocates had previously pointed me to Learn You a Haskell (LYAH), so I started learning the basics of the language there, playing around with GHCi - the interactive Haskell CLI. Only after writing my first “Hello world” application manually did I discover the existence of and how to use cabal, and how it would’ve made my life a little easier to initialize and build standalone Haskell applications. Although, I could’ve done with some simple way of adding dependencies via the CLI, because I couldn’t really figure out the .cabal
file’s syntax.
It’s really odd to me that all the Haskell tutorials I could find didn’t start by describing the tooling and how to navigate the basics of building a standalone Haskell application. In fact, they seems to focus pretty much all of their attention on the language and its use via GHCi. Compare this to:
Rust’s installation instructions (especially the direct link to the
cargo
instructions)
Building software is so much more than just writing code. There’s also initializing and organizing your project, managing dependencies, configuring your tooling, building and running the application, writing and running tests, configuring CI, refactoring, etc. I have no interest in programming languages that are a dream to code in, but are a pain-in-the-ass to build, test or get working in CI. I want a glimpse of the whole end-to-end development experience up-front so I know what I’m getting myself into and whether it’s worth investing my limited time and energy.
Advent of Code
One of the things I loved about learning Go was that it was fairly easy to understand and to start being productive. In learning any new skill, it seems as though quick wins early on tend to be a natural motivator to continue learning.
Haskell, on the other hand, made me feel as though my brain was continuously straining to reach a shelf that was too high for someone of my stature. By day 2 of the Advent of Code challenges (which took me until about day 10 of AoC to complete), I felt pretty defeated.
From the complex setup process, to feeling like I was flailing when trying to craft an application, to trying to make heads or tails of the cryptic, esoteric warnings and errors generated by the LSP and compiler, it was a painful experience. Perhaps I’ve been spoiled by Go’s simplicity and the Rust compiler’s helpful, human-readable error messages and suggestions.
Don’t get me wrong, when I finally got my solutions to work they were incredibly concise and elegant. I just didn’t enjoy the process of getting there.
Learning curve
In the early stages of learning a new programming language, I’m aware that I’m solving problems at two levels:
How do I help the user solve their problem using a computer?
How do I translate my mental model of a potential solution to (1) into this specific programming language and toolchain?
It’s the second problem, the “learning curve”, that really tripped me up with Haskell. Interestingly, I had a similar experience with Rust, and this is also one of my biggest criticisms of Rust: for most of the time when I was learning and using the language, it felt like I was trying to solve language problems more than trying to address the user story - especially when I tried to do complex things in Rust’s async
world. That’s fun for a while, but it eventually becomes tedious.
The most enjoyable programming languages, I’ve found, are ones that “get out of the way” and allow me to address the user story as quickly as possible. Maybe I’m getting old, or maybe I just get more of a kick out of seeing the user happy than solving programming language problems.
There are certainly people who just “click” with languages like Haskell - I’ve known a few of them personally. Their mental model of the world fascinates me because of how distinct it is to mine. All of them have been people who, to me at least, seem to hold a deep appreciation for the ideals embodied in mathematics and philosophy, and they generally tend to be somewhat disgusted by languages like Go. They also generally tend to be smarter than me, evidenced to me by how quickly they think and their high degree of verbal intelligence.
The language of the machine
I don’t understand that paradigm of mathematical and functional purity though in the context of computers.
It’s not that I’m against mathematics in any way - I did pretty well in math in high school and during my engineering studies, and particularly enjoyed using differential equations and stochastic processes in the realm of telecommunications. I also know that, without that math, computers wouldn’t even exist.
In my paradigm, however, computer chips just don’t work in a functionally pure manner. When you look at the language of a modern CPU (see, for example, the ARMv9 base instruction set), every part of it is an instruction to do something - i.e. an imperative mode of execution.
Maybe I misunderstand what functional programmers are aiming to do, but the functional paradigm seems to me like an attempt to express an ideal of some kind on top of that fundamentally imperative mode of execution. Never mind the fact that computers themselves, elegant as they can be nowadays, are mathematically imperfect machines that are subject to operating ranges, manufacturing tolerances and are replete with all kinds of hidden workarounds at various levels of their architecture so their various makers could meet their deadlines.
The great, and not-so-great
Okay, enough rambling, let’s get to the lists.
There are certainly a few things that I found pretty great about Haskell and its ecosystem:
One can express solutions to certain classes of problems very concisely. I do like the look of this elegance. While this elegance is obvious in solutions to the Advent of Code-style problems, I haven’t yet had enough experience with Haskell to know if this translates well to your everyday garden variety problems (e.g. building CRUD applications).
Its type system is far more expressive than other languages I’ve used. This is a big one - while I haven’t personally experienced it, I’m starting to see how its type system lends itself to better compile-time correctness, allowing one to eliminate certain classes of bugs at compile time.
The existence of Hoogle blew my mind. Who knew one could build a search engine that allowed you to look up packages and function documentation just from a function signature? For example, I used it to find a function to split a string by newlines, where my query was just:
String -> [String]
, or “find me a function that takes a string, and returns a list or array of strings”.The language server is pretty helpful, especially in terms of making your statements more concise.
Things that I think will dissuade me, at least for now, from continuing to learn it or use it in any production system:
It’s still a relatively unpopular programming language in 2024. This, to me, indicates that very few companies will actually use it for production code, and it’ll be hard to find developers to build teams, making the sustainability of such solutions questionable.
The learning curve just seems too great. My software development paradigm would need to undergo a far more substantial adaptation than I’d initially anticipated, and it’s unclear to me what the benefit would be to incur such pain at this stage of my career. Especially given Haskell’s relatively unpopular status as a programming language.
And finally, a couple of small things that really irk me:
The fact that set membership querying and map element lookups are O(log n) as opposed to O(1). Like, why?
I still can’t fully wrap my head around what a monad is, even after apparently using it for years now. After reading many definitions, examples and having it explained to me by functional programming advocates, I feel like that understanding is about to dawn on me. It is, however, still very much night time for me when it comes to monads, and category theory more generally. Given the nature of the language, until I understand category theory better, I have this feeling I won’t really be able to wield it effectively.
You can think of a monad as a design pattern with a clear contract. Basically, to have a monad, you must implement bind (also known as flatMap) and the other operations from Functor and Applicative, and that's it.
If you want, you can just ignore category theory, although it provides a nice framework for thinking about problems in abstract terms.
P.S.: One interesting fact I recall that might clarify the mathematical inclination of functional programming is related to the origins of programming itself. Programming languages were conceived before computer systems, in order to solve the decidability problem posed by Hilbert's program. Three programming formalisms were devised at the time: a general recursive function definition by Gödel, Turing machines by Turing, and the lambda calculus by Church. It’s therefore only natural to use mathematics and logic for abstraction, and I think category theory is a nice fit, even for imperative programming.
If you’ve used Rust’s Option and Result types, they can be thought of as categories, or more specifically, as coproducts and monads. But it doesn’t matter if you understand the math behind them; you can use these types without ever worrying about that aspect. You can just view them as design patterns. The benefit is that, because they were developed with mathematical rigor, they are all well-defined and obey specific laws. In contrast, while many object-oriented patterns are useful, not all of them are well-defined or must obey rigid laws.
Thanks for your honest and objective remarks. I can relate to your remarks on lack of materials on tooling. However, I don't agree with your view that FP is trying to impose a mathematical view on top of a "fundamentally imperative" execution model. Every abstraction layer in a computing is just that - an abstraction layer - and there is nothing more "fundamental" about the imperative model. After all, the hardware doesn't start at the assembly level and even something like the program counter must be implemented using logic gates. Modern CPUs actually spend a lot of effort to paralelise execution of machine instructions while preserving the illusion of sequenciality.