Rust Типы и трейты: обобщения, диспетчеризация, типажи стандартной библиотеки
0%

Типы и трейты: обобщения, диспетчеризация, типажи стандартной библиотеки

Типы и трейты: обобщения, диспетчеризация, типажи стандартной библиотеки

Мы разобрали, как долго живут значения (владение и времена жизни). Теперь вопрос другой: что значения умеют и как написать один алгоритм, работающий для многих типов, не потеряв ни скорости, ни статических гарантий.

Это не глава про синтаксис <T>. Это глава про то, где в вашей программе принимается решение «какой именно код выполнится» — на этапе компиляции или в рантайме — и почему в Rust это решение принимаете вы, а не язык за вас.

Задача: одна логика, много типов

Возьмём предельно скучную функцию: найти максимум в срезе. Напишите largest_i32(&[i32]) -> &i32 — и тут же понадобится то же самое для char, для f64, для своей структуры Version. Копипаста. Индустрия отвечала на это пятью разными способами, и у каждого своя цена:

Подход Кто Цена
Динамическая типизация, «утиная» Python, JS проверок нет вообще; ошибка типа — в рантайме, у клиента
void* плюс указатели на функции C (qsort) вся типовая информация стёрта, ошибка = UB
Наследование и виртуальные вызовы Java, C#, C++ косвенный вызов всегда; иерархия навязывает форму данных
Стирание типов в дженериках Java, Go до 1.18 одна копия кода, но боксинг и рантайм-проверки
Мономорфизация C++ шаблоны, Rust, Go 1.18+ код специализируется под тип; растут время сборки и размер бинарника

Rust берёт мономорфизацию как основной механизм и добавляет к ней две вещи, которых нет в шаблонах C++: ограничения проверяются до подстановки (сообщение об ошибке приходит на определение функции, а не на 400 строк раскрутки шаблона) и динамическая диспетчеризация как отдельная, явно запрашиваемая опция.

/// Обобщённая версия. `T: PartialOrd` — не документация, а проверяемое требование.
fn largest<T: PartialOrd>(items: &[T]) -> &T {
    let mut best = &items[0];
    for item in items {
        if item > best { best = item; }   // `>` существует именно из-за границы PartialOrd
    }
    best
}

fn main() {
    println!("{}", largest(&[3, 17, 5]));                  // 17
    println!("{}", largest(&["груша", "яблоко", "дыня"]));  // яблоко
    println!("{}", largest(&[2.5_f64, -1.0, 0.75]));        // 2.5
}

Уберите : PartialOrd — и компилятор объяснит, чего не хватает, ещё до того как вы попробуете вызвать функцию:

error[E0369]: binary operation `>` cannot be applied to type `&T`
 --> src/main.rs:4:17
  |
4 |         if item > best { best = item; }
  |            ---- ^ ---- &T
  |            |
  |            &T
  |
help: consider restricting type parameter `T`
  |
