Monads explained to my team — Part 3: Creating a monad

lomig

Lomig

Posted on July 7, 2022

Monads explained to my team — Part 3: Creating a monad

Monoid, Endofunctors… Lots of theoretical pieces have been exposed in the 2 previous parts, but it's time to try and practice with Monads.

  

Once upon a time, there were boxes

Let's create a box, to store things like numbers.

  • The initializer puts the value inside the box
  • Box#value! unbox the value and gives it back.
class Box
  private attr_reader :value

  def initialize(value)
    @value = value
  end

  def value! = value
end
Enter fullscreen mode Exit fullscreen mode

  

This box does not belong to Schrödinger

That looks utterly useless! So let's add a concept: these boxes can be full (with a value), or empty (value being nil).

So, what if we want to add 2 to such a boxed number?

  • The box is a container
  • We have to take its elements (even though there is only one here) and apply our function to add 2
  • We put back the element in the box, and then we return it

… and what if the box is empty? Well, we return an empty Box, then!

class Box
  private attr_reader :value

  def initialize(value)
    @value = value
  end

  def value! = value

  def add(x)
    return self unless value

    Box.new(value + x)
  end
end

Box.new(nil).add(4) #=> #<Box:0x0000000 @value=nil>
Box.new(130).add(4) #=> #<Box:0x0000000 @value=134>
Enter fullscreen mode Exit fullscreen mode

  

Making the box content operation-agnostic

The code above is fine, but if we want to do something else than adding numbers, we would have to add methods all over for each operation. We can do better.

Is there a way to extract something from a container, apply a function to it, and put it back? It was the subject of the previous article: let's make it a functor!

class Box
  private attr_reader :value

  def initialize(value)
    @value = value
  end

  def value! = value

  def map
    return self unless value

    Box.new(yield(value))
  end
end

Box.new(nil).map { |x| x + 4 } #=> #<Box:0x0000000 @value=nil>
Box.new(130).map { |x| x + 4 } #=> #<Box:0x0000000 @value=134>
Enter fullscreen mode Exit fullscreen mode

  

Nested boxes are the worst…

So, with a Box, we can perform actions on the content without any need to trouble ourselves checking for nil values where we are not in control!

def double(x) = 2 * x

user_count = Box.new(UserCountAPI.call)
#=> #<Box:0x0000000 @value=25>

shoes_count = user_count.map { |x| double(x) }
#=> #<Box:0x0000000 @value=50>
Enter fullscreen mode Exit fullscreen mode

It's cool enough for us to use the Box at some different places in the code. But sometimes, it could create unforeseen problems; for an example, when we are unaware that a method already uses a Box:

def double(x) = Box.new(2 * x)

user_count = Box.new(UserCountAPI.call)
#=> #<Box:0x0000000 @value=25>

shoes_count = user_count.map { |x| double(x) }
#=> #<Box:0x0000000 @value=#<Box:0x0000000 @value=50>>
Enter fullscreen mode Exit fullscreen mode

… a box inside a box? That's a problem!

To avoid such an issue, we would needs to implement a different #map method that would flatten the result: #flat_map

class Box
  private attr_reader :value

  def initialize(value)
    @value = value
  end

  def value! = value

  def map
    return self unless value

    Box.new(yield(value))
  end

  def flat_map(&block)
    map(&block).flatten
  end

  def flatten
    return self unless value

    value
  end
end

Box.new(nil).map { |x| x + 4 } #=> #<Box:0x0000000 @value=nil>
Box.new(130).map { |x| x + 4 } #=> #<Box:0x0000000 @value=134>
Box.new(130).flat_map { |x| Box.new(x + 4) } #=> #<Box:0x0000000 @value=134>
Enter fullscreen mode Exit fullscreen mode

And there we have it! A monad is just that:

  • a mappable Box,
  • a method (Box.new) to put something in the Box,
  • a flat_map method as an extension of the map method.

That's a lot of fuss for something quite simple, right?

  

Useless bit of information

Monads are indeed monoids!

  • All Box instances (that are endofunctors) represent the monoid set
  • #flat_map is a closure. As per the first article:
Box.new(4).flat_map { |x| Box.new(2 * x) } #=> #<Box:0x0000000 @value=8>

# A_THING  COMBINED_WITH  ANOTHER_THING     => ANOTHER_THING
Enter fullscreen mode Exit fullscreen mode
  • Box.new is the identity element
def double(x) = Box.new(2 * x)
content = 4

# == equality assuming we made Box be comparable
Box.new(content).flat_map { |x| double(x) } == double(content)

Box.new(content).flat_map { |x| Box.new(x) } == Box.new(content)
Enter fullscreen mode Exit fullscreen mode
  • #flat_map is associative
def double(x) = Box.new(2 * x)
def triple(x) = Box.new(3 * x)
content = 4

a = Box.new(content).flat_map { |x| double(x) }
                    .flat_map { |x| triple(x) }

b = Box.new(content).flat_map do |x|
  double(x).flat_map { |y| triple(y) }
end

# == equality assuming we made Box be comparable
a == b 
Enter fullscreen mode Exit fullscreen mode

  

So what's next?

You can see that a monad is not that complicated a concept, even though its usefulness may not be clear right at once.

Don't worry, we'll develop real-ish example in the next article!

💖 💪 🙅 🚩
lomig
Lomig

Posted on July 7, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related