Safe representation of restricted values
Table of contents
In my previous article, we explored how to build a complete Lambda function step-by-step by collaborating with the compiler. We ended up with a function that could handle all edge cases, often by checking the values of
Results, with one notable exception.
When we checked if a probability was within
1.0, we used an if expression instead.
24 // Return an error if the probability is not between 0 and 1 25 if !.contains
This is perfectly fine, but we could abstract those checks away by creating a specialized
Probability struct that can only hold values between
1.0. This will add a lot of complexity to our codebase, but introduces an extra layer of safety: if you’re manipulating a
Probability, you know it only contains valid probability values.
A new type for probabilities
Let’s define that new type.
1 2 ;
We only need to hold a single
f64, so we can define it as a tuple struct with a single value. Under the hood, Rust will store it as an
f64 with no overhead.
Creating a Probability
The inner value is private, so we cannot access it directly outside of the module where we define it. That means that users cannot accidentally pass invalid probability values.
Instead, we need to add a method that will create a new
Probability from an
f64. As a rule of thumb, if there is a standard Trait that matches what we need to do, we should use it. In this case, we can use the
Compared to the
TryFrom returns a
Result that may contain an error. As we could get
f64 values outside of the valid range, we could fail to create the
Probability. We can then use the
Result to return an error instead.
Testing the implementation
Since this is the only way to create a
Probability, we can be pretty sure that the value is valid – as long as the
try_from function is implemented correctly. Let’s add a few simple tests to make sure it works.
I’m purposefully omitting some tests here, but you might want to add checks for edge cases, like
18 /// Test if the value is within 0.0 and 1.0 19 20
I’m a big fan of randomized testing. While you lose a bit on repeatability, you gain the ability to test a wide variety of cases and make sure your implementation doesn’t break. With the
rand crate, we can generate numbers based on a range, making it quite easy to generate valid values or values under
It’s a bit tricker for values over
1.0 because we cannot define a range that would exclude its lower value. I’ll walk around that by setting a minimum value slightly over
1.0, but that means I will never test cases between
1.0 and that value.
43 /// Test if the value is within 0.0 and 1.0 44 45
That’s a lot of tests compared to what we had before! But all these tests are quite useful: they give us higher confidence that our implementation is correct. Remember: this is the critical part where we validate the input!
Interacting with probabilities
At the moment, we’ve defined a way to create a
Probability that contains a valid value, but we don’t have a way to interact with it. Since the inner
f64 is private, we can’t just refer to it directly. However, there’s a catch here: we should be able to read the value, but never modify it – at least not in safe Rust.
There are three things we can do about this:
- Allow a non-mutable reference to the inner value.
- Allow taking ownership of the inner value.
- Implement the same traits as
This last option is a bit tricky because
f64 implements many traits. Furthermore, because we have a limited range of valid values, we’d need to always return a
0.6 is a valid probability,
0.6 + 0.6 is not, so we cannot just add two probabilities without checking the result.
Let’s go back to the rule of thumb I mentioned before: if there is a standard Trait that matches what we need to do, we should use it. In this case, multiple traits could be interesting, but they all have one nice thing in common: they have an immutable and mutable version. For example, we have the
AsRef trait and its
AsMut counterpart; or
Now comes the tricky question: how do we know which one to use? The documentation for the
AsRef trait provides a detailed explanation on the topic:
AsRef has the same signature as Borrow, but Borrow is different in a few aspects:
Borrowhas a blanket impl for any
T, and can be used to accept either a reference or a value.
Borrowalso requires that
Ordfor borrowed value are equivalent to those of the owned value. For this reason, if you want to borrow only a single field of a struct you can implement
AsRef, but not
In this case, a
Probability will behave the same way for those three traits:
- It should be equal to another
Probabilityif the inner values are equal.
- It should be less than another
Probabilityif the inner values are less than the other.
- It should hash to the same value as the inner value.
However, there is a small detail that would cause an issue with
Borrow has a blanket impl for any
T. If we have a probability
p and we try to do
&p == &0.1, we’ll get an error message about comparing
f64. You can take a look at this Rust playground to see the error message.
Take the inner value
There are situations where we no longer need the
Probability and just want to take ownership of the inner value. We could use the
Into trait to do exactly that.
In the documentation, you might see that it advises against implementing
Into and recommends using the
From trait instead. This is because anything that implements
From will also have an opposite blanket implementation. Formally speaking,
From<T> for U implies
Into<U> for T.
This looks pretty similar to the
AsRef implementation, except that we take ownership of the value.
Now, all we have left to do is implement a variety of traits so users can interact with
Probability directly. At the moment, users can only create
Probability, and then either take the inner value or use the
as_ref method. That means that adding two probabilities together looks like this:
let p1 = new.unwrap; let p2 = new.unwrap; let p_sum = new.unwrap;
This is pretty lengthy, but we can’t do better with what we have at the moment. To make it easier, we should implement a bunch of traits that will allow us to perform these manipulations, like
p1 + p2, directly.
For this, there are two categories of manipulations: those that may fail and those that won’t. For example,
0.6 + 0.7 should fail, as the sum is above our threshold of
1.0. On the other side, multiplying two numbers between
1.0 will always result in a value in that range.
If we look at the
std::ops module, there are quite a few traits that could be interesting here. Depending on what you are building, you might decide only some of the traits are relevant. First, I’ll focus on maths operations:
Probability cannot contain negative values, we don’t need to implement
Neg. We could also implement the equivalent operations with assignments (
AddAssign and others), but these don’t support returning a
This is a significant number of traits to implement, but we don’t need to do all of that by hand. Instead, we can use a macro to generate them. In a nutshell, a macro is something that will generate Rust code based on inputs. Here’s one that will work with all the maths traits mentioned earlier:
A few things are going on here, so let’s break that down.
macro_rules!: this is a macro to generate macro. With it, we define a declarative macro that will take a trait as an input, and implement it for
The second one is
paste::paste!. This is a macro to concatenate and transform identifiers. We use it to transform the trait name into its method identifier:
Add needs an
Div needs a
div(), etc. We can use
paste to transform
Add into a snake case version.
Finally, you might notice that we’re not using any operator in the macro but rely on the method name instead. If we didn’t do that, we’d have to specify what operator corresponds to what macro, while we already know what’s the method name.
We can then use the macro for four of our traits in just three lines:
112 impl_op!; 113 impl_op!; 114 impl_op!; 115 impl_op!;
We are only missing
Mul now. We could use the same macro to implement it, but multiplying two probabilities will always result in values between
1.0. And here we have another choice to make: should we keep it consistent with the other operations, or should we make it return the value directly?
I’ve opted to return the
Probability directly, as users would just
unwrap it anyway and multiplication is a common operation on probabilities.
Here, I’m using
Probability(self.0 * rhs.0) directly instead of unwrapping from
try_from. This is because we can prove mathematically that the product of two probabilities is always between
1.0 – and therefore I’m fairly confident that the result will always be a valid probability.
If I was less confident, I could use
expect to unwrap the result. This would have a small performance penalty but will catch any errors in my logic.
Let’s say we want to add three probabilities together. With the implementation we have right now, if we do
p1 + p2 + p3, we’ll get the following error:
error[E0369]: cannot add `Probability` to `Result<Probability, ProbabilityError>` --> probability/src/lib.rs:189:29 | 189 | assert_eq!((p1 + p2 + p3).unwrap(), expected); | ------- ^ -- Probability | | | Result<Probability, ProbabilityError>
That makes sense given what we have at the moment: when we add two probabilities together, we get a
Result back, and we haven’t implemented adding a
Result to a
Probability. As the operation will always result in the same error type, we could create an operation that’ll add if possible, otherwise, return the error.
Since this is in the macro we defined earlier, we’ll just add a new implementation in it:
Is it worth it?
Doing all of this was quite a bit of work, with many considerations along the way. Is it worth it?
It depends on what you’ll do with it. If this was just for a single program like in my previous article, it wouldn’t be worth it. If you’re only going to check something once, you don’t need to create a custom type and do all the extra work of implementing various traits to perform maths operations.
However, if you’re building a library that will be used by hundreds or thousands of developers, it’s probably a good thing. All that extra effort from one developer will pay off by ensuring others will have the tools to safely manipulate values between
You can find examples of such wrapping structs in the
std::num part of the standard library. You have structs for values that cannot be zero (for memory optimisation reasons), and for wrapping numbers on overflow.