The Maybe Monad
Monads sound fancy, but sometimes they’re actually really simple and useful. Today we’ll look at the Maybe
monad, which is a low-overhead tool that’s extremely useful to prevent bugs.
It’s very common for a function to return an error code like -1
or null
. The caller of this function may or may not check the return value to see if the error code was returned. Various bugs occur when the caller doesn’t check and uses the returned error code. For -1
, the error might be that a value is unexpectedly negative. For null
, the error might be a NullReferenceException
being thrown.
As an example, consider a function to compute a square root:
public static double MySqrt(double val) { return Math.Sqrt(val); }
If the parameter passed is negative or NaN
, Math.Sqrt
returns NaN
. If the parameter passed is positive infinity, Math.Sqrt
returns positive infinity. Now let’s look at a caller of this function:
void ShowScore(double x) { guiText.text = "Score: " + MySqrt(x); }
If the x
parameter happens to be negative, for example, then the computed score will be NaN
and so the GUI will show Score: NaN
which is clearly a bad experience for the player.
Now let’s rewrite MySqrt
using the Maybe
monad which we’ll later create:
public static Maybe<double> Sqrt(double val) { double sqrt = Sqrt(val); return !double.IsNaN(sqrt) && !double.IsPositiveInfinity(sqrt) ? sqrt : Maybe<double>.Nothing; }
Two changes were made here. First, we return a Maybe<double>
instead of a double
. This signals to callers that the function may fail by telling them that we’ll only maybe return a valid double
, not all the time. Second, we return a valid Maybe<double>
with the computed double
when Math.Sqrt
succeeds and Nothing
when Math.Sqrt
fails.
Now let’s look at the caller:
void ShowScore(double x) { guiText.text = "Score: " + MySqrt(x).Value; }
All we needed to change here is to explicitly get the returned value from with .Value
.
It seems at this point like we’ve just introduced some extra typing and that ShowScore
is still ignoring the possibility of using an error code return value. There’s more warning in the name Maybe
and requirement to work around it by typing .Value
, but both may be ignored and cause errors.
The major opportunity here is to implement error checking into the Value
property. Maybe
can keep track of whether a value has been set and Value
can check that flag to take action when no value is set. Here’s the general strategy:
public struct Maybe<T> { private bool hasValue; private T value; public Maybe(T value) { hasValue = true; this.value = value; } public T Value { get { Assert.IsTrue(hasValue, "Can't get Maybe<T> value when not set"); return value; } } public static Maybe<T> Nothing { get { return default(Maybe<T>); } } }
So when assertions are enabled in debug builds of the game then calling .Value
will result in an assertion if a value wasn’t passed to the constructor and no assertion if a value was passed to the constructor. In release versions of the game where assertions are disabled, the assertion is removed and the value is simply returned regardless of whether a value was passed to the constructor or not.
At this point it’s worth noting that Maybe<T>
is very similar to Nullable<T>
and its syntactic sugar version T?
. The key difference is that Nullable<T>
throws exceptions in both debug and release builds while Maybe<T>
uses debug-only assertions. This means that Maybe<T>
has very little overhead in release builds.
To understand better what the Maybe<T>.Value
property returns, let’s look at all four possible runtime configurations:
Debug | Release | |
---|---|---|
Has Value | Value | Value |
No Value | Assertion | default(T) |
In comparison, Nullable<T>.Value
behaves like this:
Debug | Release | |
---|---|---|
Has Value | Value | Value |
No Value | Exception | Exception |
Both Maybe
and Nullable
are implementations of the maybe promise, but Maybe
provides an option that uses assertions instead of exceptions for those so inclined.
Now let's flesh out the Maybe
type:
using UnityEngine.Assertions; /// <summary> /// The "maybe" monad, enforced by Unity assertions /// </summary> /// /// <example> /// Create one with a value like this: /// <code> /// Maybe{int} maybe = new Maybe{int}(123); /// </code> /// /// Create one without a value like this: /// <code> /// Maybe{int} maybe = Maybe{int}.Nothing; /// </code> /// </example> /// /// <author> /// Jackson Dunstan, https://JacksonDunstan.com/articles/4930 /// </author> /// /// <license> /// MIT /// </license> public struct Maybe<T> { /// <summary> /// If the value is set. True when the constructor is called. False /// otherwise, such as when `default(T)` is called. /// </summary> private bool hasValue; /// <summary> /// The value passed to the constructor or `default(T)` otherwise /// </summary> private T value; /// <summary> /// Create the <see cref="Maybe{T}"/> with a set value /// </summary> /// /// <param name="value"> /// Value to set /// </param> public Maybe(T value) { this.value = value; hasValue = true; } /// <summary> /// Create a <see cref="Maybe{T}"/> with no set value /// </summary> /// /// <value> /// A <see cref="Maybe{T}"/> with no set value /// </value> public static Maybe<T> Nothing { get { return default(Maybe<T>); } } /// <summary> /// Convert a value to a a <see cref="Maybe{T}"/> /// </summary> /// /// <returns> /// The created <see cref="Maybe{T}"/> with the converted value set /// </returns> /// /// <param name="value"> /// Value to convert /// </param> public static implicit operator Maybe<T>(T value) { return new Maybe<T>(value); } /// <summary> /// Convert the <see cref="Maybe{T}"/> to its value. Throws an exception to /// ensure that the value is set. /// </summary> /// /// <returns> /// The value of the converted <see cref="Maybe{T}"/> /// </returns> /// /// <param name="maybe"> /// The <see cref="Maybe{T}"/> to get the value of /// </param> public static explicit operator T(Maybe<T> maybe) { Assert.IsTrue(maybe.hasValue, "Can't convert Maybe<T> to T when not set"); return maybe.Value; } /// <summary> /// Convert a <see cref="Maybe{T}"/> to a bool by returning whether it's set /// </summary> /// /// <returns> /// If the <see cref="Maybe{T}"/> has a set value /// </returns> /// /// <param name="maybe"> /// The <see cref="Maybe{T}"/> to convert /// </param> public static implicit operator bool(Maybe<T> maybe) { return maybe.hasValue; } /// <summary> /// Convert a <see cref="Maybe{T}"/> to a bool by returning whether it's set /// </summary> /// /// <returns> /// If the <see cref="Maybe{T}"/> has a set value /// </returns> /// /// <param name="maybe"> /// The <see cref="Maybe{T}"/> to convert /// </param> public static bool operator true(Maybe<T> maybe) { return maybe.hasValue; } /// <summary> /// Convert a <see cref="Maybe{T}"/> to a bool by returning whether it's /// <i>not</i> set /// </summary> /// /// <returns> /// If the <see cref="Maybe{T}"/> does <i>not</i> have a set value /// </returns> /// /// <param name="maybe"> /// The <see cref="Maybe{T}"/> to convert /// </param> public static bool operator false(Maybe<T> maybe) { return !maybe.hasValue; } /// <summary> /// Whether a value is set /// </summary> /// /// <value> /// If a value is set, i.e. by the constructor /// </value> public bool HasValue { get { return hasValue; } } /// <summary> /// Get the value passed to the construtor or assert if the constructor was /// not called, e.g. by creating with `default(T)`. /// </summary> /// /// <value> /// The set value /// </value> public T Value { get { Assert.IsTrue(hasValue, "Can't get Maybe<T> value when not set"); return value; } } /// <summary> /// Get the value passed to the construtor or `default(T)` if the /// constructor was not called, e.g. by creating with `default(T)`. /// </summary> /// /// <returns> /// The value if set or `default(T)` if not set /// </returns> public T GetValueOrDefault() { return hasValue ? value : default(T); } /// <summary> /// Get the value passed to the construtor or the parameter if the /// constructor was not called, e.g. by creating with `default(T)`. /// </summary> /// /// <returns> /// The value if set or the parameter if not set /// </returns> /// /// <param name="defaultValue"> /// Value to return if the value isn't set /// </param> public T GetValueOrDefault(T defaultValue) { return hasValue ? value : defaultValue; } }
The above code expands on the basics by adding a lot of convenience features and commenting. Here's what they're all used for:
// Convert a T to a Maybe<T>: // public static implicit operator Maybe<T>(T value) Maybe<int> maybeInt = 123; // Get the value from a Maybe<T> by casting: // public static explicit operator T(Maybe<T> maybe) int i = (int)maybeInt; // Treat a Maybe<T> like a bool: // public static implicit operator bool(Maybe<T> maybe) // public static bool operator true(Maybe<T> maybe) // public static bool operator false(Maybe<T> maybe) if (maybeInt) { print("maybeInt has the value: " + maybeInt.Value); } // Check if the Maybe<T> has a value: // public bool HasValue if (maybeInt.HasValue) { print("maybeInt has the value: " + maybeInt.Value); } // Get the value if set or default(T) if not: // public T GetValueOrDefault() int i2 = maybeInt.GetValueOrDefault(); // 123 int i3 = Maybe<int>.Nothing.GetValueOrDefault(); // 0 // Get the value if set or the parameter if not: // public T GetValueOrDefault(T defaultValue) int i4 = maybeInt.GetValueOrDefault(456); // 123 int i5 = Maybe<int>.Nothing.GetValueOrDefault(456); // 456
Next time you're considering returning an error code, be it -1
, null
, string.Empty
, or anything else, consider using a Maybe<T>
or instead. You'll express to callers that you might not return a valid value and you'll catch issues in development when callers accidentally try to use the invalid value before they become real bugs in shipping code.
#1 by Evgeniy on October 1st, 2018 ·
Hi
“The key difference is that Nullable throws exceptions in both debug and release builds while Maybe uses debug-only assertions. This means that Maybe has very little overhead in release builds”
But throwing when you try get null value this is helpful to find errors.
We have no overhead in get Nullable.Value becouse this is not using “try/catch”:
Sorry for my english
#2 by jackson on October 1st, 2018 ·
It’s true that throwing an exception in release builds could help you find errors. The exception would likely crash the game and your crash reporting tool (e.g. Crashlytics) would surface that issue to you, the developer. Unfortunately for your player, the game would have crashed. However, continuing on without crashing by using the
default(T)
value returned byMaybe<T>.Value
may lead to a crash later on that’s harder to track down. Regardless, IL2CPP will generate managed exception-related code even if the exception is never thrown and that will entail a performance hit.Keeping in mind these ramifications of using exceptions or assertions, you’ll be able to decide on a case-by-case basis what’s best for your game.
Maybe<T>.Value
just gives you the option to use assertions instead of exceptions.#3 by Liam on October 1st, 2018 ·
Thanks! This had been the bane of my AS3 life many years ago :)
#4 by JamesM on October 1st, 2018 ·
public static Maybe Sqrt(double val)
Seems to have the wrong return values from the ternary operator.
#5 by jackson on October 1st, 2018 ·
It looks right to me:
#6 by Julien on October 1st, 2018 ·
it’s missing the closing bracket after gameobject. But maybe that’s just to emphasize the invalidity :)
#7 by jackson on October 1st, 2018 ·
Ah! The example was totally changed while writing the article and it seems that some of the old
GameObject
-based example got left in. I’ve updated the article to change it toMaybe<double>
and add the closing bracket. Thanks for pointing this out!#8 by Yaniv Shaked on October 1st, 2018 ·
It should also be “sqrt” instead of “player”…?
#9 by jackson on October 1st, 2018 ·
That was also left over from the old example. I’ve updated the article with the fix. Thanks!
#10 by Bob on October 3rd, 2018 ·
This is really helpful, thanks for posting! However, it is not really a monad yet. In its current version it is rather what is called a sum type (or tagged union) in other languages.
What is missing for it to be a monad is an operation to chain operations on values together. In some languages this operation is called `bind`, in others it’s `flatMap`, but it all boils down to combining several values to get one value in the end.
For
Maybe
the operation could look something like this:In other words, if the value is already
Nothing
then we don’t bother executing the remaining methods in line. If we do have a value however, we unpack that value from ourMaybe
and feed it into the next method.What is useful about this is, that we can have several methods that return
Maybe
-values and we just chain them together without constantly having to check for theNothing
case. TheBind
method will take care of that for us. (The not so beautiful part is, that the code looks pretty ugly when we have a long chain ofBind
operations. That’s why Haskell and F# and others have special syntax for monads)I’m not an expert on this, so I might’ve gotten some details wrong, but that is basically how it works.
#11 by jackson on October 3rd, 2018 ·
Good point. I minimized the functional programming concepts in the article in favor of making a more familiar (for C#)
Nullable<T>
alternative using assertions instead of exceptions. The good part about C# is that yourBind
function can be tweaked slightly so that it’s added on as an extension method without needing to modifyMaybe<T>
:#12 by Bob on October 4th, 2018 ·
Minimizing the fp concepts is completely fine of course. But I also think that if we apply the name “monad” to things which are not one, it becomes harder for people who are not familiar with the concept to understand what a monad actually is. So I think it is better to talk about the “maybe type” or the “maybe struct” in this case.