Программист с Java-бэкграундом открывает трейты Rust и первое время не видит подвоха: методы, полиморфизм, даже методы с телом по умолчанию на месте. Разница вскрывается позже, на границе динамической диспетчеризации и чужих типов. Интерфейс в Java это описание, которое класс обязан объявить сам в своей шапке, а трейт в Rust это свойство, которое любой модуль способен приделать почти любому типу. Диспетчеризация тоже распалась на две: статическую через мономорфизацию и динамическую через vtable, и выбор между ними ложится на автора каждого вызова. Ниже разбор архитектурных различий, механики vtable, жёстких границ dyn Trait и правил выбора между генериками и трейт-объектами.

Чем трейты отличаются от интерфейсов по устройству языка

Трейт объявляет поведение, а код методов живёт в отдельном блоке impl рядом с типом или вообще в другом модуле. В Java строка implements вшита в объявление класса, и сторонняя библиотека не способна приписать свой интерфейс чужому классу из зависимостей, приходится городить адаптеры и обёртки. В Rust правило сироты запрещает лишь чужой трейт для чужого типа, а свой трейт для чужого типа или чужой трейт для своего типа разрешены. Отсюда растёт вся экосистема расширений: крейт с утилитами надевает собственные трейты на стандартные Vec и String, не трогая их код:

trait Cached {
    fn cache_key(&self) -> String;
}

impl Cached for String {
    fn cache_key(&self) -> String {
        self.to_lowercase()
    }
}

fn main() {
    let key = "Заголовок".cache_key();
    println!("{}", key);
}

Трейт не содержит полей и не даёт кода по умолчанию для состояния, только для методов. В Java интерфейс тоже без полей, но с Java 8 от марта 2014 года у него появились default методы, а с Java 9 и приватные методы. В Rust методы с телом были с самой первой стабильной версии 1.0 2015 года, и наследуют они не иерархию классов, а лишь требования к границам.

trait Shape {
    fn area(&self) -> f64;

    fn describe(&self) -> String {
        let rounded = (self.area() * 100.0).round() / 100.0;
        format!("площадь {}", rounded)
    }
}

Сравните запись на Java, где поведение вшито в объявление класса:

public interface Shape {
    double area();

    default String describe() {
        return "площадь " + area();
    }
}

Статическая диспетчеризация и мономорфизация кода при компиляции

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

trait Shape {
    fn area(&self) -> f64;
}

struct Circle {
    radius: f64,
}

impl Shape for Circle {
    fn area(&self) -> f64 {
        3.14159265 * self.radius * self.radius
    }
}

fn total<T: Shape>(shapes: &[T]) -> f64 {
    shapes.iter().map(|s| s.area()).sum()
}

fn main() {
    let circles = vec![Circle { radius: 1.0 }, Circle { radius: 2.0 }];
    println!("{}", total(&circles));
}

Каждая инстанциация total это отдельная функция в машинном коде, скорость как у руки писанного варианта без обобщений. Плата двойная: время компиляции и размер бинарника, при сотнях типов комбинации расползаются. Проекты с жёстким лимитом на размер прошивки иногда сознательно отказываются от щедрых генериков в пользу нескольких трейт-объектов, одна копия таблицы методов весит меньше, чем десять развернутых копий функции. В аргументах и возвратах ту же задачу решает короткая запись impl Trait, стабилизированная в Rust 1.26 в 2018 году:

fn make() -> impl Shape {
    Circle { radius: 3.0 }
}

Java идёт другим маршрутом: у неё единый скомпилированный байткод с type erasure, а специализацию по типу подбирает JIT-компилятор в рантайме, опираясь на инлайновые кэши вызовов. Мономорфный участок он разворачивает и встраивает, полиморфный оставляет таблицей. Итог похож, но Rust принимает решение о диспетчеризации в момент компиляции и не зависит от прогрева.

Динамическая диспетчеризация через vtable и ограничения dyn Trait

Второй режим зовётся dyn Trait. Ссылка на трейт-объект это жирный указатель из двух слов: указатель на значение и указатель на таблицу методов, vtable. В таблице лежат деструктор, размер и выравнивание типа, дальше сами указатели методов. На 64-битной платформе жирный указатель занимает 16 байт против 8 у обычной ссылки:

