In the mid 1930’s, physicist Erwin Schrödinger published a thought experiment to critique the seeming-paradoxes in the new science of quantum mechanics.
The “Schrödinger’s Cat” problem imagines a cat locked in a box with a device that might or might not release a poison gas depending on a quantum effect, such as radioactive decay of an atom. According to quantum theory, the atom exists in a “superposition” of decayed and non-decayed states simultaneously until “observed”. Therefore, the cat must be both alive and dead until the box is opened, thus “collapsing” the superposition of possibilities to a single outcome.
To this day, Schrödinger’s Cat continues to illuminate different interpretations of quantum theory, but it also happens to be a great analogy for understanding the Maybe monad.
Monads without jargon 🔗︎
Monads have a reputation as an arcane programming art, partly due to the heavy functional programming jargon that comes with them. For this article, it’s sufficient to know the following:
- A monad wraps a “context” (e.g. one or more values)
- A monad can consume a function that operates on the context and returns a new monad. This is often called “binding”.
The Maybe monad represents a computation that might or might not succeed. It’s just like Schrödinger’s box! You put a cat in the box. Things happen to the cat. Eventually, you peek in the box and the cat is either alive or dead.
The trick is this: instead of checking the health of the cat before every operation, you provide a function that assumes a healthy cat, but returns a Schrödinger’s box. If the function succeeded, it puts a new healthy cat in a new box, otherwise it returns the dead-cat box. If the cat was already dead, the function is never applied: once a dead-cat box, always a dead-cat box.
The Maybe monad in Go 🔗︎
Here’s a trivial Maybe monad in Go:
|
|
You can see that the X type has space for a value or an error. Following
Maybe monad convention, we call the valid value just
and use the empty
interface to hold a value of any type.
Let’s give it some constructors:
|
|
One of the constructors sets the just
value, the other the err
value.
That raises an interesting question: what do we do about the zero value
object, where both just
and err
are nil? It seems natural to
conclude that the zero value is invalid, and we can define a method to
encapsulate that logic:
|
|
Now that we have a way to encapsulate a context and determine validity, let’s look at a binding method to apply a function to the context and get back a new Maybe monad:
|
|
If the monad already has an error, Bind
shortcuts and returns it rather
than applying the function. Otherwise, the function is invoked on the
value.
Now that we have a way to construct a Maybe object and mutate it with
Bind
, we need a way to “open the box” and see if our Maybe cat is alive
or dead:
|
|
Focusing on the happy path 🔗︎
I was inspired to experiment with the Maybe monad for Go after reading Martin Kühl’s article “Error Handling in Go: Rob Pike Reinvented Monads” and the original Rob Pike article it cited, “Errors are values”.
In short, the Maybe monad provides a technique for avoiding Go’s verbose error handling.
Make no mistake, I think Go’s explicit error handling – returning error values instead of throwing exceptions that unwind the stack – is a good thing and leads to better, safer designs. Unfortunately, checking errors is verbose. Sometimes, verbosity is an acceptable cost for explicit code, but when this verbosity repeats in short sequence, it becomes clutter that obscures the “happy path” of the code and makes it less readable as a result.
Let’s consider an example. Here’s a function that consumes an io.Reader
and reads, splits, validates and converts the input to integers before
processing it. Each step is factored out to a function, and each could
possibly error. For reference, I’ve highlighted the error handling code:
|
|
Visually, it’s obvious that the bulk of the lines of the function are just handling the “unhappy” path when errors occur. Seeing the “happy path” requires learning to skim over the error handling bits to see the overall flow.
However, if we rewrite this using the Maybe monad, then instead of returning a value and an error to be checked, we only need to return a Maybe monad and keep binding operations to it. At the end, we can see if an error occured anywhere along the chain. Here’s a similar function to the one above, but using a Maybe monad instead of error values. This time, I’ll highlight the “happy path”:
|
|
Now, the “happy path” is the biggest block of code and it’s immediately
apparent that doWork
is just coordinating a sequence of operations. It’s
more readable (at least if you recognize the Maybe monad technique).
Beware the empty interface! 🔗︎
If you were actually reading those code samples, you might have noticed
that something went away: the error messages like invalid input: %s
. You
might also have realized that the binding functions can’t have the same
signature or code as in the non-monad example, i.e. validateData
in the
first examples isn’t the same as validateData
in the second example.
The error string isn’t a problem. It just gets pushed into the function and wrapping into a Maybe object there if an error occurs. The bigger problem is the signature:
func validateData(x interface{}) maybe.X
Can you tell this is supposed to consume a slice of strings? Of course not. The empty interface is wonderfully flexible but hides our type information. If we were sure that we’d never call this incorrectly, we could just use a type assertion in it:
func validateData(x interface{}) maybe.X {
for _, v := range x.([]string) {
// return maybe.ErrX() if v is invalid
}
return maybe.JustX(x)
}
But if we pass in the wrong thing, the function will panic. That’s not
good. We could be more defensive and do a type switch on x
, with an
invalid type also returning a maybe.ErrX()
, but we’re losing the
simplicity of the binding function.
Using the empty interface for our Maybe monad to work around the lack of generics gives us an unpleasant choice between risk or complexity.
Writing Maybe monads without generics 🔗︎
Without generics or the empty interface, the alternative is to write an explicit Maybe monad for each type we need. For example, a string Maybe:
package maybe
// S implements the Maybe monad for a string
type S struct {
just string
err error
}
// Bind applies a function that takes a string and returns an S.
func (m S) Bind(f func(s string) S) S {
if m.err != nil {
return m
}
return f(m.just)
}
This is arduous to do by hand for lots of types, but not impossibly so, and it’s a one-time cost for each type to support. It’s also relatively easy to code-generate.
I first experimented with the Maybe monad in Go to simplify some command
line processing programs I was writing. These programs read from stdin
and emit to stdout
. As a result, a lot of my needs revolved around
strings, integers and lists of those two types.
One immediate problem I found was type conversion. What if I have a
maybe.S
and I want to convert it to a maybe.I
? (Here, ‘I’ stands for
integer.) I really want to overload Bind
, but I can’t. Instead, I have
to add explicit type conversion methods:
// ToInt applies a function that takes a string and returns an I.
func (m S) ToInt(f func(s string) I) I {
if m.err != nil {
return ErrI(m.err)
}
return f(m.just)
}
The more I worked with my Maybe library, the more utility methods I found helpful for special cases, such as mapping a function returning a Maybe monad over a slice of values.
Maybe as a Go library 🔗︎
I released my maybe
package experiment on GitHub, so if you’re
interested, you can read the maybe
docs and browse the maybe
code.
The README demonstrates a Maybe monad approach to converting slices of strings into slices of non-negative integers. Here are some of the functions that consume typed data and return Maybe monads:
// Function to convert string to maybe.I. NewI takes a value, err pair.
atoi := func(s string) maybe.I { return maybe.NewI(strconv.Atoi(s)) }
// Function to validate non-negative integer.
validate := func(x int) maybe.I {
if x < 0 {
return maybe.ErrI(fmt.Errorf("%d is negative", x))
}
return maybe.JustI(x)
}
Given those and a slice of strings, c.data
, here’s the monadic
conversion:
// Wrap the []string in a maybe type (AoS -> 'Array Of Strings').
strs := maybe.JustAoS(c.data)
// Functionally convert and validate.
nums := strs.ToInt(atoi).Map(validate)
Using the maybe
package, the conversion is a declarative series of
operations around the “happy path”, still with complete type safety.
Conclusion 🔗︎
Schrödinger’s Cat was a thought experiment – one that posed a ridiculous idea to challenge accepted wisdom.
The Maybe monad for Go is similar. It has some things I like and some drawbacks. You might find it repulsive or you might be intrigued.
Perhaps it needs to wait for the Go generics proposal, or perhaps it’s sufficiently useful already.
Either way, I encourage you to “open the box” and see for yourself.