In this article I will describe the use of the state machine, I will show a simple implementation, an implementation using the State pattern. It is worth mentioning that the use of the State pattern is undesirable in the presence of fewer than three states, since this usually leads to an excessive complication of the readability of the code, accompanying support problems – everything should be in moderation.
Flags Ruler
Suppose we are developing a video player screen for a civil aircraft media system, the player must be able to download the video stream, play it, allow the user to stop the download process, rewind and perform other ordinary operations for the player.
Suppose the player has cached the next chunk of the video stream, checked that there were enough chunks to play, began to play the fragment to the user and at the same time continues to download the next one.
At this point, the user rewinds to the middle of the video, so now you need to stop playing the current track, and start loading from a new position. However, there are situations in which this can not be done – the user can not control the playback of the video stream, while he is shown a video about safety in the air. Get the isSafetyVideoPlaying flag to check this situation.
Also, the system should be able to pause the current video and broadcast an alert from the captain of the vessel and crew through the player. Let’s get another flag isAnnouncementPlaying. Plus, there was a requirement not to pause the playback while displaying help on working with the player, one more flag isHelpPresenting.
Pseudocode:
class MediaPlayer {
public var isHelpPresenting = false
public var isCaching = false
public var isMediaPlaying: Bool = false
public var isAnnouncementPlaying = false
public var isSafetyVideoPlaying = false
public var currentMedia: Media = null
fun play(media: Media) {
if isMediaPlaying == false, isAnnouncementPlaying == false, isSafetyVideoPlaying == false {
if isCaching == false {
if isHelpPresenting == false {
media.playAfterHelpClosed()
}
else {
media.playAfterCaching()
}
}
}
fun pause() {
if isAnnouncementPlaying == false, isSafetyVideoPlaying == false {
currentMedia.pause()
}
}
}
The above example is poorly readable, such code is difficult to maintain due to the large variability (entropy) This example is based on my experience with the code base * of many * projects where the state machine was not used.
Each flag should “manage” the interface elements in a special way; the application’s business logic, the developer, adding the next flag, should be able to juggle them, checking and re-checking everything several times with all possible options.
Substituting into the formula “2 ^ number of flags” you can get 2 ^ 6 = 64 variants of application behavior for a total of 6 flags, all these combinations of flags will need to be checked and maintained manually.
From the developer’s side, adding a new functionality with this system looks like this:
– You need to add the ability to show the browser page of the airline, while it should be minimized as with movies, if the crew members announce something.
– Ok, I will. (Oh, damn, you’ll have to add one more flag, and double-check all the places where the flags intersect, that’s how much you need to change!)
Also a weak point of the flag system is making changes to the behavior of the application. It is very difficult to imagine how to quickly / flexibly change the behavior based on the checkboxes, if, after changing only one flag, you have to double-check everything. This approach to development leads to a lot of problems, loss of time and money.
Enter The Machine
If you take a good look at the flags, you can understand that in fact we are trying to handle specific processes taking place in the real world. We list them: the usual mode, the display of video security, broadcast messages from the captain or crew. For each process, a set of rules is known that change the behavior of the application.
According to the rules of the state machine, we will list all the processes as states in enum, add such a concept as state to the player code, implement the behavior based on the state, removing the combinations on the checkboxes. Thus, we reduce the options for testing to exactly the number of states.
Pseudocode:
enum MediaPlayerState {
mediaPlaying,
mediaCaching,
crewSpeaking,
safetyVideoPlaying,
presentingHelp
}
class MediaPlayer {
fun play(media: Media) {
media.play()
}
func pause() {
media.pause()
}
}
class MediaPlayerStateMachine {
public state: MediaPlayerState
public mediaPlayer: MediaPlayer
public currentMedia: Media
//.. init (mediaPlayer) etc
public fun set(state: MediaPlayerState) {
switch state {
case mediaPlaying:
mediaPlayer.play(currentMedia)
case mediaCaching, crewSpeaking,
safetyVideoPlaying, presentingHelp:
mediaPlayer.pause()
}
}
}
The huge difference between the flag system and the state machine lies in the logic state switching funnel in the set (state: ..) method, it allows you to translate the human understanding of the state into the program code, without having to play logic games to convert the flags to states with further support of the code .
State pattern
Next, I will show the difference between the naive implementation of the state machine and the state pattern. Imagine that you need to add 10 steits, as a result, the state machine class will grow to the size of a godobject, it will be difficult and expensive to maintain. Of course, this implementation is better than the flag, (if the flag system is shot first by the developer, and if not, after seeing 2 ^ 10 = 1024 variations, QA hangs, but if both of them * do not notice the complexity of the task, then the user who has the application just refuses to work with a certain combination of flags)
With a large number of states, it is necessary to use the State pattern.
We will issue a set of rules in the protocol of the State:
protocol State {
func playMedia(media: Media, context: MediaPlayerContext)
func shouldCacheMedia(context: MediaPlayerContext)
func crewSpeaking(context: MediaPlayerContext)
func safetyVideoPlaying(context:MediaPlayerContext)
func presentHelp(context: MediaPlayerContext)
}
We will carry out the implementation of the set of rules in separate states, for example, the code of one state:
class CrewSpeakingState: State {
func playMedia(context: MediaPlayerContext) {
showWarning(“Can’ t play media - listen to announce!”)
}
func mediaCaching(context: MediaPlayerContext) {
showActivityIndicator()
}
func crewSpeaking(context: MediaPlayerContext) {
set(volume: 100)
}
func safetyVideoPlaying(context: MediaPlayerContext) {
set(volume: 100)
}
func presentHelp(context: MediaPlayerContext) {
showWarning(“Can’ t present help - listen to announce!”)
}
}
Next, create a context with which each state will work, integrate the state machine:
final class MediaPlayerContext {
private
var state: State
public fun set(state: State) {
self.state = state
}
public fun play(media: Media) {
state.play(media: media, context: this)
}
…
Other events
}
Application components work with the context through public methods, the state objects themselves decide from which state to which to make the transition using the state machine inside the context.
Thus, we have implemented the God Object decomposition, and it will be much easier to maintain a changing state by tracking the changes in the protocol by the compiler, reducing the complexity of understanding the states due to reducing the number of lines of code and focusing on solving a specific state task. Also, you can now share teamwork, giving the implementation of a particular state to team members, without worrying about the need to “resolve” conflicts, which happens when working with one large class of state machines.
References
https://refactoring.guru/design-patterns/state