use std::mem::size_of;

struct Circle {
    radius: f64,
}

trait Shape {
    fn area(&self) -> f64;
}

impl Shape for Circle {
    fn area(&self) -> f64 {
        3.14159265 * self.radius * self.radius
    }
}

fn main() {
    println!("{}", size_of::<&Circle>());
    println!("{}", size_of::<&dyn Shape>());
}

Сам тип dyn Trait не имеет известного размера, поэтому жить он обязан за указателем, в Box, Rc, Arc или за голой ссылкой. Обход списка разнородных фигур выглядит так:

fn total_dyn(shapes: &[Box<dyn Shape>]) -> f64 {
    shapes.iter().map(|s| s.area()).sum()
}

Разделяемое владение трейт-объектом собирается через Rc, каждый владелец держит один и тот же жирный указатель:

use std::rc::Rc;

fn main() {
    let shared: Rc<dyn Shape> = Rc::new(Circle { radius: 1.0 });
    let clone = Rc::clone(&shared);
    println!("{}", clone.area());
}

Не каждый трейт годится для dyn. Совместимость с dyn это отдельный чек-лист, раньше его звали object safety, теперь в документации пишут dyn compatibility. Ломают её обобщённые методы, возврат Self по значению, разыменование без известного размера:

trait Loader {
    fn load<T: Default>(&self, key: &str) -> T;
}

Такой трейт можно использовать только с генериками, трейт-объект из него не собрать. Компилятор объяснит отказ словами про размер и обобщения без ручной отладки. Спасает оговорка where Self: Sized, она исключает спорный метод из таблицы и возвращает трейту совместимость с dyn:

trait Loader {
    fn load<T: Default>(&self, key: &str) -> T
    where
        Self: Sized;
}

Это принципиальное различие с Java: там интерфейс без ограничений всегда можно использовать динамически, ограничения Rust вытекают из модели памяти, а не из каприза авторов.

Апкастинг и динамические типы от Any до супертрейтов

Трейт-объекты долго не умели подниматься по иерархии. Апкастинг от dyn Sub к dyn Super стабилизировали только в Rust 1.86 весной 2025 года, до этого крейты вручную генерировали мосты. Современная запись проста:

trait Shape {
    fn area(&self) -> f64;
}

trait Solid: Shape {
    fn volume(&self) -> f64;
}

fn flatten(solid: &dyn Solid) -> &dyn Shape {
    solid
}

В Java приведение интерфейса к более общему работает с самого первого релиза, программист даже не задумывается, что тут могла быть проблема. В Rust кросс-каст между ветками иерархии так и не появился, из dyn Debug в dyn Display не превратиться, только через исходное значение или явную конверсию.

Родственник трейт-объектов в стандартной библиотеке это dyn Any, динамический идентификатор типа для ситуаций, где Rust вынужден притворяться скриптовым языком:

use std::any::Any;

fn main() {
    let value: Box<dyn Any> = Box::new(42u32);
    if let Some(n) = value.downcast_ref::<u32>() {
        println!("число {}", n);
    }
}

Каждый вызов downcast_ref это сопоставление с TypeId, прямая аналогия instanceof в Java, только без иерархии классов: проверяется точное совпадение, а не наследование. Годится для систем плагинов и контейнеров разнородных значений, злоупотреблять не стоит.

Ассоциированные типы и супертрейты вместо иерархии наследования

У трейта есть ассоциированные типы, заглушки для конкретики, которую задаёт блок impl. Итератор стандартной библиотеки объявляет тип элемента, а не принимает его параметром:

pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;
}

В Java роль этого механизма играют generics с erasure, у интерфейса Collection<E> параметр типа стирается после компиляции. Ассоциированные типы дают читаемые сигнатуры без повторных границ и позволяют компилятору проверять согласованность всей цепочки.

Супертрейты записываются как требования: trait Named: Shape + Clone означает, что тип обязан поддерживать и то и другое, но код из Shape при этом не наследуется, только доступен. Иерархия в Rust это ограничение, а не перенос кода. Java разрешает интерфейсу расширять несколько интерфейсов сразу, а конфликты default методов решает правилами компилятора, обязывающим разработчика переопределить спорный метод руками.

