Introduction
Typestates allow you to define safe usage protocols for your objects. The compiler will help you on your journey and disallow errors on given states. You will no longer be able to try and read from closed streams.
#[typestate]
builds on ideas from the state_machine_future
crate.
First steps
Before you start your typestate development journey you need to declare your dependencies,
you can start using the typestate
crate by adding the following line to your Cargo.toml
file.
typestate = "0.8.0"
Citing typestate
If you find typestate
useful in your work, we kindly request you cite the following paper:
@inproceedings{10.1145/3475061.3475082,
author = {Duarte, Jos\'{e} and Ravara, Ant\'{o}nio},
title = {Retrofitting Typestates into Rust},
year = {2021},
url = {https://doi.org/10.1145/3475061.3475082},
doi = {10.1145/3475061.3475082},
booktitle = {25th Brazilian Symposium on Programming Languages},
pages = {83–91},
numpages = {9},
series = {SBLP'21}
}
What are typestates?
In a nutshell, typestates are finite state machines described at the type-level. They aim to tame stateful computations by allowing the compiler to reason about the state of the program.
Consider the following Java example:
public class ScannerFail {
void main(String... args) {
Scanner s = new Scanner(System.in);
s.close();
s.nextLine();
}
}
The example will compile and run, however it will crash during runtime, throwing an IllegalStateException
,
this happens because we tried to read a line after closing the Scanner
.
If you thought: "The compiler should have told me!" - then, typestates are for you!
In a typestated language, Scanner
would have its state be a first-class citizen of the code.
Consider the following example in typestated-Java:
public class ScannerFail {
void main(String... args) {
Scanner[Open] s = new Scanner(System.in);
// s now has type Scanner[Closed]
s = s.close();
// compilation error: Scanner[Closed] does not have a nextLine method
s.nextLine();
}
}
As made evident by the comments, the example would not compile because the Scanner
transitions to the Closed
state after the .close()
call.
Typestates in Rust
Typestates are not a new concept to Rust. There are several blog posts on the subject [1, 2, 3] as well as a chapter in The Embedded Rust Book.
In short, we can write typestates by hand, we add some generics here and there, declare them as a "state" and in the end we can keep living our lives with our new state machine.
This approach however is error-prone and verbose (especially with bigger automata). It also provides no guarantees about the automata, unless of course, you designed and tested the design previously.
As programmers, we want to automate this cumbersome job and to do so, we use Rust's powerful procedural macros!
Basic Guide to Typestates
Consider we are tasked with building the firmware for a traffic light, we can turn it on and off and cycle between Green, Yellow and Red.
We first declare a module with the #[typestate]
macro attached to it.
#[typestate]
mod traffic_light {}
This of course does nothing, in fact it will provide you an error, saying that we haven't declared an automaton.
And so, our next task is to do that.
Inside our traffic_light
module we declare a structure annotated with #[automaton]
.
#[automaton]
pub struct TrafficLight;
Our next step is to declare the states.
We declare three empty structures annotated with "[state]
.
#[state] pub struct Green;
#[state] pub struct Yellow;
#[state] pub struct Red;
So far so good, however some errors should appear, regarding the lack of initial and final states.
To declare initial and final states we need to see them as describable by transitions. Whenever an object is created, the method that created leaves the object in the initial state. Equally, whenever a method consumes an object and does not return it (or a similar version of it), it made the object reach the final state.
With this in mind we can lay down the following rules:
- Functions that do not take a valid state (i.e.
self
) and return a valid state, describe an initial state. - Functions that take a valid state (i.e.
self
) and do not return a valid state, describe a final state.
So we write the following function signatures:
fn turn_on() -> Red;
fn turn_off(self);
However, these are free functions, meaning that self
relates to nothing.
To attach them to a state we wrap them around a trait
with the name of the state they are supposed to be attached to.
So our previous example becomes:
trait Red {
fn turn_on() -> Red;
fn turn_off(self);
}
Before we go further, a quick review:
- The module is annotated with
#[typestate]
enabling the DSL.- To declare the main automaton we attach
#[automaton]
to a structure.- The states are declared by attaching
#[state]
.- State functions are declared through traits that share the same name.
- Initial and final states are declared by functions with a "special" signature.
Finally, we need to address how states transition between each other. An astute reader might have inferred that we can consume one state and return another, such reader would be 100% correct.
For example, to transition between the Red
state and the Green
we do:
trait Red {
fn to_green(self) -> Green;
}
Building on this we can finish the other states:
pub trait Green {
fn to_yellow(self) -> Yellow;
}
pub trait Yellow {
fn to_red(self) -> Red;
}
pub trait Red {
fn to_green(self) -> Green;
fn turn_on() -> Red;
fn turn_off(self);
}
And the full code becomes:
#[typestate]
mod traffic_light {
#[automaton]
pub struct TrafficLight {
pub cycles: u64,
}
#[state] pub struct Green;
#[state] pub struct Yellow;
#[state] pub struct Red;
pub trait Green {
fn to_yellow(self) -> Yellow;
}
pub trait Yellow {
fn to_red(self) -> Red;
}
pub trait Red {
fn to_green(self) -> Green;
fn turn_on() -> Red;
fn turn_off(self);
}
}
The code above will generate:
- Expand the main structure with a
state: State
field. - A sealed trait which disallows states from being added externally.
- Traits for each state, providing the described functions.
Advanced Guide
There are some features which may be helpful when describing a typestate. There are two main features that weren't discussed yet.
Self-transitioning functions
Putting it simply, states may require to mutate themselves without transitioning, or maybe we require a simple getter.
To declare methods for that purpose, we can use functions that take references (mutable or not) to self
.
Consider the following example where we have a flag that can be up or not. We have two functions, one checks if the flag is up, the other, sets the flag up.
#[state] struct Flag {
up: bool
}
impl Flag {
fn is_up(&self) -> bool;
fn set_up(&mut self);
}
As these functions do not change the typestate state, they transition back to the current state.
Non-deterministic transitions
Consider that a typestate relies on an external component that can fail, to model that, one would use Result<T>
.
However, we need our typestate to transition between known states, so we declare two things:
- An
Error
state along with the other states. - An
enum
to represent the bifurcation of states.
#[state] struct Error {
message: String
}
enum OperationResult {
State, Error
}
Inside the enumeration there can only be other valid states and only Unit
style variants are supported.
Features
Macro Attributes
The #[typestate]
macro exposes some extra features through attribute parameters.
This chapter introduces them and provides simple examples.
enumerate
The enumerate
parameter will generate an additional enum
containing all states;
this is useful when dealing with anything that requires a more "general" concept of state.
Consider the file examples/light_bulb.rs
:
#![allow(unused)] fn main() { #[typestate(enumerate = "LightBulbStates")] mod light_bulb { #[state] struct On; #[state] struct Off; // ... } }
Using the enumerate
attribute will add the following enum
to the expansion:
#![allow(unused)] fn main() { pub enum LightBulbStates { Off(LightBulb<Off>), On(LightBulb<On>), } }
state_constructors
The state_constructors
parameter will generate additional constructors
for each state with fields; this is useful when declaring states inside the automaton.
Consider the following example state:
#![allow(unused)] fn main() { #[typestate(state_constructors = "new_state")] mod light_bulb { #[state] struct On { color: [u8; 3] } // ... } }
When compiled, the following constructor is generated:
#![allow(unused)] fn main() { impl On { pub fn new_state(color: [u8; 3]) -> Self { Self { color } } } }
Compilation Flags
The typestate
macro provides several cargo
features,
mostly focused on the visualization of your typestate's automata.
Mermaid Diagrams
docs-mermaid
will embed Mermaid.js diagrams in your documentation.
This feature is activated by default, regarless, see below how you can explicitly activate it.
In the terminal, for each run:
cargo doc --features docs-mermaid
Or by declaring it in Cargo.toml
:
typestate = { version = "0.8.0", features = [ "docs-mermaid" ] }
DOT Diagrams
export-dot
- will generate a .dot
file, describing your typestate's state machine.
You can customize certain .dot
parameters through the following environment variables:
DOT_PAD
- specifies how much, in inches, to extend the drawing area around the minimal area needed to draw the graph.DOT_NODESEP
-nodesep
specifies the minimum space between two adjacent nodes in the same rank, in inches.DOT_RANKSEP
- sets the desired rank separation, in inches.EXPORT_FOLDER
- declare the target folder for exported files.
This feature is not activated by default, see below how you can activate it.
In the terminal, for each run:
cargo doc --features export-dot
Or by declaring it in Cargo.toml
:
typestate = { version = "0.8.0", features = [ "export-dot" ] }
For more information on DOT configuration, I recommend you read through DOT documentation.
Examples
These examples are present in the examples/
folder in the repository's root.
LightBulb | SmartBulb |
---|---|
PlantUML Diagrams
export-plantuml
will generate a PlantUML state diagram (.uml
file) of your state machine.
Like the previous feature, you can also customize this one through the following environment variables:
PLANTUML_NODESEP
-nodesep
specifies the minimum space between two adjacent nodes in the same rank.PLANTUML_RANKSEP
- Sets the desired rank separation.EXPORT_FOLDER
- Declare the target folder for exported files.
This feature is not activated by default, see below how you can activate it.
In the terminal, for each run:
cargo doc --features export-plantuml
Or by declaring it in Cargo.toml
:
typestate = { version = "0.8.0", features = [ "export-plantuml" ] }
For more information on PlantUML configuration, I recommend you read through PlantUML Hitchhiker's Guide.
Examples
These examples are present in the examples/
folder in the repository's root.
LightBulb | SmartBulb |
---|---|