State-dependent behaviour of Python objects using Ruby-like eigenclasses with mixins
Asked Answered
C

0

3

I've been looking for a natural way to implement state-dependent behaviour (state machines) in Python objects. The goal was for objects to have a small number of states, or "mutually orthogonal" aspects of state, which would determine their concrete behaviour at each moment. In other words, the method returned by x.foo should be determined by the current state of x, and if x changes its state, the implementation of some or all of its methods should change accordingly. (I think some call it "state design pattern.")

The most straightforward solution is to store methods as object attributes:

class StatefulThing:
    def _a_say_hello(self):
        print("Hello!")
        self.say_hello   = self._b_say_hello
        self.say_goodbye = self._b_say_goodbye
        return True

    def _a_say_goodbye(self):
        print("Another goodbye?")
        return False

    def _b_say_hello(self):
        print("Another hello?")
        return False

    def _b_say_goodbye(self):
        print("Goodbye!")
        self.say_hello   = self._a_say_hello
        self.say_goodbye = self._a_say_goodbye
        return True

    def _init_say_goodbye(self):
        print("Why?")
        return False

    def __init__(self):
        self.say_hello   = self._a_say_hello
        self.say_goodbye = self._init_say_goodbye

However, storing all methods as object attributes looks like a waste of memory, and updating all of them on every change of state looks like a waste of time/energy. Also, this approach will not work with special method names like __str__ or __len__ (unless they are set up to delegate to "ordinary" methods).

Having a separate mixin for each state comes naturally to mind. So I've figured out how to make mixins work as states using Ruby-like eigenclasses together with __bases__ mutation hack:

class T:
    """
    Descendant of `object` that rectifies `__new__` overriding.

    This class is intended to be listed as the last base class (just
    before the implicit `object`).  It is a part of a workaround for

      * https://bugs.python.org/issue36827
    """

    @staticmethod
    def __new__(cls, *_args, **_kwargs):
        return object.__new__(cls)

class Stateful:
    """
    Abstract base class (or mixin) for "stateful" classes.

    Subclasses must implement `InitState` mixin.
    """

    @staticmethod
    def __new__(cls, *args, **kwargs):
        # XXX: see https://stackoverflow.com/a/9639512
        class CurrentStateProxy(cls.InitState):
            @staticmethod
            def _set_state(state_cls=cls.InitState):
                __class__.__bases__ = (state_cls,)

        class Eigenclass(CurrentStateProxy, cls):
            __new__ = None  # just in case

        return super(__class__, cls).__new__(Eigenclass, *args, **kwargs)

# XXX: see https://bugs.python.org/issue36827 for the reason for `T`.
class StatefulThing(Stateful, T):
    class StateA:
        """First state mixin."""

        def say_hello(self):
            self._say("Hello!")
            self.hello_count += 1
            self._set_state(self.StateB)
            return True

        def say_goodbye(self):
            self._say("Another goodbye?")
            return False

    class StateB:
        """Second state mixin."""

        def say_hello(self):
            self._say("Another hello?")
            return False

        def say_goodbye(self):
            self._say("Goodbye!")
            self.goodbye_count += 1
            self._set_state(self.StateA)
            return True

    # This one is required by `Stateful`.
    class InitState(StateA):
        """Third state mixin -- the initial state."""

        def say_goodbye(self):
            self._say("Why?")
            return False

    def __init__(self, name):
        self.name = name
        self.hello_count = self.goodbye_count = 0

    def _say(self, message):
        print("{}: {}".format(self.name, message))

    def say_hello_followed_by_goodbye(self):
        self.say_hello() and self.say_goodbye()

# ----------
# ## Demo ##
# ----------
if __name__ == "__main__":
    t1 = StatefulThing("t1")
    t2 = StatefulThing("t2")
    print("> t1, say hello.")
    t1.say_hello()
    print("> t2, say goodbye.")
    t2.say_goodbye()
    print("> t2, say hello.")
    t2.say_hello()
    print("> t1, say hello.")
    t1.say_hello()
    print("> t1, say hello followed by goodbye.")
    t1.say_hello_followed_by_goodbye()
    print("> t2, say goodbye.")
    t2.say_goodbye()
    print("> t2, say hello followed by goodbye.")
    t2.say_hello_followed_by_goodbye()
    print("> t1, say goodbye.")
    t1.say_goodbye()
    print("> t2, say hello.")
    t2.say_hello()
    print("---")
    print( "t1 said {} hellos and {} goodbyes."
           .format(t1.hello_count, t1.goodbye_count) )
    print( "t2 said {} hellos and {} goodbyes."
           .format(t2.hello_count, t2.goodbye_count) )

    # Expected output:
    #
    #     > t1, say hello.
    #     t1: Hello!
    #     > t2, say goodbye.
    #     t2: Why?
    #     > t2, say hello.
    #     t2: Hello!
    #     > t1, say hello.
    #     t1: Another hello?
    #     > t1, say hello followed by goodbye.
    #     t1: Another hello?
    #     > t2, say goodbye.
    #     t2: Goodbye!
    #     > t2, say hello followed by goodbye.
    #     t2: Hello!
    #     t2: Goodbye!
    #     > t1, say goodbye.
    #     t1: Goodbye!
    #     > t2, say hello.
    #     t2: Hello!
    #     ---
    #     t1 said 1 hellos and 1 goodbyes.
    #     t2 said 3 hellos and 2 goodbyes.

This code can be adapted to situations where the state is not "monolithic" but can be decomposed into a product of smaller states: Eigenclass would need to have more than one mixin "proxy" among its bases, etc.

Has this or any similar approach of using mixins as states been described or tested? Are there any serious problems with it? Are there "better" alternatives?


Update.

I have realised an important practical issue with using __bases__ mutation: it must be a relatively expensive operation because each time it requires running a C3 linearisation algorithm to construct the MRO chain. Modifying bases on each change of state is thus very expensive. Indeed, trying to apply this approach in practice I observed a big slowdown compared to my previous solution.

I would have liked to have a cheap method for dynamically prepending classes to the MRO chain. I am going to try to hack mro directly using a metaclass...

Crossroad answered 6/5, 2019 at 15:7 Comment(6)
This looks like it belongs on Code Review.Southwestward
@mkrieger1, not really, i've checked their "What topics can I ask about here?" help section: "Is it actual code from a project rather than pseudo-code or hypothetical code? ... Generic code (such as code containing placeholders like foo, MyClass, or doSomething()) leaves too much to the imagination." In "What types of questions should I avoid asking?": "Best practices in general", "Higher-level architecture and design of software systems".Crossroad
Good job being aware of that @Alexey. Related links include see "Which Site?" and "Code Review or not?"Oecd
FWIW, I think this would be on-topic on Code Review. (Quite gray, and yes I know their rules) However for the point you've raised some members of CR may VTC the question. If you feel the answers you get here arn't sufficient then I would recommend trying on CR too.Ornithine
Always use the generic [python] tag for all python related questions. Use the version specific tags at your discretion. At the very least, the generic tag gets much more eyes.Kristin
Why aren’t you “just” changing the __class__ of the object on each state change? Python is not really meant to support a class per object (because of extensive caching of type information to speed up operations on the objects of that type).Cointon

© 2022 - 2024 — McMap. All rights reserved.