Avatar ·

Стоит ли реализовывать трейт Deref для новоявленных типов (newtypes) в Rust?

Всем привет! Я недавно начал изучать Rust и столкнулся с интересным вопросом по дизайну кода. Часто я создаю обёртки над существующими типами, чтобы добавить им семантики или ограничить функциональность. Например, у меня есть тип `UserId`, который внутри хранит `u64`: ```rust struct UserId(u64); ``` Я хочу, чтобы с `UserId` можно было работать почти как с `u64` (сравнивать, выводить, копировать). Я могу, конечно, просто вывести трейт `Display` или `PartialEq` вручную, но это много кода. Однако я наткнулся на идиому, где для таких новоявленных типов реализуют трейт `Deref`: ```rust use std::ops::Deref; impl Deref for UserId { type Target = u64; fn deref(&self) -> &u64 { &self.0 } } ``` Теперь я могу вызывать методы `u64` прямо на `UserId`, и он автоматически разыменовывается. Это кажется очень удобным. Но везде пишут, что `Deref` предназначен для умных указателей (как `Box`, `Rc`), а не для создания псевдонимов типов. Я читал, что злоупотребление `Deref` может привести к проблемам с читаемостью кода или к неожиданным ошибкам при перегрузке операторов. Но с другой стороны, это сокращает тонны бойлерплейта. Подскажите, считается ли это плохой практикой? Когда можно использовать `Deref` для newtypes, а когда лучше реализовать методы вручную? Может быть, есть другие идиомы для этой задачи, например, крейты-макросы? Вот пример, который меня смущает: если я реализую `Deref`, то я могу случайно передать `UserId` туда, где ожидается `&u64`, и это может сломать инкапсуляцию. Как быть? <<>>

Короткий ответ: да, это считается плохой практикой, за редкими исключениями

Трейт 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, и наоборот.

Когда это всё же допустимо?

Есть два редких сценария:

  1. Строгая изоморфная обёртка: когда ваш newtype семантически эквивалентен внутреннему типу, и вы не добавляете никаких инвариантов. Например, struct KiloMeters(f64) — вы хотите, чтобы с ним работали все математические операции f64. Но даже в этом случае лучше использовать Deref только для неизменяемого доступа, а для мутабельного — DerefMut избегать.
  2. Внутренние детали библиотеки: когда 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"

Такой подход даёт удоб