5

I have a class with some built-in methods. This is a abstracted example of what the class might look like:

class Foo:
    def __init__(self):
        self.a = 0
        self.b = 0

    def addOneToA(self):
        self.a += 1

    def addOneToB(self):
        self.b += 1

For the sake of simplicity, I've reduced the built-in methods to 2 total, but in actuality my class has closer to 20.

Next I have another class that is designed to work on a list of Foo instances.

class Bar:
    def __init__(self, fooInstances):
        self.fooInstances = fooInstances

# Bar([Foo(), Foo(), Foo()])

What if I wanted to apply one of the Foo methods to the Foo instances in Bar?

class Bar:
    # ...
    def addOneToA(self):
        for fooInstance in self.fooInstances:
            fooInstance.addOneToA()
    
    def addOneToB(self):
        for fooInstance in self.fooInstances:
            fooInstance.addOneToB()

The example above is one way of doing what I described, but it seems like a great deal of repetitive code to do this if there were 20 class methods of Foo. Alternatively, I could do something like this:

class Bar:
    # ...
    def applyFooMethod(self, func, *args):
        for fooInstance in self.fooInstances:
            fooInstance.func(args)

But I would prefer to have something that would allow me to call .addOneToA() on Bar and have it be applied to all Foo instances in Bar. Is there a clean way to do this without defining all methods of Foo inside Bar?

covalent47
  • 153
  • 3
  • It might be worth sharing some more context for this, it's possible that improvements could be made to the overall design. – AMC May 12 '21 at 15:05
  • @AMC absolutely, I'll update the question with some context in a bit. Broadly, my implementation of the `Bar` class is a filter that works on the list of `Foo` objects and basically narrows down the list based on parameters. For example, I could create a `Bar` class like `bar = Bar([Foo(), Foo(), Foo()])` then run something like `bar.filterElementsWith("a", "<=", 5)` and this would eliminate all `Foo` instances in the `fooInstances` list where `a` is less than or equal to 5. The actual code is a bit more involved but I'll update the question with this information when I get a chance. – covalent47 May 12 '21 at 15:16
  • @covalent47 - this question looks similar to this post: https://stackoverflow.com/questions/37075680/run-all-functions-in-class – Bogdan Ariton May 12 '21 at 15:40
  • Do you really need a separate class to handle mapping a function over a list? – chepner May 12 '21 at 15:55

2 Answers2

6

One way is to override __getattr__ of Bar:

class Bar:
    def __init__(self, fooInstances):
        self.fooInstances = fooInstances

    def __getattr__(self, attr):
        try:
            getattr(self.fooInstances[0], attr)
        except AttributeError:
            raise AttributeError(f"'Bar' object has no attribute '{attr}'")
        else:
            def foo_wrapper(*args, **kwargs):
                for foo_inst in self.fooInstances:
                    getattr(foo_inst, attr)(*args, **kwargs)
            return foo_wrapper 

__getattr__ on Bar is called if the attribute lookup on Bar object fails. Then we try and see if a Foo instance has that attribute; if not, then raise an AttributeError because neither Bar nor Foo accepts that attribute. But if Foo does have it, we return a function that, when called, invokes the method (attr) on each instant of Foo residing in Bar object.

Usage:

     ...
     # changed this method in Foo to see the passing-an-argument case
     def addOneToA(self, val):
         self.a += 1
         print(f"val = {val}")
     ...


>>> bar = Bar([Foo(), Foo(), Foo()])

>>> bar.addOneToB()
>>> [foo.b for foo in bar.fooInstances]
[1, 1, 1]

>>> bar.addOneToA(val=87)  # could also pass this positionally
val = 87
val = 87
val = 87

>>> bar.this_and_that
AttributeError: 'Bar' object has no attribute 'this_and_that'
Mustafa Aydın
  • 17,645
  • 4
  • 15
  • 38
  • wouldn't you also have to check for callable on each attribute? (like callable(getattr(fooInstance, attr)) – Bogdan Ariton May 12 '21 at 15:43
  • @bogdan_ariton right, but it will raise an error anyway and I believe it is okay to leave the error raising part to Python itself for noncallables of `Foo`.. – Mustafa Aydın May 12 '21 at 15:47
  • from what I can tell you can end up calling an uncallable attribute in the else clause, this can fail. – Bogdan Ariton May 12 '21 at 15:58
  • @bogdan_ariton In that case, a descriptive `TypeError` will be automatically raised. How should the program behave in your opinion? Manually handling the "attribute but not callable" case? – Mustafa Aydın May 12 '21 at 16:10
  • I guess it all depends on what is needed. It fine to let python raise it. – Bogdan Ariton May 12 '21 at 16:28
  • 1
    A great solution and exactly on the lines of what I was looking for. Thanks for the help. – covalent47 May 12 '21 at 18:53
1

Another way is to use setattr() to create a function which calls applyFooMethod() when you construct a bar object. This way, dir(bar) will show the methods of Foo.

class Bar:
    def __init__(self, fooInstances):
        self.fooInstances = fooInstances
        
        foo0 = fooInstances[0]
        
        for method_name in dir(foo0):
            method = getattr(foo0, method_name)

            # Make sure it's callable, but not a dunder method
            if callable(method) and not method_name.startswith("__"):
                # Make a lambda function with a bound argument for method_name
                # We simply need to call applyFooMethod with the correct name
                mfunc = lambda m=method_name, *args: self.applyFooMethod(m, *args)
                
                # Set the attribute of the `bar` object
                setattr(self, method_name, mfunc)
    
    def applyFooMethod(self, func_name, *args):
        for fooInstance in self.fooInstances:
            func = getattr(fooInstance, func_name)
            func(*args)

Then, you can run it like so:

foos = [Foo(), Foo(), Foo(), Foo()]

bar = Bar(foos)

dir(bar)
# Output: 
# [...the usual dunder methods...,
#  'addOneToA',
#  'addOneToB',
#  'applyFooMethod',
#  'fooInstances']

Now, we can call bar.addOneToA():

bar.addOneToA()

for f in foos:
    print(f.a, f.b)

bar.addOneToB()
for f in foos:
    print(f.a, f.b)

Which first increments all a values, and then all b values.

1 0
1 0
1 0
1 0
1 1
1 1
1 1
1 1
Pranav Hosangadi
  • 23,755
  • 7
  • 44
  • 70