Hacker News new | ask | show | jobs
by Rerarom 2393 days ago
My problem with understanding monads was as follows: I had no problem with understanding the mathematical definition, it was easy to check that a given monad verifies the axioms.

No, my problem was this one: everyone was saying "hey, you know if you have a purely functional language, it would be impossible to have IO, since e.g. a read function would have different outputs every time. We solve that with monads."

But that didn't feel like an explanation. I couldn't draw a line from the monad axioms to dissolving that impossibility. It was like saying "Einstein says we can't do FTL, but we can solve that with monads".

10 comments

The way most people explain this is completely backwards. Monads don't actually solve the I/O problem. So forget about monads and just consider how to mathematically model I/O.

Take something like getStrLn (which reads a line from STDIN). What is "getStrLn", as a mathematical object? It's not a string, rather, it's some kind of "I/O operation" that when executed, returns a string. Haskell calls such an object an "IO String". Similarly, the expression (putStrLn "foo") is an I/O operation that when executed, doesn't return anything. Haskell calls this an "IO ()".

Once you develop an intuition of I/O operations, you can see how you might want to combine atomic I/O operations into larger and larger I/O operation, feeding the output from one as input into the other. Or how you could treat a plain Int as an "empty" I/O operation that when executed, simply returns the Int. And how your entire program, its "main" entry point, can be modeled simply as one large I/O operation.

You can do all of that, and write every possible kind of I/O code in Haskell, without ever learning about monads.

What are monads for, then? They generalize the structure of this approach, with its (>>=) and return operators, and apply it to other cases that don't have anything to do with I/O. But this step is completely optional! This is very similar to how the idea of a mathematical "field" generalizes the arithmetic operators (+, -, *, /) and applies them to objects that don't look anything like the rational numbers. You don't have to learn about fields to successfully use rational numbers. And we would never dream of explaining the abstract idea of fields to people before we introduce them to rational numbers. Yet almost every I/O tutorial in Haskell begins with a half-baked explanation of monads.

That is how I finally got it, because I knew what groups/rings/fields were but did not realize at first that monads were just another abstraction like those and not an ontologically fundamental category. The way monads are usually presented leads you to believe that there is something magical in those axioms that somehow makes the impossible happen.
Learn the concrete, then abstract? That's sound wisdom about teaching most anything, in my opinion.
There’s an incredibly straightforward and readable paper by Simon Peyton Jones (one of the creators of Haskell and GHC) which explains how Haskell deals with IO, exceptions, and concurrency. It also explains why they settled on this, rather than some other design. In my opinion, it is the best explanation of the IO monad (specifically IO) out there. Even just reading the first 10 or so pages is completely worthwhile.

Paper: https://www.microsoft.com/en-us/research/wp-content/uploads/...

Here's another upvote for Tackling the Awkward Squad. A really nice paper. No surprise, since the author is the great Simon Peyton Jones :)
The solution is not really depending on monads. You pass "the world" as a parameter to read. If you pass a different world, you will get a different output. All function which interact with the world get a "world" parameter and returns a new modified "world". So now all IO operations are pure!

Monads are used as a trick to hide the "world" argument, so you can't cheat by e.g by reusing the same world twice. Maybe something like ownership in Rust could be used for the same effect?

Yep, if you have linear types then you could model IO with functions that explicitly accept and return the world.
I thought Monads were needed for IO to make sure IO operations happened in the correct order?
No, you can force operations to execute in linear order without the use of monads, as long as one operation depends on the output of the previous. But you have to be careful not to reuse values.

Imagine if the IO operations took an explicit world parameter instead of using a monad:

    main :: World -> World
    main world1 =
       let world2 = putStrLn world1 "Hello, what's your name?"  
       let (world3, name) <- getLine world2
       let world4 = putStrLn world3 ("Hey " ++ name ++ ", you rock!")  
       in world4
The IO operations would be executed in the expected order due to the dependency of the "world" output of the previous operation. Monads not necessary.

But if you accidentally used world2 twice then you would split the universe into two timelines. A monad can avoid this issue by hiding the world parameter and passing it on behind the scenes.

