Flecks of Rust #12: Subrange types in Rust

Rust does not have subrange types like Ada. This makes it difficult to create data types that wrap a value and enforce it to a certain range.

For example, the Yamaha DX7 patch data model is full of values that could (should!) be expressed as subrange types. The FM algorithm used in a patch has the values 1 to 32, the detune is from -7 to 7, output level is from 0 to 99, and so on.

I have tried various ways of implementing and using subrange types in Rust. Most attempts have resulted in a lot of boilerplate code.

Using const generics

When const generics were stabilized in Rust, I tried to implement a trait that has const parameters for the start and end of the range. That ended up looking really ugly and was cumbersome to use:

/// A simple struct for wrapping an `i32`
/// with const generic parameters to limit
/// the range of allowed values.
#[derive(Debug, Copy, Clone, PartialEq)]
pub struct RangedInteger {
    value: i32,
}

I wanted to make it easy to create values from types that implement this trait, so I added a constructor that uses the range defined for the values:

impl <const MIN: i32, const MAX: i32> RangedInteger<MIN, MAX> {
    /// Makes a new ranged integer if the value is in the allowed range, otherwise panics.
    pub fn new(value: i32) -> Self {
        let range = Self::range();
        if range.contains(&value) {
            Self { value }
        }
        else {
            panic!("new() expected value in range {}...{}, got {}",
                range.start(), range.end(), value);
        }
    }

    /// Gets the range of allowed values as an inclusive range,
    /// constructed from the generic parameters.
    pub fn range() -> RangeInclusive<i32> {
        MIN ..= MAX
    }

I also wanted an easy way to get a random value that falls in the range of the type:

    /// Gets a random value that is in the range of allowed values.
    pub fn random_value() -> i32 {
        let mut rng = rand::thread_rng();
        let range = Self::range();
        rng.gen_range(*range.start() ..= *range.end())
    }
}

When I want to make a type that implements this trait, this is what I would have to do:

/// Private generic type for the value stored in a `Volume`.
type VolumeValue = RangedInteger::<0, 127>;

/// Wrapper for volume parameter.
#[derive(Debug, Copy, Clone)]
pub struct Volume {
    value: VolumeValue,  // private field to prevent accidental range violations
}

impl Volume {
    /// Makes a new `Volume` initialized with the specified value.
    pub fn new(value: i32) -> Self {
        Self { value: VolumeValue::new(value) }
    }

    /// Gets the wrapped value.
    pub fn value(&self) -> i32 {
        self.value.value
    }
}

There would also be other traits to implement, like Default and From<u8>. Finally I would be able to make objects of type Volume:

let volume = Volume::new(100);

When I wanted to use the value that was wrapped inside the type, I would have to use volume.value(). Setting this all up is not that far from using a newtype, but needs a lot of boilerplate. The next obvious step would be to make a macro that generates all the necessary boilerplate, but I didn't want to go that way, at least not yet.

Using the nutype crate

Another way is to use a crate like nutype. It is a very useful crate, but you need to use a crate-specific attribute, and starting with nutype 0.4 you will need to specify your regular attributes inside the nutype attribute, like this:

/// MIDI channel
#[nutype(
    validate(greater_or_equal = 1, less_or_equal = 16),
    derive(Debug, Copy, Clone, PartialEq, Eq)
)]
pub struct MIDIChannel(u8);

So, nutype uses the Rust newtype pattern and augments it with information about the validation rules that should be applied to values of that type.

Using the deranged crate

Another possible create to use is deranged. It is labeled as proof-of-concept and very scarcely documented, but from what I can gather, it also uses const generics.

If I were to define a new data type for a 7-bit byte, which is what is used in MIDI message payloads, I would do it like this using the deranged crate:

use deranged;

type U7 = deranged::RangedU8<0, 127>;

let mut b = U7::new_static::<64>();

This makes a new data type called U7 with the minimum value of 0 and the maximum value of 127, and then declares a new variable with the initial value 64.

Trying to assign a new value that is outside the range will fail:

b = U7::new_static::<128>();

You will get a lengthy compile error, starting with:

error[E0080]: evaluation of `<(deranged::RangedU8<0, 128>, deranged::RangedU8<128, 127>) as deranged::traits::StaticIsValid>::ASSERT` failed

and continuing for some dozen lines, because it originates from one of the macros that the deranged crate uses to define the various subrange types.

