DEV Community

Cover image for State Machines in Practice: Implementing Solutions for Real Challenges
Pragati Verma
Pragati Verma

Posted on

State Machines in Practice: Implementing Solutions for Real Challenges

If you've studied engineering, you've probably heard about State Machines. But beyond the theory, you might not have seen how they're used. Did you know that State Machines play a role in many everyday applications? In this article, we'll refresh our understanding of State Machines and explore their practical applications. Plus, we'll learn how to implement them quickly using Python. Ready to dive in? Let's go!

'State' is a common programming term that is experienced by all developers as they advance from beginning to intermediate-level programming. So, what exactly does the term "State" mean?

In general, an object's state is merely the current snapshot of the object or a portion of it. Meanwhile, in computer science, a program's state is defined as its position about previously stored inputs. In this context, the term "state" is used in the same manner as it is in science: the state of an object, such as a gas, liquid, or solid, represents its current physical nature, and the state of a computer program reflects its current values or contents.

The stored inputs are preserved as variables or constants in a computer program. While assessing the state of a program, developers might examine the values contained in these inputs. The state of the program may change while it runs - variables may change, and memory values may change. A control variable, such as one used in a loop, for example, changes the state of the program at each iteration. Examining the present state of a program may be used to test or analyze the codebase.

In simpler systems, state management is frequently handled with if-else, if-then-else, try-catch statements, or boolean flags; however, this is useless when there are too many states imaginable in a program. They can lead to clunky, complicated code that is difficult to understand, maintain, and debug.

One disadvantage of if-else-clauses or booleans is that they may get fairly extensive, and adding another state is difficult because it necessitates rewriting the code of many different classes.

Let's build a video player for example:

class Video:

   def __init__(self, source):
       self.source = source
       self.is_playing = False
       self.is_paused = False
       self.is_stopped = True

   # A video can only be played when paused or stopped
   def play(self):
       if not self.is_playing or self.is_paused:
           # Make the call to play the video
           self.is_playing = True
           self.is_paused = False
       else:
           raise Exception(
               'Cannot play a video that is already playing.'
           )

   # A video can only be paused when it is playing
   def pause(self):
       if self.is_playing:
           # Make the call to pause the video
           self.is_playing = False
           self.is_paused = True
       else:
           raise Exception(
               'Cannot pause a video that is not playing'
           )

   # A video can only be stopped when it is playing or paused
   def stop(self):
       if self.is_playing or self.is_paused:
           # Make the call to stop the video
           self.is_playing = False
           self.is_paused = False
       else:
           raise Exception(
               'Cannot stop a video that is not playing or paused'
           )
Enter fullscreen mode Exit fullscreen mode

The above code snippet is an if-else implementation of a simple video player application, where the three basic states are - playing, paused, and stopped. However, if we try to add more states, the code will rapidly become complex, bloated, repetitive, and hard to understand and test. Let’s see what the code looks like when adding another state ‘rewind’:

class Video:
   def __init__(self, source):
       self.source = source
       self.is_playing = False
       self.is_paused = False
       self.is_rewinding = False
       self.is_stopped = True

   # A video can only be played when it is paused or stopped or rewinding
   def play(self):
       if self.is_paused or self.is_stopped or self.is_rewinding:
           # Make the call to play the video
           self.is_playing = True
           self.is_paused = False
           self.is_stopped = False
           self.is_rewinding = False
       else:
           raise Exception(
               'Cannot play a video that is already playing.'
           )

   # A video can only be paused when it is playing or rewinding
   def pause(self):
       if self.is_playing or self.is_rewinding:
           # Make the call to pause the video
           self.is_playing = False
           self.is_paused = True
           self.is_rewinding = False
           self.is_stopped = False
       else:
           raise Exception(
               'Cannot pause a video that is not playing or rewinding'
           )

   # A video can only be stopped when it is playing or paused or rewinding
   def stop(self):
       if self.is_playing or self.is_paused or self.is_rewinding:
           # Make the call to stop the video
           self.is_playing = False
           self.is_paused = False
           self.is_stopped = True
           self.is_rewinding = False
       else:
           raise Exception(
               'Cannot stop a video that is not playing or paused or rewinding'
           )

   # 4. A video can only be rewinded when it is playing or paused.
   def rewind(self):
       if self.is_playing or self.is_paused:
           # Make the call to rewind the video
           self.is_playing = False
           self.is_paused = False
           self.is_stopped = False
           self.is_rewinding = True
       else:
           raise Exception(
               'Cannot rewind a video that is not playing or paused'
           )
