dhilst

Functional programming in Python with partial application, pipe operator and error handling

While trying functional programming in Python there are two things that I think always come as pain points. The first, chaining or composing functions. The second is the error handling composition. In this post I how implement an usable pipe operator how to get most of the functions with partial application and an ExceptionMonad to abstract try/catchs.

Pipe operator

In OCaml we have the pipe operator foo |> bar x is equivalent to bar x foo, this really helps with chaining List high order functions, like map, fold_left, filter, so that you can do something like this.

utop[0]> List.(init 10 Fun.id |> filter (fun x -> x < 5) |> map (fun x -> x * 2));;
- : int list = [0; 2; 4; 6; 8]

In Python, without piping, this is would be something like this

>>> list(map(lambda x: x * 2, filter(lambda x: x < 5, range(0, 10))))
[0, 2, 4, 6, 8]

It’s terrible to read. The first step is to be able to partially apply a function. In OCaml we have curry so this is for free. In Python we can do something like this (credits: this post by nvbn).

The idea is, we create an object that works like partial but for one of the arguments you pass ... and this object is a callable that receives one argument and when it’s called it replaces the ... with that argument.

>>> add(1, ...)(1) # ... is replaced with 1
2

Here is the implementation

import sys
from functools import partial, reduce
from dataclasses import make_dataclass

class Pipe:
    def __init__(self, f, *args, **kwargs):
        self.f = f
        self.args = args
        self.kwargs = kwargs
    def __call__(self, replacement):
        args = [arg if arg is not Ellipsis else replacement
                for arg in self.args]
        kwargs = {k: v if v is not Ellipsis else replacement
                  for arg in self.kwargs}
        return self.f(*args, **kwargs)
    def __rmatmul__(self, other):
        return self(other)

def pipefy(f):
    def wrapper(*args, **kwargs):
        if Ellipsis not in args and Ellipsis not in kwargs:
            return f(*args, **kwargs)
        else:
            return Pipe(f, *args, **kwargs)
    return wrapper

The problem here is that you need to call pipefy for every function that you want to work in this way, I want to be able to do this for an entire module, to do this I wrote this function.

def patch_module(m, f, into, blacklist=[]):
    blacklist = blacklist + 'int str list tuple dict float bool type'.split()
    import importlib
    if m not in sys.modules:
        importlib.import_module(m)
    m = sys.modules[m]
    into = sys.modules[into]
    for k, v in m.__dict__.items():
        if (k[0].islower() and 'Error' not in k and callable(v) 
            and not k.startswith('__') and k not in blacklist):
            into.__dict__[k] = f(v)

This function will take a source module as first argument a function as second, a target module as third and a blacklist as the optinal last argument. It will replace almost all callables in m with the same callables but with f decorator applied to it and will patch this into the into module, usually you pass __name__ as into argument. This way other modules will not see the patch. Even not breaking the other modules it may break your module in someway

>>> patch_module('operator', pipefy, __name__)
>>> patch_module('functools', pipefy, __name__)
>>> map
<function pipefy.<locals>.wrapper at 0x7f03d2d5b7f0>
>>> reduce
<function pipefy.<locals>.wrapper at 0x7f03d278a440>
>>> mul
<function pipefy.<locals>.wrapper at 0x7f03d2788ca0>
>>> add
<function pipefy.<locals>.wrapper at 0x7f03d2788820>
>>> 