what actual concrete, real world, non sci-fi sounding problem does "splitting the timelines" represent?
It would just mean that IO operations would happen in random order or not at all, due to the laziness of Haskell. E.g. if you call getLine twice with the same "world" argument, then it would presumably only be executed once. And if you don't "consume" the returned world state from an IO operation, then the operation would not be executed at all.

Haskell does allow you to do something like this with the unsafePerformIO "backdoor".

The documentation states: "If the I/O computation wrapped in unsafePerformIO performs side effects, then the relative order in which those side effects take place (relative to the main I/O trunk, or other calls to unsafePerformIO) is indeterminate"

A less poetic way of stating it: if you re-use an already-used world state, you are violating a logical constraint of the system. No timelines will be violated, you just have an invalid program.

World states are supposed to consumed once only: they are "linearly typed" in fancy language. But Haskell cannot directly enforce the linear-type constraint on world states, so in theory these states could be used more than once. Fortunately, the IO monad encapsulates (hides) the states, so that you can't make this mistake in your application code -- you can't reuse what you can't access.

World-states in Haskell aren't first-class values. They aren't actual objects that are being passed around during the execution of the program. They are more like tokens that exist during type checking, but are erased when the program is compiled or interpreted.

Another way of phrasing it is that Haskell interpreters/compilers have specialized support for the IO monad: during type checking, it acts as if these states exist, but they have no operational meaning in the executed program.

> you are violating a logical constraint of the system

I'm looking for something even a little more concrete than this. Why would the program be invalid? Can you give an example of what would happen if we just went ahead and accessed a worldstate a second time, logical constraints be damned?

Due to HN's limitations, I can't reply to your reply, but I can reply here instead. :)

I'm not sure this is a helpful metaphor, but I don't think it's completely awful:

Think of a world state as being a variable representing a timestamp. Until the state is used up, the variable is empty.

Suppose I read from a file. I get a brand new state back from the file-reading action -- let's call it WorldState1 -- whose contents are "The time is now X." We don't have a value in X, it's just a variable. (You could think of it as "The time is now <NULL>" if that makes more sense.)

Later, when we use our world state in a subsequent action, X is resolved to an actual time. Now, WorldState1 means "the time is now 09:57:53.00001".

Later in your program (say, 09:57:53.00999), you try to reuse the same state. The system tries to write the current timestamp (.00999) into the state variable... but it fails, because the state already contains a value. The system requires that these variables can only be written once.

If these states existed at runtime, you would get a runtime error in your program. Instead, the type-checker simulates the execution of your program during compilation, realizes that the state would be written twice, and raises a compile-time error.

Again, it's just a metaphor. But hopefully it helps to get an intuition about these usable-once-only values.

-----

The problem with metaphors is that there are so many of them, and they are all wrong. :)

I just thought of another that might click better. Think of a world state as a container that is holding a little bit of potential energy. Like a tiny battery.

Every time you take an I/O action, you need to power it up. An I/O action converts potential energy into kinetic energy.

Once you use up a world state in an action, the battery is drained. It can't be recharged, so the battery is just dead.

Fortunately, each I/O action returns, along with its output, a brand new little battery that you can pass on to the next action.

At any point in your program, you have exactly one charged battery on hand (and a pile of used-up ones). So it only makes sense to use the charged one in your next I/O action.

Yes, this is the correct answer. For everything else you don't need monads. Monads impose an evaluation order, which Haskell by default does not have, since it is lazy.
Monads do not impose an evaluation order in Haskell. Monads let you thread state between computations, but that state may be lazily evaluated out-of-order.

The belief that monads sequence IO operations is why new Haskell programmers have difficulty writing functions like "open file, read file contents, close file, and return contents". They assume the contents will be read before the file closes, and not when the contents are lazily evaluated up the call chain (leading to a "can't read closed file" error).

> hey, you know if you have a purely functional language, it would be impossible to have IO, since e.g. a read function would have different outputs every time. We solve that with monads. But that didn't feel like an explanation.

In a pure language you can’t have functions that return the contents of a file, or prints to stdout, or anything like that.

So what do you do? Instead of performing those computations in you program, you have to return (in the main function) a bunch of lambdas chained together that will be executed by the runtime that called the main function in the first place. Inside this chain of lambdas you will probably call the rest of your program.

