samedi 11 février 2023

Best way to peeping a reference to context in State / Strategy patterns

I often implement finite state machines following the State/Strategy OOP patterns with two classes - StateMachine and State

Option 1

class State(ABC):
    context: StateMachine
    def __init__(self, context: StateMachine):
        self.context = context

    @abstractmethod
    def execute(self):
        ...

class StateMachine:
    state: State

    def __init__(self, initial_state: State):
        self.state = initial_state
    
    def enter(self, state: State):
        self.state = state
        
    def run(self):
        self.state.execute()

However, requiring context as a constructor argument for every State quickly feels redundant

class StateFoo(State):
    def execute(self):
        ...
        self.context.enter(StateBar(self.context)) # <-- feels redundant

It feels implicit that the StateMachine context being passed to StateBar will always be the same one that StateBar is being given to through .enter(). Yet I'm having to reference it twice.

I've considered moving the responsibility of giving a State it's context inside of StateMachine.enter()

Option 2

class State(ABC):
    context: StateMachine | None
    # removed constructor

    @abstractmethod
    def execute(self):
        ...

class StateMachine:
    state: State

    def __init__(self, initial_state: State):
        self.state = initial_state
    
    def enter(self, state: State):
        self.state = state
        state.context = self # <-- inject dependency here
        
    def run(self):
        self.state.execute()

class StateFoo(State):
    def execute(self) -> None:
        print("foo")
        self.context.enter(StateBar()) # <-- No need to pass context now

The ergonomics are better, but it possibly introduces a safety impurity: the type of State.context is now potentially None. It's possible (although unlikely and probably unreasonable) for a developer to create a State and call .execute() without passing it to a StateMachine.enter() first, making any internal logic that relies on a .context fail.

A solution I've considered is to have StateMachine call an inherited State.safe_execute() method which will pre-confirm that .context is set before delegating to the real .execute() that every State implements.

class State(ABC):
    context: StateMachine | None

    @abstractmethod
    def execute(self):
        ...

    def safe_execute(self):
        if self.context is None:
            raise Exception("self.context is not set!")
        self.execute()

class StateMachine:
    state: State

    def __init__(self, initial_state: State):
        self.state = initial_state
    
    def enter(self, state: State):
        self.state = state
        state.context = self
        
    def run(self):
        self.state.safe_execute() # <-- replaced self.state.execute()

This gets rid of the runtime safety concern, but I feel that it might be overcautious.

A third option is to remove the reference to .context on State and instead parameterize .execute() to take it as an argument.

Option 3

class State(ABC):
    @abstractmethod
    def execute(self, context: StateMachine): # <-- context is passed in
        ...

class StateMachine:
    state: State

    def __init__(self, initial_state: State):
        self.state = initial_state
    
    def enter(self, state: State):
        self.state = state
        
    def run(self):
        self.state.execute(self) # <-- pass self for context parameter

This isn't particularly appealing. Some State descendants will not need a reference to the context for their .execute() details, resulting in an unused parameter, potentially tunneled down through multiple State.execute() calls before ever being used. It also - like Option 1 - allows for the possibility of passing a different StateMachine context to a state on different .execute() calls, which may or may not be a bad thing.

In summary

Option 1

  • Results in long redundant lines `self.context.enter(StateFoo(self.context))
  • Allows for a StateMachine to enter/execute a State who has a reference to a different StateMachine

Option 2

  • Better code ergonomics/readability self.context.enter(StateFoo())
  • It makes sense to hide away construction details to the StateMachine, as it might be philosophically implied that a State can only exist within the context of a StateMachine. May be better separation of concerns and ontological clarity.
  • Introduces a runtime safety flaw; a State may be constructed and executed before passing it to self.context.enter() to receive .context
  • Prevents a State from having a reference to a StateMachine other than the one executing it, but does allow for another `StateMachine' to hijack it.

Option 3

  • All .execute() implementations now require a context parameter which may be unused

This naturally poses a few questions:

1. Is it ever advantageous to have State instances able to swap out their .context?

2. Is it ever advantaegeous to have StateMachine able to call .execute() on a .state whose .context is set to a different StateMachine?

3. Which of the options are best in which scenarios?

4. Are there any other alternatives?

Aucun commentaire:

Enregistrer un commentaire