Sunday, July 22, 2018

Type-level states in Rust

This is a pattern I saw recently while reading some hardware abstraction code for microcontroller programming with rust. On microcontrollers, you can access pins of your chip and use them for different kinds of work. For example, one pin could be used for input, another for output, an yet another pin could be configured for "alternate functions". Also, an input pin could have a pullup or pulldown resistor, or none ("floating").

So there's a hierarchy of configurations a pin could use:
  • Input
    • Floating (default)
    • PullUp
    • PullDown
  • Output
    • PushPull
    • OpenDrain
  • AF0 (alternate function 0)
  • AF1
  • ...
This state is stored in the microcontroller hardware, but this pattern could also be useful when the state is stored elsewhere, like connection state that's stored in the network card's driver, or state on a server.

A classical way to model this is using an enum, storing the state somewhere, and checking the state at runtime (or just trusting the programmer):
#[derive(PartialEq, Clone, Copy)]
pub enum Mode {
    InputFloating,
    InputPullUp,
    Output,
    // more features left out for brevity
}

pub struct Pin {
    mode: Mode, // just for checking
}

impl Pin {
    pub fn new() -> Self {
        Pin { mode: Mode::InputFloating, /* just for checking */ }
    }

    pub fn configure(&mut self, mode: Mode) {
        self.mode = mode;  // just for checking
        // configure the hardware
        match mode {
            Mode::InputFloating => {}
            Mode::InputPullUp => {}
            Mode::Output => {}
        }
    }

    pub fn read(&self) -> bool {
        assert!(self.mode != Mode::Output); // just for checking
        false // bit is read from hardware
    }

    pub fn write(&self, value: bool) {
        assert!(self.mode == Mode::Output); // just for checking
        // bit is written to hardware
    }
}
There are a couple of downsides with this approach:
  • If we want checks, the value is stored twice: once in the struct and once in a register of the microcontroller
  • If we want checks, we also slow down the actual program.
  • If we trust the programmer instead, we can remove all the "just for checking" lines/items, but now we're trusting the programmer.
  • Even with checks, we only see errors at runtime, and there could be lots of code paths that lead to writing or reading a pin.
Luckily, we can do better!
pub struct InputFloatingPin(());
pub struct InputPullUpPin(());
pub struct OutputPin(());

impl InputFloatingPin {
    pub fn new() -> Self {
        InputFloatingPin(())
    }

    pub fn into_input_pull_up(self) -> InputPullUpPin {
        // configure the hardware
        InputPullUpPin(())
    }

    pub fn into_output(self) -> OutputPin {
        // configure the hardware
        OutputPin(())
    }

    pub fn read(&self) -> bool {
        false // bit is read from hardware
    }
}

impl InputPullUpPin {
    // pub fn into_...

    pub fn read(&self) -> bool {
        false // bit is read from hardware
    }
}

impl OutputPin {
    // pub fn into_...

    pub fn write(&self, value: bool) {
        // bit is written to hardware
    }
}
Here, every configuration has its own struct type, and we use into_... methods to configure the pin and convert between the associated struct type. The struct definitions with (()) at the end look a little funny, that's for visibility reasons: the () is a 0-byte-sized private field that prevents the struct from being constructed outside the declaring module. Clients have to go through InputFloatingPin::new(), meaning they have to start with the reset configuration.

So we have a bunch of structs with no values in them, which seems kind of stupid. But as noted above, the configuration is in hardware anyway. Also, creating structs that don't need memory is not only cheap, it has no cost at all! We get all the checks from above, but at compile time without slowing the program. We can even use these types to specify that a function requires a pin in a certain configuration.

There is a downside however: code duplication. There's the into_... methods I left out, and there's the read method that's written twice.

So let's fix that with generics. The idea is to define a struct Pin<MODE>; all the into_... methods would be defined on Pin<_>, thus available in any configuration. Pin<Output> is trivial, but crucially, we model the proper configuration hierarchy for inputs: read is implemented for Pin<Input<_>>! So no matter what kind of input we have, we can read from it:
use std::marker::PhantomData;

pub struct Pin<MODE>(PhantomData<MODE>);

pub struct Input<MODE>(PhantomData<MODE>);

pub struct Floating(());
pub struct PullUp(());

pub struct Output(());

impl Pin<Input<Floating>> {
    pub fn new() -> Self {
        Pin(PhantomData)
    }
}

impl<MODE> Pin<MODE> {
    pub fn into_input_floating(self) -> Pin<Input<Floating>> {
        // configure the hardware
        Pin(PhantomData)
    }

    pub fn into_input_pull_up(self) -> Pin<Input<PullUp>> {
        // configure the hardware
        Pin(PhantomData)
    }

    pub fn into_output(self) -> Pin<Output> {
        // configure the hardware
        Pin(PhantomData)
    }
}

impl<MODE> Pin<Input<MODE>> {
    pub fn read(&self) -> bool {
        false // bit is read from hardware
    }
}

impl Pin<Output> {
    pub fn write(&self, value: bool) {
        // bit is written to hardware
    }
}

No comments: