Hacker News new | ask | show | jobs
by greydius 2140 days ago
It's unfortunate that so many people in the software profession have a negative attitude towards these concepts. Despite its unfortunately alien name, monad is a great abstraction for patterns that come up often in this field.

> So what is the IO monad, the most famous of them all?

IO is the State monad where the state is the entire universe.

3 comments

We don't have a negative attitude towards these concepts; we just don't have a damn clue what these concepts even are, or how they're useful. And thus far the FP crowd haven't been very effective in communicating this.

I'm still waiting for THE article that actually explains how this stuff works in an accessible way, without resorting to a bunch of haskell code (for which you need to already know FP - catch-22), an assumed proficiency in category theory, heavy maths, esoteric toy problems that don't touch the real world, or technically correct yet unapproachable jargon ("A monad is just a monoid in the category of endofunctors, what's the problem?").

Could you imagine teaching BASIC with "A variable is a container for values, which are themselves elements of a set of constrained possibilities. You assign to variables by collapsing the set into one possibility." That's what most FP articles feel like.

To have success in communication, you need to speak to your audience where they are; not where you wish they'd be.

Feynman was able to do this for physics; hopefully someday someone will do it for FP, and then you'll find us MUCH more receptive to the message.

I don't have a negative attitude towards the concept of monads nor one informed by its funny name (where did you even get that notion? Programming is filled with jargon).

I do have a bit of a negative attitude towards evangelists who expect people to be impressed by ideas whose significance they routinely fail to communicate. Functional programming enthusiasts seem to really want the rest of us to care about monads but never quite get around to telling us why, and that's on them.

I get that, say, Result types and List types both can wrap objects and both compose with themselves in some nice ways when they do. And as a mathematically curious person, I think this is a fun observation. But since there is very little overlap in their practical use cases I don't understand why having a formal model for that commonality is so important.

Since you said it comes up often, do you have an example of a time where you reached a solution faster by recognizing that it should be monad-shaped?

* Tracking which things need to happen in a database transaction

* Gathering statistics (multiple different cases)

* Authentication

* Async pipelines (I found iteratees much easier to learn than "reactive streams" because they're just monads)

Essentially any time you find yourself with a "cross-cutting concern", something you'd be tempted to use an "aspect" or "decorator" for, you probably want to use a monad. And there's a lot of complicated language features that you can just remove (or reduce to syntax sugar) if your language has monads instead: https://philipnilsson.github.io/Badness10k/escaping-hell-wit...

Thanks, those are good examples. The linked article underscores my point though: the author takes four disparate ideas and reduces them to the exact same code in each case. All that code tells me is that there are monads going on; that's no good to me if I can't tell what those monads are actually doing!

Also, for what it's worth you can get rid of a lot of those same language features with good old OO methods. See Crystal's each[1] and try[2] methods, for example.

I don't mean this to be a tit-for-tat and do appreciate the response. You've inspired me to try and make use of a state monad in some Ocaml I've been playing with. Cheers.

[1]https://crystal-lang.org/api/0.35.1/Indexable.html#each(&)-i... [2]https://crystal-lang.org/api/0.35.1/Object.html#try(&)-insta...

> Thanks, those are good examples. The linked article underscores my point though: the author takes four disparate ideas and reduces them to the exact same code in each case. All that code tells me is that there are monads going on; that's no good to me if I can't tell what those monads are actually doing!

This is like saying that a generic list type makes no sense because you can't tell what the values in the list actually are. The whole point of using monads is so that you can separate the generic structure of what you're doing (composing together effectful functions) from the specific details of what those effects are, and there are a bunch of useful generic functions that you can write (analogy: sorting the list, or combining together two lists) and reuse for any kind of effect.

> Also, for what it's worth you can get rid of a lot of those same language features with good old OO methods. See Crystal's each[1] and try[2] methods, for example.

Sure. I think if you actually really follow through on OO principles like SOLID and separation of concerns then you end up in much the same place as functional programming. If you replace that "each" and "try" with versions that return a "transformed" value (more composable and testable than just executing a function for side effects) then you more-or-less have the "map" function.

(Unfortunately most languages aren't sophisticated enough to let you pull out that common interface: I'm sure you can see that a function List<Int> -> List<String> has something in common with a function Set<Int> -> Set<String> or a function Future<Int> -> Future<String>, but programming languages that let you express what that common part is are surprisingly rare).

I'm not saying the type makes no sense, I'm saying the article doesn't communicate any value to people who aren't sold on the idea of monads before they go in. Like, if you're explaining functions to a newbie programmer, you don't just take a bunch of lines of code, replace them with `do_the_function()`, and act like you've made the program better. Obviously all those lines are still hiding in the function definition somewhere and your indirection has only increased complexity. You have to show that using functions to extract patterns from around the program improves elegance or expressiveness overall (e.g. by reducing total lines of code). I've never read a monad tutorial that does this in a way that (comparatively) simple higher-order functions wouldn't do just as well.

edit: to expand on that last sentence, the example under "Ad hoc solution: Promises" is more expressive to me than the author's monadic code, because a) it's more explicit about what depends on what, and b) the ".then" method name gives you a hint about what sort of computation is actually going on. Replacing "then" with "try" or "each" makes the code work just as well for the null-checking and for-loop examples.

