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 Option
s and Result
s, with one notable exception.
When we checked if a probability was within 0.0
and 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 0.0
and 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 TryFrom
trait:
1
Compared to the From
trait, 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.
4
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 0.0
and 1.0
.
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 0.0
.
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
f64
.
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 Result
: while 0.6
is a valid probability, 0.6 + 0.6
is not, so we cannot just add two probabilities without checking the result.
Non-mutable references
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 Borrow
and BorrowMut
.
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:
- Unlike
AsRef
,Borrow
has a blanket impl for anyT
, and can be used to accept either a reference or a value.Borrow
also requires thatHash
,Eq
andOrd
for 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 implementAsRef
, but notBorrow
.
In this case, a Probability
will behave the same way for those three traits:
- It should be equal to another
Probability
if the inner values are equal. - It should be less than another
Probability
if 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
: 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 Probability
and f64
. You can take a look at this Rust playground to see the error message.
Let’s implement AsRef
instead:
83
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
.
89
This looks pretty similar to the AsRef
implementation, except that we take ownership of the value.
Trait implementations
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 0.0
and 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: Add
, Div
, Mul
, Rem
, and Sub
. Since 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 Result
.
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:
95
A few things are going on here, so let’s break that down.
First is 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 Probability
.
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 add()
method, 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 0.0
and 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.
117
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 0.0
and 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 try_from
and expect
to unwrap the result. This would have a small performance penalty but will catch any errors in my logic.
Chaining operations
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:
95
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 0.0
and 1.0
.
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.