How do you specify value constraints in Rust?

Viewed 689

I'm looking for a way to move type constraints into some kind of wrapper. As an example, in Ada you might see something like this:

type Element is Integer range 100 .. 1000;

Which is defining a new type Element that --- while still an Integer --- is bound to a specific range. There's also mod which will loop back around (super useful).

In Rust, so far I've been checking this manually within all my functions, ie:

if x < 100 || x >= 1000 {
    // throw some kind of error
}

But it would be really nice to instead define a new type that performs this check for me on assignment, similar to how integers can't overflow by default. I know that we don't have inheritance, but maybe there's some kind of trait I can implement?

TL;DR: I'm sure my method isn't best practice, but what's the standard alternative?

3 Answers

But it would be really nice to instead define a new type that performs this check for me on assignment, similar to how integers can't overflow by default.

This would indeed be best.

You can define it as a user-type:

struct BoundedU16<const MIN: u16, const MAX: u16>(u16);

And then define all the methods you expect, starting with new:

impl<const MIN: u16, const MAX: u16> BoundedU16<MIN, MAX> {
    pub const fn new(value: u16) -> Result<Self, BoundError> {
        if value >= MIN && value <= MAX {
            Ok(Self(value))
        } else {
            Err(BoundError(value, MIN, MAX))
        }
    }
}

The main drawback is that at the moment you can't have BoundedInteger<T, const MIN: T, const MAX: T>. The work-around is to use a macro to define the multiple Bounded[I|U][8|16|32|64|size].

Then you can declare type Element = BoundedU16<100, 1000>;.

Though do note that any Element is just an alias, here, not a new type. If you declare a new type with struct Element(BoundedU16<100, 1000>) then you need to implement (or derive) all the traits again.


A trait would allow you to add a validate method, but would not allow you to implement Add, Sub, ... which automatically validate. It's a poorer solution.

One possibility is like that Playground.

I added this macro add_constraint! to make syntax of adding constraints lighter.

Note however that with the current approach you can have only one constraint per type

trait ValueConstraint {
    type MaybeError;
    fn validate(&self) -> Self::MaybeError;
}

macro_rules! add_constraint {
    ($implementor:ty, $predicate:expr) => { 
        impl ValueConstraint for $implementor {
            type MaybeError = Option<()>;
            fn validate(&self) -> Self::MaybeError {
                if $predicate(self) {
                    Some(())
                } else {
                    None
                }
            }
        }
    }
}

add_constraint!(i32, |&x| x >= 100 && x < 1000);


fn work_with_number(mut x: i32) -> Option<i32> {
    x.validate()?;
    x += 42;
    x.validate()?;
    Some(x)
}

fn main() {
    println!("{:?}", work_with_number(100)); // Some(142)
    println!("{:?}", work_with_number(999)); // None
}

You could possibly wrap your numeric type into a struct that can do a bounds check on a range.

use std::ops::Range;

#[derive(Debug)]
struct Bounded<T: PartialOrd> {
    val: T,
    range: Range<T>
}

impl<T: PartialOrd> Bounded<T> {
    fn try_new(val: T, range: Range<T>) -> Option<Self> {
        if range.contains(&val) {
            return Some(Bounded{val, range});
        }
        else {
            return None;
        }
    }
}

fn main() {
    let a = Bounded::try_new(4u32, 0..5);
    println!("{:?}", a);
    
    let b = Bounded::try_new(6u32, 0..5);
    println!("{:?}", b);
}

Gives us

Some(Bounded { val: 4, range: 0..5 })
None

Optionally, you return a Result<Self> instead.

Related