How to create a typesafe range-limited numeric type?

763 views Asked by At

In Rust, I have need of a numeric type with the property of having a domain symmetric around 0. If a number n is a valid value, then the number -n must also be valid. How would I ensure type-safety during initialization and arithmetic? How would it be best to implement modular and saturation arithmetic on the type?


The simplest example of the problem is:

type MyNumber = i8; // Bound to domain (-100, 100)

fn main() {
    let a = MyNumber(128); // Doesn't panic when 128 > 100
}

There are a few considerations to make, and I've attempted different solutions. I'll avoid generic programming for the examples of them below:

  • Basing the type off enum ensures that only valid values are possible values. This becomes messy very fast:

    enum MyNumber {
        One,
        Two,
        ...
    }
    impl MyNumber {
        fn convert(i8) -> MyNumber {
            match {
                1 => MyNumber::One,
                2 => MyNumber::Two,
                ...
            }
        }
    }
    
  • Expose a method which checks parameters before setting the fields, the textbook associated function. This doesn't prevent assigning using the struct constructor.

  • Validate operands (and forcibly rectify them) whenever an operation occurs. This seems reasonable, but requires each method to repeat the validation code.

    extern crate num;
    
    use num::Bounded;
    use std::cmp;
    struct MyNumber {
        val: i8,
    }
    
    impl Bounded for MyNumber {
        fn max_value() -> Self {
            MyNumber { val: 65 }
        }
        fn min_value() -> Self {
            MyNumber { val: -50 }
        }
    }
    impl MyNumber {
        fn clamp(&mut self) {
            self.val = cmp::min(MyNumber::max_value().val, 
                                cmp::max(MyNumber::min_value().val, self.val))
        }
        fn add(&mut self, mut addend: Self) {
            self.clamp();
            addend.clamp(); 
            //TODO: wrap or saturate result
            self.val = self.val + addend.val
        }
    }
    
    fn main() {
        let mut a = MyNumber { val: i8::max_value() };
        let b = MyNumber { val: i8::min_value() };
        a.add(b);
        println!("{} + {} = {}",
                 MyNumber::max_value().val,
                 MyNumber::min_value().val, 
                 a.val);
    }
    

None of the solutions above are very elegant - to some degree this is because they are prototype implementations. There must be a cleaner way to limit the domain of a numeric type!

What combination of type and traits would check bounds, use them for modular/saturation arithmetic, and easily convert to a numeric primitive?

EDIT: This question has been flagged as a duplicate of a much older question from 2014. I do not believe the questions are the same on the grounds that Rust was pre alpha and major improvements to the language were brought with version 1.0. The difference is of a greater scale than that between Python 2 and 3.

1

There are 1 answers

0
Chris Emerson On BEST ANSWER

Expose a method which checks parameters before setting the fields, the textbook associated function. This doesn't prevent assigning using the struct constructor.

It does if the field is private.

In Rust, functions in the same module, or submodules, can see private items... but if you put the type into its own module, the private fields are not available from outside:

mod mynumber {
    // The struct is public, but the fields are not.
    // Note I've used a tuple struct, since this is a shallow
    // wrapper around the underlying type.
    // Implementing Copy since it should be freely copied,
    // Clone as required by Copy, and Debug for convenience.
    #[derive(Clone,Copy,Debug)]
    pub struct MyNumber(i8);

And here's a simple impl with a saturating add, which leverages i8's built in saturating_add to avoid wrapping so that simple clamping works. The type can be constructed using the pub fn new function, which now returns an Option<MyNumber> since it can fail.

    impl MyNumber {
        fn is_in_range(val: i8) -> bool {
            val >= -100 && val <= 100
        }
        fn clamp(val: i8) -> i8 {
            if val < -100 {
                return -100;
            }
            if val > 100 {
                return 100;
            }
            // Otherwise return val itself
            val
        }
        pub fn new(val: i8) -> Option<MyNumber> {
            if MyNumber::is_in_range(val) {
                Some(MyNumber(val))
            } else {
                None
            }
        }

        pub fn add(&self, other: MyNumber) -> MyNumber {
            MyNumber(MyNumber::clamp(self.0.saturating_add(other.0)))
        }
    }
}

Other modules can use the type:

use mynumber::MyNumber;

And some example uses:

fn main() {
    let a1 = MyNumber::new(80).unwrap();
    let a2 = MyNumber::new(70).unwrap();
    println!("Sum: {:?}", a1.add(a2));
    // let bad = MyNumber(123); // won't compile; accessing private field
    let bad_runtime = MyNumber::new(123).unwrap();  // panics
}

Playground

In a more complete implementation I would probably implement std::ops::Add etc. so that I could use a1 + a2 instead of calling named methods.