2 | fn largest<T: std::cmp::PartialOrd>(items: &[T]) -> &T {
  |             +++++++++++++++++++++

Это принципиально: обобщённая функция проверяется один раз, сама по себе. Внутри largest компилятор знает про T ровно то, что написано в границах, — и ни байтом больше. Шаблон C++ так не умеет: он проверяется при инстанцировании, поэтому ошибка всплывает в точке вызова и выглядит как страница текста.

Модель: трейт — это набор требований к типу

Трейт не тип. Трейт — это предикат «тип T умеет вот это», плюс словарь методов, которые из умения следуют.

/// Контракт: «фигура умеет считать площадь и знает своё имя».
trait Shape {
    /// Обязательный метод: реализация должна его дать.
    fn area(&self) -> f64;

    /// Метод по умолчанию: реализация может его переопределить, а может нет.
    fn name(&self) -> &'static str { "фигура" }

    /// Метод по умолчанию поверх обязательного — типичный приём std.
    fn describe(&self) -> String {
        format!("{} площадью {:.2}", self.name(), self.area())
    }
}

struct Circle { r: f64 }
struct Square { a: f64 }

impl Shape for Circle {
    fn area(&self) -> f64 { std::f64::consts::PI * self.r * self.r }
    fn name(&self) -> &'static str { "круг" }
}

impl Shape for Square {
    fn area(&self) -> f64 { self.a * self.a }   // name() берём по умолчанию
}

fn main() {
    println!("{}", Circle { r: 1.0 }.describe());   // круг площадью 3.14
    println!("{}", Square { a: 3.0 }.describe());   // фигура площадью 9.00
}

Четыре отличия от интерфейса Java/C#, которые меняют способ проектирования:

  1. Реализация отделена от определения типа. impl Shape for Circle можно написать в другом файле, в другом модуле, для чужого типа из другого крейта (impl Shape for f64 — законно). Интерфейсы Java требуют править объявление класса.
  2. Наследования состояния нет. Трейт не добавляет полей. trait Shape: Debug (супертрейт) — это требование «кто реализует Shape, обязан реализовать и Debug», а не наследование в смысле ООП. Подробнее про разницу — в статье про ООП трека paradigms.
  3. Диспетчеризация — выбор вызывающего, а не свойство метода. В Java метод либо виртуальный, либо нет. В Rust один и тот же area() компилируется в прямой вызов или в прыжок по таблице в зависимости от того, как вы записали параметр.
  4. Трейт может содержать ассоциированные типы и константы, а не только методы, — это делает его инструментом описания отношений между типами, а не только поведения.

Ближайший родственник — классы типов Haskell; трейты Rust выросли именно оттуда (см. обзор дженериков в треке paradigms и функторы в треке functional-programming).

Мономорфизация: что реально попадает в бинарник

Когда вы вызываете largest(&[3, 17, 5]), компилятор не «стирает» T. Он порождает отдельную функцию largest::<i32> с подставленным типом — и делает это для каждого набора аргументов типов, встреченного в программе.

Отсюда все свойства обобщений в Rust — и хорошие, и плохие:

  • В рантайме дженерик стоит ноль. largest::<i32> не отличим от рукописной largest_i32. Это и называется «абстракция с нулевой стоимостью».
  • Каждое инстанцирование — новый код для LLVM. Основной вклад в медленную сборку Rust дают не проверка заимствований и не разрешение трейтов, а объём кода после мономорфизации. Смотрите cargo build --timings и утилиту cargo-bloat.
  • Инстанцирование происходит в том крейте, где вызов, а не там, где определение. Поэтому обобщённая функция из библиотеки компилируется в вашем крейте — и правка библиотеки роняет ваш кэш сборки.

Практический приём, которым пользуется сама стандартная библиотека: обобщённой делают только тонкую оболочку, а работу выносят в необобщённую функцию.

use std::path::Path;
use std::{fs, io};

/// Обобщена только оболочка: под каждый P (&str, String, PathBuf, Cow) копируется
/// одна строка вызова as_ref, а не сто строк разбора.
pub fn load<P: AsRef<Path>>(path: P) -> io::Result<String> {
    load_inner(path.as_ref())
}

/// Тело существует в бинарнике в единственном экземпляре.
fn load_inner(path: &Path) -> io::Result<String> {
    let bytes = fs::read(path)?;
    Ok(String::from_utf8_lossy(&bytes).into_owned())
}

Так устроен File::open в std. Приём стоит одной строки и снимает раздувание бинарника; он же — стандартный ответ на вопрос «почему мой релизный бинарник 12 МБ».

Три способа записать «принимаю что-то, умеющее X»

use std::fmt::Display;

fn a<T: Display>(x: T) {}                 // классическая граница
fn b(x: impl Display) {}                  // APIT: то же самое, но без имени типа
fn c<T>(x: T) where T: Display {}         // where — когда границ много или они сложные
fn d(x: &dyn Display) {}                  // это уже другая история: динамика

a, b, c — одна и та же мономорфизация. Разница только в эргономике: у impl Display нельзя указать тип турбофишем (b::<i32>(...) не скомпилируется), зато сигнатура читается лучше. where обязателен, когда граница касается не самого параметра, а производного типа: where T::Item: Debug, where for<'a> &'a T: IntoIterator.

Границы на структуре — отдельная тема, где легко переусложнить:

/// Границу НЕ пишем в объявлении: контейнер сам ничего не обязан уметь.
struct Pair<T> { a: T, b: T }

/// Условная реализация: метод существует только для тех T, где он осмыслен.
impl<T: PartialOrd + std::fmt::Display> Pair<T> {
    fn print_larger(&self) {
        let m = if self.a >= self.b { &self.a } else { &self.b };
        println!("больше: {m}");
    }
}

Правило: границы держите на impl-блоках и функциях, а не на определении типа. Граница в объявлении структуры заражает вообще всё, включая Drop, и потом не снимается без ломающего изменения API.

Диспетчеризация: два разных механизма

Обобщения решают «много типов, но каждый вызов знает свой». Они не решают «в одном векторе лежат разные фигуры»: Vec<S> — это вектор одного типа S, и массив [Circle { r: 1.0 }, Square { a: 2.0 }] не скомпилируется.

Здесь нужен объект-типаж&dyn Shape, Box<dyn Shape>, Arc<dyn Shape>. Это не тип-обёртка, а указатель из двух половин: адрес данных и адрес таблицы методов.

Мономорфизация против vtable: что происходит с кодом и как устроен толстый указатель dyn Trait

fn total_static<S: Shape>(items: &[S]) -> f64 {
    items.iter().map(|s| s.area()).sum()      // вызов area инлайнится
}

fn total_dynamic(items: &[Box<dyn Shape>]) -> f64 {
    items.iter().map(|s| s.area()).sum()      // прыжок по vtable на каждый элемент
}

fn main() {
    let circles = [Circle { r: 1.0 }, Circle { r: 2.0 }];
    println!("{:.2}", total_static(&circles));                       // 15.71

    let mixed: Vec<Box<dyn Shape>> = vec![
        Box::new(Circle { r: 1.0 }),
        Box::new(Square { a: 3.0 }),
    ];
    println!("{:.2}", total_dynamic(&mixed));                        // 12.14

    // Толстый указатель ровно вдвое шире обычного.
    // size_of лежит в прелюдии начиная с Rust 1.80, импорт не нужен.
    println!("{} {}", size_of::<&Circle>(), size_of::<&dyn Shape>()); // 8 16
}

Цена динамики — не сам косвенный вызов (предсказатель переходов справляется), а потерянный инлайнинг: оптимизатор не видит тело вызываемого метода и не может ни свернуть константы, ни векторизовать цикл. На горячем цикле из миллиона элементов разница бывает кратной; на вызове раз в запрос — неизмеримой. Методику измерения смотрите в треке performance.

Вторая цена — раскладка данных. Она обычно важнее первой:

Vec из Box dyn Trait против Vec из enum: раскладка в памяти и локальность

Vec<Box<dyn Shape>> — это массив указателей на разбросанные по куче объекты: два разыменования и промах кэша на каждый элемент. Vec<ShapeKind> с перечислением — плотный массив значений, который префетчер читает линейно (про механику — кэш и локальность). Третий вариант — enum-диспетчеризация: сохраняем трейт как контракт, но реализуем его для перечисления.

/// Закрытый набор вариантов: все типы известны нашему крейту.
enum ShapeKind { Circle(Circle), Square(Square) }

impl Shape for ShapeKind {
    fn area(&self) -> f64 {
        match self {                       // match компилируется в таблицу переходов,
            ShapeKind::Circle(c) => c.area(),  // а тела веток инлайнятся
            ShapeKind::Square(s) => s.area(),
        }
    }
}

Ручную писанину снимает крейт enum_dispatch, генерирующий такой impl из атрибута. Как выбирать:

Эвристика по умолчанию: обобщения, пока не появилась причина. Причины: гетерогенная коллекция, набор типов задаётся снаружи (плагины, реестры обработчиков, слои tower), борьба с размером бинарника, необходимость хранить «что-то реализующее трейт» в поле структуры без параметризации всей структуры.

dyn-совместимость: почему не каждый трейт становится объектом

Чтобы вызвать метод через vtable, компилятору нужно уметь заполнить эту таблицу. Не всякий метод в неё влезает. Раньше свойство называли «object safety», с версии 1.83 в документации и диагностике — dyn compatibility.

trait Repo {
    fn get(&self, id: u64) -> Option<String>;
    /// Дженерик-метод: под какой T кладём указатель в таблицу? Их бесконечно много.
    fn all<C: FromIterator<String>>(&self) -> C;
}

// let r: Box<dyn Repo> = ...;
error[E0038]: the trait `Repo` is not dyn compatible
 --> src/main.rs:9:16
  |
9 |     let r: Box<dyn Repo>;
  |                ^^^^^^^^ `Repo` is not dyn compatible
  |
note: for a trait to be dyn compatible it must not have any generic methods
  |
5 |     fn all<C: FromIterator<String>>(&self) -> C;
  |        ^^^ ...because method `all` has generic type parameters
  = help: consider moving `all` to another trait

Метод не попадает в vtable, если он:

  • обобщён по типу (fn all<C>()) — вариантов инстанцирования бесконечно;
  • возвращает Self (fn clone(&self) -> Self) — размер результата неизвестен; именно поэтому Clone нельзя сделать dyn;
  • не принимает self (fn new() -> Self) — вызывать не на чем;
  • использует Self в аргументах (fn eq(&self, other: &Self)) — поэтому PartialEq не dyn-совместим;
  • является ассоциированной константой.

Два выхода. Первый — пометить проблемный метод where Self: Sized (fn all<C>(&self) -> C where Self: Sized;): он исчезнет из vtable и трейт снова станет dyn-совместимым, но на конкретных типах метод останется доступным. Второй — вручную «обойти» ограничение через возврат Box<dyn Trait>; так делают клонируемые объекты-типажи:

trait Stage {
    fn apply(&self, s: &str) -> String;
    /// Self не возвращаем — возвращаем объект. Метод влезает в vtable.
    fn boxed_clone(&self) -> Box<dyn Stage>;
}

impl Clone for Box<dyn Stage> {
    fn clone(&self) -> Self { self.boxed_clone() }
}

Ещё две тонкости объектов-типажей. Первая: dyn Trait по умолчанию подразумевает + 'static; если объект держит ссылки, пишите Box<dyn Stage + 'a> (механика — в статье про времена жизни). Вторая: в одном объекте нельзя объединить два обычных трейта — dyn Read + Write даст E0225. Разрешены только автотрейты: dyn Stage + Send + Sync — законно, и именно так выглядят типы в асинхронном коде.

Ассоциированные типы против параметров типа

Оба механизма связывают трейт с другим типом. Разница — в количестве реализаций.

/// Ассоциированный тип: у конкретного типа РОВНО ОДНА реализация Iterator,
/// значит Item — часть этой реализации, а не её параметр.
pub trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
}

