Трейты (Rust)

Материал из llmdocs — справочник разработчика
Портал: Go и Rust
Трейты (Rust)
Аналогиинтерфейсы (Java, Go), классы типов (Haskell)
Реализацияimpl Трейт for Тип
Статическая диспетчеризацияобобщения с ограничениями (T Display), impl Trait
Динамическая диспетчеризацияdyn Trait через таблицу виртуальных методов
Автоматическая реализация

[derive(...)]

Ограничениеправило сироты (orphan rule)
Стандартные трейтыDebug, Display, Clone, Copy, From, Iterator, Send, Sync

Трейт (англ. trait — «черта», «свойство»; в русских переводах также «типаж») — механизм Rust для описания общего поведения: набор методов, которые разные типы могут реализовать каждый по-своему. Трейты похожи на интерфейсы в Java и Go и на классы типов в Haskell. На них построено обобщённое программирование в Rust: функция может принимать «любой тип, который умеет выводиться на экран» или «любой итератор», а компилятор проверяет это на этапе сборки.[1]

Многие ключевые свойства языка выражены трейтами: копирование (Copy, Clone), вывод (Debug, Display), сравнение (PartialEq, Ord), итерация (Iterator), преобразования (From, Into) и потокобезопасность (Send, Sync).

Определение и реализация

trait Shape {
    fn area(&self) -> f64;                       // обязательный метод
    fn describe(&self) -> String {               // метод с реализацией по умолчанию
        format!("фигура площадью {:.2}", self.area())
    }
}

struct Circle { r: f64 }
struct Rect { w: f64, h: f64 }

impl Shape for Circle {
    fn area(&self) -> f64 { std::f64::consts::PI * self.r * self.r }
}

impl Shape for Rect {
    fn area(&self) -> f64 { self.w * self.h }
    fn describe(&self) -> String { format!("прямоугольник {}×{}", self.w, self.h) }
}

В отличие от классического наследования, реализация трейта пишется отдельно от определения типа: можно реализовать свой трейт для чужих типов (например, для i32 или Vec<T>) и чужой трейт для своих.

Обобщения и ограничения

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

use std::fmt::Display;

fn largest<T: PartialOrd + Copy>(items: &[T]) -> T {
    let mut max = items[0];
    for &x in items { if x > max { max = x; } }
    max
}

fn print_all(items: &[impl Display]) {           // сокращённая запись ограничения
    for x in items { println!("{x}"); }
}

fn total_area<S>(shapes: &[S]) -> f64
where
    S: Shape,
{
    shapes.iter().map(|s| s.area()).sum()
}

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

Трейт-объекты

Когда в одной коллекции нужны значения разных типов, используют трейт-объекты dyn Trait. Значение хранится за указателем (Box, &, Arc), а метод выбирается во время выполнения через таблицу виртуальных методов — динамическая диспетчеризация:[2]

let shapes: Vec<Box<dyn Shape>> = vec![
    Box::new(Circle { r: 1.0 }),
    Box::new(Rect { w: 2.0, h: 3.0 }),
];
for s in &shapes {
    println!("{}", s.describe());
}
Обобщения (T: Trait, impl Trait) Трейт-объекты (dyn Trait)
Выбор метода при компиляции во время выполнения
Скорость максимальная, возможна оптимизация косвенный вызов
Разнородные коллекции нет да
Размер кода растёт с числом типов одна копия

Не каждый трейт можно сделать объектом: например, методы с обобщёнными параметрами или возвращающие Self этому мешают.[3]

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

Трейт может объявлять ассоциированный тип, который определяет реализация. Классический пример — Iterator:

struct Countdown(u32);

impl Iterator for Countdown {
    type Item = u32;                              // ассоциированный тип
    fn next(&mut self) -> Option<u32> {
        if self.0 == 0 { None } else { self.0 -= 1; Some(self.0 + 1) }
    }
}

let v: Vec<u32> = Countdown(3).filter(|x| x % 2 == 1).collect();   // [3, 1]

Реализовав один метод next, тип бесплатно получает десятки методов по умолчанию — map, filter, sum, collect.

Многие стандартные трейты можно реализовать автоматически атрибутом #[derive]:

#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
struct UserId(u64);

Руководство по API для Rust рекомендует реализовывать для публичных типов стандартные трейты, где это имеет смысл, — так типы лучше сочетаются с остальной экосистемой.[6]

Правило сироты

Реализовать трейт для типа можно, только если трейт или тип определены в вашем крейте. Это правило сироты (orphan rule) не позволяет двум библиотекам дать конфликтующие реализации одного и того же трейта для одного типа. Обходной путь — обёртка-новый тип (newtype): struct Wrapper(Vec<String>);.

Трейты и асинхронность

С Rust 1.75 (декабрь 2023) в трейтах можно объявлять асинхронные методы async fn и методы, возвращающие impl Trait; до этого требовались обходные решения вроде крейта async-trait (см. async в Rust и Tokio).[5] Маркерные трейты Send и Sync сообщают компилятору, можно ли передавать значение между потоками и разделять его, — на этом основана защита от гонок данных (см. Владение и заимствование (Rust)). Больше примеров — в Rust By Example.[4]

См. также

Источники

  1. The Rust Programming Language — Traits: Defining Shared Behavior
  2. The Rust Programming Language — Using Trait Objects
  3. The Rust Reference — Traits
  4. Rust By Example — Traits
  5. Rust Blog — Announcing async fn and return-position impl Trait in traits (Rust 1.75)
  6. Rust API Guidelines — Interoperability (common traits)