Monads explained to my team — Part 3: Creating a monad
Lomig
Posted on July 7, 2022
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
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>
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>
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>
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>>
… 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>
And there we have it! A monad is just that:
- a
mappable
Box
, - a method (
Box.new
) to put something in theBox
, - a
flat_map
method as an extension of themap
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
-
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)
-
#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
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!
Posted on July 7, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.