/// Параметр типа: реализаций МНОГО, по одной на каждый источник.
pub trait From<T> {
    fn from(value: T) -> Self;
}

Проверка простая: можно ли осмысленно реализовать трейт для одного типа дважды? From<&str> for Version и From<(u32, u32)> for Version — да, оба нужны. Iterator с двумя разными Item для одного типа — нет, это был бы бред, и попытка написать два impl Iterator for Counter даст:

error[E0119]: conflicting implementations of trait `Iterator` for type `Counter`

Свой итератор — упражнение, показывающее силу трейтов std: вы реализуете один метод и бесплатно получаете больше семидесяти адаптеров.

/// Числа Фибоначчи, останавливающиеся до переполнения.
struct Fib { a: u64, b: u64 }

impl Iterator for Fib {
    type Item = u64;

    fn next(&mut self) -> Option<u64> {
        let next = self.a.checked_add(self.b)?;   // None вместо паники при overflow
        self.a = self.b;
        self.b = next;
        Some(self.a)
    }
}

fn main() {
    let s: u64 = Fib { a: 0, b: 1 }
        .take_while(|&x| x < 1000)
        .filter(|x| x % 2 == 0)
        .sum();
    println!("{s}");   // 798
}

Ленивость и стоимость адаптеров — тема статьи про коллекции и итераторы.