> I'm not saying the type makes no sense, I'm saying the article doesn't communicate any value to people who aren't sold on the idea of monads before they go in. Like, if you're explaining functions to a newbie programmer, you don't just take a bunch of lines of code, replace them with `do_the_function()`, and act like you've made the program better. Obviously all those lines are still hiding in the function definition somewhere and your indirection has only increased complexity.

I'm not convinced. Imagine a tutorial for the foreach construct: it would show code for iterating over a list, code for iterating over a set, and code for iterating over an array, and then a foreach loop. Would you say that obviously the iteration code is still hiding somewhere and your indirection has only increased complexity? Introducing a common interface - which is proven by the use of a common syntax - is something that we recognise as valuable, I think.

> You have to show that using functions to extract patterns from around the program improves elegance or expressiveness overall (e.g. by reducing total lines of code). I've never read a monad tutorial that does this in a way that (comparatively) simple higher-order functions wouldn't do just as well.

FWIW my own effort is https://m50d.github.io/2013/01/16/generic-contexts

Well, this shows one simplistic partial syntactic solution to a few common problems. Partial because it does not handle errors in the continuation example, and simplistic because it only works at the function level, it's not clear how this would look like when 'distributed' through a large code base, like cross-cutting concerns typically are. Not to mention, it's not clear how to compose all of these separate solutions - how will the do notation work if you have a list of continuations that can each return optional values that return errors if something is not authenticated?

Note, I'm not claiming that these problems are not solved by monads. I'm arguing that the article you showed gives me no information on how really complicated problems are actually solved, it just shows a neat bit of syntax sugars that works on a few toy problems (again, that's what's shown, not claiming that this is all that 'do' is).

> Well, this shows one simplistic partial syntactic solution to a few common problems.

It's not just syntactic - the different cases genuinely do implement a common interface.

> Partial because it does not handle errors in the continuation example

Nor does the non-monad version, so it's a fair comparison.

> simplistic because it only works at the function level, it's not clear how this would look like when 'distributed' through a large code base, like cross-cutting concerns typically are. Not to mention, it's not clear how to compose all of these separate solutions - how will the do notation work if you have a list of continuations that can each return optional values that return errors if something is not authenticated?

Sure, but again, a problem that exists even more strongly if these are implemented as (non-monad) language features (e.g. if my language has both continuations and exceptions, what happens when some continuation-based code throws an exception).

The point is that you can get rid of a whole bunch of complex language features and language keywords, and write everything in terms of plain functions and values (in particular, the result is that you can refactor fearlessly because everything follows the normal rules of the language). You don't have to look at anything large scale to see the benefit of that.

> It's not just syntactic - the different cases genuinely do implement a common interface.

Do notation is a syntactic solution, it is not an object of the runtime.

> Nor does the non-monad version, so it's a fair comparison.

The initial version does explicitly handle errors. The async/await based version also handles errors (any exceptions thrown by intermediate functions will be thrown by whoever is trying to use the results). What will the do-notation continuation version do with any errors returned by the intermediate functions?

> Sure, but again, a problem that exists even more strongly if these are implemented as (non-monad) language features (e.g. if my language has both continuations and exceptions, what happens when some continuation-based code throws an exception).

Not really - it is usually very clear how exceptions integrate with other control flow features such as continuations or, much more easily, promises or async/await (full continuations a la scheme call/cc happen to not work with exceptions, but they break any other control flow feature anyway, so that's not very surprising).

> The point is that you can get rid of a whole bunch of complex language features and language keywords, and write everything in terms of plain functions and values (in particular, the result is that you can refactor fearlessly because everything follows the normal rules of the language). You don't have to look at anything large scale to see the benefit of that.

I completely disagree with the idea that language keywords make refactoring difficult in any way, or that a language with less syntax is always easier to work with. Even the designers of Haskell disagree with this, as they have included both do-notation and list comprehensions, instead of letting programmers simply use bind and return. Even in Lisp, which doesn't have any syntax sugar in the base language, designers always add DSLs and Reader macros to add syntax back into the language.

> Do notation is a syntactic solution

But it's coupled to a monad interface in the language (or equivalent concept e.g. a typeclass in Haskell). It's not just a superficial similarity of syntax, you can write reusable functions that work for any monad, and the monad abstraction is very useful even if you prefer to write https://fsharpforfunandprofit.com/rop/ - style code that doesn't make use of do notation at all. The syntax looks the same because the values its working on implement a common interface - do notation itself is a very lightweight sugar that translates directly into interface methods.

> The initial version does explicitly handle errors. The async/await based version also handles errors (any exceptions thrown by intermediate functions will be thrown by whoever is trying to use the results). What will the do-notation continuation version do with any errors returned by the intermediate functions?

Neither the async/await nor the promise-based version includes any explicit error handling. So you're applying a real double standard; how is it any more or less clear what the monadic version will do versus what those versions will do?

> Not really - it is usually very clear how exceptions integrate with other control flow features such as continuations or, much more easily, promises or async/await

Having had to deal with some exceptions in sequence-continuation and async/await-based Kotlin code only last week, I have to disagree. Often there is more than one possible way to combine two effects, but if those effects are language features then the language designer will have picked one and you have to hope it's the behaviour you wanted. (E.g. if a parallel loop throws an exception, does this fail the other cases or just that one result? If an async function throws an exception before the first await, does the exception happen immediately or get suspended? There's no right answer, there are use cases where each behaviour is what you want).