Enter fullscreen mode Exit fullscreen mode

Without the state pattern, you'd have to examine the program's current state throughout the code, including the update and draw methods. If you want to add a fourth state, such as a settings screen, you'll have to update the code of many distinct classes, which is inconvenient. This is where the idea of state machines comes in handy.

What is a state machine?

State machines are not a novel concept in computer science; they are one of the basic design patterns utilized in the software business. It is more system-oriented than coding-oriented and is used to model around use cases.

Let's look at a simple real-life example of hiring a cab through Uber:

  1. When you initially launch the program, it takes you to the home screen, where you type in your destination in the search area.
  2. Once the right location has been identified, Uber displays recommended travel options such as Pool, Premier, UberGo, Uber XL, and others, along with a pricing estimate.
  3. The trip is confirmed and a driver is assigned once you select the payment option and press the 'Confirm' button with the specified journey time if necessary.
  4. Uber now displays a map on which you can locate your driver.

Screen 1 is the first screen that all users in this use case see, and it is self-contained. Screen 2 is reliant on Screen 1, and you will not be able to go to Screen 2 until you give accurate data on Screen 1. Likewise, screen 3 is dependent on screen 2, while screen 4 is dependent on screen 3. If neither you nor your driver cancels your trip, you'll be moved to screen 4, where you won't be able to plan another trip until your current one ends.

Let's say it's raining severely and no driver accepts your trip or no available driver in your region is found to finish your travel; an error notification warning you of driver unavailability shows, and you remain on screen 3. You may still return to screen 2, screen 1, and even the very first screen.

You are in a different step of the cab reservation process, and you may only go to the next level if a specified action in the current stage is successful. For example, if you input the wrong location on screen 1, you won't be able to proceed to screen 2, and you won't be able to proceed to screen 3 unless you pick a travel option on screen 2, but you may always return to the previous stage unless your trip is already booked.

In the above example, we've divided the cab booking process into several activities, each of which may or may not be authorized to call another activity based on the status of the booking. A state machine is used to model this. In principle, each of these stages/states should be autonomous, with one summoning the next only after the current one has been finished, successfully or otherwise.

In more technical words, the state machine enables us to split down a large complicated action into a succession of separate smaller activities, such as the cab booking activity in the preceding example.

Events connect smaller tasks, and shifting from one state to another is referred to as transition. We normally conduct some actions after changing from one state to another, such as creating a booking in the back end, issuing an invoice, saving user analytics data, capturing booking data in a database, triggering payment after the trip is finished, and so on.

Hence, the general formula for a state machine can be given as:

Current State + Some Action / Event= Another State

Let’s see what a state machine designed for a simple video player application would look like:

State Diagram for a simple video player application

And we can implement it in code using transitions as follows:

from transitions import Machine

class Video:
   # Define the states
   PLAYING = 'playing'
   PAUSED = 'paused'
   STOPPED = 'stopped'

   def __init__(self, source):
       self.source = source

       # Define the transitions
       transitions = [
           # 1. A video can only be played when it is paused or stopped.
           {'trigger': 'play', 'source': self.PAUSED, 'dest': self.PLAYING},
           {'trigger': 'play', 'source': self.STOPPED, 'dest': self.PLAYING},
           # 2. A video can only be paused when it is playing.
           {'trigger': 'pause', 'source': self.PLAYING, 'dest': self.PAUSED},
           # 3. A video can only be stopped when it is playing or paused.
           {'trigger': 'stop', 'source': self.PLAYING, 'dest': self.STOPPED},
           {'trigger': 'stop', 'source': self.PAUSED, 'dest': self.STOPPED},
       ]

       # Create the state machine
       self.machine = Machine{
           model = self,
           transitions = transitions,
           initial = self.STOPPED
       }

   def play(self):
       pass

   def pause(self):
       pass

   def stop(self):
       pass
Enter fullscreen mode Exit fullscreen mode

Now, in case, we want to add another state, say rewind, we can do that easily as follows:

State diagram with rewind state