Родственный механизм — параметры типа со значением по умолчанию, на которых держится перегрузка операторов:

use std::ops::Add;

#[derive(Debug, Clone, Copy, PartialEq)]
struct Meters(f64);

/// Add<Rhs = Self>: по умолчанию складываем с собой же.
impl Add for Meters {
    type Output = Meters;
    fn add(self, rhs: Meters) -> Meters { Meters(self.0 + rhs.0) }
}

/// Вторая реализация — с другим Rhs. Единицы измерения теперь в типах.
impl Add<f64> for Meters {
    type Output = Meters;
    fn add(self, rhs: f64) -> Meters { Meters(self.0 + rhs) }
}

fn main() {
    println!("{:?}", Meters(1.5) + Meters(2.0));   // Meters(3.5)
    println!("{:?}", Meters(1.5) + 0.5);           // Meters(2.0)
    // Meters(1.5) + 1_i32 — ошибка компиляции: impl Add<i32> не существует
}

Обобщённые ассоциированные типы (GAT, стабильны с 1.65) позволяют ассоциированному типу самому иметь параметры — type Item<'a> для «одалживающих» итераторов. Это продвинутый инструмент библиотечных авторов; в прикладном коде он встречается редко.

Карта типажей стандартной библиотеки

Половина идиоматичности Rust — это знание того, какой трейт std уже описывает вашу задачу. Реализуйте существующий трейт вместо метода с придуманным именем: тогда ваш тип встроится в чужой код без единой строчки склейки.

Разберём то, где чаще всего ошибаются.

Преобразования: реализуйте From, требуйте Into

Механика в трёх строках из стандартной библиотеки:

impl<T, U> Into<U> for T where U: From<T> {
    fn into(self) -> U { U::from(self) }
}

Такие реализации называют сквозными (blanket impl): один impl покрывает бесконечное множество типов. Следствия практические:

  • Реализуйте From, а Into придёт сам. Clippy так и скажет: clippy::from_over_into.
  • В аргументах требуйте Into: fn new(name: impl Into<String>) принимает и &str, и String, и Cow<str>.
  • Реализовав Display, вы получаете to_string() — потому что impl<T: Display> ToString for T уже написан в std.

Пара AsRef и Borrow выглядит одинаково, но Borrow несёт дополнительное обещание: заимствованное значение должно вести себя как исходное относительно Eq, Hash и Ord. Именно поэтому HashMap<String, V>::get принимает &str — его сигнатура требует Q: Borrow-совместимости, и без обещания про хеш такой поиск был бы некорректен.

Сравнение и хеш: контракты, которые компилятор не проверяет

Здесь Rust перестаёт быть «если компилируется, то работает». Трейты Eq, Ord, Hash несут математические обещания, за которые отвечаете вы.

fn main() {
    let mut xs = vec![3.0_f64, f64::NAN, 1.0];

    // Классическая паника: f64 реализует PartialOrd, но не Ord — NaN несравним.
    // xs.sort_by(|a, b| a.partial_cmp(b).unwrap());   // panicked at 'called `unwrap()` on a `None`'

    xs.sort_by(f64::total_cmp);       // правильно: полный порядок, стабилен с 1.62
    println!("{xs:?}");               // [1.0, 3.0, NaN]
}

Если Ord реализован непоследовательно (a < b и одновременно b < a), сортировка начиная с Rust 1.81 честно паникует вместо тихой порчи данных:

thread 'main' panicked at library/core/src/slice/sort/shared/smallsort.rs:
user-provided comparison function does not correctly implement a total order

