Two thoughts here.
First: the benefit of purity in Haskell is not about making it look pure - it is about making it easier to reason about the code, and whether you do or do not get this benefit only superficially depends on the syntax you choose to use for your monadic code. The key thing about pure code is the absence of (side) effects; and depending on how you look at it, monadic code is either effectful, or it’s not, but massaging it into a different surface syntax does not change that. getLine >>= putStrLn
has exactly the same effects as do { x <- getLine; putStrLn x }
, there is literally no difference wrt purity.
In other words: we don’t write pure functional code so we can be all smug about our superior looking coding style; we do it because it creates opportunities for equational reasoning, because it makes code easier to truly understand, and as a consequence, makes it easier to write correct code.
Then; Haskell offers a wide range of equivalent but different-looking ways of writing the same code, and that is a good thing - it means we can pick the style that most closely matches what we want to say. Code is first and foremost a human-to-human language, and while it needs to “do the right thing”, that’s just the starting point - we also want code to express the programmer’s intentions, make it easy for a reader (possibly the original programmer’s future self) to retrace the thought patterns, knowledge, and assumptions that are encoded in it, see the structure of the problem being solved and how it maps to the machine side of the code, etc. Having many alternative ways of saying the same thing gives us more options to structure code in such a way as to reflect our mental model, beyond the technicalities of making the machine do the right thing.
Sometimes, we want to take the reader by the hand and walk them through a piece of code along a sequence of events. “First, we ask for a name, then we ask for a social security number, then we look up the name in a database, and then we report the result” - I’d write that in quasi-imperative style, hands down:
do
name <- readPrompt "Name"
ssn <- readPrompt "Social Security Number"
row <- query db (getUserByNameAndSSN name ssn)
print row
Other times, we might prefer to think of a series of monadic actions as a pipeline, and Kleisli arrows might be a better fit; an example might be chaining middlewares in an HTTP API, where each middleware takes a request and produces a response, either short-circuiting, or forwarding things to the next middleware:
myApp =
sessionMiddleware >=>
fullPageCacheMiddleware >=>
staticFilesMiddleware >=>
autoContentTypeMiddleware >=>
apiMain
Yet another common situation is where we need to gather a bunch of inputs, and then we construct some kind of data structure from them, like a record type. This is a staple in serialization/deserialization code, for example. Applicative style shines here:
getUser =
User
<$> getInt
<*> getString
<*> getEmail
And then we have the where
vs. let
debate - but this one is not specific to monadic code or do
notation, the same decision still needs to be made in non-monadic code, and AFAICT, there is no objective winner. Some people prefer a top-down approach, where you state the “big picture” first, and then the reader can, if they so wish, scroll down to read the definitions of the things you used in the big picture. You would generally use where
for this. Others prefer a bottom-up approach, where you start by presenting your building blocks, and then proceed to using and combining them, stating the “big picture” last. For this, you would use let
. (Of course there are also technical differences between where
and let
, most importantly scope, but those are relatively minor, and in most cases, the two can be used more or less interchangeably, modulo ordering).
Interestingly, the choice for top-down vs. bottom-up also has a cultural component to it. I learned about these cultural differences while working for a Dutch company that had just bought a German competitor, and I could witness the argument culture clash first hand (and it was occasionally hilarious). In a nutshell: Dutch argument culture starts with the conclusion and then provides the reasoning and evidence as needed; German argument culture starts with the evidence and then proceeds to reasoning and ends with the conclusion. “We should do X, because…” vs. “given the facts …, we should do X”. Both are valid, but if you are unaware of the cultural difference, the two can clash rather violently, with the Dutch thinking “get to the point”, and the Germans thinking “but where is the evidence”.
Personally, I’m a huge fan of having all these different styles at your disposal, and using whichever describes your thought patterns best. And if that’s a tie, I’d go with whichever style looks most straightforward, assuming a reader who is equally fluent in all styles. More often than not, this leads to a “mixed” style, and again, I think that’s a good thing. I’ll even mix let
and where
within the same function, if that helps me express myself better - e.g., I might introduce intermediate variables used in a multi-step calculation using let
, as they become relevant, so that the reader can follow the calculation as it’s built up, but I might factor out sub-calculations into local functions defined in a where
block, assuming that their names make it sufficiently clear what they are supposed to do, and putting the definitions upfront would be detrimental to the reading flow.