u64 的使用者 ID 傳給一個預期接收訂單 ID 的函式,Rust 不會抱怨。兩者都是 u64。編譯器看到的是完全相同的型別,所以幫不上忙。你會在執行期才發現,通常是在正式環境,通常是在一次你以為安全的重構之後。

這正是 newtype 存在要消滅的 bug 類別。

newtype 是一個單欄位的 tuple struct,用來包裝既有的型別:struct UserId(u64);。在編譯期,UserIdOrderId 互不相容。在執行期,它們佔用的位元組跟一個裸的 u64 完全相同。沒有額外的配置、沒有間接取值、沒有成本。

這到底解決了什麼問題?

每個有靜態型別的語言都有這個問題。你有兩個值,內部表示相同但語意不同。資料庫鍵、物理單位、貨幣金額、百分比與原始計數。在 C 你會用 typedef,在 Go 會用 type alias,但那些只是同一個底層型別的不同名字。編譯器仍然把它們當成同一個東西。

Rust 的 newtype 模式不同。struct UserId(u64); 創造了一個獨立的型別。你不能把 UserId 傳到預期 OrderId 的地方。你不能不小心把百分比加到原始計數上。錯誤會在編譯期浮現,而不是在正式環境的 incident 中。

這就是人們說的「讓非法狀態無法被表示」。這不是理論。我曾經看過一個 API endpoint,同時接受 account ID 和 transfer amount,兩者都是 u64。一次重構在 caller 中把它們調換了。編譯時什麼都沒壞。錢跑到了錯誤的地方。有了 newtype,那次重構在任何人部署之前就會編譯失敗。

newtype 的底層運作原理

Rust 中的 newtype 就是一個只有一個未命名欄位的 struct:

struct UserId(u64);
struct OrderId(u64);

編譯器把 UserId 視為與 OrderIdu64 完全分開的型別。你明確地構造它:let id = UserId(42);。你用 id.0 存取內部值。

因為這個 struct 只有單一欄位且大小已知,編譯器會套用一項「最佳化」——但其實這根本不是最佳化,只是 struct 本來就這樣運作。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、沒有 discriminant、沒有 wrapper object。把 UserId 傳進函式所產生的機器碼,與傳入 u64 完全相同。型別系統強制區分。執行期將它抹除。

具體範例:混淆 pixels 與 points

這裡有一個曾經讓我中過招的模式。某個圖形函式庫同時使用 pixels 與 device-independent points 來表示距離。兩者都是 f32。我把 points 傳到預期 pixels 的地方,結果我的 UI 在高 DPI 螢幕上只渲染了一半大小。編譯器沒有出聲,因為兩者都是 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`
}

編譯器會在你執行程式之前就拒絕這個錯誤。PointsPixels 的值在執行期使用的位元組數與 f32 相同。這份安全性在執行期完全不花成本。

沒人告訴你的 trade-offs

newtype 不是免費就能寫出來的。被包裝型別的方法不會自動繼承。u64wrapping_addleading_zeros 等幾十個方法。一個裸的 UserId 除非你自己實作,否則一個都沒有。

你有三個選項,而且只有一個是好的。

選項 1:實作 Deref 這讓你透過 auto-deref 取得內部型別的所有方法:

use std::ops::Deref;

struct UserId(u64);

impl Deref for UserId {
    type Target = u64;
    fn deref(&self) -> &u64 { &self.0 }
}

這的確有效,但它違背了目的。Deref 允許從 UserId 隱含強制轉型為 u64,這表示你可以把 UserId 傳到任何預期 u64 的地方。你剛買來的型別安全性就這樣丟掉了。不要對 newtype 這麼做。

選項 2:全部手動實作。 這很繁瑣,但你只會公開對你的領域有意義的操作。對於一個 ID 型別,也許你只需要 DisplayDebugPartialEqEqHash

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

對於需要算術的數值型別,你實作 AddSub 等等。這是 boilerplate。標準的 derive 巨集與 derive_more 這類 crate 有幫助,但這仍然比直接用原始型別多寫一些程式碼。

選項 3:需要時直接使用內部值。 這是我的偏好。在 API 邊界保留 newtype,需要原始值時用 .0 解開,並完全避免 Deref。這會稍微囉唆一點,但它保全了安全保證。

讓這件事變得可忍受的 derive 巨集

Rust 的標準函式庫免費提供 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]。對於算術,你會用到 std::opsderive_more 這類 crate:

#[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 trait 也是一個 zero-cost abstraction。它會 monomorphize 並將操作內聯。

什麼時候 newtype 是錯誤的工具

不是每個原始型別都需要 newtype。如果一個函式只在本地使用 timeout_ms: u64,包裝它只是增加噪音,而不會抓到真正的 bug。把 newtype 保留給那些跨越 API 邊界、持久化在資料庫中,或是代表混用後會有實際後果的概念。

序列化也會變得奇怪。如果你使用 serdeUserId(u64) 預設會序列化成 map {"0": 42},而不是 42。你需要 #[serde(transparent)] 來修正:

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize)]
#[serde(transparent)]
struct UserId(u64);

這很容易忘記,而且當你的 JSON API 突然預期物件而不是數字時,debug 起來很惱人。

從你的公開 API 開始

找出任何一個接受多個相同原始型別但語意不同的引數的函式。把那些跨模組或跨服務邊界的包裝起來。不要 derive Deref。如果你在做序列化,使用 #[serde(transparent)]

如果你有安裝 cargo-expand,對一個 newtype 執行 cargo expand 並看看產生的程式碼。你會看到那個 struct 就是帶著不同型別名稱的原始值。zero-cost 的宣稱不是行銷話術。它真的是編譯器發出的東西。