Нарушение согласованности Hash и Eq не паникует — оно просто ломает HashMap: два равных ключа с разными хешами оба попадут в таблицу, и get вернёт None для существующего значения. Правило: если реализуете Hash руками, хешируйте ровно те поля, которые сравниваете в Eq. Лучше — не реализуйте руками, используйте derive.

Deref: это не наследование

Deref даёт автоматическое разыменование при поиске метода. Именно поэтому String умеет len(), split(), trim() — эти методы есть у str, а String: Deref<Target = str>.

fn shout(s: &str) -> String { s.to_uppercase() }

fn main() {
    let owned = String::from("привет");
    println!("{}", shout(&owned));    // &String приводится к &str автоматически
    println!("{}", owned.trim());     // метода trim у String нет — он взят у str
}

Компилятор ищет метод по такой цепочке: сначала у самого типа T, потом у &T и &mut T, затем разыменовывает по Deref и повторяет всё заново, пока цепочка не кончится. Отсюда «магия» вроде rc.borrow_mut() на Rc<RefCell<T>>.

Отсюда же — антипаттерн. Deref предназначен только для умных указателей. Реализовав Deref для struct User(Database), вы получите «наследование для бедных»: методы Database начнут появляться у User, поиск метода станет непредсказуемым, а IDE — бесполезной. Это прямо описано как ошибка в Rust API Guidelines и в Rust Design Patterns.

derive: что он генерирует и где врёт

#[derive(Clone)] — процедурный макрос, который дописывает impl. Важно понимать, какой именно:

use std::rc::Rc;

#[derive(Clone)]                       // генерирует impl<T: Clone> Clone for Shared<T>
struct Shared<T> { inner: Rc<T> }

struct NotClone;

fn main() {
    let a = Shared { inner: Rc::new(NotClone) };
    let b = a.clone();                 // а вот и ошибка
}
error[E0599]: the method `clone` exists for struct `Shared<NotClone>`, but its trait bounds were not satisfied
  --> src/main.rs:11:15
   |
4  | struct Shared<T> { inner: Rc<T> }
   | ---------------- method `clone` not found for this struct because it doesn't satisfy `Shared<NotClone>: Clone`
   |
note: the following trait bounds were not satisfied:
      `NotClone: Clone`
      which is required by `Shared<NotClone>: Clone`

Логически это неверно: Rc<T> клонируется всегда, независимо от T — клонируется-то счётчик. Но derive умеет только одну стратегию: «требовать трейт от каждого параметра типа». Это известная проблема, «perfect derive», подробно разобранная в «Rust for Rustaceans» Джона Гьенгсета. Лечение — написать impl вручную:

impl<T> Clone for Shared<T> {          // никакой границы на T
    fn clone(&self) -> Self {
        Shared { inner: Rc::clone(&self.inner) }
    }
}

Та же ловушка со PhantomData<T>, Arc<T>, ссылками и любым полем, клонируемость которого не зависит от T.

И отдельно: Display нельзя вывести через derive никогда. Это не недоработка: у «человекочитаемого представления» не бывает единственного правильного вида, и std сознательно заставляет вас его выбрать. Если хочется автоматизации — крейт thiserror генерирует Display из атрибутов, и это основной инструмент следующей статьи трека.

Правило сироты, newtype и когерентность

Реализовать трейт можно, если трейт или тип определён в вашем крейте. Иначе — E0117.

// impl std::fmt::Display for Vec<u8> { ... }
error[E0117]: only traits defined in the current crate can be implemented for types defined outside of the crate
 --> src/lib.rs:3:1
  |
3 | impl std::fmt::Display for Vec<u8> {
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^------
  | |                          |
  | |                          `Vec` is not defined in the current crate
  | impl doesn't use only types from inside the current crate
  |
  = note: define and implement a trait or new type instead

Это не бюрократия, а когерентность: гарантия, что для пары (тип, трейт) во всей программе существует не более одной реализации. Без неё добавление зависимости могло бы сломать сборку конфликтом двух чужих impl-ов — ровно та проблема, которую в C++ называют ODR-нарушением. Компилятор охраняет её и внутри крейта:

error[E0119]: conflicting implementations of trait `Shape` for type `Circle`

Штатный обход — newtype: кортежная структура из одного поля, обёртка нулевой стоимости.

use std::fmt;

/// Обёртка живёт только на этапе компиляции: в рантайме это тот же Vec.
struct Csv(Vec<String>);

impl fmt::Display for Csv {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0.join(";"))
    }
}

fn main() {
    let row = Csv(vec!["id".into(), "имя".into(), "город".into()]);
    println!("{row}");                                          // id;имя;город
    println!("{}", size_of::<Csv>() == size_of::<Vec<String>>()); // true
}

Newtype полезен не только для обхода правила сироты: struct UserId(u64) и struct OrderId(u64) невозможно перепутать в аргументах, хотя в памяти это одно и то же. Цена — методы внутреннего типа не наследуются: нужные придётся прокинуть (или реализовать Deref, если обёртка действительно указательная, — см. предупреждение выше).

