Типы и трейты: обобщения, диспетчеризация, типажи стандартной библиотеки
Мы разобрали, как долго живут значения (владение и времена жизни). Теперь вопрос другой: что значения умеют и как написать один алгоритм, работающий для многих типов, не потеряв ни скорости, ни статических гарантий.
Это не глава про синтаксис <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#, которые меняют способ проектирования:
- Реализация отделена от определения типа.
impl Shape for Circleможно написать в другом файле, в другом модуле, для чужого типа из другого крейта (impl Shape for f64— законно). Интерфейсы Java требуют править объявление класса. - Наследования состояния нет. Трейт не добавляет полей.
trait Shape: Debug(супертрейт) — это требование «кто реализуетShape, обязан реализовать иDebug», а не наследование в смысле ООП. Подробнее про разницу — в статье про ООП трека paradigms. - Диспетчеризация — выбор вызывающего, а не свойство метода. В Java метод либо виртуальный, либо нет. В Rust один и тот же
area()компилируется в прямой вызов или в прыжок по таблице в зависимости от того, как вы записали параметр. - Трейт может содержать ассоциированные типы и константы, а не только методы, — это делает его инструментом описания отношений между типами, а не только поведения.
Ближайший родственник — классы типов 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>. Это не тип-обёртка, а указатель из двух половин: адрес данных и адрес таблицы методов.
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 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— только для умных указателей. Использовать его как наследование — задокументированный антипаттерн.
Источники
- The Rust Programming Language, глава 10 «Generic Types, Traits, and Lifetimes» и глава 18 «Object-Oriented Programming Features» — канонический вход.
- The Rust Reference: Traits и Trait objects — точные правила, включая dyn-совместимость.
- std::marker, std::convert, std::cmp, std::ops — документация ключевых трейтов; читать её как справочник по проектированию, а не только по API.
- Rust API Guidelines — какие трейты обязан реализовать публичный тип (
Debug,Clone,Default,Send,Sync) и почему. - Rust Design Patterns — newtype, антипаттерн
Deref, идиомы вокруг трейтов. - Jon Gjengset, «Rust for Rustaceans» — главы 2–3: диспетчеризация, когерентность, «perfect derive», проектирование трейтов для чужих крейтов.
- Jim Blandy, Jason Orendorff, Leonora Tindall, «Programming Rust», 2nd ed. — главы 11 и 13, лучшее печатное объяснение трейтов и операторных типажей.
- The Rustonomicon: Exotically Sized Types — DST,
?Sized, типы нулевого размера. - Rust Error Codes Index — расшифровки E0038, E0117, E0119, E0277, E0034; локально то же даёт
rustc --explain E0038. - Rust Performance Book: Type sizes and generics — измеримое влияние мономорфизации на сборку и бинарник.
- Blog: «Generic returns in Rust» и материалы Niko Matsakis о RPITIT — как трейты меняются от версии к версии.
Что дальше
Обработка ошибок: Result, Option, оператор ?, свои типы ошибок — соберём всё сказанное про типы в одном месте: Result как обычное перечисление, ? как синтаксис поверх трейта From, Box<dyn Error> как объект-типаж и собственные типы ошибок, которые не стыдно показать в публичном API.