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
StateMachineto enter/execute aStatewho has a reference to a differentStateMachine
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
Statecan only exist within the context of aStateMachine. May be better separation of concerns and ontological clarity. - Introduces a runtime safety flaw; a
Statemay be constructed and executed before passing it toself.context.enter()to receive.context - Prevents a
Statefrom having a reference to aStateMachineother than the one executing it, but does allow for another `StateMachine' to hijack it.
Option 3
- All
.execute()implementations now require acontextparameter which may be unused
This naturally poses a few questions:
Aucun commentaire:
Enregistrer un commentaire