Step 6 of 7
PhantomData and typestate
Type-level programming means letting types carry information the compiler checks, so whole classes of bugs become compile errors. The most useful form at work is typestate: the state of an object is part of its type, and each state has only the methods that make sense in it.
use std::marker::PhantomData;
struct Locked;
struct Closed;
struct Open;
struct Door<State> {
name: String,
_state: PhantomData<State>,
}
impl Door<Locked> {
fn new(name: &str) -> Door<Locked> {
Door { name: name.to_string(), _state: PhantomData }
}
fn unlock(self) -> Door<Closed> {
println!("{}: unlocked", self.name);
Door { name: self.name, _state: PhantomData }
}
}
impl Door<Closed> {
fn open(self) -> Door<Open> {
println!("{}: opened", self.name);
Door { name: self.name, _state: PhantomData }
}
fn lock(self) -> Door<Locked> {
println!("{}: locked", self.name);
Door { name: self.name, _state: PhantomData }
}
}
impl Door<Open> {
fn close(self) -> Door<Closed> {
println!("{}: closed", self.name);
Door { name: self.name, _state: PhantomData }
}
}
fn main() {
let door = Door::new("vault");
let door = door.unlock().open();
let door = door.close();
let _door = door.lock();
println!("{}", std::mem::size_of::<Door<Open>>() == std::mem::size_of::<String>());
}
vault: unlocked
vault: opened
vault: closed
vault: locked
true
How it works:
Locked,ClosedandOpenare empty structs used only as markers. No value of them is ever created.Door<Locked>andDoor<Open>are different types, and eachimplblock adds methods to just one of them. A locked door simply has noopenmethod.- Each transition takes
selfby value and returns the door in its new state. The old value is moved, so you can't keep using a staleDoor<Closed>after opening it. - Methods every state needs go in a generic impl:
impl<S> Door<S> { fn name(&self) -> &str { &self.name } }.
Trying to open a locked door isn't a runtime error; it doesn't compile:
use std::marker::PhantomData;
struct Locked;
struct Closed;
struct Door<S> {
name: String,
_state: PhantomData<S>,
}
impl Door<Locked> {
fn new(name: &str) -> Self {
Door { name: name.to_string(), _state: PhantomData }
}
}
impl Door<Closed> {
fn open(self) -> String {
self.name
}
}
fn main() {
let door = Door::new("vault");
println!("{}", door.open());
}
error[E0599]: no method named `open` found for struct `Door<Locked>` in the current scope
Why PhantomData?
Rust rejects a type parameter that no field uses: struct Door<State> { name: String } fails with error[E0392]: type parameter `State` is never used. PhantomData<State> (from std::marker) is a field of size zero that says "act as if this struct holds a State". It costs nothing at runtime, as the size_of line shows: the door is exactly as big as its String. You create it with the value PhantomData.
Where you'll meet typestate
Builders that can't be finished before the required fields are set, connections that can't send before they're authenticated, and hardware drivers (embedded Rust uses it so a pin configured as an input has no set_high method). It's worth it when misuse is costly; for simple cases an enum with a runtime check is easier to read. The patterns module comes back to this.
Your task
Finish a typestate HTTP request builder that can't be sent until a URL is set. RequestBuilder::new() starts as RequestBuilder<NoUrl> with method GET. Add:
url(self, url: &str) -> RequestBuilder<HasUrl>, only onRequestBuilder<NoUrl>.method(self, m: &str) -> Selfandheader(self, name: &str, value: &str) -> Self, available in both states (a generic impl).send(self) -> String, only onRequestBuilder<HasUrl>. It returns the method, a space, the URL, then each header as[Name: value]in the order they were added:POST https://api.shop/orders [Accept: json].
Challenge 2
A blog post goes Draft, then Review, then Published. Finish the typestate Post:
- On
Post<Draft>:write(&mut self, text: &str)appends text, andsubmit(self) -> Post<Review>. - On
Post<Review>:approve(self) -> Post<Published>andreject(self) -> Post<Draft>(the text is kept). - On
Post<Published>:content(&self) -> &str. Only published posts can be read.
Challenge 3
Write a database connection with three states from scratch. Use marker structs Disconnected, Connected and Authenticated and a Connection<S> holding a host: String and a PhantomData<S>.
Connection::new(host: &str)creates aConnection<Disconnected>;connect(self)returns aConnection<Connected>.login(self, password: &str)onConnection<Connected>returnsResult<Connection<Authenticated>, Connection<Connected>>:Okwhen the password is"hunter2", otherwiseErrwith the connection given back so the caller can retry.query(&self, sql: &str) -> Stringonly onConnection<Authenticated>, returning"<host> ran: <sql>".host(&self) -> &strin every state.
Previous: Newtypes, the orphan rule and type aliases Next: The never type and Sized