Servoにおける単位系の静的チェック

ウェブ・ブラウザは幾何学的な座標上で多くの計算を行っていますが、様々な座標系と単位系が混在しています。たとえばブラウザは、画面の原点から相対的にきまるハードウェアのピクセルとして表現された位置を、ドキュメントの原点からみたCSSのpx単位へと変換する必要があります.

もし異なる単位系や座標系の間でコードを正しく変換できなければ、厄介なバグを引き起こすでしょう (世界で最も知られていない単位変換のバグに書かれたほどひどいバグではないかもしれませんが)。

このたび私は、Servoプロジェクトの一環で、Rustで書かれた2Dのジオメトリーライブラリーの一つであるrust-geomに、こうしたバグを避けるための新機能をいくつか追加しました。その新機能中には、長さの単位の一つにタグ付けされた数値を一つ持つことのできる型 Length や、異なる単位系の間の変換に用いられる型 ScaleFactor などがあります。一例を以下に示します。

use geom::{Length, ScaleFactor};

// 要素を持たない型を単位として用いる
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;

単位のチェックは静的に行われます。ある表現部分で使われるべき単位系とは別の単位系を持つLengthの値を使おうとすると、あなたのコードはコンパイルに失敗するので、単位系の変換を明示的に追加する必要があります。

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

さらに、この単位が使われるのはコンパイル時だけです。実行時には、Length<Inch, f32>の値は何らの追加データなしに単一の浮動小数点の数値f32としてメモリー上に格納されます。その単位の型は“Phantom types”となり、参照されることもないし実行時に何もしません。

Lengthの値はまた、このライブラリーがサポートするあらゆる幾何学演算の前後で単位系を正しく保つために、RectSizePoint2D などの他のrust-geomの型と共に用いることができます。またこれらを複合した型を使うとき便利なように、型のエイリアスやコンストラクタもあります。一例を示します。

// 型がついた単位と共にPoint2Dを用いる場合:
let p: Point2D<Length<Mm, int32>> = Point(Length(30), Length(40));

// 上記の簡潔表現:
let p: TypedPoint2D<Mm, int32> = TypedPoint2D(30, 40);

こうした機能をServoで利用すれば、デバイスの画素や画面上の座標、それにCSSのpxなど、異なる座標系や測定単位系の間で数値が正しく変換されるかどうか確認できます。Servoで用いられる単位についてはgeometry.rsに記述があります。これらの型は、コンポジターやウィンドウイングのモジュールで使われています。現在、より多くのServoコードがこの機能を利用するように記述変更を進めています。

このおかげでいくつかのバグも見つけることができました。たとえば、下の記述例では、当初はデバイスの画素からページの画素への変換が抜け落ちていました。そのため開いているウィンドウの解像度がCSS pxあたりハードウェアの1画素分と異なる場合に、マウスを動かしても思い通りの結果が得られなかったのです。:

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

そこで私たちがこのモジュールで用いられる型に単位を付加したところ、このコードが修正されるまでビルドできないようになりました。

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);
    }
}

私のRustコードは、Kartikaya GuptaによるGecko上で静的チェックされた単位のC++コードによる実装をそのまま組み込んだものが基本になっています。いくつか細かな相違点 (例えばGeckoには一次元の 「長さ」 を示す型が無いなど) がありますが、C++からRustに移植したにもかかわらず基本的なデザインは容易にわかるようになっています。

この問題に取り組んでいるプロジェクトはほかにも数多くあります。とりわけ、F#言語はその言語の特徴として単位系を組み込んでいます。たとえば、異なる単位系の任意の組合せの上で行われる演算の静的な解析 (これはまだrust-geomには実装前) などです。彼らの研究報告には関連のある仕事が引用されています。RustやC++のような言語は単位系に関して特別言語レベルの範疇には入っていませんが、一般的な型やphantom型を利用すれば、ライブラリー・コードにオーバーヘッドなく同様の静的チェックを組み込むことができます。私たちがGeckoとServoで行った仕事は、このアプローチが実用上いかに有益であるかを示しました。