Чего в Rust нет: специализации (impl<T> Foo for T плюс более точный impl Foo for u8) — она годами лежит в nightly и в стабильном виде не появилась; отрицательных границ (where T: !Copy); типов высших родов, из-за чего «настоящую» монаду в Rust не выразить, а GAT покрывают только часть потребностей.

Типы как средство проектирования: типовые состояния

Самая недооценённая возможность системы типов — держать в типе состояние протокола, чтобы неправильный порядок вызовов не компилировался.

use std::marker::PhantomData;

struct Draft;
struct Validated;

/// Состояние живёт в параметре типа, а не в поле. PhantomData занимает 0 байт.
struct Request<S> {
    url: String,
    _state: PhantomData<S>,
}

impl Request<Draft> {
    fn new(url: impl Into<String>) -> Self {
        Request { url: url.into(), _state: PhantomData }
    }

    /// Переход состояния забирает self по значению: старое состояние больше недоступно.
    fn validate(self) -> Result<Request<Validated>, String> {
        if self.url.starts_with("https://") {
            Ok(Request { url: self.url, _state: PhantomData })
        } else {
            Err(format!("небезопасная схема: {}", self.url))
        }
    }
}

impl Request<Validated> {
    fn send(self) -> String { format!("GET {}", self.url) }
}

fn main() {
    let ok = Request::new("https://example.org").validate().unwrap();
    println!("{}", ok.send());                       // GET https://example.org

    // Request::new("http://x").send();
    // error[E0599]: no method named `send` found for struct `Request<Draft>`

    println!("{}", size_of::<Request<Draft>>() == size_of::<String>());   // true
}

Обратите внимание: это не стоит ничего в рантайме. PhantomData<S> — тип нулевого размера, переходы состояний компилируются в ноль инструкций, а проверка происходит один раз при сборке. Так устроены типизированные регистры в embedded-hal, состояния соединений в драйверах и билдеры, которые нельзя собрать наполовину. Связь с моделью владения прямая: validate(self) забирает значение, поэтому «старую» версию запроса физически нельзя использовать после перехода.

Практика: конвейер с обеими диспетчеризациями

Живой код почти всегда смешивает подходы: динамику — на границе, где конфигурация решает, что делать; обобщения — внутри, где всё известно.

use std::collections::HashMap;
use std::fmt;

/// Одно измерение с датчика.
#[derive(Debug, Clone, PartialEq)]
struct Sample { sensor: String, value: f64 }

impl fmt::Display for Sample {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}={:.1}", self.sensor, self.value)
    }
}

/// Контракт этапа обработки. Методы берут &mut self — этап может нести состояние.
trait Stage {
    fn name(&self) -> &str;
    fn apply(&mut self, batch: &mut Vec<Sample>);
}

/// Этап 1: отбросить физически невозможные показания.
struct DropOutliers { max_abs: f64 }

impl Stage for DropOutliers {
    fn name(&self) -> &str { "outliers" }
    fn apply(&mut self, batch: &mut Vec<Sample>) {
        batch.retain(|s| s.value.abs() <= self.max_abs);
    }
}

/// Этап 2: перевести сырые единицы в физические.
struct Scale { factor: f64 }

impl Stage for Scale {
    fn name(&self) -> &str { "scale" }
    fn apply(&mut self, batch: &mut Vec<Sample>) {
        for s in batch.iter_mut() { s.value *= self.factor; }
    }
}

/// Этап 3: ничего не меняет, зато накапливает статистику между партиями.
#[derive(Default)]
struct CountPerSensor { seen: HashMap<String, usize> }

impl Stage for CountPerSensor {
    fn name(&self) -> &str { "count" }
    fn apply(&mut self, batch: &mut Vec<Sample>) {
        for s in batch.iter() {
            *self.seen.entry(s.sensor.clone()).or_default() += 1;
        }
    }
}

/// Набор этапов приходит из конфигурации — значит он открыт,
/// значит диспетчеризация динамическая. Box<dyn Stage> здесь единственный вариант.
struct Pipeline { stages: Vec<Box<dyn Stage>> }

impl Pipeline {
    fn from_spec(spec: &str) -> Self {
        let mut stages: Vec<Box<dyn Stage>> = Vec::new();
        for token in spec.split(',').map(str::trim) {
            match token {
                "outliers" => stages.push(Box::new(DropOutliers { max_abs: 1000.0 })),
                "celsius"  => stages.push(Box::new(Scale { factor: 0.1 })),
                "count"    => stages.push(Box::new(CountPerSensor::default())),
                other      => eprintln!("неизвестный этап: {other}"),
            }
        }
        Pipeline { stages }
    }

    fn run(&mut self, batch: &mut Vec<Sample>) {
        for stage in self.stages.iter_mut() {
            stage.apply(batch);
            println!("после {}: записей {}", stage.name(), batch.len());
        }
    }
}

