把一个 u64 用户 ID 传给期望 order ID 的函数,Rust 不会报错。两者都是 u64。编译器看到的是完全相同的类型,所以帮不了你。直到运行时你才会发现,通常是在生产环境,通常是在一次你以为安全的重构之后。

这正是 newtype 模式存在所要消灭的那类 bug。

newtype 是一种单字段元组结构体,用来包装已有类型:struct UserId(u64);。在编译期,UserIdOrderId 互不兼容。在运行时,它们占用的字节与裸 u64 完全一致。没有额外分配,没有间接寻址,没有开销。

这到底解决了什么问题?

每种静态类型语言都有这个问题。两个值底层表示相同,但含义不同。数据库主键、物理单位、货币金额、百分比与原始计数。在 C 里你会用 typedef,在 Go 里会用 type alias,但这些只是给同一个底层类型起了个别名。编译器仍然把它们当成完全相同的类型。

Rust 的 newtype 模式不一样。struct UserId(u64); 创建了一个独立的类型。你不能在期望 OrderId 的地方传入 UserId。你不能不小心把百分比加到原始计数上。错误在编译期就暴露出来,而不是在生产事故中。

这就是人们说的“让非法状态无法表示”(making illegal states unrepresentable)。这不是理论。我曾经见过一个 API 端点同时接收 account ID 和转账金额,两者都是 u64。一次重构在调用方把它们弄反了。编译时什么都没坏。钱转到了错误的地方。如果使用 newtype,那次重构在任何人部署之前就会编译失败。

newtype 的底层原理

Rust 中的 newtype 就是一个只有一个未命名字段的结构体:

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,没有 discriminant,没有包装对象。将 UserId 传入函数所生成的机器码与传入 u64 时完全一致。类型系统负责强制区分,运行时将其抹除。

具体例子:混淆像素与点

这里有一个曾经坑过我的模式。某个图形库同时用像素(pixels)和设备无关点(points)表示距离,两者都是 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`
}

编译器在程序运行前就拒绝了这个错误。PointsPixels 的值占用的字节与 f32 完全相同。这种安全性在运行时没有任何开销。

没人告诉你的权衡

newtype 写起来不是零成本。被包装类型的方法不会自动继承。u64 拥有 wrapping_addleading_zeros 等几十种方法。裸的 UserId 除非你手动实现,否则一个都没有。

你有三种选择,只有一种是对的。

选项 1:实现 Deref 这让你通过自动解引用获得内部类型的所有方法:

use std::ops::Deref;

struct UserId(u64);

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

这能工作,但它违背了你的初衷。Deref 允许从 UserIdu64 的隐式强制转换,意味着你可以在期望 u64 的任何地方传入 UserId。你刚刚买来的类型安全就此丢失。不要对 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 等等。这是样板代码。标准库的 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 也是一种零成本抽象。它会单态化并内联这个操作。

什么时候 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 突然期望对象而不是数字时,调试起来很烦。

从公共 API 开始

找到那些接收多个同类型原始参数但含义不同的函数。把跨越模块或服务边界的那些包起来。不要实现 Deref。如果要做序列化,就用 #[serde(transparent)]

如果你安装了 cargo-expand,对一个 newtype 运行 cargo expand,看看生成的代码。你会看到结构体只是带着不同类型名的原始值。“零开销”不是营销话术。它就是编译器实际发出的东西。