Safe API
Imagine we are writing a library for a game.
For this library, we will expose a player object. The player object will have the following methods:
new()
-> to create newPlayer
objectbuy_item()
sell_item()
list_items()
die()
get_resurrected()
pretty usual stuff so far. Where things get interesting is here: we want some methods to be callable on the dead
player objects, some on the alive
player objects, and some on the both.
Dead:
get_resurrected()
Alive:
die()
sell_item()
buy_item()
both:
list_items()
-> we can list the items of the dead players, why not…
Designing the API
Say, our Player
object is defined as such:
struct Player {
username: String,
health: u8,
items: Vec<String>,
}
now, if we implement all the above methods for the Player
struct, like this:
impl Player {
fn die(&self) {
todo!()
}
// and other methods below
}
we can call die()
multiple times on the Player
, which makes no sense.
I’ll use die()
as an example throughout this article.
But the same principles apply to all other methods.
Going back to our API, it is definitely not safe! We should do better…
Solution candidate 1:
we can introduce an additional field to the struct alive
:
struct Player {
username: String,
health: u8,
items: Vec<String>,
alive: bool // it is here in case you are blind
}
and we can do runtime checks when necessary:
impl Player {
fn die(&self) {
if self.alive {
todo!()
} else {
panic!("🤷♀️ cannot die twice 🤷♀️")
}
}
}
this will work… But it’s disgusting. And surprisingly, not only because of the emoji:
- runtime check? we can do compile-time check 🤓
- we introduced another field to the struct and now it will occupy more memory (kind of tolerable)
- we increased runtime complexity. I hate the fact that we will now do an extra check before each call to the methods of
Player
to be able to determine if we can call them in the first place… (intolerable)
Solution candidate 2:
One approach for achieving compile-time checks, might be having two different structs for alive
and dead
player, and have the necessary methods implemented for them respectively.
struct AlivePlayer {
username: String,
health: u8,
items: Vec<String>,
}
struct DeadPlayer {
username: String,
health: u8,
items: Vec<String>,
}
impl DeadPlayer {
// no die method here
}
impl AlivePlayer {
fn die(self) -> DeadPlayer {
todo!()
}
}
better than the previous solution. But still bad
why it’s bad:
- the API is not clean. We are storing the same fields in both
Dead
andAlive
player, while they are both justPlayers
. - the end-user has to know when to create an instance of
Alive
player andDead
player. It might be simple to guess in this example, but imagine much more complex/abstract types. If possible, our API should be responsible for when to use which type, not the end user.
Solution candidate 3:
This is called type state pattern, and I really like it.
We will introduce type
as a generic parameter for our Player
struct, and we will define methods accordingly.
struct Player<State> {
username: String,
health: u8,
items: Vec<String>,
_state: PhantomData<State>, // i'll explain this, wait
}
struct Alive;
struct Dead;
impl Player<Alive> {
pub fn die(self) -> Player<Dead> {
Player {
username: self.username,
health: 0,
items: self.items,
_state: PhantomData,
}
}
// and other methods
}
impl Player<Dead> {
// no `die` method here :)
}
What’s going on here:
- by making our struct
generic
, we can have analive
and adead
version of it. alive
anddead
are represented by unit structs, allowing us to use them as types.die()
method is only implemented for thePlayer<Alive>
, and it is returningPlayer<Dead>
after consuming the self. In other words, alive player is gone.- for the
Player<Dead>
, we don’t have thedie()
method, making it impossible to calldie()
on a dead player. - we achieve compile-time warranty of
die()
method being not callable on a dead player.
What the hell is state: PhantomData<State>
in the Player
struct?:
If I hadn’t put that, it wouldn’t compile.
Rust does not allow us to declare struct Player<State>
and not use State
in any of the fields.
But, we don’t want to use the State
in the fields. We don’t want it to put extra memory on runtime.
Luckily, PhantomData
is is a zero-sized-type, meaning that, it won’t be in the compiled code. No runtime effects will be present, totally zero cost! It just acts like it owns/uses State
, so that compiler can shut up about:
In the end, we did introduce a new field to the Player
struct, but only in compile-time. It does not exist in runtime.
Very cool, but not cool enough.
The small problem:
What happens if the user wanted to mess around and provided their own type to the Player
struct:
struct Player<State> {
username: String,
health: u8,
items: Vec<String>,
_state: PhantomData<State>,
}
fn main() {
let player = Player::<u8> {
username: "potato".to_string(),
health: 100,
items: vec!["frostmourne".to_string()],
_state: PhantomData,
};
}
The Final Solution
By applying a trait bound to the generic, we can also solve the above problem:
struct Alive;
struct Dead;
trait PlayerState {}
impl PlayerState for Alive {}
impl PlayerState for Dead {}
struct Player<State>
where
State: PlayerState,
{
username: String,
health: u8,
items: Vec<String>,
_state: PhantomData<State>,
}
Now, the user won’t be able to do this:
let player = Player::<u8> {
username: "potato".to_string(),
health: 100,
items: vec!["frostmourne".to_string()],
_state: PhantomData,
};
The Final Final Solution
Actually, the user still can do this:
let player = Player::<u8> {
username: "potato".to_string(),
health: 100,
items: vec!["frostmourne".to_string()],
_state: PhantomData,
}
by implementing PlayerState
for u8
:
impl PlayerState for u8 {}
To prevent this, we can use a neat trick called sealed traits. If this is the first time you hear about sealed traits, here are 2 great articles about it:
- https://predr.ag/blog/definitive-guide-to-sealed-traits-in-rust/
- https://rust-lang.github.io/api-guidelines/future-proofing.html
Basically, we “seal” the trait, so that no one can implement it.
We will rewrite our PlayerState
trait as such:
mod private {
pub trait Sealed {}
}
trait PlayerState: private::Sealed {}
struct Alive;
struct Dead;
impl private::Sealed for Alive {}
impl private::Sealed for Dead {}
impl PlayerState for Alive {}
impl PlayerState for Dead {}
Now, the user can’t implement PlayerState
for any type, because the PlayerState
depends on Sealed
trait, which is private and can only be accessed internally from our library.
Full Code
Here, have a big chunk of code:
#![allow(unused)]
use std::marker::PhantomData;
mod private {
pub trait Sealed {}
}
trait PlayerState: private::Sealed {}
struct Alive;
struct Dead;
impl private::Sealed for Alive {}
impl private::Sealed for Dead {}
impl PlayerState for Alive {}
impl PlayerState for Dead {}
struct Player<State = Alive>
where
State: PlayerState,
// or `Player<State: PlayerState = Alive>`
{
username: String,
health: u8,
items: Vec<String>,
_state: PhantomData<State>,
}
impl Player<Alive> {
pub fn die(self) -> Player<Dead> {
Player {
username: self.username,
health: 0,
items: self.items,
_state: PhantomData,
}
}
pub fn sell_item(&mut self, item_idx: usize) {
let _ = &self.items.remove(item_idx);
}
pub fn buy_item(&mut self, item: String) {
self.items.push(item);
}
}
impl Player<Dead> {
pub fn get_resurrected(self) -> Player<Alive> {
Player {
username: self.username,
health: 100,
items: self.items,
_state: PhantomData,
}
}
}
impl<State: PlayerState> Player<State> {
pub fn list_items(&self) -> &[String] {
self.items.as_slice()
}
}
impl Player {
pub fn new(username: String) -> Self {
Player {
username,
health: 100,
items: Default::default(),
_state: PhantomData,
}
}
}
fn main() {
let player = Player::new("HiMom".to_owned()); // create an `Alive` player
/* the below commented out code creates compile time error */
/*****************/
// player.get_resurrected(); // no method named `get_resurrected()` found for struct `Player` in the current scope
let player = player.die(); // we can kill a an `Alive` player
/* CANNOT kill what's already dead */
/*****************/
// player.kill(); // no method named `kill` found for struct `Player<Dead>` in the current scope
player.list_items(); // can list items for `Alive` or `Dead` player
let player = player.get_resurrected(); // can resurrect a `Dead` player
player.list_items(); // can list items for `Alive` or `Dead` player;
/* we can ensure the TraitBound also in compile-time, the below code creates the error: */
/* the trait bound `u8: PlayerState` is not satisfied */
/*****************/
// let invalid_player = Player::<u8> {
// username: "potato".to_string(),
// health: 100,
// items: vec!["frostmourne".to_string()],
// state: PhantomData,
// };
}
Some notes on the code
First note:
if this is new syntax to you: struct Player<State = Alive>
, it means, if the generic type is not mentioned, assume the default value would be Alive
.
So, this code:
impl Player {
pub fn new(username: String) -> Self {
Player {
username,
health: 100,
items: Default::default(),
_state: PhantomData,
}
}
}
does not mention the generic, and thus, it is actually equivalent to:
impl Player<Alive> { // notice the `Alive` here
pub fn new(username: String) -> Self {
Player {
username,
health: 100,
items: Default::default(),
_state: PhantomData,
}
}
}
Then, why did I not put new
method in impl Player<Alive>
block next to other alive
methods?
Because, I like constructors in a separate place, completely subjective reason. Do as you please.
Second note:
impl<State: PlayerState> Player<State> {
pub fn list_items(&self) -> &[String] {
self.items.as_slice()
}
}
this means, as long as State
implements PlayerState
trait, list_items
should be available. In other words, list_items
is only available for Alive
and Dead
players.
Is it worth it?
Let’s talk about the elephant in the room: is it really worth writing all this code? I honestly think no.
The good looks ugly, it’s much less maintainable, and overall it provides a worse developer experience.
But the promises are also hard to turn your back to:
- compile-time type safety
- state based methods
- better auto-completion (much better DevX in terms of this)
That’s why I wrote this crate: state-shift
Now it is worth it with State-Shift
The above code can be written in a much simpler way with state-shift.
Revised code:
use std::marker::PhantomData;
use state_shift::{require, states, switch_to, type_state};
#[type_state(state_slot = 1, default_state = Alive)]
struct Player {
username: String,
health: u8,
items: Vec<String>,
}
#[states(Alive, Dead)]
impl Player {
#[require(Alive)] // requiring the default state for the constructor
pub fn new(username: String) -> Self {
Player {
username,
health: 100,
items: Default::default(),
}
}
#[require(Alive)]
#[switch_to(Dead)]
pub fn die(self) -> Player {
Player {
username: self.username,
health: 0,
items: self.items,
}
}
#[require(Alive)]
pub fn sell_item(&mut self, item_idx: usize) {
let _ = &self.items.remove(item_idx);
}
#[require(Alive)]
pub fn buy_item(&mut self, item: String) {
self.items.push(item);
}
#[require(Dead)]
#[switch_to(Alive)]
pub fn get_resurrected(self) -> Player {
Player {
username: self.username,
health: 100,
items: self.items,
}
}
#[require(A)]
pub fn list_items(&self) -> &[String] {
self.items.as_slice()
}
}
fn main() {
let player = Player::new("HiMom".to_owned()); // create an `Alive` player
let player = player.die(); // we can kill a an `Alive` player
player.list_items(); // can list items for `Alive` or `Dead` player
let player = player.get_resurrected(); // can resurrect a `Dead` player
player.list_items(); // can list items for `Alive` or `Dead` player;
}
So, we get all the benefits of the type-state pattern, but without the boilerplate.
If you like this article and the state-shift
crate, I’d greatly appreciate your stars on GitHub!