So, in the last article we created a monad, but there's room for improvement — let's pimp it a little then!
For reference, here is the code from the last article:
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
Make it readable!
Let's rename the Box
into something more abstract: it's full or empty, it wraps maybe something, maybe nothing — let's call it a Maybe
object.
I'll also throw in a helper to ease initialization, and I'll override the default #inspect
. (If you are not well versed into Ruby, it allows for the sweet examples below the class
definition)
def Maybe(x) = Maybe.new(x)
class Maybe
private attr_reader :value
def initialize(value)
@value = value
end
def value! = value
def map
return self unless value
Maybe.new(yield(value))
end
def flat_map(&block)
map(&block).flatten
end
def flatten
return self unless value
value
end
def to_s = "Maybe(#{value.inspect})"
alias_method :inspect, :to_s
end
Maybe(nil).map { |x| x + 4 } #=> Maybe(nil)
Maybe(130).map { |x| x + 4 } #=> Maybe(134)
Thanks to inheritance, we also can make the difference between a Maybe_but_in_fact_no and a Maybe_but_in_fact_yes nicer: when a Maybe
has a value, it's a Some
, and if not, it's a None
.
def Maybe(x) = Maybe.new(x).coerce
class Maybe
private attr_reader :value
def initialize(value)
@value = value
end
def value! = value
def map(&block) = coerce.map(&block)
def flat_map(&block) = map(&block).flatten
def flatten
return self unless value
value
end
def coerce
return None.new unless value
Some.new(value)
end
end
class Some < Maybe
def initialize(value)
raise ArgumentError if value.nil?
super
end
def map = Maybe.new(yield(value)).coerce
def to_s = "Some(#{value.inspect})"
alias_method :inspect, :to_s
end
class None < Maybe
def initialize; end
def map = self
def to_s = "None"
alias_method :inspect, :to_s
end
Maybe(nil).map { |x| x + 4 } #=> None
Maybe(130).map { |x| x + 4 } #=> Some(134)
Make it dry-like
The main gem providing monads to Ruby and Rails is called dry-monads
. Of course, my implementation is quite naive compared to theirs, but let's rename our two mapping methods to follow their convention — even if some choices seem strange enough.
I'll change the code a last time as such:
- rename
#map
as#fmap
- rename
#flat_map
as#bind
- add
#none?
and#some?
methods
def Maybe(x) = Maybe.new(x).coerce
class Maybe
private attr_reader :value
def initialize(value)
@value = value
end
def value! = value
def fmap(&block) = coerce.fmap(&block)
def bind(&block) = fmap(&block).flatten
def flatten
return self unless value
value
end
def coerce
return None.new unless value
Some.new(value)
end
def none? = false
def some? = false
end
class Some < Maybe
def initialize(value)
raise ArgumentError if value.nil?
super
end
def fmap = Maybe.new(yield(value)).coerce
def some? = true
def to_s = "Some(#{value.inspect})"
alias_method :inspect, :to_s
end
class None < Maybe
def initialize; end
def fmap = self
def none? = true
def to_s = "None"
alias_method :inspect, :to_s
end
Maybe(nil).fmap { |x| x + 4 } #=> None
Maybe(130).fmap { |x| x + 4 } #=> Some(134)
Maybe(130).bind { |x| Maybe(x + 4) } #=> Some(134)
As a Rubyist, why do I care?
Ruby handles itself quite nicely regarding the Maybe monad, thanks to the safe navigation operator (&.
).
Still, imagine a method that would be used to show the weather for the county of the user. In this example, there are special rules to get the county from the user's zipcode. It could go like that:
def regional_weather(user_id)
return :unavailable unless user_id
user = User.find(user_id)
zipcode = user&.addresses&.first&.zipcode
return :unavailable unless zipcode
county = county_from_zipcode(zipcode)
return :unavailable unless county
WeatherAPI.call(county) || :unavailable
end
def county_from_zipcode(zipcode)
return zipcode unless zipcode.match?(/^\d+$/)
zip = zipcode[..1].to_i
# Paris!
return 75 if [77, 78, 91, 92, 93, 94, 95].include?(zip)
zip
end
Let's use our Maybe monad:
def regional_weather(user_id)
Maybe(user_id)
.fmap { |user_id| User.find(user_id) }
.fmap(&:addresses)
.fmap(&:first)
.fmap(&:zipcode)
.fmap { |zipcode| county_from_zipcode(zipcode) }
.fmap { |county| WeatherAPI.call(county) }
.then { |result| return result.value! if result.some? }
:unavailable
end
def county_from_zipcode(zipcode)
return zipcode unless zipcode.match?(/^\d+$/)
zip = zipcode[..1].to_i
# Paris!
return 75 if [77, 78, 91, 92, 93, 94, 95].include?(zip)
zip
end
Arguably a simpler way of handling nil
values, isn't it?
Going further
Dry-Monads
I hope that I have been able to explain what is a monad in a simple way as well as explaining the usual meme-prone face-palming definition.
But moreover, with this last article I hope that I have been able to show how monads can help a developer maintaining an healthy and clear codebase.
dry-monads is a wonderful gem which implements lots of monad patterns and a DSL to use them in a very efficient way in an existing codebase.
There are also a ton of useful mapping methods built-in in their monads, for example to unwrap results, letting you do things like:
def regional_weather(user_id)
Maybe(user_id)
.fmap { |user_id| User.find(user_id) }
.fmap { |user| user&.addresses&.first&.zipcode }
.fmap { |zipcode| county_from_zipcode(zipcode) }
.fmap { |county| WeatherAPI.call(county) }
.value_or { :unavailable }
end
That's brilliant!
Other Noteworthy Monads
You may find this video interesting when it comes to the Result monad which is used to handle failures and errors:
There is a wonderful article here by @baweaver — don't miss it.
Another common use case that you already heard of is Promise — but unfortunately JS Promises are not monads (it could have been, but unfortunately…)
A logger is also a classic use case of monads.
In any case, thanks for you attention, and merry functional programming!
Top comments (0)