Current section
Files
Jump to
Current section
Files
lib/replika.ex
defmodule Replika do
@moduledoc """
Replika is a pure functional finite state machine library for Elixir.
Unlike process-based state machines, Replika implements
state machines as immutable data structures that transform as they
transition between states. This provides significant performance
benefits while simplifying state management.
## Key Benefits
* **High Performance** - State transitions are simple struct updates (O(1))
* **No Process Overhead** - Works as a lightweight data structure
* **Immutable & Pure** - Predictable state transitions without side effects
* **Embeddable** - Can be stored in ETS, databases, or embedded in other processes
* **Pattern Matching** - Pattern matching is optimized by the BEAM VM
* **Deterministic Memory Usage** - Low memory footprint at scale
## Structure
When you use Replika in a module, it defines a struct with the following fields:
```elixir
%YourModule{
state: atom(), # The current state of the FSM
data: any() # The data associated with the current state
}
```
The `state` field holds the current state name (atom), and the `data`
field can hold any associated data for that state.
## Example
The following diagram illustrates a simple state machine with four states:
```
+---------------+
| v
+----------------+ +----------------+ +-------------------+
| :no_id_card |--->| :has_id_card |--->| :at_elevator |
+----------------+ +----------------+ +-------------------+
|
v
+--------------------+
| :elevator_accessed |
+--------------------+
```
This diagram can then be represented by the following:
```elixir
defmodule Unit.AccessState do
use Replika, initial_state: :no_id_card, initial_data: %{inventory: []}
defstate no_id_card do
defevent pickup_card do
next_state(:has_id_card, %{inventory: [:id_card]})
end
end
defstate has_id_card do
defevent approach_elevator do
next_state(:at_elevator, %{inventory: [:id_card]})
end
end
defstate at_elevator do
defevent use_card, data: %{inventory: inventory} do
if :id_card in inventory do
next_state(:elevator_accessed, %{inventory: inventory})
else
# Remain in the same state if missing card
next_state(:at_elevator, %{inventory: inventory})
end
end
defevent leave_elevator do
next_state(:has_id_card, %{inventory: [:id_card]})
end
end
defstate elevator_accessed do
defevent select_floor(floor) do
respond({:travelling_to, floor}, :elevator_accessed, %{inventory: [:id_card]})
end
end
end
```
## Usage:
We can interact with the created state machine like this:
```elixir
elster = Unit.AccessState.new()
# Progress through the game
elster = elster
|> Unit.AccessState.pickup_card()
|> Unit.AccessState.approach_elevator()
|> Unit.AccessState.use_card()
# Unit can now use the elevator
Unit.AccessState.state(elster) # :elevator_accessed
# Select a floor
{response, elster} = Unit.AccessState.select_floor(elster, "b6")
```
## Working with Data
FSMs often need to carry data alongside their state:
```elixir
defmodule InventorySystem do
use Replika, initial_state: :empty, initial_data: %{items: []}
defstate empty do
defevent add_item(item), data: %{items: items} do
new_items = [item | items]
if length(new_items) > 0 do
next_state(:has_items, %{items: new_items})
else
next_state(:empty, %{items: new_items})
end
end
end
defstate has_items do
defevent add_item(item), data: %{items: items} do
next_state(:has_items, %{items: [item | items]})
end
defevent remove_item(item), data: %{items: items} do
new_items = List.delete(items, item)
if new_items == [] do
next_state(:empty, %{items: []})
else
next_state(:has_items, %{items: new_items})
end
end
defevent list_items, data: %{items: items} do
respond(items)
end
end
end
```
## Error Handling
Replika provides clear error messages for invalid transitions:
```elixir
# Trying an invalid transition
inventory = InventorySystem.new()
|> InventorySystem.add_item("Keycard")
# This would cause an error because 'list_items' is only defined in :has_items state
InventorySystem.list_items(inventory)
# You'll see a clear error:
# ** (Replika.Error.InvalidTransitionError) Invalid transition: cannot execute
# event 'list_items' with args [] in state ':empty'
```
## Pattern Matching and Guards
You can leverage Elixir's pattern matching and guards for sophisticated state logic:
```elixir
defstate at_elevator do
# Different handling based on item in inventory
defevent use_card, data: %{inventory: inventory} when :id_card in inventory do
next_state(:elevator_accessed, %{inventory: inventory})
end
# Handle case where ID card is missing
defevent use_card, data: %{inventory: inventory} do
respond({:error, :missing_id_card}, :at_elevator, %{inventory: inventory})
end
end
```
"""
@typedoc """
Action responses returned by event handlers.
Can be one of:
- `{:next_state, atom()}` - Change state
- `{:new_data, any()}` - Update data
- `{:respond, any()}` - Return a value
"""
@type action_response :: {:next_state, atom()} | {:new_data, any()} | {:respond, any()}
# Inline hot functions
@compile {:inline,
[
next_state: 1,
next_state: 2,
respond: 1,
respond: 2,
respond: 3
]}
@doc """
When used, defines a new Replika state machine.
## Options
* `:initial_state` - The initial state of the FSM (required)
* `:initial_data` - The initial data of the FSM (optional, defaults to `nil`)
## Examples
defmodule Unit.AccessState do
use Replika, initial_state: :no_id_card, initial_data: %{inventory: []}
end
"""
defmacro __using__(opts) do
quote do
import Replika,
only: [
next_state: 1,
next_state: 2,
respond: 1,
respond: 2,
respond: 3,
defstate: 2,
defevent: 1,
defevent: 2,
defevent: 3,
defeventp: 1,
defeventp: 2,
defeventp: 3
]
@initial_state unquote(opts[:initial_state]) ||
raise(ArgumentError, "initial_state is required")
defstruct state: @initial_state,
data: unquote(opts[:initial_data])
@declaring_state nil
@declared_events MapSet.new()
@doc """
Creates a new FSM instance.
## Parameters
- `params`: Optional keyword list of parameters to override defaults
## Examples
# Create with default state and data
Unit.AccessState.new()
# Override the initial state
Unit.AccessState.new(state: :has_id_card)
# Override both state and data
Unit.AccessState.new(state: :has_id_card, data: %{inventory: [:id_card]})
"""
@spec new(keyword()) :: %__MODULE__{}
def new(params \\ []), do: struct!(__MODULE__, params)
@doc """
Returns the current state of the FSM.
## Examples
fsm = Unit.AccessState.new()
Unit.AccessState.state(fsm) # Returns :no_id_card
"""
@spec state(%__MODULE__{}) :: atom()
def state(%__MODULE__{state: state}), do: state
@doc """
Returns the current data of the FSM.
## Examples
fsm = Unit.AccessState.new()
Unit.AccessState.data(fsm) # Returns %{inventory: []}
"""
@spec data(%__MODULE__{}) :: any()
def data(%__MODULE__{data: data}), do: data
@dialyzer {:no_match, change_state: 2}
defp change_state(%__MODULE__{} = fsm, {:action_responses, responses}),
do: parse_action_responses(fsm, responses)
defp change_state(%__MODULE__{} = fsm, _), do: fsm
defp parse_action_responses(fsm, responses) do
# Extract the responses by type for direct application
{next_state, new_data, response} = extract_responses(responses)
# Apply the transformations directly
fsm = if next_state, do: %__MODULE__{fsm | state: next_state}, else: fsm
fsm = if new_data, do: %__MODULE__{fsm | data: new_data}, else: fsm
# Return with response if present
if response, do: {response, fsm}, else: fsm
end
defp extract_responses(responses) do
Enum.reduce(responses, {nil, nil, nil}, fn
{:next_state, state}, {_, data, resp} -> {state, data, resp}
{:new_data, data}, {state, _, resp} -> {state, data, resp}
{:respond, resp}, {state, data, _} -> {state, data, resp}
end)
end
end
end
@doc """
Transitions to a new state without changing data.
This function is used within event handlers to change the state machine's
state while preserving its existing data.
## Parameters
- `state`: The target state to transition to (atom)
## Returns
An action response tuple `{:action_responses, [{:next_state, atom()}]}`
that will be processed by the state machine.
## Examples
defevent pickup_card do
next_state(:has_id_card) # Transition to :has_id_card state
end
"""
@spec next_state(atom()) :: {:action_responses, [{:next_state, atom()}]}
def next_state(state), do: {:action_responses, [next_state: state]}
@doc """
Transitions to a new state and updates data.
This function is used within event handlers to simultaneously change the
state machine's state and update its associated data.
## Parameters
- `state`: The target state to transition to (atom)
- `data`: The new data value to store
## Returns
An action response tuple `{:action_responses, [{:next_state, atom()}, {:new_data, any()}]}`
that will be processed by the state machine.
## Examples
defevent pickup_card do
next_state(:has_id_card, %{inventory: [:id_card]})
end
"""
@spec next_state(atom(), any()) ::
{:action_responses, [{:next_state, atom()} | {:new_data, any()}]}
def next_state(state, data), do: {:action_responses, [next_state: state, new_data: data]}
@doc """
Returns a response without changing state or data.
This function is used within event handlers to return a value to the caller
without modifying the state machine's state or data.
## Parameters
- `response`: The value to return from the event handler
## Examples
defevent list_items, data: %{items: items} do
respond(items) # Return items to caller, no state/data change
end
"""
@spec respond(any()) :: {:action_responses, [{:respond, any()}]}
def respond(response), do: {:action_responses, [respond: response]}
@doc """
Returns a response and transitions to a new state.
## Parameters
- `response`: The value to return from the event handler
- `state`: The state to transition to
## Examples
defevent examine_card do
respond({:card_info, "SECTOR B ACCESS"}, :has_id_card)
end
"""
@spec respond(any(), atom()) :: {:action_responses, [{:next_state, atom()} | {:respond, any()}]}
def respond(response, state), do: {:action_responses, [next_state: state, respond: response]}
@doc """
Returns a response, transitions to a new state, and updates data.
## Parameters
- `response`: The value to return from the event handler
- `state`: The state to transition to
- `data`: The new data value
## Examples
defevent select_floor(floor) do
respond({:travelling_to, floor}, :elevator_accessed, %{inventory: [:id_card]})
end
"""
@spec respond(any(), atom(), any()) ::
{:action_responses, [{:next_state, atom()} | {:new_data, any()} | {:respond, any()}]}
def respond(response, state, data),
do: {:action_responses, [next_state: state, new_data: data, respond: response]}
@doc """
Defines a state in the FSM.
## Parameters
- `state`: The name of the state
- `state_def`: The state definition block
## Examples
defstate at_elevator do
defevent use_card, data: %{inventory: inventory} do
if :id_card in inventory do
next_state(:elevator_accessed, %{inventory: inventory})
else
next_state(:at_elevator, %{inventory: inventory})
end
end
end
"""
defmacro defstate(state, state_def) do
quote do
state_name =
case unquote(Macro.escape(state, unquote: true)) do
name when is_atom(name) -> name
{name, _, _} -> name
end
@declaring_state state_name
unquote(state_def)
@declaring_state nil
end
end
@doc """
Declares an event in the FSM without implementation.
## Parameters
- `event`: The name of the event (and optionally its arity)
## Examples
# Declare a zero-arity event
defevent pickup_card
# Declare a two-arity event
defevent use_item/2
"""
defmacro defevent(event) when is_atom(event) or is_tuple(event) do
decl_event(event, false)
end
@doc """
Defines an event in the FSM with options but no implementation block.
## Parameters
- `event`: The name of the event
- `opts`: Options for the event (must include :do option with the implementation)
## Examples
# Event with implementation in the options
defevent pickup_card, do: next_state(:has_id_card, %{inventory: [:id_card]})
"""
defmacro defevent(event, opts) when is_list(opts) do
if Keyword.has_key?(opts, :do) do
do_defevent(event, opts, opts[:do])
else
raise ArgumentError, "defevent/2 requires a :do option when given a keyword list"
end
end
@doc """
Defines an event in the FSM with options and implementation block.
## Parameters
- `event`: The name of the event
- `opts`: Options for the event
- `event_def`: The event implementation block
## Examples
# Event with options and block
defevent use_card, data: %{inventory: inventory} do
if :id_card in inventory do
next_state(:elevator_accessed, %{inventory: inventory})
else
next_state(:at_elevator, %{inventory: inventory})
end
end
"""
defmacro defevent(event, opts, do: event_def) do
do_defevent(event, opts, event_def)
end
@doc """
Declares a private event in the FSM without implementation.
Works the same as `defevent/1` but generates a private function instead of a public one.
## Examples
# Declare a private zero-arity event
defeventp internal_transition
"""
defmacro defeventp(event) when is_atom(event) or is_tuple(event) do
decl_event(event, true)
end
@doc """
Defines a private event in the FSM with options but no implementation block.
## Parameters
- `event`: The name of the event
- `opts`: Options for the event (must include :do option with the implementation)
## Examples
# Private event with implementation in the options
defeventp internal_scan, do: next_state(:scanning)
"""
defmacro defeventp(event, opts) when is_list(opts) do
if Keyword.has_key?(opts, :do) do
do_defevent(event, [{:private, true} | opts], opts[:do])
else
raise ArgumentError, "defeventp/2 requires a :do option when given a keyword list"
end
end
@doc """
Defines a private event in the FSM with options and implementation block.
## Parameters
- `event`: The name of the event
- `opts`: Options for the event
- `event_def`: The event implementation block
## Examples
# Private event with options and block
defeventp validate_card, data: %{inventory: inventory} do
:id_card in inventory
end
"""
defmacro defeventp(event, opts, do: event_def) do
do_defevent(event, [{:private, true} | opts], event_def)
end
# Implementation details
defp do_defevent(event_decl, opts, event_def) do
quote do
unquote(extract_args(event_decl, opts, event_def))
unquote(define_interface())
unquote(implement_transition())
end
end
defp extract_args(event_decl, opts, event_def) do
quote do
{event_name, args} =
case unquote(Macro.escape(event_decl, unquote: true)) do
:_ -> {:_, []}
name when is_atom(name) -> {name, []}
{name, _, args} -> {name, args || []}
end
private = unquote(opts[:private])
state_arg = unquote(Macro.escape(opts[:state] || quote(do: _), unquote: true))
data_arg = unquote(Macro.escape(opts[:data] || quote(do: _), unquote: true))
event_arg = unquote(Macro.escape(opts[:event] || quote(do: _), unquote: true))
args_arg = unquote(Macro.escape(opts[:args] || quote(do: _), unquote: true))
event_def = unquote(Macro.escape(event_def, unquote: true))
guard = unquote(Macro.escape(opts[:when]))
end
end
defp define_interface do
quote bind_quoted: [] do
unless event_name == :_ or MapSet.member?(@declared_events, {event_name, length(args)}) do
interface_args =
if args == [] do
[]
else
for idx <- 0..(length(args) - 1), do: {:"arg#{idx}", [], nil}
end
body =
quote do
try do
transition(fsm, unquote(event_name), [unquote_splicing(interface_args)])
rescue
FunctionClauseError ->
reraise Replika.Error.InvalidTransitionError,
[
state: state(fsm),
event: unquote(event_name),
args: [unquote_splicing(interface_args)]
],
__STACKTRACE__
end
end
interface_args = [quote(do: fsm) | interface_args]
if private do
defp unquote(event_name)(unquote_splicing(interface_args)), do: unquote(body)
else
def unquote(event_name)(unquote_splicing(interface_args)), do: unquote(body)
end
@declared_events MapSet.put(@declared_events, {event_name, length(args)})
end
end
end
defp implement_transition do
quote bind_quoted: [] do
transition_args = [
if @declaring_state do
quote do
%__MODULE__{
state: unquote(@declaring_state) = unquote(state_arg),
data: unquote(data_arg)
} = fsm
end
else
quote do
%__MODULE__{state: unquote(state_arg), data: unquote(data_arg)} = fsm
end
end,
quote do
unquote(if event_name == :_, do: quote(do: _any_event), else: event_name) =
unquote(event_arg)
end,
quote do
unquote(if event_name == :_, do: quote(do: _any_args), else: args) = unquote(args_arg)
end
]
body = quote(do: change_state(fsm, unquote(event_def)))
if guard do
def transition(unquote_splicing(transition_args)) when unquote(guard), do: unquote(body)
else
def transition(unquote_splicing(transition_args)), do: unquote(body)
end
end
end
defp decl_event(event, private) do
quote do
{event_name, arity} =
case unquote(Macro.escape(event, unquote: nil)) do
event_name when is_atom(event_name) -> {event_name, 0}
{:/, _, [{event_name, _, _}, arity]} -> {event_name, arity}
{event_name, _, _} -> {event_name, 0}
end
args =
case arity do
0 -> []
n -> Enum.to_list(1..n)
end
private = unquote(private)
unquote(define_interface())
end
end
end