> I completely disagree with the idea that language keywords make refactoring difficult in any way, or that a language with less syntax is always easier to work with.

It's not the syntax that's the issue, it's the semantics. In my experience the majority of production regressions caused by code changes (most production bugs tend to be caused by config changes, but that's a separate rant) come down to a particular category of language feature: things that can't be understood as plain old functions or values (to use the FP jargon, things that violate referential transparency). E.g. hoisting a value out of a loop changes the behaviour because the method that computes the constant was actually throwing an exception. E.g. inlining a method means a transaction decorator isn't applied. E.g. pulling out a common expression into its own function means it gets executed on a different thread. So replacing a language feature with a function that I can read the source code of (and, even more importantly, can be confident will follow the normal rules for functions in the language) is the best way I've found to eliminate whole categories of bugs at a stroke.

> Even the designers of Haskell disagree with this, as they have included both do-notation and list comprehensions, instead of letting programmers simply use bind and return.

What I'm arguing is that dedicated language constructs have a cost - of course that cost is sometimes outweighed by the benefits. But if you can replace five different special cases with a general case that covers all of them (and isn't five times more complicated to understand), that's a huge win.

> I don't have a negative attitude towards the concept of monads nor one informed by its funny name (where did you even get that notion? Programming is filled with jargon).

It is a really weird idea which goes back to Simon Peyton Jones himself. Quote: Our biggest mistake was using the scary term “monad” rather than “warm fuzzy thing”.

I love Haskell, but the condescending attitude of some of it's adherents are off-putting. If some programmers don't grasp the wonder of monads, surely it can only be because they are scared of the word.

Haskellers regularly hear complaints of the form "you should have called it 'Mappable' instead of 'Functor' -- that would have made it much easier to understand" or "you should have called it 'Chainable' instead of 'Monad' -- that would have made it much easier to understand", so I don't think we can say that this purely comes from a position of assumed superiority.
> I don't understand why having a formal model for that commonality is so important.

So generic libraries can be built and abstracting frequently used operations over many different data types. I've never implemented a monad myself outside of a toy project and am by no means an expert, but it's quite nice to be able to transfer certain knowledge on lists to options or futures. Similar to generic interfaces like a collection interface, so whether you are working on a set or a list you know that 'get' or 'exists' can be used.

Sure, but what generic libraries can you meaningfully built above optional, list, either, futures? Do-notation is one, but what else?
Essentially any library function that takes any kind of callback benefits from being able to take a generically-effectful callback. There's plenty of mileage in basic things like database row mappers.

If you want fancy examples, things like generic data structure traversals (I use a cataM like https://github.com/ekmett/recursion-schemes/issues/3 all the time). Another good example is iteratees - you can define an effectful source, an effectful sink, or an effectful transformation stage in a stream pipeline, which makes it very practical to have very small reusable pieces. Since it's using the generic monad interface you can define an intermediate stream transformer even for some custom effect that you wrote yourself, but be confident that everything will be plumbed together correctly.

There are a lot of simple examples right in `Control.Monad`. As soon as I know that something provides the `Monad` interface, I know that I can use things like `mapM`, `foldM`, `replicateM`, `filterM`, `forever`, `sequence`, `zipWithM`, the list goes on...

Certainly not all of these are useful in every case - eg. `forever` needs your action to do something or you're just hanging forever.

But it's a toolbox that's easy to reach for, and which applies to a large pile of things. And when you can structure a new function only in terms of things in that toolbox, you've added another thing to the toolbox. See, for instance, `monad-loops` for more.

Exploring pairs of utility function and Monad instance can be interesting, asking (eg.) "what does `unfoldM` mean when I use it with `State`?"

> IO is the State monad where the state is the entire universe.

Maybe in the early days, but that doesn't really describe how it works these days (in particular, async exceptions). In practice it's been used as a "sin bin" type for any side effect that we don't know how to model nicely.

Are you referring to the argument that Haskell's IO type is insufficiently granular to distinguish between something like erasing a disk and something like catching an async exception, or that the language's first-class feature set should be extended to things like async exceptions so they don't need to be part of "the rest of the universe" ?
I'm saying that if you think of IO as being a state monad then you will be surprised by the behaviour of async exceptions (and likely introduce bugs in your program). I'm not taking a view on what Haskell should do, just saying that it's something people using the current IO type need to be aware of.