from transitions import Machine

class Video:
   # Define the states
   PLAYING = 'playing'
   PAUSED = 'paused'
   STOPPED = 'stopped'
   REWINDING = 'rewinding' # new

   def __init__(self, source):
       self.source = source

       # Define the transitions
       transitions = [
           # 1. A video can only be played when it is paused or stopped.
           {'trigger': 'play', 'source': self.PAUSED, 'dest': self.PLAYING},
           {'trigger': 'play', 'source': self.STOPPED, 'dest': self.PLAYING},
           {'trigger': 'play', 'source': self.REWINDING, 'dest': self.PLAYING}, # new
           # 2. A video can only be paused when it is playing.
           {'trigger': 'pause', 'source': self.PLAYING, 'dest': self.PAUSED},
           {'trigger': 'pause', 'source': self.REWINDING, 'dest': self.PAUSED}, # new
           # 3. A video can only be stopped when it is playing or paused.
           {'trigger': 'stop', 'source': self.PLAYING, 'dest': self.STOPPED},
           {'trigger': 'stop', 'source': self.PAUSED, 'dest': self.STOPPED},
           {'trigger': 'stop', 'source': self.REWINDING, 'dest': self.STOPPED}, # new
           # 4. A video can only be rewinded when it is playing or paused.
           {'trigger': 'rewind', 'source': self.PLAYING, 'dest': self.REWINDING}, #new
           {'trigger': 'rewind', 'source': self.PAUSED, 'dest': self.REWINDING}, # new
       ]

       # Create the state machine
       self.machine = Machine{
           model = self,
           transitions = transitions,
           initial = self.STOPPED
       }

   def play(self):
       pass

   def pause(self):
       pass

   def stop(self):
       pass

   def rewind(self):
       pass
Enter fullscreen mode Exit fullscreen mode

Thus, we can see how state machines can simplify a complex implementation and save us from writing incorrect code. Having learned the capabilities of state machines, it’s now important to understand why and when to use state machines.

Why & When to Use State Machines?

State Machines can be utilized in applications that have distinct states. Each stage can lead to one or more subsequent states, as well as end the process flow. A State Machine employs user input or in-state computations to choose which state to enter next.

Many applications necessitate an "initialize" stage, followed by a default state that allows for a wide range of actions. Previous and present inputs, as well as states, can all have an impact on the actions that are executed. Clean-up measures can then be carried out when the system is "shut down."

A state machine can help us conceptualize and manage those units more abstractly if we can break down a hugely complex job into smaller, independent units, where we simply need to describe when a state can transition to another state and what happens when the transition occurs. We don't need to be concerned with how the transition occurs after setup. After that, we only need to think about when and what, not how.

Furthermore, state machines let us see the entire state process in a very predictable way; once transitions are set, we don't have to worry about mismanagement or erroneous state transitions; the improper transition may occur only if the state machine is configured properly. We have a comprehensive view of all states and transitions in a state machine.

If we don't use a state machine, we are either unable to visualize our systems in various possible states, or we are knowingly or unknowingly coupling our components tightly together, or we are writing many if-else conditions to simulate state transitions, which complicates unit and integration testing because we must ensure that all test cases are written to validate the possibility of all the conditions and branching used.

Advantages of State Machines

State machines, in addition to their ability to develop decision-making algorithms, are functional forms of application planning. As applications get more complex, the need for effective design grows.

State diagrams and flowcharts are useful and occasionally essential throughout the design process. State Machines are important not just for application planning but are also simple to create.

Following are some of the major advantages of state machines in modern-day computing:

  • It helps you eliminate hard coding conditions. On your behalf, the state machine abstracts all logic related to states and transitions.

  • State machines often have a finite number of states with definite transitions, making it simple to identify which transition/data/event triggered the present state of a request.

  • After establishing a state machine, developers may concentrate on building actions and preconditions. With sufficient validation and preconditioning, state machines restrict out-of-order operations. As in the Uber example, a driver cannot be rewarded until the voyage is done.

  • State machines can be quite easy to maintain. The actions taken during each transition are logically independent of one another. As a result, the corresponding code can be isolated.

  • State machines are less prone to change and are more stable. It becomes much easier to maintain such systems if the current and future use cases are very obvious.

Disadvantages of State Machines

