Hacker News new | ask | show | jobs
by _rend 37 days ago
My personal alternative take to the usual monad tutorial — greatly simplified:

"Functor", "Applicative", and "Monad" are all just generalizations of the concept of `map` and `flatMap`.

  1. Something is a "Functor" if you know how to call `map` on it, nothing more. "I can take a box of things and turn it into a box of other things, 1-to-1". On lists, for example, this is just `map` itself
  2. Something is an "Applicative" if you know how to call `map` on it, but also know how to take a non-boxed value and put it in a box, and also know how to combine boxes in order
  3. Something is a "Monad" if you know how to do all of the above, but also know how to call `flatMap` on it, nothing more. "I can take a box of things, turn each thing into a new box, and then combine them all in order". On lists, for example this is just `concatMap`
There's nothing really more complex to it, besides how you squint at various things (like functions) to fit them into the concept of `map` and `flatMap`.

To answer your questions more directly:

  1. Monads themselves are neither necessary nor sufficient to perform side effects in Haskell; they don't directly enable the effects, but they *do* help place guardrails on the actual unsafe, low-level code which *can* perform the effects, safely and in an ergonomic and composable way
  2. Yes, "Monad" is just a name for a recurring way to approach a problem. Like in most math and programming, a certain repeating pattern was noticed, and given a name. Because of the math origin of the term, you get "Monad" instead of "flat-mappable"
  3. Like any other tool, you reach for a monad when you have a monad-shaped problem. They're just one (powerful) tool for solving certain problems
1 comments

Perhaps you can also explain what a box is, and what a flatMap is please? Fortunately I know what map is already. Also, what does it mean to combine boxes in order? Thanks!
Sure! "Box" here is used to just abstractly describe a value that contains other values. Let's take a list as an example:

  [1, 2, 3] :: [Int]
Here, the "box" is a list, and inside of it are the values 1, 2, and 3.

As you know, `map` is an operation that converts the values inside of the box into other values; for example, adding 1 to every element:

  [1, 2, 3] :: [Int]
   |  |  |     (+ 1)
   v  v  v
  [2, 3, 4] :: [Int]
But the operation you perform with `map` doesn't need to keep the values of the same type:

  [ 1,   2,   3 ] :: [Int]
    |    |    |      (show)
    v    v    v
  ["1", "2", "3"] :: [String]
The operation can also produce new boxes! Since `String` is actually itself a list (`[Char]`), the result above is the same as

  [  1,     2,     3  ] :: [Int]
     |      |      |       (show)
     v      v      v
  [['1'], ['2'], ['3']] :: [[Char]]
In some cases, you might want to "flatten" this box-of-boxes together. In some languages this operation is called "flatten"; for lists in Haskell, it's called `concat`

  [['1'], ['2'], ['3']] :: [[Char]]
     |      |      |       (concat)
     v      v      v
  [ '1',   '2',   '3' ] :: [Char]
This example isn't terribly motivating, but you can see when you have deeper lists-of-lists how this might be handy:

  [[1,2,3], [4,5,6], [7,8,9]] :: [[Int]]
      |        |        |        (concat)
      v        v        v
  [1, 2, 3, 4, 5, 6, 7, 8, 9] :: [Int]
Here, we took a collection of boxes (`[[Int]]`) and combined them in order (sequentially) to produce a new box (`[Int]`).

What other languages call `flatMap` is just a `map` operation followed by a `flatten` operation. Very roughly, `Functor` gives you "map" (`map`), `Applicative` gives you "flatten" (`concat`), and `Monad` gives you "flatMap" (`concatMap`).

The power of these comes from considering different types of "boxes". `Maybe`, for example, works almost like a list that can contain up to 1 element, and its operations behave pretty much identically. Other types are interesting because how you define their "box-ness" can lead to interesting/useful results. It can be tough to envision how, e.g., a function could look like a "box", but it turns out that you can define rules for it that make it useful. (What does "map" look like for a function? Well, it turns out that mapping a function over another function is already just... function composition!)

You can go a lot deeper into these definitions, and it helps to look at some implementations to grok them better, but the core concepts themselves are not very complicated. The "magic" is in how you define the "boxes".

To expand a bit, too, on how these definitions make side effects easier to represent in Haskell:

One way to represent side effects in a purely functional language is to model them as if they aren't side effects, by representing them as state changes in the "outside world". You don't need to grok the specifics of this, but the definition of the `IO` monad is:

  newtype IO a = IO (State# RealWorld -> (# State# RealWorld, a #))
i.e., it's a "pure" transformation of the "real world".

This allows you to define a "box" called `IO` that represents a computation that can perform a side-effect (by affecting the "real world"), then returning a value.

The real trick to this is that the "box" is entirely opaque to you: unlike a list or a `Maybe` where you know how to reach in and pull values _out_ (e.g., `head`, `last`, `fromJust`, etc.), `IO` doesn't allow you to do this*. Once you have something inside of an `IO` box, it's stuck there.

This means that you can separate the "impure" world from the "pure" world: you can't perform side effects arbitrarily — you're can only do so in an `IO` context that's intentionally "viral".

The functor/applicative/monad rules just make `IO` easier to use and consume:

  1. `Functor` allows you to "map" over the results of a computation
  2. `Applicative` allows you to chain computations together in order so side effects happen in sequence
  3. `Monad` makes it easier to repeatedly chain computations within a single `IO` context (so if you need to perform repeated side effects, you can "stay" in the outer context — `IO a` instead of `IO (IO (IO (IO (... (IO a)))))`)
This is just one way to represent side effects, and the monad rules are only really needed to make this representation ergonomic to actually use.

(*There is technically a way to "escape" the `IO` monad called `unsafePerformIO`, but you basically never need to use this. If you find yourself reaching for it, don't.)