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