Fun with flat_map (in Ruby)
How I accidentally learned about...
Let's look at computing squares and square-roots using map
.
How do we represent that there's no answer? Let's use an array so it may or may not have an element in it.
So our input is an array of one element and the output is an array of an array of zero or one element. Let's get rid of the extra nested array.
What does flatten
actually do?
Flatten will recursively undo the array nesting. We're only really dealing with one extra level of nesting so we can say flatten(1)
.
This idea of performing a map
followed by a flatten(1)
is a common pattern so it actually has its own combined operation: flat_map
.
Just for kicks, what if we were to give flat_map
multiple values to perform computations upon?
It drops any empty array results from the output. Using map
would collect into the output each array of each computation step, empty or otherwise. You can think of flat_map
as concatenating each step's result to the final output array 'flatly' rather than creating nested array elements. Concatenating an empty array has no effect so does not appear in the final output.
Now let's look at the Optional type and see how it compares.
First set up a Ruby environment with bundler
and the optional
gem.
Create a Gemfile
with the following contents:
Install the bundler named gem.
Now let's use it.
We have the similar situation as before with [-1].map
with a block that returns numbers.
Sure, we get all that, but what's the point of all this?
Notice now the last Optional.none.and_then
never entered the block and merely returned None
.
The purpose of making a function have the form that takes a plain argument and return a 'wrapped' value is that we can chain many operations without intervening checks for presence of values. If at any time during the chain of computation no value is produced, any further operations along the chain will continue to produce no value.
A better example than maybe_root
as we defined it here would be a maybe_whole_root
which only returns roots if the input is a square value. Then if the computation on the value 2
does not return a whole number, further chained computations will continue to return None
.
There are other things than Optional
which can be used with this behaviour. Using an array with a chain of flat_map
operations has a similar effect.
The Results::Result type is very much like an Optional
with the added benefit that when there's no value, the reason for the missing value can be stored and later retrieved.
In addition to 'boxed' values there are other 'wrapped' forms which can be handled by functions that take a plain value and return a 'wrapped-in-some-context' value. Asynchronous computation is such another example.
In general, if we have a domain of input, say numbers denoted by A
and a function that can turn any instance of A
into M[A]
where M
is some context such as Optional
, Result
, Async
, etc, then we can chain computations together using these function without examining or adapting their shapes in-between.
BTW, things like M
are called Monads in Category Theory. Not only can you chain computations within the same context, you can compose contexts, e.g. M1[M2[M3[A]]]
with each context handling a different computational aspect or concern. Some examples would be configuration, logging, generating metrics, sending notifications, and the list is quite endless when decomposing problems this way. In a highly-typed language, the type itself identifies the computations that exactly will have had to occurred (or at least attempted) to produce a value of the type.
Last updated