A typed purely-functional state machine

Modeling state is a central concern for a software system of any reasonable size. It is also one of the main sources of bugs when not handled carefully. John Carmack said

A large fraction of the flaws in software development are due to programmers not fully understanding all the possible states their code may execute in.

Many best practices in software engineering exist for the purpose of containing and controlling state in order to minimize unintended interactions and therefore unexpected states. Some examples: encapsulation, data hiding, modularity, immutability. In this post we are going to suggest a way to model a state machine that results in some nice safety properties. For this example we will use a state machine that represents a very simple process, baking a cake in four steps. The technique could in principle be used for more complex cases.

Here’s the state machine for a simple cake baking process.

We also add some data that describes the details of each baked cake.

Number of eggs

Number of cups of flour

Number of spoons of butter

Number of teaspoons of sugar

Amount of time mixing

Amount of time baking in the oven

This data must be captured by our model during the baking process. Let’s first consider a (very) naive approach:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

classCake{

finalval STARTED=0

finalval POURED=1

finalval MIXED=2

finalval BAKED=3

varstate:Int=STARTED

vareggs:Int=0

varflour:Int=0

varsugar:Int=0

varbutter:Int=0

varmixingTime:Int=0

varbakingTime:Int=0

def pour(eggs:Int,flour:Int,sugar:Int,butter:Int)={

// ...

state=POURED

}

def mix(time:Int)={

// ...

state=MIXED

}

def bake(time:Int)={

// ...

state=BAKED

}

}

The cake is constructed in the STARTED state, the rest of the data is not present yet. State transitions are implemented with methods that receive the data that becomes available at the corresponding steps in the process. The transitions advance the state of the cake modifying the state variable seen on the last line. The complete implementation is not present, represented by the placeholder ‘// …’.

So, whats wrong with this approach? Strictly speaking we can’t say there’s something wrong about the implementation because it is not complete. If, by definition, the missing implementation were correct, the cake model would be correct. The point of this obvious statement is to note that mostly any approach can lead to a correct implementation. What makes an approach better or worse is how easily it leads to a good implementation. We can make several observations about the above in terms of what kind of implementation will result and what it will need to be correct. To begin we have

1

2

3

4

5

6

finalval STARTED=

finalval POURED=1

finalval MIXED=2

finalval BAKED=3

varstate:Int=STARTED

This way of representing a discrete set of possible values with an integer along with symbolic constants is common in older languages. The obvious problem is one that illustrates how one must think about types in general. The type Int has a range of values that is larger than the set of values that are correct for our state. This means that our implementation must ensure this. In some languages one can use the typesafe enum pattern to improve upon this. In general, the lesson to learn here is that one should try to constrain values as much as possible with types rather than with runtime checks. A typed approach places less demands on the programmer being correct, as the code will be checked by the compiler. This is the theme we outlined above.

Next part:

1

2

3

4

5

6

vareggs:Int=0

varflour:Int=0

varsugar:Int=0

varbutter:Int=0

varmixingTime:Int=0

varbakingTime:Int=0

We have the same problem as above, although in a more subtle form. Let’s assume for the sake of argument that we need to allow 0 as an unitialized value, and that other values are correct. In this case it’s not that the range of the individual types is larger than the set of possible correct values, it’s that the range of the conjunction of possible values is larger. This conjunction of possible values can represent inconsistent states, because the specification of the process does not allow certain values to be missing while other are present. The state machine only allows a specific evolution of these values, but the representation we are using does not reflect this, allowing any combination. Again, it is up to the implementation to enforce this, the compiler cannot help us.

Next part:

1

2

3

4

5

6

7

8

9

10

11

12

13

def pour(eggs:Int,flour:Int,sugar:Int,butter:Int)={

// ...

state=POURED

}

def mix(time:Int)={

// ...

state=MIXED

}

def bake(time:Int)={

// ...

state=BAKED

}

}

These methods implement the state transitions, advancing the state and accumulating data during the process. But we know from the state diagram that we cannot bake a cake before pouring the ingredients, and we cant mix a cake that has already been baked. These constraints are not enforced by the implementation as is. Nothing in the method types stops us from calling any of them at any time in the process. A correct implementation would have to check the current state at runtime and throw some kind of IllegalStateException if we were trying to execute an illegal state transition. Again, this relies on the programmer being on top of things, the compiler wont help.

We’ve seen three things wrong with this approach, which boil down to one pattern: the types do not enforce as much of the state machine specification as they should, it is up to the programmer to prevent an inconsistent state with runtime checks. Let’s look at another way.

This fixes two problems seen before. First, the state has type CakeState, so it is impossible to assign something that is not a state of the cake. Second, the classes that represent the state of the cake are not empty. Instead, they contain immutable variables that precisely correspond to the data available at that step in the process. This immediately enforces that a certain state must have certain information coupled to it (including that corresponding to previous states). As opposed to the previous approach, this typed representation makes it impossible to store an inconsistent state, independently of the rest of the implementation. Let’s look at the state transitions:

The transitions are functional, they do not modify a Cake in place, but rather return a new cake resulting from applying the transition on the inputted cake. As opposed to the previous case, this allows method types (signatures) to enforce the state machine specification. One cannot bake a cake unless it has been mixed, and one cannot mix a cake that has been baked:

Any of these attempts at illegal state transitions are compile time errors, again preventing to enter an illegal state. Finally let’s see how the cake is actually baked.

1

2

3

4

val started=Cake.start

val poured=Cake.pour(started,3,2,2,2)

val mixed=Cake.mix(poured,5)

val baked=Cake.bake(mixed,5)

Pretty simple.

The full example below also includes error handling to demonstrate that the approach is compatible with real world cases where some of the data is not predictable and has to be validated. The last thing to note is that the functional approach gives an added bonus, it is possible to audit or replay the evolution of the cake. This is possible because values are not modified in place, but rather new values are calculated. Here’s the complete example

What can still be improved? How about turning those Int’s into types that better represent the possible values the ingredients can take.. In the next post we’ll see this technique applied to a practical example, a cryptographically secure voting system prototype. This will also feature encoding list lengths using shapeless.