The Maybe monad by Mark Seemann
The Maybe sum type forms a monad. An article for object-oriented programmers.
This article is an instalment in an article series about monads.
The Maybe sum type is a useful data type that forms a functor. Like many other useful functors, it also forms a monad.
It can be useful when querying another data structure (like a list or a tree) for a value that may or may not be present. It's also useful when performing a calculation that may not be possible, such as taking the square root of of a number, or calculating the average of a collection of values. A third common use case is when parsing a value, although usually, the Either sum type is often a better option for that task.
Bind #
A monad must define either a bind or join function. In C#, monadic bind is called SelectMany
. Depending on how you've implemented Maybe<T>
the details of the implementation may vary, but the observable behaviour must always be the same. In this article I'm going to continue to use the Maybe<T>
class first shown in the article about the Maybe functor. (That's not really my favourite implementation, but it doesn't matter.)
In that code base, I chose to implement SelectMany
as an extension method. Why I didn't use an instance method I no longer remember. Again, this doesn't matter.
public static Maybe<TResult> SelectMany<T, TResult>( this Maybe<T> source, Func<T, Maybe<TResult>> selector) { if (source.HasItem) return selector(source.Item); else return new Maybe<TResult>(); }
If the source
has an item, SelectMany
calls selector
with the value and returns the result. If not, the result is an empty Maybe<TResult>
.
Monadic bind becomes useful when you have more than one function that returns a monadic value. Consider, for example, this variation of Average
:
public static Maybe<double> Average(IEnumerable<double> values) { if (values.Any()) return new Maybe<double>(values.Average()); else return new Maybe<double>(); }
You can't calculate the average of an empty collection, so if values
is empty, this function returns an empty Maybe<double>
.
If you only needed this Average
function, then you could use Maybe's functor affordance to map the result. On the other hand, imagine that you'd like to pass the Average
result to this Sqrt
function:
public static Maybe<double> Sqrt(double d) { var result = Math.Sqrt(d); switch (result) { case double.NaN: case double.PositiveInfinity: return new Maybe<double>(); default: return new Maybe<double>(result); } }
This is another calculation that may not be possible. If you try to compose Average
and Sqrt
with Select
(the functor's map function), you'd get a Maybe<Maybe<double>>
. Fortunately, however, monads are functors you can flatten:
[Theory] [InlineData(new double[0], -100)] [InlineData(new double[] { 5, 3 }, 2)] [InlineData(new double[] { 1, -2 }, -100)] public void ComposeAverageAndSqrt(double[] values, double expected) { Maybe<double> actual = Average(values).SelectMany(Sqrt); Assert.Equal(expected, actual.GetValueOrFallback(-100)); }
The SelectMany
method flattens as it goes. Viewed in this way, Scala's flatMap
seems like a more appropriate name.
The above test demonstrates how SelectMany
returns a flattened Maybe<double>
. The first test case has zero elements, so the Average
method is going to return an empty Maybe<double>
. SelectMany
handles that gracefully by returning another empty value.
In the second test case Average
returns a populated value that contains the number 4
. SelectMany
passes 4
to Sqrt
, which returns 2
wrapped in a Maybe<double>
.
In the third test case, the average is -.5
, wrapped in a Maybe<double>
. SelectMany
passes -.5
on to Sqrt
, which returns an empty value.
Query syntax #
Monads also enable query syntax in C# (just like they enable other kinds of syntactic sugar in languages like F# and Haskell). As outlined in the monad introduction, however, you must add a special SelectMany
overload:
public static Maybe<TResult> SelectMany<T, U, TResult>( this Maybe<T> source, Func<T, Maybe<U>> k, Func<T, U, TResult> s) { return source.SelectMany(x => k(x).Select(y => s(x, y))); }
As I also mentioned in the monad introduction, it seems to me that you can always implement this overload with the above expression. Once this overload is in place, you can rewrite the above composition in query syntax, if that's more to your liking:
[Theory] [InlineData(new double[0], -100)] [InlineData(new double[] { 5, 3 }, 2)] [InlineData(new double[] { 1, -2 }, -100)] public void ComposeAverageAndSqrtWithQuerySyntax(double[] values, double expected) { Maybe<double> actual = from a in Average(values) from s in Sqrt(a) select s; Assert.Equal(expected, actual.GetValueOrFallback(-100)); }
In this case, query syntax is more verbose, but the behaviour is the same. It's just two different ways of writing the same expression. The C# compiler desugars the query-syntax expression to one that composes with SelectMany
.
Join #
In the introduction you learned that if you have a Flatten
or Join
function, you can implement SelectMany
, and the other way around. Since we've already defined SelectMany
for Maybe<T>
, we can use that to implement Join
. In this article I use the name Join
rather than Flatten
. This is an arbitrary choice that doesn't impact behaviour. Perhaps you find it confusing that I'm inconsistent, but I do it in order to demonstrate that the behaviour is the same even if the name is different.
The concept of a monad is universal, but the names used to describe its components differ from language to language. What C# calls SelectMany
, Scala calls flatMap
, and what Haskell calls join
, other languages may call Flatten
.
You can always implement Join
by using SelectMany
with the identity function.
public static Maybe<T> Join<T>(this Maybe<Maybe<T>> source) { return source.SelectMany(x => x); }
Instead of flattening as you go with SelectMany
, you could also use Select
and Join
to compose Average
and Sqrt
:
[Theory] [InlineData(new double[0], -100)] [InlineData(new double[] { 5, 3 }, 2)] [InlineData(new double[] { 1, -2 }, -100)] public void JoinAverageAndSqrt(double[] values, double expected) { Maybe<double> actual = Average(values).Select(Sqrt).Join(); Assert.Equal(expected, actual.GetValueOrFallback(-100)); }
You'd typically not write the composition like this, as it seems more convenient to use SelectMany
, but you could. The behaviour is the same.
Return #
Apart from monadic bind, a monad must also define a way to put a normal value into the monad. Conceptually, I call this function return (because that's the name that Haskell uses). You don't, however, have to define a static method called Return
. What's of importance is that the capability exists. For Maybe<T>
in C# the idiomatic way would be to use a constructor. This constructor overload does double duty as monadic return:
public Maybe(T item) { if (item == null) throw new ArgumentNullException(nameof(item)); this.HasItem = true; this.Item = item; }
Why is it that overload, and not the other one? Because if you try to use new Maybe<T>()
as return, you'll find that it breaks the monad laws. Try it. It's a good exercise.
Left identity #
Now that we're on the topic of the monad laws, let's see what they look like for the Maybe monad, starting with the left identity law.
[Theory] [InlineData(0)] [InlineData(1)] [InlineData(2)] public void LeftIdentity(int a) { Func<int, Maybe<int>> @return = i => new Maybe<int>(i); Func<int, Maybe<double>> h = i => i != 0 ? new Maybe<double>(1.0 / i) : new Maybe<double>(); Assert.Equal(@return(a).SelectMany(h), h(a)); }
To provide some variety, h
is another Maybe-valued function - this time one that performs a safe multiplicative inverse. If i
is zero, it returns an empty Maybe<double>
because you can't devide by zero; otherwise, it returns one divided by i
(wrapped in a Maybe<double>
).
Right identity #
In a similar manner, we can showcase the right identity law as a test.
[Theory] [InlineData("")] [InlineData("foo")] [InlineData("42")] [InlineData("1337")] public void RightIdentity(string a) { Maybe<int> f(string s) { if (int.TryParse(s, out var i)) return new Maybe<int>(i); else return new Maybe<int>(); } Func<int, Maybe<int>> @return = i => new Maybe<int>(i); Maybe<int> m = f(a); Assert.Equal(m.SelectMany(@return), m); }
Again, for variety's sake, for f
I've chosen a function that should probably be named TryParseInt
if used in another context.
Associativity #
The last monad law is the associativity law that we can demonstrate like this:
[Theory] [InlineData("bar")] [InlineData("-1")] [InlineData("0")] [InlineData("4")] public void Associativity(string a) { Maybe<int> f(string s) { if (int.TryParse(s, out var i)) return new Maybe<int>(i); else return new Maybe<int>(); } Func<int, Maybe<double>> g = i => Sqrt(i); Func<double, Maybe<double>> h = d => d == 0 ? new Maybe<double>() : new Maybe<double>(1 / d); var m = f(a); Assert.Equal(m.SelectMany(g).SelectMany(h), m.SelectMany(x => g(x).SelectMany(h))); }
Here, I've copy-and-pasted the TryParseInt function I also used in the right identity example, and combined it with the Sqrt
function and a safe multiplicative inverse. The law, however, should hold for all pure functions that type-check. The functions shown here are just examples.
As usual, a parametrised test is no proof that the law holds. I provide the tests only as examples of what the laws looks like.
Conclusion #
The Maybe sum type is a versatile and useful data structure that forms a monad. This enables you to compose disparate functions that each may err by failing to return a value.
Next: An Either monad.