← Назад к списку тем

07. Трейты и полиморфизм

Определение трейтов, default-методы, trait objects (dyn), impl Trait, associated types, supertraits, orphan rule.

Определение трейтов и impl

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 ниже).

🔑 Nominal vs structural: в Go тип реализует интерфейс автоматически, если у него есть нужные методы — компилятор не требует явного объявления. В Rust соответствие типа трейту всегда явное (impl Trait for Type), что фиксируется в одном месте и облегчает поиск реализаций и работу компилятора при выводе типов.

Default-методы

Трейт может содержать методы с реализацией по умолчанию — 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, реализовав всего один метод.

Trait objects: dyn Trait

По умолчанию 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.

Object safety

Не любой трейт можно превратить в trait object. Трейт должен быть object-safe:

  • методы не должны возвращать Self по значению (компилятор не знает конкретный размер за dyn);
  • методы не должны иметь generic-параметры типа (нельзя построить одну vtable под все возможные инстанциации);
  • трейт не должен требовать Self: Sized для самого себя как для целого (отдельные методы можно исключить через where Self: Sized);
  • ассоциированные константы также запрещают object safety.
⚠️ Классический пример нарушения: трейт Clone с методом fn clone(&self) -> Self не object-safe — поэтому нельзя написать Box<dyn Clone>. Решение — обёрточные крейты вроде dyn-clone или архитектурный редизайн через отдельный трейт-обёртку.

impl Trait: статическая диспетчеризация

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 })
    }
}

dyn Trait vs generics (impl Trait): сравнение

Выбор между статической и динамической диспетчеризацией — один из ключевых архитектурных вопросов на 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
Время компиляции растёт с числом инстанциацийКомпиляция быстрее — код не дублируется
✅ Практическое правило: используйте generics/impl Trait там, где важна производительность и набор типов известен на этапе компиляции (библиотечный код, горячие пути). Используйте dyn Trait, когда нужна настоящая гетерогенность в runtime (плагины, коллекции разнородных обработчиков) или чтобы избежать чрезмерного разрастания бинарника.

Associated types

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 явно кодируют отношение «один тип — одна конкретизация».

🔑 Когда associated type, а когда generic-параметр: если для типа логически может существовать только одна реализация трейта — используйте associated type (Iterator::Item, Add::Output). Если тип осмысленно реализует трейт для разных параметров одновременно — используйте generic-параметр трейта (From<T> реализуется многократно: From<i32>, From<&str> и т.д.).

Supertraits

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 и newtype pattern

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) во всей программе существует не более одной реализации. Без этого правила два разных крейта могли бы независимо реализовать один и тот же чужой трейт для одного и того же чужого типа с разной логикой — и компилятор не смог бы детерминированно выбрать, какую использовать.

Обход через newtype pattern

Стандартное решение — обернуть внешний тип в собственную 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> автоматически.

🚫 Частая ошибка на собеседовании: путать orphan rule с приватностью. Дело не в видимости типа/трейта, а в том, в каком крейте они определены — правило действует даже если оба элемента публичны.

Derive-макросы: автогенерация impl

Многие трейды из стандартной библиотеки имеют механическую, предсказуемую реализацию — компилятор может сгенерировать её сам через атрибут #[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).

✅ Best practice: начинайте почти каждую структуру данных с #[derive(Debug)] — это почти ничего не стоит и сильно облегчает отладку через {:?}/{:#?} и вывод в панике. Добавляйте Clone/Copy/PartialEq по мере необходимости, а не «на всякий случай» — лишние трейты увеличивают API-поверхность и время компиляции.

Типовые ловушки и best practices

  • Blanket implementations — можно реализовать трейт для всех типов, удовлетворяющих ограничению: impl<T: Display> MyTrait for T { ... }. Мощный инструмент (так устроен ToString в std поверх Display), но легко создать конфликт с orphan rule или неочевидное покрытие типов.
  • Trait bound vs where-клауза — при нескольких ограничениях используйте where для читаемости: fn f<T>(x: T) where T: Display + Clone + Send вместо длинной строки в угловых скобках.
  • Auto traitsSend и 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"
}