Передайте u64 с идентификатором пользователя в функцию, которая ожидает идентификатор заказа, и Rust не пожалуется. Оба типа — u64. Компилятор видит одинаковые типы, поэтому не может вам помочь. Вы узнаёте об этом во время выполнения, обычно в продакшене, обычно после рефакторинга, который вы считали безопасным.
Именно этот класс багов newtypes и существуют, чтобы устранить.
Newtype — это кортежная структура с одним полем, которая оборачивает существующий тип: struct UserId(u64);. На этапе компиляции UserId и OrderId несовместимы. Во время выполнения они занимают ровно столько же байт, что и голый u64. Никаких дополнительных аллокаций, никакой косвенности, никаких затрат.
Какую проблему это реально решает?
В каждом языке со статической типизацией есть эта проблема. У вас есть два значения с одинаковым представлением, но разным смыслом. Ключи баз данных, физические единицы измерения, суммы валют, проценты против сырых счётчиков. В C вы бы использовали typedef, в Go — псевдоним типа, но это всего лишь имена одного и того же базового типа. Компилятор по-прежнему считает их идентичными.
Паттерн newtype в Rust другой. struct UserId(u64); создаёт отдельный тип. Вы не можете передать UserId туда, где ожидается OrderId. Вы не можете случайно добавить процент к сырому счётчику. Ошибка всплывает на этапе компиляции, а не в продакшен-инциденте.
Это то, что люди подразумевают под «деланием некорректных состояний невыразимыми». Это не теория. Однажды я видел endpoint API, который принимал идентификатор аккаунта и сумму перевода, оба u64. Рефакторинг поменял их местами в вызывающем коде. При компиляции ничего не сломалось. Деньги ушли не туда. С newtypes этот рефакторинг не скомпилировался бы ещё до того, как кто-либо задеплоил его.
Как newtypes работают под капотом
Newtype в Rust — это просто структура с одним безымянным полем:
struct UserId(u64);
struct OrderId(u64);
Компилятор считает UserId совершенно отдельным типом от OrderId и от u64. Вы конструируете её явно: let id = UserId(42);. Доступ к внутреннему значению — через id.0.
Поскольку у структуры одно поле с известным размером, компилятор применяет оптимизацию, которая на самом деле вовсе не оптимизация, а просто то, как работают структуры. Раскладка памяти UserId идентична u64. Тот же размер, тот же alignment, тот же 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. Система типов обеспечивает различие. Runtime стирает его.
Конкретный пример: путаница пикселей и поинтов
Вот паттерн, который однажды укусил меня. Графическая библиотека имела расстояния и в пикселях, и в device-independent points. Оба были f32. Я передал points туда, где ожидались pixels, и мой UI отрендерился в половинном размере на high-DPI экранах. Компилятор молчал, потому что оба типа были f32.
С newtypes:
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. Безопасность не стоит ничего во время выполнения.
Компромиссы, о которых вам никто не расскажет
Newtypes не бесплатны в написании. Методы обёрнутого типа не распространяются автоматически. У u64 есть wrapping_add, leading_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. Вы теряете типовую безопасность, которую только что купили. Не делайте этого для newtypes.
Вариант 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 и так далее. Это boilerplate. Стандартные макросы 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 — тоже zero-cost абстракция. Он мономорфизируется для инлайна операции.
Когда newtypes — неправильный инструмент
Не каждому примитиву нужен newtype. Если функция принимает timeout_ms: u64 и используется только локально, оборачивание добавляет шума, не ловя реальных багов. Резервируйте newtypes для значений, которые пересекают границы API, сохраняются в базах данных или представляют концепции, где их путаница имеет реальные последствия.
Сериализация тоже становится странной. Если вы используете serde, UserId(u64) по умолчанию сериализуется как map {"0": 42}, а не как 42. Вам нужен #[serde(transparent)], чтобы это исправить:
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
#[serde(transparent)]
struct UserId(u64);
Это легко забыть и раздражающе отлаживать, когда ваш JSON API вдруг начинает ожидать объекты вместо чисел.
Начните со своих публичных API
Найдите любую функцию, которая принимает несколько аргументов одного примитивного типа, но с разным смыслом. Оборачивайте те, которые пересекают границы модулей или сервисов. Не делайте derive Deref. Используйте #[serde(transparent)], если сериализуете.
Если у вас установлен cargo-expand, запустите cargo expand на newtype и посмотрите на сгенерированный код. Вы увидите, что структура — это просто сырое значение с другим именем типа. Утверждение о zero-cost — это не маркетинг. Это буквально то, что компилятор emits.