The problem I have with functional programming is debugging. Or more precisely, I would say it is a strength of imperative programming, especially the procedural kind.
In functional/declarative style, you generally describe how things should be, not how things are made, and you let the language piece everything together to get the expected result in the end. It is all well and good (and even better) if you did everything right, but what if you didn't and you don't get the expected result? How do you find the bug?
In a language like C, it is relatively straightforward: go line by line, look at the execution state (the RAM, essentially) between each step and if it isn't as expected, something wrong must happen at that line, so you step in and progress like that. Harder to do when the language goes out of its way to hide the state from you, as it is the case for functional programming.
It is interesting that the longest section of the article is about this problem: "design for introspection", where the author has to go out of his way to make his code debuggable. A good insight on the often overlooked practical use of Haskell.
A lot of code ends up being easy to factor out into small pieces for tests. I can't speak for Haskell but coming from another ML with eager evaluation step debugging works as you would expect
My trick to debugging is to simply make every nontrivial piece of code return the same output for the same input. (The trivial pieces of code too!)
No other (mainstream) language comes close.
But what about situations where the code cannot be written in such a form (like shared memory concurrency)? I use transactions for that.
No other (mainstream) language comes close.
And that's without the low hanging fruit of no nulls, no implicit integer casts, etc.
It is absolutely true that debugging Haskell code is harder than debugging other languages. If you took away the bottom 90% of footguns, how could it not be?
Same output for same input is implicitly part of FP. (Not for OOP, due to mutation and side-effects.) I would think that when writing Haskell, one naturally always aims for same input same output.
Functional Programming debugging is often "REPL-guided" in a way that imperative programming often is not. This is not unique to functional programming, though. Even the (mostly) imperative languages Python and Javascript you may be more likely to use REPLs of one sort or another (Python shells, browser consoles, Node/Deno/Bun shells, notebooks, etc.) as your first layer of debugging.
There are interesting trade-offs in REPL-oriented debugging. One of the big things is that in a language like C you might often start first from whole program debugging and breakpoints to try to hit exactly where you think the problem is. In a REPL-oriented world you often try to build the components of your program in a way that you can test more units of it directly in the REPL.
Your module/API/Type boundaries in a REPL world become to mirror your debuggability story. There is sometimes more pressure to get those right and easy to use than in imperative languages like C/C++ because you might want to reach for them directly in a REPL.
But yes, a tradeoff versus whole program-first debugging is sometimes it becomes harder to isolate complex integration issues between your units in strange real world scenarios. However, that REPL-first approach is often encouraging of minimizing your integration "surface" to a bare minimum so often FP languages don't exhibit some of the same integration effects you see in imperative languages.
> Harder to do when the language goes out of its way to hide the state from you, as it is the case for functional programming.
Functional programming languages aren't really hiding any state from you. They also are running on imperative hardware and still dealing with real hardware states. At some point there is a translation between the "worlds" (which also likely aren't as different as you seem to think that they are). You still have those imperative breakpoints and imperative debuggers to fallback on.
That's why the term is "REPL-guided" debugging. You can use a REPL to pinpoint the problematic unit (the exact module/API/function) and the problematic input giving you the surprise output. If you can't see the bug in the source as written you can still send it to an imperative debugger and watch nearly the same "line-by-line" experience and hope it provides additional missing context. Even better by that point you probably don't need to choose good "breakpoints" because you've already isolated the problem enough in the REPL to have "natural breakpoints" because the unit you are debugging may be small and narrow enough that stepping just that unit is all you need.
> It is interesting that the longest section of the article is about this problem: "design for introspection", where the author has to go out of his way to make his code debuggable.
I think you found the wrong message from that section. That section wasn't about debuggability it was about observability. It was about connecting logging/telemetry systems correctly, mocking fakes during testing, adding retries/circuit-breakers at a systematic/app-wide level rather than relying on individual libraries to get it right. In the imperative world these aren't debugging issues either: These are Dependency Injection issues. These are Middleware installing issues. These are factoring concerns like using Abstract Interfaces over Concrete Classes at your public API boundary.
The design suggestions are factorings. They don't impact debuggability, they impact how easy it is to install observability middleware to someone else's public API.
In functional/declarative style, you generally describe how things should be, not how things are made, and you let the language piece everything together to get the expected result in the end. It is all well and good (and even better) if you did everything right, but what if you didn't and you don't get the expected result? How do you find the bug?
In a language like C, it is relatively straightforward: go line by line, look at the execution state (the RAM, essentially) between each step and if it isn't as expected, something wrong must happen at that line, so you step in and progress like that. Harder to do when the language goes out of its way to hide the state from you, as it is the case for functional programming.
It is interesting that the longest section of the article is about this problem: "design for introspection", where the author has to go out of his way to make his code debuggable. A good insight on the often overlooked practical use of Haskell.