Not everything about state machines is good, they can sometimes lead to drawbacks and challenges too. Here are some of the common problems with state machines:

  • State machines are usually synchronous. So, if you need asynchronous background API calls/job execution, you'll have to weigh the pros and cons carefully before deciding on the best option.

  • The code can quickly get jumbled. Because state machines are data-driven, your product team may ask you to execute different transitions from the same state based on different data/input parameters. As a result, this type of demand may result in multiple transitions with a clumsy precondition check. It is entirely dependent on the product and the machine's current configuration.

  • If you need to load balance state machine instances, go with the one that has persistence enabled; otherwise, you'll need to implement your persistence layer and necessary validations to ensure that multiple requests fired at separate state machine instances produce consistent results.

  • Because there are few resources or communities dedicated to distinct state machine implementations, assistance may be limited once you've chosen a library.

Things to keep in mind when using State Machines

When using a state machine, your system should ideally have two logical components:

  • the state machine/workflow system itself
  • the business logic contained in one or more services.

The state machine may be thought of as the infrastructure that drives state transitions; it verifies state transitions and executes configured actions before, during, and after a transition; however, it should not know what business logic is done in those actions.

So, in general, it's a good idea to isolate the state machine from the core business logic by using correct abstractions; otherwise, managing the code would be a nightmare.

Here are some other real-life scenarios where we need to employ state machine logic with caution:

  • A state machine is more than just states, transitions, and actions. It should also be capable of defining a border around a state change. A transition can only be successful in particular cases if it is triggered by a trustworthy system or user. There might be a variety of similar situations. As a consequence, we should be able to develop appropriate state transition guarding logic.

  • We commonly end up with many processes for the same business entity that can run concurrently. In such cases, one process does not obstruct other workflows; they may or may not be triggered concurrently, but they may coexist; the second workflow may begin from one of the first workflow's eligible phases, after which it may branch off and work independently. This sort of use case is established by the business; not every organization will have it.

  • In principle, workflow systems are independent of the business domain. As a consequence, in the same workflow system, many processes with no link to the same business organization can be established. They may have a shared or distinct starting point, depending on whether the workflow system enables multiple starting points.

  • When numerous separate workflows are formed in the same workflow system, you get a global picture of all business processes running across various business entities in your system. Depending on the business use cases, different processes may also have certain identical stages.

Practical or real-life use cases for state machines:

Following are some of the practical applications that benefit from the concept of state machines in our daily lives:

  • Single-page or tabbed dialogue boxes A tab in the conversation box represents each state. A user can initiate a state transition by selecting a certain tab. The status for each tab includes any actions that the user can take.

  • A self-service banking machine (ATM). In this application, states such as waiting for user input, confirming the needed amount against the account balance, distributing the money, printing the receipt, and so on are all conceivable.

  • A software that takes a single measurement, stores it in memory, and then waits for the user to take another action. This program's steps include waiting for user input, performing the measurement, recording the data, displaying the results, and so on. Configuring ETL Jobs, for example.

  • State machines are commonly used for designing user interfaces. While designing a user interface, distinct user actions shift the user interface into separate processing segments. Each of these elements will function as a state in the State Machine. These segments can either lead to another segment for further processing or wait for another user event. In this scenario, the State Machine monitors the user to determine what action they should take next.

When you purchase something from an online e-commerce site, for example, it goes through various phases, such as Ordered, Packed, Shipped, Cancelled, Delivered, Paid, Refunded, and so on. The transition occurs automatically as things move through a warehouse or logistics center and are scanned at various stages, such as when a user cancels or wants a refund.

  • Process testing is another typical application for State Machines. In this example, each stage of the process is represented by a state. Depending on the results of each state's exam, a separate state may be declared. This might happen frequently if the process under examination is thoroughly studied.

Conclusion

The notion of state machines is extremely useful in programming. It not only streamlines the process of developing more complicated use case apps but also reduces the development work necessary. It provides a more simple and elegant grasp of modern-day events and when correctly applied, may work miracles.


If you find this insightful, do let me know your views in the comments. Also, any kind of feedback is welcome. In case you want to connect with me, follow the links below:

LinkedIn | GitHub | Twitter

Top comments (1)

Collapse
 
matteo-m profile image
Matteo Müller

Thank you!