u64 のユーザIDを、注文IDを期待する関数に渡しても、Rustは文句を言わない。両方とも u64 だ。コンパイラは同じ型を見ているので、助けてくれない。実行時に気づく。通常は本番環境で、通常は安全だと思っていたリファクタリングの後だ。
これこそが、newtypeが存在する理由、つまり排除すべきバグの類型だ。
newtypeとは、既存の型をラップする1フィールドのタプル構造体だ: struct UserId(u64);。コンパイル時には UserId と OrderId は互換性がない。実行時には、素の u64 とまったく同じバイト数を占める。追加の割り当てもなければ、間接参照もなく、コストはゼロだ。
これは実際にどんな問題を解決するのか?
静的型付きの言語ならどれもこの問題を抱えている。同じ表現で意味の異なる2つの値がある。データベースキー、物理単位、通貨金額、パーセンテージと生のカウント。Cなら typedef を使い、Goなら型エイリアスを使うだろうが、あれは同じ基礎型に対する名前に過ぎない。コンパイラは依然として両者を同一視する。
Rustのnewtypeパターンは違う。struct UserId(u64); は別個の型を作り出す。OrderId が求められる場所に UserId を渡せない。うっかりパーセンテージを生のカウントに足すこともできない。エラーはコンパイル時に表面化し、本番インシデントではない。
これが人々が「違法な状態を表現不能にする」と言うときの意味だ。理論上の話ではない。かつて、口座IDと送金金額の両方を u64 で受け付けるAPIエンドポイントを目にした。リファクタリングが呼び出し側で両者を入れ替えた。コンパイル時に何も壊れなかった。お金が間違った場所に移動した。newtypeがあれば、そのリファクタリングは誰もデプロイする前にコンパイルエラーになっていた。
newtypeの内部動作
Rustにおけるnewtypeは、名前のないフィールドを1つ持つ構造体に過ぎない:
struct UserId(u64);
struct OrderId(u64);
コンパイラは UserId を OrderId や u64 から完全に別の型として扱う。明示的に構築する: let id = UserId(42);。内部の値には id.0 でアクセスする。
構造体が既知のサイズを持つ単一フィールドであるため、コンパイラは最適化を適用するが、実際には最適化ではなく、構造体の動作原理そのものだ。UserId のメモリレイアウトは u64 と同一である。同じサイズ、同じアライメント、同じABI。
自分で確認できる:
use std::mem;
assert_eq!(mem::size_of::<UserId>(), mem::size_of::<u64>());
assert_eq!(mem::align_of::<UserId>(), mem::align_of::<u64>());
vtableもなければ、判別子もなく、ラッパーオブジェクトもない。UserId を関数に渡すための生成された機械語は、u64 を渡す場合と同一だ。型システムが区別を強制する。実行時にはそれが消去される。
具体的な例:ピクセルとポイントの混同
これはかつて私を噛んだパターンだ。あるグラフィックスライブラリは、ピクセルとデバイス非依存ポイントの両方で距離を持っていた。両方とも f32 だった。ピクセルが求められる場所にポイントを渡し、高DPI画面でUIが半分のサイズで描画された。コンパイラは黙っていた。両方とも f32 だったからだ。
newtypeを使えば:
struct Pixels(f32);
struct Points(f32);
fn scale_to_pixels(points: Points, dpi: f32) -> Pixels {
Pixels(points.0 * dpi / 96.0)
}
fn draw_line(length: Pixels) {
// render at this pixel length
}
fn main() {
let width = Points(150.0);
let dpi = 192.0;
// This compiles:
draw_line(scale_to_pixels(width, dpi));
// This does not:
// draw_line(width);
// error: expected `Pixels`, found `Points`
}
コンパイラはプログラムを実行する前にその間違いを拒否する。Points と Pixels の値は、f32 と同じ4バイトを使う。実行時にこの安全性にコストはかからない。
誰も教えてくれないトレードオフ
newtypeを書くのはタダではない。ラップされた型のメソッドは自動的には伝播しない。u64 には wrapping_add や leading_zeros など、何十ものメソッドがある。素の UserId には、自分で実装しない限りそれらは一切ない。
3つの選択肢があるが、良いのは1つだけだ。
選択肢1: Deref を実装する。 これにより、自動間接参照を通じて内部型のすべてのメソッドが使える:
use std::ops::Deref;
struct UserId(u64);
impl Deref for UserId {
type Target = u64;
fn deref(&self) -> &u64 { &self.0 }
}
これは動くが、目的を損なう。Deref は UserId から u64 への暗黙的な型強制を許し、つまり u64 が求められる場所ならどこにでも UserId を渡せるということだ。今手に入れた型安全性を失う。newtypeにはこれを使うな。
選択肢2: すべて手動で実装する。 面倒だが、ドメインにとって意味のある操作だけを公開できる。ID型なら、必要なのは Display、Debug、PartialEq、Eq、Hash だけかもしれない:
use std::fmt;
#[derive(Debug, PartialEq, Eq, Hash)]
struct UserId(u64);
impl fmt::Display for UserId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
算術を伴う数値型なら、Add や Sub などを実装する。これはボイラープレートだ。標準の derive マクロや derive_more のようなクレートは助けになるが、それでも素の型よりはコードが増える。
選択肢3: 必要なときに内部値を直接使う。 これが私の好みだ。newtypeはAPI境界に留めておき、生の値が必要なときは .0 でアンラップし、Deref は完全に避ける。少し冗長だが、安全性の保証を保つ。
これを我慢できるようにするderiveマクロ
Rustの標準ライブラリは #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] を無償で提供する。算術のためには、std::ops や derive_more のようなクレートを使う:
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct UserId(u64);
#[derive(Debug, Clone, Copy, PartialEq)]
struct Meters(f64);
impl std::ops::Add for Meters {
type Output = Meters;
fn add(self, other: Meters) -> Meters {
Meters(self.0 + other.0)
}
}
コンパイラは依然として、素の f64 の加算と同じ機械語を生成する。Add トレイトもゼロコスト抽象化だ。単相化によって操作がインライン化される。
newtypeが間違った道具である場合
すべてのプリミティブがnewtypeを必要とするわけではない。関数が timeout_ms: u64 を受け取り、それがローカルでのみ使われるなら、ラップしても実際のバグは捉えずノイズが増えるだけだ。newtypeは、API境界を超える値、データベースに永続化される値、混同したときに実際の影響がある概念を表す値に限定すべきだ。
シリアライゼーションも変になる。serde を使っている場合、UserId(u64) はデフォルトでは 42 ではなく、マップ {"0": 42} としてシリアライズされる。これを修正するには #[serde(transparent)] が必要だ:
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
#[serde(transparent)]
struct UserId(u64);
これは忘れやすく、JSON APIが突然数値の代わりにオブジェクトを期待し始めたときのデバッグは面倒だ。
公開APIから始めよう
同じプリミティブ型で意味の異なる複数の引数を取る関数を探せ。モジュールやサービスの境界を超えるものをラップせよ。Deref はderiveするな。シリアライズするなら #[serde(transparent)] を使え。
cargo-expand をインストールしているなら、newtypeに対して cargo expand を実行し、生成されたコードを見よ。構造体は単に異なる型名を持つ生の値に過ぎないことがわかるだろう。ゼロコストという主張はマーケティングではない。文字通り、コンパイラが出力するものなのだ。