I'm not sure I particularly like the ergonomics of the declarations, but failing at compile time is obviously the right way to handle a situation like this. I did not try to construct a test of the runtime behavior, because I couldn't figure out how (or if) you could assign the value of another variable instead of a constant to a type like this.

So maybe, just maybe, this is yet another example of basically going against the grain.

Embrace the lack of subrange types

While I admire the efforts of the crate authors, this is a long way from Ada and declarations like

type U7 is 0 .. 127;

In Rust the path of least resistance seems to be to just use a newtype and forget about making an actual range-checking data type. Whenever you use the newtype in a struct, you write functions to enforce that any incoming values are in the desired range. Then you just hope that there will not be too many of them.

While I like Rust a lot, this kind of thing really leaves me pining for Ada. It seems like subrange types are a lost art, and attempts to suggest that they be added are met with indifference, possibly because it is not quite clear to all what undisputable advances they do have.

Maybe some day I will come up with a better approximation of a subrange type in Rust, because they most likely will never be added to the language.

Using the newtype pattern

For leveraging the newtype pattern in Rust, I have found the article The Newtype Pattern in Rust by Justin Wernick to be quite helpful. It also brought the derive_more crate by Jelte Fennema to my attention.

One more thing: associated consts

While reading "Programming in Rust" 2nd Edition I came across a feature of traits (is that a tautology or what) that seems to fit the bill. You can define associated consts in a trait, but leave their actual values for the implementor of the trait.

For example, I can define a trait with MIN, MAX, and DEFAULT and related methods like this:

pub trait Ranged {
    const MIN: i32;
    const MAX: i32;
    const DEFAULT: i32;

    fn new(value: i32) -> Self;
    fn value(&self) -> i32;
    fn is_valid(value: i32) -> bool;
    fn random_value() -> Self;
}

Then I make a newtype, and make it implement this trait:

pub struct Algorithm(i32);

impl Ranged for Algorithm {
    const MIN: i32 = 1;
    const MAX: i32 = 32;
    const DEFAULT: i32 = 32;

    fn new(value: i32) -> Self {
        if Self::is_valid(value) {
            Self(value)
        }
        else {
            panic!("expected value in range {}...{}, got {}",
                Self::MIN, Self::MAX, value);
        }
    }

    fn value(&self) -> i32 { self.0 }

    fn is_valid(value: i32) -> bool {
        value >= Self::MIN && value <= Self::MAX
    }

    fn random_value() -> Self {
        let mut rng = rand::thread_rng();
        Self::new(rng.gen_range(Self::MIN..=Self::MAX))
    }
}

How's that for a subrange type? It is immutable, and other newtypes like this are easy to implement. Essentially the only things that need to change are the name of the type and the values of the associated consts in the trait implementation.

The ranged_impl! macro

It becomes quite tedious to implement the Ranged trait for all the required types. The code you need to write can be radically shortened by defining a Rust macro to handle the grunt work.

The ranged_impl! macro implements the Ranged trait for a given type, along with the Default and Display traits. The default value is simply the DEFAULT associated const, while the displayed value is the value wrapped by the type.

Remember to install cargo-expand if you want to see the result of the macro expansion:

cargo install cargo-expand

Then use the cargo expand command.

Alternatively, you can also use Rust Analyzer in Visual Studio Code to recursively expand the macro; see the Expand macro recursively at caret command.

The implementation of the ranged_impl! macro looks like this:

#[macro_export]
macro_rules! ranged_impl {
    ($typ:ty, $min:expr, $max:expr, $default:expr) => {
        impl Ranged for $typ {
            const MIN: i32 = $min;
            const MAX: i32 = $max;
            const DEFAULT: i32 = $default;

            fn new(value: i32) -> Self {
                if Self::is_valid(value) {
                    Self(value)
                }
                else {
                    panic!("expected value in range {}...{}, got {}",
                        Self::MIN, Self::MAX, value);
                }
            }

            fn value(&self) -> i32 { self.0 }

            fn is_valid(value: i32) -> bool {
                value >= Self::MIN && value <= Self::MAX
            }

            fn random() -> Self {
                let mut rng = rand::thread_rng();
                Self::new(rng.gen_range(Self::MIN..=Self::MAX))
            }
        }

        impl Default for $typ {
            fn default() -> Self {
                Self::new(Self::DEFAULT)
            }
        }

        impl fmt::Display for $typ {
            fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
                write!(f, "{}", self.0)
            }
        }
    }
}

For more information about writing Rust macros, see the Macros chapter in The Rust Programming Language.