Specializing tests

11 April 2017

I recently published a new version of dimensioned, a Rust library for compile-time dimensional analysis. In so doing, I found a neat little pattern coupling specialization with test generation.

The Problem

Among other things, dimensioned defines various unit systems, and many constants in each unit system. All of this is performed by a build script that creates documentation at the same time. For example, from this code, this documentation is created.

In addition to generating documentation, we would like to generate some tests. One such test is to compare constants across unit systems. If the same constant is defined in two unit systems, then we should ensure that, when converted, they have the same value. This ensures both that the conversion does what it should and that we haven’t made a typo when defining one of the constants.

Given unit systems a and b, it should be as easy as

assert_ulps_eq!(a::CONST, b::CONST.into(),
                epsilon = a::A::new(0.0),
                max_ulps = 2);

Note: Due to the non-associativity of floating point math, we can’t use assert_eq!. Instead, we verify that the constants are within 2 ULPS using the approx crate.

Unfortunately, this fails. We can’t always convert from a to b. Or, sometimes we can convert from a to b, but we can’t go from b to a. For example, we can convert from SI to the centimeter-gram-second (CGS) system, but only from a subset of SI. We can’t convert a candela to CGS; there’s no unit to represent it. In addition, it makes no sense to convert from CGS to SI, as CGS represents electricity and magnitism units in terms of centimeters, grams, and seconds, so such a conversion would be ambiguous.

What this means is From isn’t implemented for all possible constants from all possible constants, so we’ll get unimplemented errors for some of those into() calls.

We could set up either a whitelist or blacklist of what constants to compare, but that would be a pain and a maintainibility nightmare. Fortunately, there’s a better way.

The Solution

First, let’s make a trait for performing these comparisons:

pub trait CmpConsts<B> {
    fn test_eq(self, b: B);
}

Then, let’s implement it for when we can perform the conversion:

impl<A, B> CmpConsts<B> for A where
    A: From<B> +
       fmt::Debug +
       Dimensioned<Value=f64> +
       ApproxEq<Epsilon=Self>
{
    fn test_eq(self, b: B) {
        assert_ulps_eq!(self, b.into(), epsilon = A::new(0.0), max_ulps = 2);
    }
}

So, when we call a.test_eq(b), we perform the same test as before, with the same problems. So far we have done nothing but added a useless layer of abstraction. Yay us!

However, with a default impl, it suddenly becomes useful:

impl<A, B> CmpConsts<B> for A {
    default fn test_eq(self, _: B) {
    }
}

Now, CmpConsts is implemented for any two types A and B. If we can convert from B to A, then we perform the test. If not, then we do nothing and move on. No more compiler errors, and we can generate all the comparison tests we want!

Feel free to discuss this on reddit.