The Haskell function you can call that “prints a string” doesn’t do much: it just returns some dummy structure (similar to an AST node, but you can chain your pure functions there too!) that will be read and executed later by the runtime. All the work is done later!

Your “main” in Haskell returns immediately, it doesnt do much. It does zero IO.

This is similar to how promises work in JavaScript: you return a structure instead of the result. Also similar to how React works (If you know its internals) but don’t let that distract you!!!

This is how the IO monad works.

People later discovered that this kind of structure could be used to a lot of stuff, and other monads (State monad, Maybe monad, Free monad) were born.

But these others don’t use the IO runtime.

Also look up “functional core imperative shell”, this is how IO monad and the IO runtime works.

Actually I/O in general is something that almost always comes from outside the language, so to speak. How do you define a file writing function in C? You have to appeal to some already existing magic syscall which actually cannot be formally defined in the base language. It has no real semantics in the base language, it’s a magic function-like thing that happens to have a “side effect.”

Same with Haskell, only that the base language semantics absolutely forbids functions with side effects, so you have to think of a different way to model it. The IO type is that way. It models effects not as side effects of function application but with these explicitly sequenced command values that are returned from the main function.

It’s just a different formalism. Just like how math equations don’t themselves have any physical effects but can still model physical systems in different ways. Computer languages don’t do I/O, they model and represent, with different semantics and restrictions.

You are right, but this is something that is not usually emphasized. I think I could have been persuaded back then that with enough cleverness you could write printf out of if's and while's :)

Also, some years ago I even thought -- but not consciously -- that you could implement preemptive multitasking just from software (you can do it trivially via emulation, but it's slow and most importantly it's not what happens in actual OSes, where the code you write is the actual code that runs on the CPU). I think when I began reading about assembly and OS development, I was expecting the solution to appear at some point. But again, I was not aware of it (the moment I became, I realized how absurd it was), so I got disappointed in assembly without even knowing why.

According to one of my lecturers, who knows both Haskell and is a Category Theorist, and according to some of my fellow students at the time, monads in functional programming should not be seen as strict versions of the category theoretical concept.

The key concepts for FP are: you are able to reference system state, you are able to leverage composibility better than non-FP languages, you can abstract and prove things around how your program should behave.

I have a copy of Category Theory for Programmers that I haven't read in earnest, but I would think that the section on monads can clarify the main differences between the mathematical monad and the FP monad.

> According to one of my lecturers, who knows both Haskell and is a Category Theorist, and according to some of my fellow students at the time, monads in functional programming should not be seen as strict versions of the category theoretical concept.

Indeed, e.g. lists are monadic, but it is more natural to talk about maps from one list to another, and folding.

But neither is a fundamental monad operation. The natural operation for monads in the example of lists is flatmap, which takes in a function (X -> List Y), and a List X, and returns a List Y. The `map` function is a less powerful sibling of flatmap, and the `fold` operation is more powerful and general than flatmap.

So whereas CT monads distinguish one (admittedly very powerful) notion of composability, monad-like notions in FP may find other notions of composability more natural.

Of course, the main draw of using monads in FP is with the way it quarantines off state and context.

The execution of the main function has side effects. All io functions have side effects that are only observable by main. The print function itself is a side effect. Yes I'm talking about Haskell.

What the io monad does is force the user seperate pure functions and impure functions via type checking.

I think it's sort of incorrect to say Haskell is pure. It's more that only from a certain perspective Haskell is pure.

the way the impossibility is resolved is that the language itself isn't responsible for doing io, but rather it is responsible for (purely) constructing a value which represents an io computation to be performed. this value is constructed by using the bind operator to combine simpler io computation values together. that computation is then actually executed by the runtime system, which is conceptually external to the language itself. The language knows about evaluation of pure expressions, the runtime knows about execution of effectful actions.
You're referring to the axioms defined by the type class.

The actual math definition is pretty deep and requires knowledge and intuition on what a functor is and what a natural transformation is.

Yeah, but I know what those are, I have a math background.
here is a good wiki resource that helped me to understand the use of monads.

https://wiki.haskell.org/IO_inside