Стоит ли реализовывать трейт Deref для новоявленных типов (newtypes) в Rust?
Короткий ответ: да, это считается плохой практикой, за редкими исключениями
Трейт Deref в Rust предназначен для умных указателей (smart pointers) — типов, которые управляют временем жизни или владением данных (как Box<T>, Rc<T>, MutexGuard<T>). Его основная цель — обеспечить прозрачную работу с внутренним значением через * и автоматическое разыменование при вызове методов. Использовать Deref для новоявленных типов (newtypes) — это злоупотребление системой типов.
Почему это плохо?
- Нарушение инкапсуляции:
Derefавтоматически даёт доступ ко всем публичным методам внутреннего типа. Если вы оборачиваетеStringвEmail, то реализацияDeref<Target = String>позволит вызывать наEmailметоды.len(),.is_empty(),.clear()и даже.push_str(). Это может разрушить инварианты вашего типа (например, вы хотели гарантировать, что email всегда содержит символ '@'). - Неявные преобразования:
Derefвключает механизм deref coercion — автоматическое преобразование&Tв&U(гдеT: Deref<Target=U>). Это может приводить к неочевидным ошибкам при перегрузке операторов или передаче аргументов в функции. Например, если у вас естьfn process(x: &u64), вы сможете передать&UserIdбез ошибки, что может быть неожиданно для читателя кода. - Путаница с семантикой: Код, где
UserIdведёт себя какu64, но не является им, сбивает с толку. При рефакторинге вы можете случайно начать использоватьUserIdтам, где ожидается сыройu64, и наоборот.
Когда это всё же допустимо?
Есть два редких сценария:
- Строгая изоморфная обёртка: когда ваш newtype семантически эквивалентен внутреннему типу, и вы не добавляете никаких инвариантов. Например,
struct KiloMeters(f64)— вы хотите, чтобы с ним работали все математические операцииf64. Но даже в этом случае лучше использоватьDerefтолько для неизменяемого доступа, а для мутабельного —DerefMutизбегать. - Внутренние детали библиотеки: когда newtype — это деталь реализации, не выставляемая в публичный API. Но это редкость.
Альтернативы и лучшие практики
Вместо Deref используйте комбинацию из:
- Публичного поля
.0: если обёртка тривиальная и не имеет инвариантов, просто сделайте поле публичным:pub struct UserId(pub u64);. Тогда доступ черезuser_id.0явен и понятен. - Методов-геттеров:
impl UserId { pub fn get(&self) -> &u64 { &self.0 } }. Это даёт контроль над доступом. - Производных макросов и трейтов: используйте
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]для common traits. ДляDisplay— напишите одну строку. Это не так много кода, как кажется. - Крейты-помощники: крейты
derive_moreилиnewtype_deriveпозволяют автоматически реализовать трейты для newtypes безDeref. Например:
use derive_more::{Display, From, Into};
#[derive(Display, From, Into, Clone, Copy, Debug, PartialEq, Eq)]
pub struct UserId(u64);
Это даёт явные преобразования (.into(), .from()) без автоматического разыменования.
Итоговый пример: правильный подход
pub struct Email(String);
impl Email {
// Публичный конструктор с валидацией
pub fn new(value: String) -> Result<Self, &'static str> {
if value.contains('@') {
Ok(Email(value))
} else {
Err("Email must contain '@'")
}
}
// Геттер — только для чтения
pub fn as_str(&self) -> &str {
&self.0
}
}
// Явные трейты — без Deref
impl Display for Email {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
Вывод: не реализуйте Deref для newtypes. Это анти-паттерн, который ломает инкапсуляцию и создаёт неявные преобразования. Используйте явные геттеры, derive_more или просто публичное поле.
Альтернативный взгляд: когда Deref может быть оправдан
Я согласен, что злоупотреблять Deref не стоит, но есть ситуации, где это может быть прагматичным решением. Особенно, когда вы пишете внутренний код (не библиотеку для других) и хотите минимизировать бойлерплейт.
Например, если у вас есть тип struct NonEmptyString(String), который гарантирует, что строка не пуста. Если вы реализуете Deref<Target = String>, вы предоставляете доступ ко всем методам String, включая .clear(), что может нарушить инвариант. Но если вы реализуете Deref<Target = str> (срез), то вы даёте доступ только к неизменяемым методам (чтение, сравнение, поиск), а мутабельные методы остаются недоступными. Это более безопасный компромисс:
use std::ops::Deref;
pub struct NonEmptyString(String);
impl NonEmptyString {
pub fn new(s: String) -> Option<Self> {
if s.is_empty() { None } else { Some(NonEmptyString(s)) }
}
}
impl Deref for NonEmptyString {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
// Теперь можно:
fn takes_str(s: &str) { /* ... */ }
let nes = NonEmptyString::new("hello".into()).unwrap();
takes_str(&nes); // работает через deref coercion
println!("{}", &nes[0..2]); // "he"
Такой подход даёт удоб
Ответов пока нет.
Войдите чтобы оставить ответ
Блоги