6

When implementing a state machine and it comes to the fact that a transition depends on an external boolean a, or an enum value b which can be one out of three (which are set somewhere else in the control flow), like

// ... more states ...
L : { // example for a ‘common’ state
   event27 → G
   event12 → J
   // events not listed throw an exception. State remains unchanged
}
M : {
   // ... other events ...
   event33 {
      case(b){
        ONE: → C
        TWO: → Q
        THREE: → R
      }
   }
N : {
   // ... other events ...
   event17 {
      if(a) → J
      else  → D
      }
   }
}
// ... more states ...

The states are not only the value of a variable, but they are active, so each of the states contains some code to execute to do something. How to implement the new requiremet as a state machine? I come to three possible solutions:

  • multiply all states. If we had 18 states before, we will now need 18 × 2 × 3 = 108 states (classes), where always six represent the same behaviour (contain the same code, or ar subclasses of a parent class which contains the code) Also, state switches (a becomes false) may happen during execution, so state should switch but code should not be re-run. This one seems totally non-maintainable.
  • hold the state as an additional variable in the state machine and apply it (as in the pseudo-code sample above) from within the state machine in case that the event occurs (no duplicate code, but not a “clean” state model any more? Or isn’t it a problem?)
  • in all places where the code currently thows event 33, check b and throw three different events (little duplicate code, but maybe some), same for event17

Or maybe there is a better approach to it? Suggestions?

1 Answer 1

7

The computer science answer is that you do indeed have up to 18*3*2 distinct states to deal with. How these states should be encoded into your program depends on your goals.

By writing down all possible states and restricting yourself to simple state transitions, you have the advantage that your code corresponds directly to a DFA. This lets you check all state transitions easily (though tediously). By analysing the state transition table, you can prove that the program will always respond correctly to any input sequence. Without this explicitness, it can get difficult to show that the control flow exhibits certain constraints, e.g. that some state is only reachable when a is set. You can also show that the program will always halt.

Unfortunately, large numbers of states are not very maintainable. This is only a good solution if you need formal verification, and can preferably generate the necessary code from an automatically-checked model.

In most cases, you will want to focus on maintainability instead. Being obvious is more important than being absolutely 100% correct (you can ensure some level of correctness more cheaply through testing). The bad news is that state machines are not very easy to understand for more than three states or so. The there are various techniques to manage the cognitive load of state machines.

One you have already found: expressing only a set of primary states in the state machine, and handling secondary state separately. This can drastically simplify the code when most state transitions do not depend on the secondary states. However, it's easy to accidentally get the various states out of sync, e.g. forgetting to reset a secondary state whenever a certain primary state is left. This can be made more unlikely by requiring all state changes to go through a single function, that makes sure a complete state has been intentionally provided. If the state space is somehow constrained to certain combinations of values, this function can also perform consistency checks. So instead of

oldstate: event {
  if (a) {
    b = A
    → newstate1
  } else {
     // forgot: b = B
     → newstate2
  }
}

we would be forced to provide a b value with

oldstate: event {
  if (a) → state(newstate1, a: a, b: A)
  else   → state(newstate2, a: a, b: B)  // can't forget value for b
}

The biggest simplifications are possible when chopping up your complete DFA with its 108 states into nested DFAs. When you look at the complete state transition diagram, some parts of the graph will feature many transitions within that subgraph, but will only have few transitions entering that subgraph and one transition leaving the subgraph. You can then extract the subgraph as a separate DFA, and call the extracted DFA like a procedure/function. If the subgraph has multiple entry points, you need to pass the relevant state as parameters so that the suitable start state can be chosen. Compare also the Extract Method refactoring.

Sometimes, multiple extractable DFAs have the overall same structure: same states, same inputs, but differ in their output and in one dimension of the state (e.g. each is used for a different value of the b variable). Extracting these separately would hide their similarity and thus make maintenance more difficult. In such a case, you can make parts of the state transition table pluggable. We unify the states of the extracted DFAs, and represent the extracted table as data. If the state transitions have side effects or if the target state of a transition depends on other variables, the state transition table will hold function pointers. In an object oriented system, each extracted state transition table would be an object, and the cells of the table would be methods on these objects. The main state transition table now only considers the unified states, and delegates the state transition to that extracted state transition table that is required by the currently active secondary state.

oldstate: event {
  → transition_table_for(b).oldstate_event
}

In many cases, the secondary state can be directly represented as a pointer to the active state transition table.

Finally, it might be better to represent the state implicitly in terms of the control flow of imperative code, rather than hand-rolling a difficult to maintain state machine. Most programmers are used to reasoning about imperative programs. For example, any DFA can be expressed as a set of mutually recursive functions, where each function corresponds to a major state. (E.g. most handwritten parsers use a recursive descent approach, rather than specifying the tables for a LL or LR parser). Smaller states can be represent implicitly in the control flow within the function. The debuggability of such function-encoded state machines is excellent, since the stack trace contains relevant states that lead to the current state. However, an imperative encoding becomes tedious unless the state transition table is fairly sparse. If the DFA you are implementing was derived from an NFA, imperative encodings allow you to reduce the number of states back to the NFA states by using backtracking (though this does have an exponential worst case).

At this point, we have completely given up any chance of formal verification, but have possible arrived at a much terser and more maintainable representation. Where on this scale you want to be depends on the requirements and constraints of your project. E.g. I once saw a parser that could have been simplified by using conrol flow to represent the states. However, a requirement was that it operated asynchronously. Without language-level support for async operation, the state necessarily had to be made explicit.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.