Trait — это способ описать поведение, общее для разных типов: набор методов, которые тип обязуется реализовать. По духу это близко к interface в Go/Java, но с важным отличием: Rust использует nominal typing с явным impl Trait for Type, а не структурную типизацию (в отличие от Go, где имплементация неявна — «duck typing» на уровне компиляции). Тип явно объявляет, какие трейты он реализует, и это видно прямо в коде рядом с определением типа или трейта.
trait Shape {
fn area(&self) -> f64;
fn perimeter(&self) -> f64;
}
struct Circle {
radius: f64,
}
impl Shape for Circle {
fn area(&self) -> f64 {
3.14159 * self.radius * self.radius
}
fn perimeter(&self) -> f64 {
2.0 * 3.14159 * self.radius
}
}
fn main() {
let c = Circle { radius: 2.0 };
println!("area = {}", c.area());
}
Ключевое отличие от интерфейсов Go/Java: трейт можно реализовать для любого типа, включая примитивы и типы из стандартной библиотеки — не только для «своих» структур. Это делает трейты мощным инструментом расширения поведения без модификации исходного типа (аналог extension methods в C#/Kotlin, но с проверкой когерентности на уровне компилятора — см. orphan rule ниже).
impl Trait for Type), что фиксируется в одном месте и облегчает поиск реализаций и работу компилятора при выводе типов.
Трейт может содержать методы с реализацией по умолчанию — default-методами. Реализующий тип может либо унаследовать поведение как есть, либо переопределить метод собственной версией. Это снижает дублирование кода и позволяет добавлять новые методы в трейт, не ломая существующие реализации (обратная совместимость).
trait Greet {
fn name(&self) -> String;
// Default-метод — использует другой метод трейта
fn greeting(&self) -> String {
format!("Привет, {}!", self.name())
}
}
struct Person {
name: String,
}
impl Greet for Person {
fn name(&self) -> String {
self.name.clone()
}
// greeting() не переопределён — используется default
}
struct Robot;
impl Greet for Robot {
fn name(&self) -> String {
"R2-D2".to_string()
}
// Переопределяем default-метод
fn greeting(&self) -> String {
format!("BEEP-BOOP {}", self.name())
}
}
Классический пример из стандартной библиотеки — трейт Iterator: единственный обязательный метод next(), а десятки методов вроде map, filter, sum, collect — default-реализации, построенные поверх next(). Это позволяет получить богатый API, реализовав всего один метод.
По умолчанию Rust выбирает статическую диспетчеризацию (compile-time), но иногда нужен единый список/поле, хранящий значения разных конкретных типов, объединённых общим трейтом — например, Vec из разных фигур. Для этого используется trait object: dyn Trait, доступный только через указатель — &dyn Trait или Box<dyn Trait>.
trait Shape {
fn area(&self) -> f64;
}
struct Circle { radius: f64 }
struct Square { side: f64 }
impl Shape for Circle {
fn area(&self) -> f64 { 3.14159 * self.radius * self.radius }
}
impl Shape for Square {
fn area(&self) -> f64 { self.side * self.side }
}
fn total_area(shapes: &[Box<dyn Shape>]) -> f64 {
shapes.iter().map(|s| s.area()).sum()
}
fn main() {
// Гетерогенный вектор — общий владеющий указатель Box<dyn Shape>
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { radius: 1.0 }),
Box::new(Square { side: 2.0 }),
];
println!("{}", total_area(&shapes));
}
Под капотом dyn Trait — это fat pointer (толстый указатель) из двух слов: указатель на данные и указатель на vtable (таблицу указателей на методы этого конкретного типа). Вызов метода через dyn Trait — это косвенный вызов через vtable (dynamic dispatch), аналогично тому, как устроены interface-значения в Go (itable) или virtual-методы в C++/Java.
Не любой трейт можно превратить в trait object. Трейт должен быть object-safe:
Self по значению (компилятор не знает конкретный размер за dyn);Self: Sized для самого себя как для целого (отдельные методы можно исключить через where Self: Sized);Clone с методом fn clone(&self) -> Self не object-safe — поэтому нельзя написать Box<dyn Clone>. Решение — обёрточные крейты вроде dyn-clone или архитектурный редизайн через отдельный трейт-обёртку.
impl Trait — синтаксический сахар, который говорит «здесь конкретный тип, реализующий данный трейт, но я не хочу его называть». В отличие от dyn Trait, компилятор точно знает конкретный тип на этапе компиляции и генерирует под него отдельный, специализированный код — это называется monomorphization (аналогично generics в Go 1.18+).
// impl Trait в позиции аргумента — сахар над generic-параметром
fn print_area(shape: impl Shape) {
println!("{}", shape.area());
}
// Полностью эквивалентно:
fn print_area_generic<T: Shape>(shape: T) {
println!("{}", shape.area());
}
// impl Trait в позиции возврата — "непрозрачный" тип
fn make_circle(radius: f64) -> impl Shape {
Circle { radius }
}
// Особенно полезно для замыканий, чей тип невозможно назвать явно
fn make_adder(x: i32) -> impl Fn(i32) -> i32 {
move |y| x + y
}
Главное ограничение impl Trait в позиции возврата: функция должна возвращать ровно один конкретный тип во всех ветках — компилятор подставляет этот единственный тип на месте вызова. Нельзя условно вернуть то Circle, то Square:
fn make_shape(is_circle: bool) -> impl Shape {
if is_circle {
Circle { radius: 1.0 }
} else {
Square { side: 1.0 } // ОШИБКА: несовпадающие типы
}
}
// Решение — Box<dyn Shape>, ценой динамической диспетчеризации
fn make_shape_dyn(is_circle: bool) -> Box<dyn Shape> {
if is_circle {
Box::new(Circle { radius: 1.0 })
} else {
Box::new(Square { side: 1.0 })
}
}
Выбор между статической и динамической диспетчеризацией — один из ключевых архитектурных вопросов на Rust-собеседовании Senior+ уровня. Оба подхода решают задачу полиморфизма, но с разными компромиссами.
| Generics / impl Trait (static dispatch) | dyn Trait (dynamic dispatch) |
|---|---|
| Компилятор генерирует отдельную копию кода под каждый конкретный тип (monomorphization) | Одна общая реализация, вызов методов через vtable |
| Вызов метода инлайнится, нет косвенности — быстрее | Косвенный вызов через указатель на функцию — дороже на предсказание ветвления |
| Бинарник больше (code bloat при многих инстанциациях) | Бинарник компактнее — код переиспользуется |
| Тип известен на этапе компиляции — компилятор может провести inlining, autovectorization | Оптимизации ограничены — компилятор не знает конкретный тип |
| Нельзя хранить разнородные типы в одной коллекции без enum | Позволяет гетерогенные коллекции: Vec<Box<dyn Trait>> |
| Требует object safety не касается — работает с любым трейтом | Требует, чтобы трейт был object-safe |
| Время компиляции растёт с числом инстанциаций | Компиляция быстрее — код не дублируется |
dyn Trait, когда нужна настоящая гетерогенность в runtime (плагины, коллекции разнородных обработчиков) или чтобы избежать чрезмерного разрастания бинарника.
Associated type — это тип-«слот» внутри трейта, который каждая реализация конкретизирует один раз. В отличие от generic-параметра трейта (trait Container<T>), associated type жёстко привязан к реализации: для конкретного типа у трейта с associated type может быть только одна реализация, что упрощает вывод типов на стороне вызывающего кода.
trait Iterator {
type Item; // associated type
fn next(&mut self) -> Option<Self::Item>;
}
struct Counter {
count: u32,
}
impl Iterator for Counter {
type Item = u32; // конкретизация ровно одна
fn next(&mut self) -> Option<u32> {
if self.count < 5 {
self.count += 1;
Some(self.count)
} else {
None
}
}
}
// Использование Self::Item в generic-функции через связывание
fn sum_all<I>(iter: I) -> u32
where
I: Iterator<Item = u32>,
{
let mut total = 0;
let mut it = iter;
while let Some(v) = it.next() {
total += v;
}
total
}
Если бы Iterator был обобщён параметром (trait Iterator<Item>), один и тот же тип мог бы реализовать его несколько раз с разными Item — например, Counter: Iterator<u32> и Counter: Iterator<String> одновременно. Это усложнило бы вывод типов (какой Item использовать при вызове .next()?) и обычно семантически не нужно. Associated types явно кодируют отношение «один тип — одна конкретизация».
Iterator::Item, Add::Output). Если тип осмысленно реализует трейт для разных параметров одновременно — используйте generic-параметр трейта (From<T> реализуется многократно: From<i32>, From<&str> и т.д.).
Supertrait — это способ сказать «мой трейт требует, чтобы тип уже реализовывал другой трейт». Синтаксис trait B: A означает, что любой тип, реализующий B, обязан также реализовывать A. Это не наследование в ООП-смысле (нет наследования полей или переопределения виртуальных методов), а декларация зависимости между контрактами — ближе к тому, как в Go один интерфейс встраивает другой через embedding.
use std::fmt::Display;
// Summary требует, чтобы тип уже реализовывал Display
trait Summary: Display {
fn summarize(&self) -> String {
// Можно вызывать методы supertrait — Display гарантирован
format!("Summary: {}", self)
}
}
struct Article {
title: String,
}
impl Display for Article {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{}", self.title)
}
}
// Обязаны реализовать Display до Summary, иначе ошибка компиляции
impl Summary for Article {}
// Множественные supertraits
trait Widget: Display + Clone + std::fmt::Debug {
fn render(&self) -> String;
}
Supertraits активно используются для построения иерархий возможностей: например, Eq: PartialEq (полное равенство требует частичного), Ord: Eq + PartialOrd. Это позволяет компилятору и читателю кода понимать зависимости между гарантиями типа без единой цепочки наследования классов.
Orphan rule (правило когерентности) гласит: вы можете реализовать трейт Trait для типа Type, только если хотя бы один из них — трейт или тип — определён в вашем текущем крейте. Нельзя реализовать чужой трейт для чужого типа (например, impl std::fmt::Display for Vec<i32> — оба элемента внешние, компиляция запрещена).
// ОШИБКА: и Display, и Vec<T> — из внешних крейтов (std)
// impl std::fmt::Display for Vec<i32> { ... }
// error[E0117]: only traits defined in the current crate can be
// implemented for types defined outside of the crate
Смысл правила — гарантировать когерентность (coherence): для пары (trait, type) во всей программе существует не более одной реализации. Без этого правила два разных крейта могли бы независимо реализовать один и тот же чужой трейт для одного и того же чужого типа с разной логикой — и компилятор не смог бы детерминированно выбрать, какую использовать.
Стандартное решение — обернуть внешний тип в собственную tuple-структуру (newtype). Обёртка — уже «наш» тип, поэтому orphan rule разрешает реализовать для неё внешний трейт.
use std::fmt;
// Обёртка над Vec<i32> — теперь "наш" тип
struct Wrapper(Vec<i32>);
impl fmt::Display for Wrapper {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "[{}]", self.0.iter()
.map(|x| x.to_string())
.collect::<Vec<_>>()
.join(", "))
}
}
fn main() {
let w = Wrapper(vec![1, 2, 3]);
println!("{}", w); // [1, 2, 3]
}
Плата за newtype — нужно вручную "прокинуть" методы внутреннего типа (через Deref или явные делегирующие методы), поскольку Wrapper не наследует API Vec<i32> автоматически.
Многие трейды из стандартной библиотеки имеют механическую, предсказуемую реализацию — компилятор может сгенерировать её сам через атрибут #[derive(...)], вместо того чтобы вы писали impl вручную. Это процедурные макросы (derive macros), разворачивающиеся во время компиляции.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let p1 = Point { x: 1, y: 2 };
let p2 = p1.clone(); // требует Clone
println!("{:?}", p1); // требует Debug
println!("{}", p1 == p2); // требует PartialEq
}
// derive работает только если ВСЕ поля тоже реализуют трейт:
// derive(Clone) для структуры с полем, не реализующим Clone, — ошибка компиляции
Наиболее востребованные derive-трейты: Debug (форматированный вывод для отладки через {:?}), Clone (глубокое копирование через явный вызов .clone()), Copy (неявное побитовое копирование вместо перемещения — только для типов без владеющих ресурсов), PartialEq/Eq (сравнение на равенство), PartialOrd/Ord (сравнение и сортировка), Default (значение «по умолчанию»), Hash (использование как ключа HashMap).
#[derive(Debug)] — это почти ничего не стоит и сильно облегчает отладку через {:?}/{:#?} и вывод в панике. Добавляйте Clone/Copy/PartialEq по мере необходимости, а не «на всякий случай» — лишние трейты увеличивают API-поверхность и время компиляции.
impl<T: Display> MyTrait for T { ... }. Мощный инструмент (так устроен ToString в std поверх Display), но легко создать конфликт с orphan rule или неочевидное покрытие типов.where для читаемости: fn f<T>(x: T) where T: Display + Clone + Send вместо длинной строки в угловых скобках.Send и Sync реализуются компилятором автоматически на основе состава полей; их обычно не пишут вручную, а при необходимости явно запрещают через impl !Send for T {} (только в nightly) или маркерное поле PhantomData.dyn Trait «по умолчанию» — в горячих циклах статическая диспетчеризация зачастую в разы быстрее за счёт инлайнинга; выбирайте dyn осознанно, а не как «универсальный полиморфизм».// Blanket impl: любой тип с Display получает ToJson бесплатно
trait ToJson {
fn to_json(&self) -> String;
}
impl<T: std::fmt::Display> ToJson for T {
fn to_json(&self) -> String {
format!("\"{}\"", self)
}
}
fn main() {
println!("{}", 42.to_json()); // "42"
println!("{}", "hi".to_json()); // "hi"
}