Right in the Monads
That title right there is the last time we’re going to use the m-word this chapter until the discussion section at the end. Often this concept is presented as something difficult or mathematical or otherwise hopelessly arcane; the author hopes to demonstrate that it is nothing more than a design pattern for functional programming.
This chapter is about a problem that arises when we attempt to use pure functions exclusively. Sometimes, we want to pass along extra information along with our raw result, but we don’t want to deal with handling that information in every function we write.
A familiar example
To illustrate, let’s write some null-handling code. The following is a pretty common pattern in all sorts of software:
def get_street_from_company(company):
address = company.get('address')
if address is not None:
street = address.get('street')
if street is not None:
return street
return None
That sort of nesting could really stand to be flattened out:
def get_address(company):
return company.get('address') or None
def get_street(address):
return address.get('street_name') or None
def get_street_from_company(company):
return get_street(get_address(company))
But now, although we’ve done the work of returning a null value when no value is present. we haven’t handled the case of calling a function with a null value. Let’s see how we might do that:
def get_address(company):
if company is not None:
return company.get('address') or None
def get_street(address):
if address is not None:
return address.get('street') or None
def get_street_from_company(company):
return get_street(get_address(company))
Now, we can be assured that get_street_from_company
will properly short-circuit, and return None
if either address
or street
are not present on their respective data. But we don’t really want to add a handler for this case to every function we write! Let’s try to abstract out that part.
def call_if_not_none(fn, arg):
if arg is None:
return None
return fn(arg)
def get_address(company):
return company.get('address') or None
def get_street(address):
return address.get('street_name') or None
def get_street_from_company(company):
address = call_if_not_none(get_address, company)
return call_if_not_none(get_street, address)
This is a bit better. We now have a generic function, call_if_not_none
, that we can apply to any number of similar situations. But let’s formalize it a bit:
class Perhaps(option):
def __init__(self, value):
self.value = value
def bind(self, fn):
if self.value is None:
return self
return fn(self.value)
def get_value(self):
return self.value
# And our getters will now returns 'Perhaps' objects:
def get_address(company):
return Perhaps(company.get('address') or None)
def get_street(address):
return Perhaps(address.get('street') or None)
def get_street_from_company(company):
return (Perhaps(company)
.bind(get_address)
.bind(get_street)
.get_value())
It’s added a little bit extra compared to the other one, but what we’ve gained is a repeatable pattern that we can use for more complex situations. Our API features simply a constructor (the unit
) and a bind
function. Bind always accepts a function that accepts some value, and returns a “wrapped” value; that’s all you need to remember to make this pattern work.
You’ve probably seen constructs like Perhaps
in other languages, called things like Maybe
or Option
. Java 8’s Optional
even has a bind
, although they call it flatMap
, making it a full fledged onad-may.
The general form of this pattern is as follows:
- You create a wrapper of some sort or another, and write a function to wrap a value
- You write functions that accept an unwrapped value and return a wrapped one
- You write a function that unpacks the value, and passes it to a provided function.
Let’s see how things look when we do some more complicated error handling. Here, we’ll see how you might attach validation information to your values. This time we’ll use plain-old-dicts and functions, so you can see the pattern from a slightly different angle.
import re
# Unit: Our "wrapper" function
def validated_data(data, errors=None):
"""Wrap <data> up in a dict with any <errors> provided"""
return {
'data': data,
'errors': errors or {}
}
# Bind: Apply a function to a "wrapped" value
def bind_validated_data(vd, fn):
"""Apply each validator, using its returned data and merging any
errors"""
result = fn(vd['data'])
return {
'data': result['data'],
'errors': dict(vd['errors'], **result['errors'])
}
# Usage: Functions that accept unwrapped values and return wrapped ones.
def validate_name(data):
if not data.get('name'):
return validated_data(data, {'name': 'No name found'})
return validated_data(data)
def clean_phone(data):
"replace all non-numeric characters in the phone field"
phone = data.get('phone')
if phone:
data['phone'] = re.replace(r'[^0-9]', '')
return validated_data(data)
return validated_data(data, {'phone': 'Please provide a phone number'})
def validate(data):
return reduce( # Take note! This is a handy way to thread data through functions.
bind_validated_data,
[validate_name, clean_phone],
data)
Here, we construct a “validated data” dict, which is just the data combined with any associated errors. Each validator function accepts just the data, and returns an instance of “validated data”. At the core of all of it are three things: a unit
(the validated_data
function), a bind
(the bind_validated_data
function), and consumer functions that accept raw values and return wrapped ones.
Notice that the signature for bind
is a perfect candidate for use with reduce
, as demonstrated in the validate
function above.
Discussion
We’ve seen how using monads can help you concisely write solutions to certain problems in Python. The bad news is that most of the other monads that exist aren’t too useful in Python. For example, we can also easily add a sequence monad to python. However, as you can see by the very spartan definitions of bind
and unit
, it’s not adding a lot of value to Python’s list comprehensions:
seq_unit = lambda v: [v]
seq_bind = lambda seq, fn: map(fn, seq)
square = lambda x: x * x
half = lambda x: x / 2
expected = [half(square(x)) for x in range(10)]
actual = reduce(seq_bind, [square, half], range(10))
assert expected == actual
The same applies, for the most part, to higher-level monad operations such as lift
; you could use them in Python, but in a stateful dynamic language with Python’s feature set there’s really little point. Still, learning to get comfortable with patterns such as these should help you when writing pure-functional code.
Next article: For the Record