Rust Lulz: Implementing (floating point) approximate equality via traits
A common concept to deal with when working with floating point numbers, is approximate equality. While the implementation itself is simple (a specialized function will do), Rust gives a more elegant option.
In this article, I’ll explain how to implement approximate equality via traits.
Content:
- The basic approach to the problem
- The doubt
- The solution
- Implementing the solution on a trait
- Conclusion
The basic approach to the problem
The logic to perform approximate equality between floating point numbers is trivial:
abs(n1 - n2) < ε
In Rust, we can simply define a constant, and a function that takes two floats:
const EPSILON: f64 = 0.00001;
fn approximate_eq(n1: f64, n2: f64) -> bool {
(n1 - n2).abs() < EPSILON
}
The doubt
The design above raises a strong doubt: where should the logic be placed?
It doesn’t belong to a very specific place in the program. It would be convenient to associate it to f64, but it is a built-in data type; in general, it may be a type defined in an external crate.
Rust, however, allows adding behavior to any type (putting in place appropriate constraints), which is perfect for the case.
The solution
The solution, implemented via traits, is actually very simple.
We define a trait that defines the epsilon constant and the method that tests for approximate equality:
trait ApproximateEq {
const EPSILON: f64 = 0.00001;
fn approximate_eq(self, other: Self) -> bool;
}
This looks better: the epsilon constant and the function are not hanging around anymore.
There are a couple of details worth reviewing:
- the function is going to own the object, as it uses
self
(as opposed to the reference&self
); in this context, this doesn’t matter, asf64
isCopy
Self
is used as type of the right operand; this indicates that the implementing types will perform the comparison on instances of the same type (in this case,f64
withf64
).
Now, let’s just implement it for the f64
type:
impl ApproximateEq for f64 {
fn approximate_eq(self, other: f64) -> bool {
(self - other).abs() < <f64 as ApproximateEq>::EPSILON
}
}
A notable concept to review here, is the reference to the ApproximateEq::EPSILON
constant.
Something that may confuse programmers coming from other languages, is the role of trait constants: in Rust, constants defined in a trait constitute defaults, and, like trait methods, they must be referenced on the concrete types; in this case, on f64
.
Now, there is a small complication in this specific implementation: f64
already implements an EPSILON
constant, which makes access to ApproximateEq::EPSILON
ambiguous; see this:
impl ApproximateEq for f64 {
fn approximate_eq(self, other: f64) -> bool {
(self - other).abs() < f64::EPSILON
}
}
Which EPSILON
is this implementation referencing?
In order to make sure we reference the appropriate constant, we therefore need to disambiguate, via the so-called Fully Qualified Syntax: <f64 as ApproximateEq>::EPSILON
.
That was all! Let’s check an example:
fn test_comparison() {
assert!(1.000000.approximate_eq(1.000001));
assert!(! 1.0000.approximate_eq(1.0001));
}
Easy, and elegant! 🤩
Implementing the solution on a trait
For the lulz, let’s implement this logic as default method on a trait.
We define the trait as a simplistic representation of a bidimensional shape:
trait Shape {
fn vertices(&self) -> Vec<(f64, f64)>;
}
First, we need to change the trait method to receive a reference; this is required for implementing the method on the trait, but it’s important anyway, since it’s intended to be generically implemented (e.g. on non-Clone
types):
trait ApproximateEq {
const EPSILON: f64 = 0.00001;
fn approximate_eq(&self, other: &Self) -> bool; // see here
}
impl ApproximateEq for f64 {
fn approximate_eq(&self, other: &f64) -> bool {
(self - other).abs() < <f64 as ApproximateEq>::EPSILON
}
}
Then, we implement it on the trait:
impl ApproximateEq for dyn Shape + '_ {
fn approximate_eq(&self, other: &Self) -> bool {
if self.vertices().len() != other.vertices().len() {
return false;
}
self.vertices()
.iter()
.zip(other.vertices().iter())
.all(|(vs, vo)| vs.0.approximate_eq(&vo.0) && vs.1.approximate_eq(&vo.1))
}
}
Again, simple and functional!
Interestingly, we can override EPSILON
for dyn Shape
(and referencing it as <dyn Shape as ApproximateEq>::EPSILON
), but in this example, it’s not intended.
Conclusion
By leveraging Rust functionalities, we’ve achieved a clean, generic solution to implementing approximate equality.
It’s typical for Rust newcomers to perceive more advanced functionalities as obscure and/or complex. This is a fair point; languages like Rust may not be suited for all the use cases, or all the tastes.
However, for the cases where Rust is deemed an appropriate tool, it makes it possible to implement elegant solutions.