Goal
To describe the monad pattern and its uses in Python.
Contents
- Goal
- Contents
- Introduction
- Truth monad
- Error monad
- Case study: Bitcoin address generation
- Flexibility
- Conclusion
Introduction
A monad is a pattern in several programming languages that is sometimes referred to as "a programmable semicolon". This refers to many programming languages in which a semicolon is used to terminate an expression, delineating parts of a process within a scope.
A semicolon might appear simple, but it's possible to zoom out and think about its abstract semantic role. Consider the following code:
const a = 10; | |
const b = a * 10; |
JavaScript's complicated relationship with semicolons aside, the meaning of the semicolon on line 1 is essentially: "The variable
a
is no longer being assigned and is available to be referred to from this point forward."More generally: "The execution of this statement is complete, so go to the next statement."
The idea of a monad is an expanded version of this. What if, instead of "execution is complete", the code encoded what it would do if an error occurred and was redirected by the "semicolon". An example of this can be found in JavaScript with promises:
somePromise().then(() => { | |
// code here is executed on resolve | |
}).catch(() => { | |
// code here is executed on reject | |
}) |
The
.then()
wrapper is essentially a monad that guides the behaviour of the process. Another example can be found in Python using
with
:with open(filename, 'r') as file_handler: | |
# regardless of an operation here, .close() will be called on file_handler before exiting the scope. |
It's probably the case that
with
is not strictly a monad since it does not govern the behaviour of each individual expression. This article presents some examples of how the monad pattern can be implemented in Python and be used effectively when in a number of different applications.The ultimate goal of this pattern is to present a process in a way that is easy to understand so that it can be modified and maintained quickly and easily.
Truth Monad
The Truth monad can be used when running a series of related, but not interdependent, processes, each of which could end with a fatal error.
class Car: | |
def start(self): | |
self.startup_error = TruthMonad( | |
self._check_key_in_ignition, | |
self._check_key_turned, | |
self._check_battery_charge, | |
self._check_fuel_level, | |
).render() |
The
TruthMonad
class governs the behaviour of each expression:class TruthMonad: | |
def __init__(self, *funcs): | |
self.error = None | |
self._run(funcs) | |
def _run(self, funcs): | |
func = funcs[0] | |
funcs = funcs[1:] | |
error = func() | |
if error: | |
self.error = error | |
return | |
if funcs: | |
self._run(funcs) | |
def render(self): | |
return self.error |
Any of the functions could result in an error that would prevent the process from continuing. In this case, the function should return any truthy value, which will be assumed to be the error. If everything is proceeding as expected, the value
None
should be returned instead. This is a possible alternative to the following code:# Previous expression ... | |
error = expression_function() | |
if error: | |
return error # Or some other way to stop the process | |
# Next expression ... |
Or worse, a
try/catch
.It's possible the
TruthMonad
class could be improved, but in this form, it can make a series of checks simpler and easier to update.Error Monad
The
ErrorMonad
class expands on
TruthMonad
by adding an explicit value for success and failure, akin to
.then()
in JavaScript.class ErrorMonad: | |
def __init__(self, data, *funcs): | |
self.error = None | |
self.result = None | |
self._run(funcs, result=data) | |
def _run(self, funcs, result=None): | |
func = funcs[0] | |
funcs = funcs[1:] | |
result, error = func(result) | |
if error: | |
self.error = error | |
return | |
if funcs: | |
self._run(funcs, result=result) | |
else: | |
self.result = result | |
return | |
def render(self): | |
return self.result, self.error |
This allows it to be used as a pipeline, passing the result of each expression to the next if it succeeds, and exiting the execution if there is an error value. This is useful for calculations that need to mutate a value in several stages to yield a final result. One very good example is the generation of a Bitcoin address.
Case Study: Bitcoin Address Generation
To generate a Bitcoin address, the process must start with an ECDSA private key, which is simply a string of 64 hex characters, representing 32 bytes of data. This data must be taken through a series of checks and transformations to yield the Bitcoin address.
Using the
ErrorMonad
class, the process can be represented as follows:class Address: | |
def __init__(self, secret_key=None): | |
self.secret_key = secret_key | |
if self.secret_key: | |
self.is_valid = False | |
self._generate() | |
def _generate(self): | |
self.value, self.error = ErrorMonad( | |
self.secret_key, | |
self._validate_secret_key_hex, | |
self._validate_secret_key_length, | |
self._validate_secret_key_integer_domain, | |
self._convert_secret_key_to_public_key, | |
self._convert_public_key_to_sha256_digest, | |
self._convert_sha256_digest_to_ripemd160_digest, | |
self._prepend_bitcoin_chain_flag, | |
self._append_checksum, | |
self._convert_hex_to_base58, | |
).render() | |
if not self.error: | |
self.is_valid = True |
Note that the initial data,
self.secret_key
, is passed as a first argument. This is the
data
argument to the
ErrorMonad
constructor.This requires that any function included in this process return two values; one to represent the error, and one to represent the value resulting from that step.
Flexibililty
The Bitcoin address generation process is done entirely using the
ErrorMonad
class, but it could be mixed with
TruthMonad
for the parts of the process that are not required to pass a result.class Address: | |
def __init__(self, secret_key=None): | |
self.secret_key = secret_key | |
if self.secret_key: | |
self.is_valid = False | |
self._generate() | |
def _generate(self): | |
self.value, self.error = ErrorMonad( | |
self._validate, | |
self._transform, | |
).render() | |
if not self.error: | |
self.is_valid = True | |
def _validate(self): | |
self.error = TruthMonad( | |
self._validate_secret_key_hex, | |
self._validate_secret_key_length, | |
self._validate_secret_key_integer_domain, | |
).render() | |
return None, self.error | |
def _transform(self): | |
return ErrorMonad( | |
self.secret_key, | |
self._convert_secret_key_to_public_key, | |
self._convert_public_key_to_sha256_digest, | |
self._convert_sha256_digest_to_ripemd160_digest, | |
self._prepend_bitcoin_chain_flag, | |
self._append_checksum, | |
self._convert_hex_to_base58, | |
).render() |
The priority is to make sure the code is as clear as possible, so this might not be ideal in this case, but in general, these monad classes can be composed as needed.
Conclusion
Python may not have the in-built idea of monads in the strictest sense, but the pattern can inspire related machinery to make code clearer. Other requirements, such as dealing with multiple initial values, can be constructed in a similar manner.