Avoiding generic types for safer code
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.