/// Горячий путь: этап известен на этапе компиляции, поэтому обобщение,
/// а не dyn — вызов apply здесь инлайнится целиком.
fn run_hot<S: Stage>(stage: &mut S, batch: &mut Vec<Sample>) {
    stage.apply(batch);
}

fn main() {
    let mut batch = vec![
        Sample { sensor: "t1".into(), value: 235.0 },
        Sample { sensor: "t1".into(), value: 9000.0 },   // выброс
        Sample { sensor: "t2".into(), value: 198.0 },
    ];

    let mut pipe = Pipeline::from_spec("outliers, celsius, count");
    pipe.run(&mut batch);

    let mut doubling = Scale { factor: 2.0 };
    run_hot(&mut doubling, &mut batch);

    // to_string() доступен, потому что реализован Display — сквозной impl ToString
    let line = batch.iter().map(Sample::to_string).collect::<Vec<_>>().join(" ");
    println!("{line}");
}

Ожидаемый вывод:

после outliers: записей 2
после scale: записей 2
после count: записей 2
t1=47.0 t2=39.6

Что здесь стоит заметить. Vec<Box<dyn Stage>> — не «медленно», а «правильно»: этапы приходят строкой конфигурации, набор типов открыт, и один косвенный вызов на партию из тысячи измерений не измерим. run_hot — обобщённая, потому что вызывается в цикле по элементам, и там инлайнинг реально решает. Sample::to_string работает, хотя мы его не писали: сквозная реализация ToString для всех Display уже есть в std.

Типичные грабли

Граница написана, но не там. Ошибка E0277 в точке вызова означает, что тип не удовлетворяет границе; ищите блок note: required by a bound in ... — он укажет на определение, а help часто предложит готовый derive:

error[E0277]: the trait bound `Version: Ord` is not satisfied
   --> src/main.rs:14:21
    |
14  |     let m = largest(&versions);
    |             ------- ^^^^^^^^^ the trait `Ord` is not implemented for `Version`
    |             |
    |             required by a bound introduced by this call
    |
note: required by a bound in `largest`
   --> src/main.rs:5:15
    |
