Yet another SWE Blog.

Avoiding generic types for safer code

Nigel Schuster
Nigel Schuster

Generic types like int or string are quick and easy to use. However, sometimes they represent a specific concept. Declaring a new type is an easy way to avoid confusion. Let's look at the following code:

pub fn rectangle_circumference(width: u32, height: u32) -> u32 {
    2 * width + 2 * height
}

This code is meant to calculate the circumference of some arbitrary rectangle. It is simple and universally usable. However, it is very easy to use incorrectly. We have no guarantee that the units are all the same. width could be in centimeters, while height is in meters. To alleviate this problem, we can declare a specific type for a unit:

pub struct Meter(pub u32);
pub fn rectangle_circumference(width: Meter, height: Meter) -> Meter {
    Meter(2 * width.0 + 2 * height.0)
}

With this, we now put the burden on the caller to ensure that both inputs are of type Meter, and we guarantee that the output is of type Meter as well. At first sight, you may think that this incurs some overhead, however, the Godbolt output is the same for both versions when using -C opt-level=3:

example::rectangle_circumference:
        add     edi, esi
        lea     eax, [rdi + rdi]
        ret

Of course, we should consider making this more generic while maintaining precise types:

pub trait LengthUnit {}

pub struct Rectangle<T: LengthUnit> {
    width: T,
    height: T,
}

impl<T: LengthUnit> Rectangle<T>
where
    T: std::ops::Add<Output = T> + Copy,
{
    pub fn circumference(self) -> T {
        let Rectangle { width, height } = self;
        width + width + height + height
    }
}

#[derive(Clone, Copy)]
struct Meter(u32);
impl LengthUnit for Meter {}
impl std::ops::Add for Meter {
    type Output = Meter;
    fn add(self, rhs: Self) -> Meter {
        Meter(self.0 + rhs.0)
    }
}
pub fn rectangle_circumference<T>(width: T, height: T) -> T
where
    T: LengthUnit + Copy + std::ops::Add<Output = T>,
{
    (Rectangle { width, height }).circumference()
}

And even this code compiles down to the same assembly in Gobolt when calling it with Meter(u32) input.

Bonus

Rust makes it particularly easy for us to deal with different units. You can design a create function for the rectangle to account for different types:

impl<T: LengthUnit> Rectangle<T> {
    pub fn create<W, H>(width: W, height: H) -> Self
    where
        W: std::convert::Into<T>,
        H: std::convert::Into<T>,
    {
        Self {
            width: width.into(),
            height: height.into(),
        }
    }
}

This allows us to write the following program:

#[derive(Clone, Copy, Eq, PartialEq, Debug)]
struct Meter(u32);
...
#[derive(Clone, Copy)]
struct Centimeter(u32);
impl LengthUnit for Centimeter {}
impl std::convert::Into<Meter> for Centimeter {
    fn into(self) -> Meter {
        Meter(self.0 / 100)
    }
}
pub fn main() {
    let width = Meter(3);
    let height = Centimeter(300);
    let circumference: Meter = (Rectangle::create(
        width,
        height,
    ))
    .circumference();
    assert_eq!(circumference, Meter(12));
}

Published on 2021-08-30.


More Stories