Search code examples
pythonsetchainingmethod-chaining

How to chain Python inplace methods if they don't return the object?


One of Python object methods which don't return the modified object is the .add() method of Python set(). This prevents chaining multiple calls to the method:

S = set()
S = S.add('item1').add('item2').add('item3')

giving:

AttributeError: 
    'NoneType' object has no attribute 'add'

Why I tend to prefer usage of chaining .add()s over usage of .update() or union() or the | operator? Because it is a clear self-explaining code which mimics spoken language and therefore best suited for private use by occasional programmers where readability of own code from the time perspective is the main issue to cope with.

A known to me work-around to make above chaining possible is to overwrite set methods. I have coded for this purpose the class chainOfSets. With this class I can write:

S = set()
S = chainOfSets(S).add('item1').add('item2').add('item3').get()
print(S) # gives: {'item1', 'item3', 'item2'}

My question is:

Is there a better approach to allow chaining of object methods which don't return the object they manipulate as using an own class (e.g. chainOfSets, chainOfLists, chainOfPandas, etc)?



Below the chainOfSets class with implemented + operator:

class chainOfSets: 
    """ 
    Allows chaining (by dot syntax) else not chainable set() methods  
    and addition/subtraction of other sets. 
    Is doesn't support interaction of objects of this class itself as 
    this is considered to be out of scope of the purpose for which this 
    class was created.  
    """
    def __init__(s, sv=set()):
        s.sv = sv
    # ---
    def add(s, itm):
        s.sv.add(itm)
        return s
    def update(s, *itm):
        s.sv.update(itm)
        return s
    def remove(s, itm):     # key error if not in set
        s.sv.remove(itm)
        return s
    def discard(s, itm):    # remove if present, but no error if not
        s.sv.discard(itm)
        return s
    def clear(s):
        s.sv.clear()
        return s
    # ---
    def intersection(s, p):
        s.sv = s.sv.intersection(p)
        return s
    def union(s, p):
        s.sv = s.sv.union(p)
        return s
    def __add__(s, itm):
        if isinstance(itm, set): 
            s.sv = s.sv.union(itm)
        else: 
            s.sv.update(itm)
        return s
    def difference(s,p):
        s.sv = s.sv.difference(p)
        return s
    def __sub__(s, itm):
        if isinstance(itm, set): 
            s.sv = s.sv - itm
        else: 
            s.sv.difference(set(itm))
        return s
    def symmetric_difference(s,p): 
        # equivalent to: union - intersection
        s.sv = s.sv.symmetric_difference(p)
        return s
    # ---
    def len(s):
        return len(s.sv)
    def isdisjoint(s,p):
        return s.sv.isdisjoint(p)
    def issubset(s,p): 
        return s.sv.issubset(p)
    def issuperset(s,p):
        return s.sv.issuperset(p)
    # ---
    def get(s):
        return s.sv
#:class chainOfSets(set) 

print((chainOfSets(set([1,2,3]))+{5,6}-{1}).intersection({1,2,5}).get())
# gives {2,5}


Solution

  • Is there a better approach to allow chaining of object methods which don't return the object they manipulate as using an own class (e.g. chainOfSets, chainOfLists, chainOfPandas, etc)?

    A better approach as this one provided in the question is to write a short general class working for all Python objects and their methods instead of writing voluminous separate class for each single object kind.

    The core of the mechanism making it possible in Python is to utilize the fact that calls to object/class methods go, in case when the preliminary (proxy) __getattribute__ method fails, through a __getattr__ method. Overwriting this method is sufficient to intercept and forward the calls to their proper destination.

    The code below ( named chainPy, chainObj or objProxy to mirror what is will be used for) does the 'trick' of intercepting method calls, forwarding them to the right destination and checking their return value. The class always memorizes either the return value or the modified object and returns itself for the next use in chain. At the end of the chain the final result is then retrieved with the .get() method of the class:

    Important Note: the purpose of chainPy is to help chain object methods which modify the object inplace and return None, so it should be only ONE chainPy object and ONE identifier used in code to avoid side-effects with e.g. the copy() method. The final link in the chain should be .get() and the chainPy object shouldn't be reused later on (thanks to ShadowRanger for pointing this out in comments).

    class chainPy:
        def __init__(s, pyObj):
            s._p = pyObj
        def __getattr__(s, method_name):
            def method(*args, **kwargs):
                print(f"chainPy<class>: forwarding: '{method_name}' with {args=} {kwargs=} for pyObj={s._p}")
                bckp_p = s._p
                s._p = getattr(s.p, method_name)(*args, **kwargs)
                if s._p is None:
                    s._p = bckp_p
                return s
                # return getattr(s._p, method_name)(*args, **kwargs)
            return method
        def get(s):
            return s._p
    # (a proxy is a class working as an interface to something else) 
    chainObj = objProxy = chainPy
    #:class chainPy
    

    Using the class above the following code runs as expected successfully chaining multiple set.add() calls:

    S = set()
    S = chainPy(S).add('item1').add('item2').add('item3').get()
    print(S) # gives: {'item2', 'item1', 'item3'}