5   | fn largest<T: Ord>(items: &[T]) -> &T {
    |               ^^^ required by this bound in `largest`
help: consider annotating `Version` with `#[derive(Eq, Ord, PartialEq, PartialOrd)]`

Два трейта с одинаковым именем метода. Ambiguity разрешается явным синтаксисом:

trait Animal { fn name(&self) -> String; }
trait Employee { fn name(&self) -> String; }

struct Person;
impl Animal for Person { fn name(&self) -> String { "Homo sapiens".into() } }
impl Employee for Person { fn name(&self) -> String { "Иванов".into() } }

fn main() {
    // Person.name();                          // error[E0034]: multiple applicable items in scope
    println!("{}", Animal::name(&Person));      // Homo sapiens
    println!("{}", <Person as Employee>::name(&Person));  // Иванов
}

impl Trait в возврате — это один конкретный тип, а не «любой». Две разные лямбды имеют два разных типа:

fn make(kind: &str) -> impl Fn(i64) -> i64 {
    if kind == "inc" { |x| x + 1 } else { |x| x * 2 }   // не скомпилируется
}
error[E0308]: `if` and `else` have incompatible types
 --> src/main.rs:2:43
  |
2 |     if kind == "inc" { |x| x + 1 } else { |x| x * 2 }
  |                        ----------          ^^^^^^^^^ expected closure, found a different closure
  |
  = help: consider boxing your closure and/or using it as a trait object

Починка — -> Box<dyn Fn(i64) -> i64>: два разных типа приводятся к одному объекту-типажу. Ровно та же ошибка ждёт вас с двумя ветками, возвращающими разные итераторы.

Переобобщение. fn f<S: AsRef<str>>(s: S) вместо fn f(s: &str) выглядит гибко, а на деле умножает код и портит сообщения об ошибках. Обобщайте, когда есть больше одного реального типа на входе, а не «на будущее». Clippy подсказывает: clippy::needless_pass_by_value, clippy::ptr_arg.

Ожидание, что dyn дешевле дженериков всегда. Обратное тоже неверно. dyn экономит размер кода и время сборки, дженерики экономят такты. Ответ даёт измерение, а не интуиция — см. рабочий процесс оптимизации.

Забытая граница ?Sized. По умолчанию каждый параметр типа неявно T: Sized. Поэтому struct Wrapper<T> { inner: Box<T> } не примет Box<dyn Shape>, пока вы не напишете T: ?Sized. Читается как «не обязательно Sized», и это единственная граница со знаком «может не выполняться».

async fn в трейте не даёт объектов. Асинхронные методы в трейтах стабильны с 1.75, но трейт с ними перестаёт быть dyn-совместимым: возвращаемый Future — анонимный тип. Отсюда живучесть крейта async-trait, который боксирует фьючерсы за вас. Подробности — в статье про асинхронный Rust.

Ожидание, что PartialEq достаточно для ключей HashMap. Нужны Eq и Hash. Поэтому f64 ключом быть не может — и это спасает от целого класса баг-репортов вида «строка исчезла из справочника».

Честно про цену

Что даёт система типов Rust:

  • Один и тот же трейт обслуживает и абстракцию с нулевой стоимостью, и полноценный полиморфизм в рантайме — выбор ваш и он локальный.
  • Границы проверяются до подстановки, поэтому ошибки в обобщённом коде читаемы, в отличие от шаблонов C++ (сравните с разбором в статье про C++).
  • Когерентность даёт свойство, которого нет у monkey-patching и у частичных классов: добавление зависимости не меняет поведение уже написанного кода.
  • Типовые состояния и newtype переносят целые классы ошибок из тестов в компиляцию — бесплатно в рантайме.

Что вы платите:

  • Время сборки. Мономорфизация — главный источник. Крейт с активными дженериками, serde и макросами собирается минутами. Меры: воркспейс с мелкими крейтами, cargo check в цикле разработки, тонкие обобщённые оболочки, dyn в холодном коде, линкер lld/mold, sccache.
  • Кривая обучения. Границы, ассоциированные типы, dyn-совместимость, вариантность, сквозные реализации — это второй по величине барьер после владения. Обычно он проходится за месяц реальной практики.
  • Абстракции легко переусложнить. Rust даёт достаточно выразительности, чтобы построить башню из трейтов, которую через год никто не разберёт. Признаки болезни: трейт с одной реализацией; трейт, введённый «для тестов», где хватило бы функции; четыре параметра типа в структуре.
  • Пределы выразимости. Нет специализации, нет отрицательных границ, нет типов высших родов. Часть библиотечных задач упирается в это и решается макросами.

Куда эту машинерию тащить не стоит. Не заводите трейт, пока у него не появилась вторая реализация: в прикладном коде обычная функция или перечисление почти всегда проще. Не пишите обобщения там, где вход всегда один тип. И общий вывод про язык: если задача — CRUD поверх базы, где всё время уходит на сеть и SQL, преимущества системы типов Rust не окупят ни времени сборки, ни времени команды; там уместнее Go, C# или Kotlin. Rust окупается там, где типами описывается предметная сложность: протоколы, парсеры, драйверы, движки, обработка недоверенного ввода.

Связь с соседними треками

  • systems-programming. Vtable — это ровно то, чем в C является структура из указателей на функции: сравните с struct file_operations в ядре Linux (драйверы и ввод-вывод). Толстый указатель — обычная пара машинных слов (память и указатели), а мономорфизация превращается в отдельные символы в объектном файле (сборка и компоновка).
  • performance. Разница между статикой и динамикой — это разница в инлайнинге и в локальности данных, а не в цене прыжка (кэш и локальность).
  • design-patterns. Половина поведенческих паттернов в Rust выражается трейтом плюс enum или Box<dyn> вместо иерархии классов (поведенческие паттерны).
  • paradigms и functional-programming. Трейты — это классы типов; сквозные реализации — их прямое следствие (обобщённое программирование).

Мини-итог

  • Трейт — предикат на типе плюс словарь методов, а не тип и не базовый класс. Реализация живёт отдельно от определения типа.
  • Обобщения мономорфизируются: в рантайме нулевая цена, при сборке — время и размер бинарника. Тонкая обобщённая оболочка вокруг необобщённого тела снимает большую часть раздувания.
  • Границы проверяются на определении, а не при подстановке. Поэтому ошибки читаемы, а help часто содержит готовую починку.
  • dyn Trait — толстый указатель: данные плюс vtable. Настоящая цена — потерянный инлайнинг и разбросанные по куче объекты, а не косвенный вызов.
  • Три способа полиморфизма: обобщения (набор типов известен), enum (набор закрыт, важна локальность), dyn (набор открыт снаружи).
  • dyn-совместимость ломают дженерик-методы, Self в возврате и аргументах, ассоциированные константы. Лечится where Self: Sized или возвратом Box<dyn Trait>.
  • Ассоциированный тип — когда реализация одна на тип (Iterator::Item); параметр типа — когда их много (From<T>).
  • Реализуйте From, требуйте Into. Реализуйте Display — получите ToString. Сквозные реализации std работают на вас, если вы говорите на её языке.
  • Eq, Ord, Hash несут контракты, которые компилятор не проверяет: нарушите — получите панику сортировки или потерянные ключи в HashMap.
  • derive требует трейт от каждого параметра типа, даже когда это не нужно; для Rc, Arc, PhantomData пишите impl руками.
  • Правило сироты защищает когерентность; обход — newtype, который в рантайме ничего не стоит.
  • Deref — только для умных указателей. Использовать его как наследование — задокументированный антипаттерн.

Источники

Что дальше

Обработка ошибок: Result, Option, оператор ?, свои типы ошибок — соберём всё сказанное про типы в одном месте: Result как обычное перечисление, ? как синтаксис поверх трейта From, Box<dyn Error> как объект-типаж и собственные типы ошибок, которые не стыдно показать в публичном API.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов