Static checking of units in Servo

Matt Brubeck

0

Web browsers do a lot of calculations on geometric coordinates, in many different coordinate systems and units of measurement. For example, a browser may need to translate a position expressed in hardware pixels relative to the screen origin into CSS px units relative to the document origin. Tricky bugs can occur if the code doesn’t convert correctly between different units or coordinate systems (though fortunately these bugs are unlikely to compete with the the world’s most infamous unit conversion bug).

I recently added some new features to help prevent such bugs in rust-geom, a 2D geometry library written in Rust as part of the Servo project. These new features include Length, a type that can holds a single numeric value tagged with a unit of distance, and ScaleFactor, a type for converting between different units. Here’s a simple example:

use geom::{Length, ScaleFactor};

// Define some empty types to use as units.
enum Mm {};
enum Inch {};

let one_foot: Length<Inch, f32> = Length(12.0);
let two_feet = one_foot + one_foot;

let mm_per_inch: ScaleFactor<Inch, Mm> = ScaleFactor(25.4);
let one_foot_in_mm: Length<Mm, f32> = one_foot * mm_per_inch;

Units are checked statically. If you try to use a Length value with one unit in an expression where a different unit is expected, your code will fail to compile unless you add an explicit conversion:

let d1: Length<Inch, f32> = Length(2.0);
let d2: Length<Mm, f32> = Length(0.1);
let d3 = d1 + d2; // Type error: Expected Inch but found Mm

Furthermore, the units are used only at compile time. At run time, a Length<Inch, f32> value is stored in memory as just a single f32 floating point value, with no additional data. The units are “phantom types” that are never instantiated and have no run-time behavior.

Length values can also be used in combination with the other rust-geom types like Rect, Size, and Point2D to keep track of units in any of the library’s supported geometric operations. There are also convenient type aliases and constructor functions to make these combined types a little easier to work with. For example:

// Using Point2D with typed units:
let p: Point2D<Length<Mm, int32>> = Point(Length(30), Length(40));

// Shorthand for the above:
let p: TypedPoint2D<Mm, int32> = TypedPoint2D(30, 40);

We use this in Servo to ensure that values are scaled correctly between different coordinate systems and units of measure such as device pixels, screen coordinates, and CSS px. You can find some of Servo’s units documented in geometry.rs. These types are used in the compositor and windowing modules, and we’re gradually converting more Servo code to use them.

This has already helped us find some bugs. For example, the function below was originally missing a conversion from device pixels to page pixels, which would cause incorrect mouse move events in any window whose resolution is not 1 hardware pixel per CSS px:

fn on_mouse_window_move_event_class(&self, cursor: Point2D<f32>) {
    for layer in self.compositor_layer.iter() {
        layer.send_mouse_move_event(cursor);
    }
}

Once we added units to the types in this module, this code would not build until it was fixed:

fn on_mouse_window_move_event_class(&self, cursor: TypedPoint2D<DevicePixel, f32>) {
    let scale = self.device_pixels_per_px();
    for layer in self.compositor_layer.iter() {
        layer.send_mouse_move_event(cursor / scale);
    }
}

My Rust code is based directly on Kartikaya Gupta’s C++ code implementing statically-checked units in Gecko. There are some minor differences (for example, Gecko does not include a type for one-dimensional “length” values), but the basic design is easily recognizable despite being translated from C++ to Rust.

Several other projects have also tackled this problem. Notably, the F# language has built-in support for units of measure as a language feature, including static analysis of operations on arbitrary combinations of units (not currently implemented in rust-geom). Their research cites other related work. Languages like Rust and C++ do not include special language-level features for units, but through use of generics and phantom types they allow library code to implement similar zero-overhead static checks. Our work in Gecko and Servo demonstrates how useful this approach can be in practice.

No responses yet

Post a comment

Post Your Comment