View Source Statechart (Statechart v0.2.0)

A pure-Elixir implementation of statecharts inspired by

installation

Installation

This package can be installed by adding statechart to your list of dependencies in mix.exs:

def deps do
  [
    {:statechart, "~> 0.2.0"}
  ]
end

concepts

Concepts

We'll model a simple traffic light to illustrate some statechart concepts.

traffic light diagram

  • This "machine" defaults to the off state (that's what the dot-arrow signifies).
  • If we then send the machine a TOGGLE event, it transitions to the on state. From there, it automatically drops into the red state (again, because of the dot-arrow). At this point, the machine is in both the on and red states.
  • If we send it a NEXT event, we transition to the green state (which you can also think of as the on/green state). Another NEXT event, and we transition to the yellow state. In this way, the light will just keep cycling through the colors.
  • If we send it a TOGGLE at this point, it will transition back to off.
  • If we now send the machine a NEXT event (while it's in the off state), nothing happens.

usage

Usage

There are three steps to modeling via the Statechart library:

We'll model the above traffic light using these three steps.

define

Define

defmodule TrafficLight do
  use Statechart

  statechart default: :off do
    state :off do
      :TOGGLE >>> :on
    end

    state :on, default: :red do
      :TOGGLE >>> :off
      state :red,    do: :NEXT >>> :green
      state :yellow, do: :NEXT >>> :red
      state :green,  do: :NEXT >>> :yellow
    end
  end
end

instantiate

Instantiate

The module containing your statechart definition automatically has a new/0 function injected into it.

traffic_light = TrafficLight.new()

It returns you a statechart struct that you then pass to all the 'MANIPULATE' functions.

manipulate

Manipulate

The machine starts in the off state:

[:off] = Statechart.states(traffic_light)
true   = Statechart.in_state?(traffic_light, :off)
false  = Statechart.in_state?(traffic_light, :on)

Send it a NEXT event without it being on yet:

traffic_light = Statechart.trigger(traffic_light, :NEXT)
# Still off...
true = Statechart.in_state?(traffic_light, :off)
# ...but we can see that the last event wasn't valid:
:error = Statechart.last_event_status(traffic_light)

Let's turn it on:

traffic_light = Statechart.trigger(traffic_light, :TOGGLE)
[:on, :red]   = Statechart.states(traffic_light)
true  = Statechart.in_state?(traffic_light, :on)
true  = Statechart.in_state?(traffic_light, :red)
false = Statechart.in_state?(traffic_light, :off)
false = Statechart.in_state?(traffic_light, :green)

Now the NEXT events will have an effect:

traffic_light = Statechart.trigger(traffic_light, :NEXT)
[:on, :green] = Statechart.states(traffic_light)

error-checking

Error-checking

Statechart has robust compile-time checking. For example, compiling this module will result in a StatechartError at the state :on line.

defmodule ToggleStatechart do
  use Statechart

  statechart default: :on do
    # Whoops! We've misspelled "off":
    state :on, do: :TOGGLE >>> :of
    state :off, do: :TOGGLE >>> :on
  end
end

actions

Actions

You can associate two types of actions (side effects) with each state:

  • an entry action: performed when entering the state, and
  • an exit action: performed when exiting the state.

Here is a Lightswitch that prints a message every time it exits and enters a new state:

defmodule LightSwitch do
  use Statechart

  statechart default: :off do
    state :on,
      entry: fn -> IO.puts("entering :on") end,
      exit: fn -> IO.puts("exiting :on") end do
      :TOGGLE >>> :off
    end

    state :off,
      entry: fn -> IO.puts("entering :off") end,
      exit: fn -> IO.puts("exiting :off") end do
      :TOGGLE >>> :on
    end
  end
end

lightswitch = LightSwitch.new
# => "entering :off"

Statechart.trigger(lightswitch, :TOGGLE)
# => "exiting :off"
# => "entering :on"

The actions above are all arity-0 functions that have side effects. It's usually much more useful though to use arity-1 functions that modify a context:

context

Context

First, let's clear up some confusion created by the word "state" in relation to state machines and statecharts. Generally in computer science, "state" basically refers to anything that a process remembers or keeps track of. For example, a clock knows what time it is and an object-oriented-programming "Person" object might know the first and last name of the person it represents. Anything that has state is referred to as "stateful".

Basic state machines are stateful too. The state they keep track is (confusingly) called their "state". For example, the above light switch "knows" whether it's in the :on state or the :off state. This wouldn't be half so bad were it not for the fact that many state machines keep track of a second kind of state, which we call the "context". The "context" is any data the state machine keeps track of in addition to its FSM-state. For example, a smart lightswitch might keep track of how many times it's been cycled on and off. A card game state machine might have a "drawing cards" state, and might have a context that tracks the cards each player has, whose turn it it, and which cards are in the draw and discard piles.

From now on, "state" will refer to the FSM-specific state (:on, :off, etc).

With all that out of the way, let's talk about the context.

Let's model that lightswitch that tracks how many cycles it's undergone.

defmodule LightSwitch do
  use Statechart
  statechart default: :off, context: {non_neg_integer, 0} do
    state :on, entry: &(&1 + 1), do: :OFF >>> :off
    state :off, do: :ON >>> :on
  end
end

In this example we see:

  • The context type (non_neg_integer()) and initial value (0) declared using the :context option on statechart/2. When this statechart is instantiated, it will start with a context of 0.
  • Every time the switch is turned on, the context gets incremented by 1. This is because the :on state has a "entry action" of &(&1 + 1).

multiple-actions

Multiple Actions

In statecharts where multiple actions are declared per state and/or where states are nested, many actions might take place as a result of a single event. In these cases, order matters. Let's look at a contrived example.

statechart default: :alpaca,
           context: {pos_integer, 1} do
  :ALPHA >>> :beetle

  state :alpaca,
    entry: &(&1 + 1),
    entry: &(&1 * 3),
    exit: &(&1 - 2)

  state :beetle,
    entry: fn val -> val - 1 end
end

The context is modified from its initial value of 1 to 6. Note the order of operations here. The first action added one (1 + 1 = 2) and the second action multiplied by three (2 * 3 = 6).

When we trigger the :ALPHA event (statechart = Statechart.trigger(statechart, :ALPHA)), we exit :alpaca, then enter :beetle, giving us a new context of 3. The first action (from exiting :alpaca) subtracted two (6 - 2 = 4). The second action (from entering :beetle) subtracted one (4 - 1 = 3).

default-context

Default Context

:context is an optional key for statechart/2. If left out, the context type defaults to term/0 and the value to nil.

defaults

Defaults

One advantage statecharts have over FSMs is that they can have nested states. Here is the TrafficLight module from above.

statechart module: TrafficLight, default: :off do
  state :off do
    :TOGGLE >>> :on
  end

  state :on, default: :red do
    :TOGGLE >>> :off
    state :red,    do: :NEXT >>> :green
    state :yellow, do: :NEXT >>> :red
    state :green,  do: :NEXT >>> :yellow
  end
end

You can be in the red/on state for example, but you cannot be in the on state without also being in red, yellow, or green. What this means for you, the developer, is that you can target a less-specific state (e.g. on), as long as it is marked with a default, so the statechart knows with more-specific state to "fall into". This is why we added a default: :red options to the :on state.

Note that note every parent state requires a default, only those targeted by transitions. Also, the root statechart needs a default (in our example, it has default: :off).

submodules

Submodules

statechart/2 accepts a :module option. In the below example, the module containing the statechart is Toggle.Statechart

defmodule Toggle do
  use Statechart

  statechart module: Statechart do
    state :on, default: true, do: :TOGGLE >>> :off
    state :off, do: :TOGGLE >>> :on
  end
end

In this way, many statecharts may be declared easily in one file:

defmodule MyApp.Statechart do
  use Statechart

  # module: MyApp.Statechart.Toggle
  statechart module: Toggle, default: :on do
    state :on, do: :TOGGLE >>> :off
    state :off, do: :TOGGLE >>> :on
  end

  # module: MyApp.Statechart.Switch
  statechart module: Switch, default: :on do
    state :on, do: :SWITCH_OFF >>> :off
    state :off, do: :SWITCH_ON >>> :on
  end
end

other-statechart-state-machine-libraries

Other statechart / state machine libraries

With a plethora of other related libraries, why did we need another one? I wanted one that had very strict compile-time checks and a simple DSL.

Other libraries you might look into:

roadmap

Roadmap

  • [X] v0.1.0 hierarchical states (see Harel, §2)
  • [X] v0.1.0 defaults (see Harel, Fig.6)
  • [X] v0.2.0 context and actions (see Harel, §5)
  • [ ] actions associated with events (see γ/W in Harel, Fig.37)
  • [ ] events triggered by actions (see β in Harel, Fig.37)
  • [ ] orthogonality (see Harel, §3)
  • [ ] event conditions
  • [ ] composability via subcharts
  • [ ] final state
  • [ ] state history (see Harel, Fig.10)
  • [ ] transition history

Link to this section Summary

define

Register a transtion from an event and target state.

Create a statechart node.

Create a statechart node.

Create or register a statechart to this module.

Create and register a statechart to this module.

Manipulate

Get current context data.

Determine if the given state is in the given compound state

Returns :ok if last event was valid and caused a transition

Get the current compound state

Send an event to the statechart

Link to this section Types

@type action() :: (context() -> context()) | (() -> :ok)
@type context() :: term()
@type event() :: term()
@opaque local_id()
@type state() :: atom()
@opaque t()
@type t(context) :: %Statechart.Machine{
  context: context,
  current_local_id: local_id(),
  last_event_status: :ok | :error,
  statechart_module: atom()
}

Link to this section define

Link to this macro

event >>> target_state

View Source (macro)

Register a transtion from an event and target state.

Link to this macro

state(name, opts_or_do_block \\ [])

View Source (macro)
@spec state(state(), Keyword.t() | term()) :: term()

Create a statechart node.

See state/3 for details

Link to this macro

state(name, opts, do_block)

View Source (macro)
@spec state(state(), Keyword.t(), term()) :: term()

Create a statechart node.

Examples

arity-1 (name only)

statechart do
  state :my_only_state
end

arity-2 (name and opts)

statechart do
  state :state_with_opts, entry: fn -> IO.inspect "hello!" end
                          exit: fn -> IO.inspect "bye" end
end

arity-2 (name and do block)

statechart do
  state :parent_state do
    state :child_state
  end
end

arity-3 (name and opts and do-block)

statechart do
  state :parent_state,
    entry: fn -> IO.inspect("hello!") end,
    exit: fn -> IO.inspect("bye") end do
    state :child_state
  end
end

module's statechart. The way to have multiple nodes sharing the same name is to define statechart partials in separate module and then insert those partials into a parent statechart.

options

Options

  • :default name of a child node to auto-transition to when this node is targeted. Required for any non-leaf node. (see Defaults)
  • :entry an action/0 to be executed when this node is entered (see Actions)
  • :exit an action/0 to be executed when this node is exited (see Actions)
Link to this macro

statechart(opts_or_do_block \\ [])

View Source (macro)

Create or register a statechart to this module.

See statechart/2 for details.

Link to this macro

statechart(opts, do_block)

View Source (macro)

Create and register a statechart to this module.

defmodule ToggleStatechart do
  use Statechart

  statechart do
    state :on, default: true, do: :TOGGLE >>> :off
    state :off, do: :TOGGLE >>> :on
  end
end

options

Options

  • :default name of a child node to auto-transition to when this node is targeted. Required for any non-leaf node. (see Defaults)
  • :module nests the chart in a submodule of the given name (see Submodules)
  • :entry an action/0 to be executed when this node is entered (see Actions)
  • :context expects a tuple whose second element is context/0 and the first is its type (see Actions)

Link to this section Manipulate

@spec context(t(context)) :: context when context: var

Get current context data.

Link to this function

in_state?(statechart, state)

View Source
@spec in_state?(t(), state()) :: boolean()

Determine if the given state is in the given compound state

Link to this function

last_event_status(statechart)

View Source
@spec last_event_status(t()) :: :ok | :error

Returns :ok if last event was valid and caused a transition

@spec states(t()) :: [state()]

Get the current compound state

Link to this function

trigger(statechart, event)

View Source
@spec trigger(t(context), event()) :: t(context)

Send an event to the statechart