Богатство трейтов стандартной библиотеки от итераторов до операторов

В стандартной библиотеке трейтами описано всё подряд: итераторы, конверсии From и Into, значения по умолчанию, сравнения, отладочная печать. Оператор плюс это тоже трейт:

use std::ops::Add;

#[derive(Clone, Copy)]
struct Point {
    x: i32,
    y: i32,
}

impl Add for Point {
    type Output = Point;

    fn add(self, other: Point) -> Point {
        Point {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

fn main() {
    let sum = Point { x: 1, y: 2 } + Point { x: 3, y: 4 };
    println!("{} {}", sum.x, sum.y);
}

Макрос #[derive] вешает на структуру готовую поддержку трейтов Clone, Debug, PartialEq и других:

#[derive(Default)]
struct Config {
    retries: u32,
    timeout_ms: u64,
}

fn main() {
    let cfg = Config {
        retries: 3,
        ..Config::default()
    };
    println!("{} {}", cfg.retries, cfg.timeout_ms);
}

Трейт Default даёт значения по умолчанию и синтаксис точечной достройки структуры. В Java аналогичный результат дают генераторы кода вроде Lombok, но там это сторонняя аннотация, а тут встроенная механика языка. Трейты же позволяют расширять и чужие типы: крейт itertools добавляет десятки комбинаторов к любому итератору, не правя стандартную библиотеку. Показательно, что один и тот же трейт io::Read работает в обеих моделях диспетчеризации, функция принимает ли и генерик, и трейт-объект:

use std::fs::File;
use std::io::Read;

fn main() {
    let mut file = File::open("notes.txt").unwrap();
    let mut text = String::new();
    file.read_to_string(&mut text).unwrap();
    println!("{}", text.len());
}

В Java место таких расширений занимают статические хелперы вроде Collections, вызов выглядит как утилитарная функция, а не как метод самого объекта.

Практический выбор между генериками и трейт объектами в проекте

Рабочая линейка решений выглядит так:

  1. набор типов известен на этапе компиляции, скорость критична, генерик с границей и мономорфизация;
  2. состав коллекции разнородный и пополняется без перекомпиляции, Box<dyn Trait> со списком объектов;
  3. тип известен только как способность, а набор методов фиксирован, тонкая обёртка с enum и match внутри;
  4. нужна подмена в тестах, трейт-объект либо генерик, оба поддаются макетам;
  5. требуется хранить значения разных типов с проверкой в рантайме, dyn Any с аккуратным downcast.

Для замкнутого набора вариантов честнее enum: match покрывает все ветви на этапе компиляции, новых участников не добавят без правки кода, а диспетчеризации нет вовсе:

enum ShapeKind {
    Circle(f64),
    Square(f64),
}

impl ShapeKind {
    fn area(&self) -> f64 {
        match self {
            ShapeKind::Circle(r) => 3.14159265 * r * r,
            ShapeKind::Square(a) => a * a,
        }
    }
}

Подмену поведения в тестах удобно строить на крейте mockall, он генерирует макеты трейтов по одному атрибуту. Границу производительности здесь честнее всего проверять бенчмарком criterion на своём цикле вызовов, но общая картина стабильна: прямой вызов встроился, косвенный через vtable не встроился и стоит ветвления предсказателя. Для горячего цикла из миллиона вызовов разница заметна, для пары сотен вызовов в секунду не окупится минута профилирования.

Короткий итог для перехода с Java: трейты мощнее интерфейсов в статике и беднее в динамике. Мономорфизация даёт скорость без участия рантайма, правила сироты позволяют расширять чужие типы, ассоциированные типы дают строгость без параметров-заглушек. За dyn Trait приходится платить жирными указателями, совместимостью с dyn и ручным управлением кастингом. Интерфейсы Java держат дисциплину одной моделью, трейты Rust дают выбор модели на каждый вызов, и в этом выборе вся суть: он требует от разработчика решения, которое в Java приняло бы за него слово implements в шапке класса.