FredMaster
FredMaster

Reputation: 1469

Class method composition

I want to compose functions by method chaining them.

My goal is to achieve something like collect(10, mul(3).div(2).add(10)) which should do add(10)(div(2)(mul(3)*10)) which results in 25.0

Obviously this is just a toy example to indicate that I want to compose different methods via chaining them together.

I created a class Expr to handle the different methods.

However, the only way I am able to achieve this is using a global variable container which stores the called methods as outlined below:

Question: Is there another (a better) way to store the called methods somehow except for a global container?

Edited: added def compose(...)

container = []
class Expr:

    def __init__(self, f):
        self.f = f
        global container
        container.append(f)
        
    @staticmethod
    def add(y):
        return Expr(lambda x: x + y)
    
    @staticmethod
    def mul(y):
        return Expr(lambda x: x*y)
    
    @staticmethod
    def sub(y):
        return Expr(lambda x: x - y)
    
    @staticmethod    
    def div(y):
        return Expr(lambda x: x/y)

# Dummy function to be able to start without `Expr.mul(...)`
def mul(y):
    return Expr.mul(y)

# Handles composition of functions
def composer(*functions):
    def inner_func(arg):
        for f in reversed(functions):
            arg = f(arg)
        return arg
    return inner_func
  
def collect(el, comp_funcs: Expr):
    global container
    try:
        f = composer(*reversed(container)) # create composed method
        container = [] # delete container elements
        return f(el) # call composed function
    except:
        print("Upps, something went wrong")
        container = []

collect(10, mul(3).div(2).add(10))
# Result is 25.0 

Thanks for your help!

Upvotes: 0

Views: 404

Answers (1)

blueteeth
blueteeth

Reputation: 3565

I've been thinking about this for a few days.

You should definitely not use global. The simple method for you is to put the entire thing inside another class, then access container as an instance variable.

But I think there's a trivial way that you've missed. This is similar to what you've done where the class has to predefine each specific method it wants to be able to chain.

class Foo:
    def __init__(self, n):
        self._n = n

    @property
    def data(self):
        return self._n

    def add(self, x):
        self._n += x
        return self

    def div(self, x):
        self._n /= x
        return self

print(repr(Foo(10).add(2).div(2).data))
# 6.0

I don't like this because it's not very generic; it operates on the basis of side-effects; and you need to have everything defined from the outset.

So here's a more generic setup where you have a Composer which manages the functions you can chain, and a Chainable which alternates between two states:

  1. Storing the "current value" and the next function
  2. Storing the result of the previous function

The composer has a decorator built in which allows you to register other generic functions at a later time.

from typing import *
from dataclasses import dataclass


T = TypeVar("T")  # the initial data
Func = Callable[[T], T]  # the function that transforms the data
RegisteredFunc = Callable[[Any], Func]  # the function that returns the function that transforms the data


class Composer:
    functions: Dict[str, RegisteredFunc]

    def __init__(self):
        self.functions = {}

    def register(self, func: RegisteredFunc) -> RegisteredFunc:
        self.functions[func.__name__] = func
        return func


@dataclass
class Chainable:
    data: T
    fun: Optional[RegisteredFunc] = None
    composer: Optional["Composer"] = None

    def __getattr__(self, item) -> "Chainable":
        return Chainable(self.data, self.composer.functions[item], self.composer)

    def __call__(self, *args, **kwargs) -> "Chainable":
        next_data = self.fun(*args, **kwargs)(self.data) if self.fun else self.data
        return Chainable(next_data, composer=self.composer)

    def __str__(self):
        return str(self.data)

    def __repr__(self):
        return repr(self.data)


comp = Composer()


@comp.register
def add(x) -> Func:
    return lambda n: n + x


@comp.register
def div(x) -> Func:
    return lambda n: n / x


print(repr(Chainable(10, composer=comp).add(2).div(2)))
# 6.0

print(repr(Chainable(1, composer=comp).add(2).add(1).add(2).div(4)))
# 1.5

Upvotes: 1

Related Questions