1

I am working on a simple simulation where I would like to change the methods of a class instance at runtime. I am quite new to OOP so I am not sure which approach fits my case best.

I have created a couple of samples with the example being a Cat class which can turn into a zombie cat at runtime, changing it's behaviour.

class Cat:
    def __init__(self, foo):
        self.foo = foo
        self.is_zombie = False

    def turn_to_zombie(self, bar):
        self.is_zombie = True
        self.zombie_bar = bar

    def do_things(self):
        if self.is_zombie:
            print('Do zombie cat things')
        else:
            print('Do cat things')

This is the desired behaviour however I would like to separate the Cat and ZombieCat methods and skip the if statement.

class Cat:
    def __init__(self, foo):
        self. foo = foo

    def do_things(self):
        print('Do cat things')

    def turn_to_zombie(self, bar):
        self.bar = bar
        self.__class__ = ZombieCat

class ZombieCat(Cat):
    def __init__(self, foo, bar):
        super().__init__(self, foo)
        self.bar = bar

    def do_things(self):
        print('Do zombie cat things')

This works well but I am not sure if there are any side effects to changing self.__class__, it seems to be discouraged How dangerous is setting self.__class__ to something else?

class Cat:
    def __init__(self, foo):
        self.foo = foo
        self.strategy = CatStrategy

    def do_things(self):
        self.strategy.do_things(self)

    def turn_to_zombie(self, bar):
        self.bar = bar
        self.strategy = ZombieCatStrategy

class CatStrategy:
    @staticmethod
    def do_things(inst):
        print('Do cat things')

class ZombieCatStrategy(CatStrategy):
    @staticmethod
    def do_things(inst):
        print('Do zombie cat things')

When googling I came across the strategy pattern. This also works but feels a bit messier than creating a child class. For example to override an additional method when the cat is a zombie it requires changes in 3 places instead of 1.

Feel free to suggest other patterns, I'm sure there are things I have not considered yet.


Edit: After a helpful answer from @martineau I'd like to add that it would be useful if any references to a Cat instance are updated when .turn_to_zombie is called, i.e.

cats_list1 = [cat1, cat2]
cats_list2 = [cat1, cat2]
cats_list1[0].do_things() # -> Do cat things
cats_list1[0].turn_to_zombie('bar')
cats_list2[0].do_things() # -> Do zombie cat things
martineau
  • 119,623
  • 25
  • 170
  • 301
shaippen
  • 21
  • 4

2 Answers2

0

While something like @Anton Abrosimov's answer using __getattr__(), is probably the canonical way to do it, it does have a negative side-effect of introducing the overhead of an additional function-call to every call to one of the instance's methods.

Well, like the saying goes, there's more than one way to skin a cat, so here's an alternative approach which avoids that overhead by changing what function associated with the method's name of the given instance. (Technically it could also be used to add methods to an instance that didn't already exist.)

import types


class Cat:
    def __init__(self, foo):
        self.foo = foo

    def do_things(self):
        print('Doing Cat things')

    def _change_method(self, method_name, method, **kwattrs):
        bound_method = types.MethodType(method, self)
        setattr(self, method_name, bound_method)
        self.__dict__.update(kwattrs)


class ZombieCat(Cat):
    def __init__(self, foo, bar):
        super().__init__(foo)
        self.bar = bar

    @classmethod
    def turn_into_zombie(cls, cat, bar):
        cat._change_method('do_things', cls.do_things, bar=bar)

    def do_things(self):
        print(f'Doing ZombieCat things (bar={bar!r})')


if __name__ == '__main__':

    foo, bar = 'foo bar'.split()

    cat = Cat(foo)
    cat.do_things()  # -> Doing Cat things
    ZombieCat.turn_into_zombie(cat, bar)
    cat.do_things()  # -> Doing ZombieCat things (bar='bar')
martineau
  • 119,623
  • 25
  • 170
  • 301
  • Hi @martineau, thank you for your comment. I tested your solution a bit and it looks promising. The one issue I have is that it seems references to the class are not updated when the new class instance is created. For example `cat1 = Cat(foo)` `cat2 = Cat(foo)` `cats = [cat1, cat2]` `cat2 = ZombieCat.turn_to_zombie(cat2, bar)` `cats[0].do_things() # -> Do cat things` `cats[1].do_things() # -> Do cat things (!)` I think it would be possible for me to work around this but it would be more convenient if it updated automatically. – shaippen Jul 07 '19 at 21:59
  • Thank you martineau, works great. I'll keep Antons anwers as accepted as the more canonical answer but this was very useful to me as well. – shaippen Jul 08 '19 at 12:04
0

Something like that, I think:

class Cat:
    def __init__(self, foo):
        self.foo = foo

    def do_things(self):
        print('Do cat things')

    def turn_to_zombie(self, bar):
        self.bar = bar
        self.__class__ = ZombieCat

class ZombieCat(Cat):
    def __init__(self, foo, bar):
        super().__init__(foo)
        self.bar = bar

    def do_things(self):
        print('Do zombie cat things')

class SchroedingerCat:
    _cat = Cat
    _zombie = ZombieCat
    _schroedinger = None

    def __init__(self, foo, bar=None):
        if bar is not None:
            self._schroedinger = self._zombie(foo, bar)
        else:
            self._schroedinger = self._cat(foo)

    def turn_to_zombie(self, bar):
        self._schroedinger = self._zombie(self._schroedinger.foo, bar)
        return self

    def __getattr__(self, name):
        return getattr(self._schroedinger, name)

SchroedingerCat('aaa').do_things()
SchroedingerCat('aaa').turn_to_zombie('bbb').do_things()

Self merged classes is too complex and not intuitive, I think.

Dark magic attention Do not use it, but it's worked:

from functools import partial

class DarkMagicCat:
    _cat = Cat
    _zombie = ZombieCat
    _schroedinger = None

    def __init__(self, foo, bar):
        self.foo = foo
        self.bar = bar
        self._schroedinger = self._cat

    def turn_to_zombie(self, bar):
        self._schroedinger = self._zombie
        return self

    def __getattr__(self, name):
        return partial(getattr(self._schroedinger, name), self=self)
Anton Abrosimov
  • 349
  • 4
  • 6
  • Hi Anton, thank you it works well! `__getattr__` is new to me, would it be correct to say that it reroutes attributes to `_schroedinger`? – shaippen Jul 07 '19 at 22:23
  • Yes. If `SchroedingerCat` has no `attribute`, `__getattr__` will be called and reroute request. – Anton Abrosimov Jul 07 '19 at 22:36