Flecks of Rust #7: From bytes to Rust enums and back

Binary file formats often use very compact representations of data, from single bytes to individual bits (or bit fields) in a byte. Converting between binary data and Rust bytes is essential, to be able to write more understandable programs that are able to both parse the binary format and to generate it.

For example, a certain digital MIDI synthesizer uses digital LFO (Low Frequency Oscillator) to generate control waveforms that affect the pitch or amplitude of the sound. The LFO can generate five different waveforms, which can be represented using a Rust enumerated type:

#[derive(Debug, Eq, PartialEq, Copy, Clone)]
pub enum Waveform {
    Triangle,
    Square,
    Sawtooth,
    Sine,
    Random
}

In this case the waveforms are represented in the binary data as single bytes, with values that correspond to those assigned by default to the enum members: 0, 1, 2, 3, and 4. So, when any of the values 0x00, 0x01, 0x02, 0x03, or 0x04 appear in a certain position in the binary file, we know that it means an LFO waveform of the corresponding type.

To be able to construct a new instance of Waveform based on a byte (in Rust, the u8 data type) we need to consider all the different possibilities:

let b: u8 = 0x02;
let w = match b {
    0x00 => Waveform::Triangle,
    0x01 => Waveform::Square,
    0x02 => Waveform::Sawtooth,
    0x03 => Waveform::Sine,
    0x04 => Waveform::Random,
    _ => panic!("Waveform byte out of range: should be 0...4, was {}", b),
};
println!("{:?}", w);

If you run this with a value of b that falls in the range, everything is fine. But if you initialize b with a value that is out of the desired range, the program will panic:

let b: u8 = 0x08;
% cargo run --quiet
thread 'main' panicked at 'Waveform byte out of range: should be 0...4, was 8', src/main.rs:30:14
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

There are two problems with this approach. First, the byte value could be out of range if the file is corrupted, or if there is an error in the program, but we don't know which, so maybe a panic is a bit too much here; we might want to recover from the situation somehow.

Second, the code to handle the initialization is quite verbose, and you wouldn't want to repeat it every time you need to initialize a Waveform instance from a u8.

We could try to mitigate the second problem by implementing the From trait for Waveform, removing duplication:

impl From for Waveform {
    fn from(item: u8) -> Self {
        match item {
            0x00 => Waveform::Triangle,
            0x01 => Waveform::Square,
            0x02 => Waveform::Sawtooth,
            0x03 => Waveform::Sine,
            0x04 => Waveform::Random,
            _ => panic!("Waveform byte out of range: should be 0...4, was {}", item)
        }
    }
}

Now we can instantiate a Waveform simply by saying:

let w = Waveform::from(b);
However, this only moves the panic! macro out of the call site, so the first problem still remains.

There is a handy crate called num_enum which contains helpers for turning primitive values into enumerations and back. To use it, add the crate into your Cargo.toml file:

[dependencies]
num_enum = "0.5.7"

Then add the required imports to your program:

use num_enum::TryFromPrimitive;
use std::convert::TryFrom;

Finally, derive TryFromPrimitive for the Waveform type, and also specify a primitive representation for it with #[repr(u8)]:

#[derive(Debug, Eq, PartialEq, Copy, Clone, TryFromPrimitive)]
#[repr(u8)]
pub enum Waveform {
    Triangle,
    Square,
    Sawtooth,
    Sine,
    Random,
}

Now we can use try_from to instantiate a Waveform:

let b: u8 = 0x02;
let w = Waveform::try_from(b);
println!("{:?}", w);
% cargo run --quiet
Ok(Sawtooth)

So far, so good. If you pass in a byte that does not have a corresponding enum value, you will get an Err back:

% cargo run --quiet
Err(TryFromPrimitiveError { number: 8 })

Clearly, we need to handle the result better if we want to recover from the error:

if let Ok(w) = Waveform::try_from(b) {
    println!("{:?}", w);
}
else {
    println!("Waveform byte out of range: should be 0...4, was {}", b);
}

Once you have a valid Waveform instance, turning it back into a byte is also very easy with a few initial definitions:

use num_enum::{TryFromPrimitive, IntoPrimitive};
use std::convert::TryFrom;

#[derive(Debug, Eq, PartialEq, Copy, Clone, TryFromPrimitive, IntoPrimitive)]
#[repr(u8)]
pub enum Waveform {
    Triangle,
    Square,
    Sawtooth,
    Sine,
    Random,
}

Now you can use into():

let b2: u8 = waveform.into();
println!("{}", b2);

You should see the result 2 printed out in the console.