Almost all the function of these modules are “pipefied” now, the exceptions are callables that are likely to be a class or an error. The problem with this is that is tests do not work anymore (this is why I exclude int, str … for examble. This is why I try to be conservative when overriding the functions in pactch_module one way to me extra conservative is to do this in a single module and the import only the functions that you want from these modules. For example start to return false (when used to return true).

>>> map
<function pipefy.<locals>.wrapper at 0x7f03d2d5b7f0>
>>> type(map(lambda x: x, [])) is map
False
>>> map = __builtins__.__dict__['map']
>>> map
<class 'map'>
>>> type(map(lambda x: x, [])) is map
True

If you pipefy int for example this would stop to work type(1) is int.

The next thing is to invert the order of the calls we want something like (1) add(2, ...) instead of add(2, ...) so we can compose functions in the order that they are called. If you paid attention to the Pipe class it supports the @ operator (__rmatmul__ method), with this is just a matter of chaining with @, here is the example from the beginning of the post.

>>> patch_module('builtins', pipefy, __name__)
>>> patch_module('functools', pipefy, __name__)
>>> patch_module('operator', pipefy, __name__)
>>> list(range(0, 10) @ filter(lt(..., 5), ...) @ map(mul(2, ...), ...))
[0, 2, 4, 6, 8]

The error monad

I assume that you know what is a monad, if not there are plent of posts about it, to the matter of this post, the Error Monad is a way to abstract the exception handling in a composable way, so that you can write code that raise exceptions, but don’t have to spread tons of try/catchs everywhere.

To make my point, exception handling is too noisy and too syntax heavy, in other hand unpacking Result[T, E] values are also heavy in someway when you have to compose them without a monadic operator, also stdlib and other’s people code will not wrap their errors in your result type, they just throw errors and now that errors are your problem. I want something so that I can compose multiple function calls in an expressive way and but that the error handling is still consise, and that work with stdlib functions.

Here is the ExceptionMonad class

class ExceptionMonad:
    blacklist = (AssertionError, NameError, ImportError, SyntaxError, MemoryError,
                 OverflowError, StopIteration, StopAsyncIteration, SystemError, Warning)
    def __init__(self, v):
        self.v = v

    def map(self, f):
        if isinstance(self.v, Exception):
            return self
        else:
            try:
                return ExceptionMonad(f(self.v))
            except Exception as e:
                if isinstance(e, self.blacklist):
                    raise
                return ExceptionMonad(e)

    def join(self, other):
        if isinstance(other, ExceptionMonad):
            return other.v
        else:
            return other

    def flatmap(self, other):
        return self.join(self.map(other))
    
    @staticmethod
    def ret(a):
        return ExceptionMonad(a)

    def __matmul__(self, other):
        return self.map(other)

    def __eq__(self, other):
        if isinstance(self.v, Exception) and isinstance(other, Exception):
            return (type(self.v), *self.v.args) == (type(other), *other.args)
        elif isinstance(self.v, Exception) and type(other) is ExceptionMonad and isinstance(other.v, Exception):
            return (type(self.v), *self.v.args) == (type(other.v), *other.v.args)
        elif isinstance(other, ExceptionMonad):
            return self.v == other.v
        else:
            return self.v == other

The idea is that you write functions that are not aware of the error monad, if your function need to fail it just raises an exception. The error monad catches this exception and like a result type it will propagate it over map chain calls. For example, here is a safe subtraction funtion that never returns negative.

@pipefy
def sub(a, b):
    c = a - b
    if c < 0:
        throw ValueError("underflow")
    return c

To call it with the error monad you do this

ExceptionMonad.ret(1).map(sub(..., 1))
# or 
ExceptionMonad.ret(1) @ sub(..., 1)

ret is the monad return function/method it takes a non monadic value and puts in the monadic context in this case ErrorException. Then we call .map(foo) where this callable will may throw an exception. The return value of foo is wrapped in another ErrorException instance, if foo throws an exception then the exception is catched and wrapped in a ErrorException instance too, so that if we do .map(foo).map(bar) and foo raises an exception, then bar is never called. If foo does not raise an exception then bar is called as with the result of foo. Again we implement @ as an alias for map. Here is an complete example:

assert (ExceptionMonad.ret(1) @ sub(..., 1) @ sub(..., 1)) == ValueError("underflow") 
assert (ExceptionMonad.ret(3) @ sub(..., 1) @ sub(..., 1)) == 1

Also in this way you can handle native python functions that throw exceptions. If the functions are not unary you can use pipefy do supply the other arguments and to chose where the missing argument should fit.

By mixing pipefy, ExceptionMonad and adt I achieved a great level of expresivity for functional programming in python, enforcing using imutabilty, purity, composability, etc. The mypy is not always happy with this solutions but I think is better to have them than not.

The only thing missing I think now is immutable data types like lists and dicts, you can achive this by using [a, *args] or {**kw, k: v} forms or update, there is also frozenset and the pip library frozendict.

That’s it, I hope you liked it,

Cheers!