A monad is just a monoid in the category of endofunctors.
Oh… you aren’t fluent in the arcane language of Category Theory?
Hm…
I suppose an example will have to do.
Alright. Let’s say we’re programming a small class to manage an Inventory in a game. We want to add, remove, and display the items in the inventory.
Let’s go ahead and write out some simple code:
class Inventory:
def __init__(self):
self.items = []
def add(self, item):
self.items.append(item)
return self
def remove(self, item):
self.items.remove(item)
return self
def display(self):
print("Inventory: ", self.items)
Enter fullscreen mode Exit fullscreen mode
We can use the Inventory class with ease:
Inventory().add("Sword").add("Potion").add("Shield").remove("Potion").display()
Enter fullscreen mode Exit fullscreen mode
Output: Inventory: ['Sword', 'Shield']
Enter fullscreen mode Exit fullscreen mode
So far, so good. One more thing though; the player can only hold a limited amount of items. We just need to change the __init__ and add functions:
class Inventory:
def __init__(self, capacity):
self.items = []
self.capacity = capacity
def add(self, item):
if len(self.items) >= self.capacity:
#inventory is over capacity!
raise Exception("Out of Inventory Space")
else:
self.items.append(item)
return self
#implementation of remove() and display() omitted; same as above
Enter fullscreen mode Exit fullscreen mode
Ok… we’re done.
So, to recap.
We made an Inventory class, where we can add, remove, and display the state of the list.
It has a max capacity, and throws an Exception when we surpass inventory capacity; we can use try/except to catch and display them neatly instead of letting them crash the program.
Alright, let’s just test it real quick.:
try:
Inventory(capacity=10).add("Sword").add("Potion").remove("sword").display()
except Exception:
print("Inventory out of space: Try removing some items first.")
Enter fullscreen mode Exit fullscreen mode
And, just as expected, we see
Oh…
that’s not right…
Inventory out of space: Try removing some items first.
Enter fullscreen mode Exit fullscreen mode
We’re supposed to have plenty of space.
OHHH. Wait…
Calling Inventory.remove(item) can also result in an Exception, when we pass in an item that doesn’t exist in the list we’re removing from.
I mean… I guess we could surround every single use of Inventory with try/catch and use an if-statement to check the Exception but…
it’s not really elegant anymore. The first version was so simple to use; data simply flowed through the operations and there wasn’t any complexity.
Maybe there’s a better way to do all this. It’s time to call upon one of the dark arts of functional programming.
Alright.
The problem is that our functions don’t fully behave predictably.
They might throw an error, or they might return normally.
So, to make them behave predictably, what if we made them always return, even if an error happened?
We could technically return an Exception object in Python, but that doesn’t really solve our problem. What we want is a clear way to represent success or failure as part of the function’s normal output.
We could do so with something like this:
class Result:
def __init__(self, value, is_error):
self.is_error = is_error
if is_error:
self.error = value
else:
self.ok = value
#helper functions for easily constructing Ok/Error
def Ok(value):
return Result(value, False)
def Error(value):
return Result(value, True)
Enter fullscreen mode Exit fullscreen mode
Now, when something returns a Result, we can check is_error to determine whether it succeeded or failed.
Alright. Let’s modify our Inventory code to return Results instead of throwing errors.
class Inventory:
def __init__(self, capacity):
self.items = []
self.capacity = capacity
def add(self, item):
if len(self.items) >= self.capacity:
return Error("OutOfSpace")
else:
self.items.append(item)
return Ok(self)
def remove(self, item):
if item not in self.items:
return Error("ItemNotFound")
else:
self.items.remove(item)
return Ok(self)
def display(self):
print("Inventory: ", self.items)
return Ok(self)
Enter fullscreen mode Exit fullscreen mode
Alright, but how does this even solve anything?
I mean you still need to unwrap the Result every single time:
inventory_v0 = Inventory(3)
inventory_v1 = inventory_v0.add("Sword")
if inventory_v1.is_error:
print(inventory_v1.error)
else:
inventory_v2 = inventory_v1.ok.remove("Potion")
if inventory_v2.is_error:
print(inventory_v2.error)
else:
#and so on
Enter fullscreen mode Exit fullscreen mode
What progress have we even made?
Now it’s worse off than before…
No. Stay steadfast. We’re right at the boundary and we cannot turn back.
We must make the final, daring leap.
The problem isn’t the Result, it’s that we have to keep unwrapping it.
Programmers are lazy; how can we be expected to do such a boring chore again and again? It’s madness!
So, we delegate the work to a new method, called and_then:
class Result:
def __init__(self, value, is_error):
self.is_error = is_error
if is_error:
self.error = value
else:
self.ok = value
def and_then(self, f):
if self.is_error:
return self
else:
return f(self.ok)
Enter fullscreen mode Exit fullscreen mode
So, and_then takes a function. If the result is an error, it propagates that error down the chain. Otherwise, it applies the function to the value inside.
Interestingly enough, nothing in and_then specifically knows about errors. It only knows how to inspect the Result and decide what to do based on which variant it contains.
To use it, all we need to do is convert Inventory’s methods to functions:
class Inventory:
def __init__(self, capacity):
self.items = []
self.capacity = capacity
def add(inventory, item):
if len(inventory.items) >= inventory.capacity:
return Error("OutOfSpace")
else:
inventory.items.append(item)
return Ok(inventory)
def remove(inventory, item):
if item not in inventory.items:
return Error("ItemNotFound")
else:
inventory.items.remove(item)
return Ok(inventory)
def display(inventory):
print("Inventory: ", inventory.items)
return Ok(inventory)
Enter fullscreen mode Exit fullscreen mode
And done!
Alright. Now everything is put into place.
Using Inventory now may look a bit strange at first, but it’s quite elegant:
inventory = Ok(Inventory(capacity=3)).and_then(lambda x: add(x, "Sword")).and_then(lambda x: add(x, "Potion")).and_then(display)
if inventory.is_error:
#handle error gracefully
Enter fullscreen mode Exit fullscreen mode
Why the lambdas? Well, add takes two arguments: an Inventory and an item. But and_then only provides one argument: the value inside the Result.
So we create a small temporary function that remembers the item we want to add:
lambda x: add(x, "Sword")
Enter fullscreen mode Exit fullscreen mode
Now, and_then just needs to fill in the inventory, as the item is already filled in.
And, after all that work, we’ve kinda re-invented the monad.
I guess you don’t need a degree in category theory or experience in Arcane magic to understand a monad after all (though, speaking from personal experience, the latter helps).
Well, technically we didn’t make a proper monad. A real monad needs three pieces: a wrapper type (our Result), a way to wrap the starting value (like we did with Ok), and a chaining operation (and_then; usually called bind). Real monads also avoid mutating state and side effects like printing, which we cheated on here to keep things readable.
The category theory definition and the programming definition are really just describing the same concept from different perspectives. Category theory gives the general mathematical structure; Result.and_then is one example of what that structure looks like when we write code. And, thankfully, you don’t really need to know category theory at all to benefit from the ideas behind monads.
This is really just the beginning. Remember how and_then only knew how to inspect the box and decide what to do next? That same structure works even if we swap out the context. Replace Error with None and Ok with Some, and now you can handle optional values without repeating if x is not None. The same pattern reappears for async computations, state management, and plenty more. The beauty of monads is that the shape stays the same even when the payload changes.
Do let me know your thoughts and what you’d be interested in reading next. Until then, don’t be too afraid of venturing into the arcane.
Sincerely,
ein-monarch
답글 남기기