Перейти к содержимому

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

На странице Трейты ты писал функции, которые принимают «любой тип, реализующий Speak». На странице Обобщения ты видел, как Rust делает отдельную копию обобщённой функции для каждого типа. Оба способа отлично работают, когда тип известен во время компиляции. А что, если нужен один список, в котором лежат значения разных типов?

Для этого и существует трейт-объект (trait object). Представь учителя музыки и класс, полный учеников. Один играет на пианино, другой — на скрипке, третий — на барабанах. Учителю не нужно знать, у кого какой инструмент: он говорит «играйте!», и каждый играет по-своему. Трейт-объект позволяет коду делать то же самое: «точный тип я не знаю, но знаю, что он умеет вот это».

Допустим, у тебя есть две фигуры, и ты хочешь хранить их в одном Vec:

struct Circle { radius: f64 }
struct Square { side: f64 }
fn main() {
let shapes = vec![Circle { radius: 1.0 }, Square { side: 2.0 }];
}

Это не компилируется:

error[E0308]: mismatched types
--> src/main.rs:5:47
|
5 | let shapes = vec![Circle { radius: 1.0 }, Square { side: 2.0 }];
| ^^^^^^^^^^^^^^^^^^^^ expected `Circle`, found `Square`

Компилятор сообщает о несовпадении типов: он ожидал Circle, а нашёл Square. Vec может хранить только один тип. Все элементы должны занимать в памяти одинаковое место, чтобы Rust мог выложить их один за другим. Circle и Square — разные типы, поэтому в обычный Vec вместе они не попадут.

Решение состоит из двух частей. Во-первых, дай обеим фигурам общий трейт. Во-вторых, положи каждую фигуру в Box и укажи тип Box<dyn Shape>:

trait Shape {
fn name(&self) -> String;
fn area(&self) -> f64;
}
struct Circle { radius: f64 }
struct Square { side: f64 }
impl Shape for Circle {
fn name(&self) -> String { String::from("круг") }
fn area(&self) -> f64 { 3.14159 * self.radius * self.radius }
}
impl Shape for Square {
fn name(&self) -> String { String::from("квадрат") }
fn area(&self) -> f64 { self.side * self.side }
}
fn main() {
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { radius: 2.0 }),
Box::new(Square { side: 3.0 }),
Box::new(Circle { radius: 1.0 }),
];
for shape in &shapes {
println!("{}: площадь {:.2}", shape.name(), shape.area());
}
}

Вывод:

круг: площадь 12.57
квадрат: площадь 9.00
круг: площадь 3.14
▶ Попробуй сам

Разберём Box<dyn Shape> по частям:

  • dyn Shape значит «какой-то тип, реализующий Shape, — какой именно, узнаем во время работы программы». dyn — сокращение от dynamic («динамический»).
  • Box кладёт значение в кучу и хранит указатель на него (с Box ты познакомился на странице Умные указатели). Любой Box одного и того же размера, что бы ни лежало внутри. Поэтому теперь Vec доволен.

Значение внутри коробки (плюс скрытый указатель, который говорит «это Circle») и называется трейт-объектом.

Box нужен не всегда. Если значения уже где-то живут, подойдёт и ссылка. &dyn Shape значит «заимствованная ссылка на какой-то тип, реализующий Shape»:

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 print_area(shape: &dyn Shape) {
println!("Площадь: {:.1}", shape.area());
}
fn main() {
let circle = Circle { radius: 1.0 };
let square = Square { side: 4.0 };
print_area(&circle);
print_area(&square);
let both: Vec<&dyn Shape> = vec![&circle, &square];
println!("Всего фигур: {}", both.len());
}

Вывод:

Площадь: 3.1
Площадь: 16.0
Всего фигур: 2
▶ Попробуй сам

Простое правило: используй Box<dyn Trait>, когда список должен владеть своими значениями, и &dyn Trait, когда их нужно лишь ненадолго заимствовать.

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

Заголовок раздела «Статическая и динамическая диспетчеризация»

«Диспетчеризация» (dispatch) значит просто «решить, какой метод вызвать». Rust умеет делать это двумя способами.

Статическая диспетчеризация происходит с обобщениями и impl Trait. Компилятор знает точный тип и делает отдельную копию функции для каждого типа (как ты видел на странице Обобщения). Вызов идёт прямо в нужный метод. Это очень быстро.

Динамическая диспетчеризация происходит с dyn Trait. Компилятор не знает точного типа. Поэтому каждый трейт-объект несёт с собой маленькую скрытую таблицу (она называется vtable, таблица виртуальных методов), в которой написано: «у этого значения area лежит вот здесь». Во время работы программы Rust находит метод в этой таблице и вызывает его. Этот поиск стоит совсем чуть-чуть.

// Статическая диспетчеризация: Rust делает копию функции для каждого типа
fn static_area(shape: &impl Shape) -> f64 {
shape.area()
}
// Динамическая диспетчеризация: одна функция, метод ищется во время работы
fn dynamic_area(shape: &dyn Shape) -> f64 {
shape.area()
}

Для одной и той же фигуры обе функции дают одинаковый ответ. Разница только в том, как Rust находит нужный метод area. Так что же выбрать?

Обобщения / impl Trait, когда… dyn Trait, когда…
Каждый вызов работает с одним известным типом Нужно смешать разные типы в одной коллекции
Нужен максимально быстрый код Тип выбирается во время работы (например, по вводу пользователя)
Чуть больший размер программы не страшен Хочется кода поменьше и компиляции побыстрее

Хороший выбор по умолчанию для новичка: начинай с обобщений или impl Trait. Бери dyn, когда упрёшься в задачу «разные типы в одном списке». О разнице в скорости особо не переживай: в большинстве программ ты её никогда не заметишь.

Иногда функция должна решить во время работы, какой тип вернуть. Можно попробовать -> impl Shape, но это работает, только если функция всегда возвращает один и тот же тип. Если разные ветки возвращают разные типы, возвращай Box<dyn Shape>:

trait Shape {
fn describe(&self) -> String;
}
struct Circle { radius: f64 }
struct Square { side: f64 }
impl Shape for Circle {
fn describe(&self) -> String { format!("круг радиусом {}", self.radius) }
}
impl Shape for Square {
fn describe(&self) -> String { format!("квадрат со стороной {}", self.side) }
}
fn make_shape(kind: &str, size: f64) -> Box<dyn Shape> {
if kind == "circle" {
Box::new(Circle { radius: size })
} else {
Box::new(Square { side: size })
}
}
fn main() {
let a = make_shape("circle", 1.5);
let b = make_shape("square", 2.0);
println!("Создан {}", a.describe());
println!("Создан {}", b.describe());
}

Вывод:

Создан круг радиусом 1.5
Создан квадрат со стороной 2
▶ Попробуй сам

Это частый приём для функций-«фабрик», систем плагинов и кода, который управляется конфигурацией, — везде, где точный тип зависит от данных, которые видны только во время работы программы.

Практический пример: отправка уведомлений

Заголовок раздела «Практический пример: отправка уведомлений»

Вот что-то ближе к настоящему приложению. Программе нужно уведомить пользователя, но каждый пользователь выбрал свои каналы: почту, SMS или оба. Каждый канал — отдельный тип, и у всех общий трейт Notifier:

trait Notifier {
fn send(&self, message: &str) -> String;
}
struct Email { address: String }
struct Sms { phone: String }
impl Notifier for Email {
fn send(&self, message: &str) -> String {
format!("Письмо на {}: {}", self.address, message)
}
}
impl Notifier for Sms {
fn send(&self, message: &str) -> String {
format!("SMS на {}: {}", self.phone, message)
}
}
fn notify_all(channels: &[Box<dyn Notifier>], message: &str) {
for channel in channels {
println!("{}", channel.send(message));
}
}
fn main() {
let channels: Vec<Box<dyn Notifier>> = vec![
Box::new(Email { address: String::from("[email protected]") }),
Box::new(Sms { phone: String::from("+1-555-0100") }),
];
notify_all(&channels, "Ваш заказ отправлен!");
}

Вывод:

Письмо на [email protected]: Ваш заказ отправлен!
SMS на +1-555-0100: Ваш заказ отправлен!
▶ Попробуй сам

Самое приятное: если позже ты добавишь push-уведомления, понадобятся одна новая структура Push и один impl Notifier for Push. Функция notify_all не изменится вообще.

Не каждый трейт может стать трейт-объектом. Трейт должен быть совместимым с dyn (dyn compatible; в старых книгах и статьях это называют объектной безопасностью, object safety). Полные правила длинные, но большинство реальных случаев покрывают два:

  • Метод не может возвращать Self. (За dyn Rust не знает, что такое Self, а значит, не знает, какого размера возвращаемое значение.)
  • У метода не может быть собственных обобщённых параметров типа, как в fn process<T>(&self, item: T).

Вот что будет, если нарушить первое правило:

trait Shape {
fn area(&self) -> f64;
fn duplicate(&self) -> Self;
}
// ... позже:
let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Circle { radius: 1.0 })];
error[E0038]: the trait `Shape` is not dyn compatible
|
14 | let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Circle { radius: 1.0 })];
| ^^^^^ `Shape` is not dyn compatible
|
1 | trait Shape {
| ----- this trait is not dyn compatible...
2 | fn area(&self) -> f64;
3 | fn duplicate(&self) -> Self;
| ^^^^ ...because method `duplicate` references the `Self` type in its return type
= help: consider moving `duplicate` to another trait

Ошибка честная и полезная: компилятор пишет, что трейт Shape несовместим с dyn, потому что метод duplicate упоминает Self в возвращаемом типе. Варианты такие: перенести этот метод в другой трейт или добавить к методу where Self: Sized. Это говорит Rust: «этот метод есть только у настоящих, известных типов, а не у трейт-объектов»:

trait Shape {
fn area(&self) -> f64;
fn duplicate(&self) -> Self where Self: Sized;
}

По этой же причине некоторые стандартные трейты, например Clone, нельзя использовать как dyn Clone: clone() возвращает Self.

Забытый Box. Так и хочется написать Vec<dyn Shape>:

let shapes: Vec<dyn Shape> = Vec::new();
error[E0277]: the size for values of type `dyn Shape` cannot be known at compilation time
--> src/main.rs:3:17
|
3 | let shapes: Vec<dyn Shape> = Vec::new();
| ^^^^^^^^^^^^^^ doesn't have a size known at compile-time
|
= help: the trait `Sized` is not implemented for `dyn Shape`

Компилятор говорит, что размер значений типа dyn Shape нельзя узнать во время компиляции. dyn Shape может быть любого размера — маленький Square или большая структура со множеством полей. Rust нужен фиксированный размер, поэтому трейт-объект всегда должен жить за указателем: Box<dyn Shape>, &dyn Shape или Rc<dyn Shape>.

dyn повсюду. Если функция всегда получает только один тип за раз, impl Trait или обобщения проще и быстрее (подробнее об impl Trait — на странице Продвинутые трейты). Оставь dyn для случаев, когда действительно нужно смешивать типы.

Объяви трейт Animal с методами fn name(&self) -> String и fn sound(&self) -> String. Реализуй его для Dog, Cow и Duck, положи по одному в Vec<Box<dyn Animal>> и пройди по нему циклом, выводя, что говорит каждое животное. Подсказка: структуры могут быть пустыми (struct Dog;), а каждая кладётся в вектор через Box::new(...).

Показать решение
trait Animal {
fn name(&self) -> String;
fn sound(&self) -> String;
}
struct Dog;
struct Cow;
struct Duck;
impl Animal for Dog {
fn name(&self) -> String { String::from("Собака") }
fn sound(&self) -> String { String::from("гав") }
}
impl Animal for Cow {
fn name(&self) -> String { String::from("Корова") }
fn sound(&self) -> String { String::from("му") }
}
impl Animal for Duck {
fn name(&self) -> String { String::from("Утка") }
fn sound(&self) -> String { String::from("кря") }
}
fn main() {
let zoo: Vec<Box<dyn Animal>> = vec![Box::new(Dog), Box::new(Cow), Box::new(Duck)];
for animal in &zoo {
println!("{} говорит: {}", animal.name(), animal.sound());
}
}

Вывод:

Собака говорит: гав
Корова говорит: му
Утка говорит: кря
▶ Попробуй сам

Объяви трейт Greeter с методом fn hello(&self, name: &str) -> String и два типа: English и Spanish. Напиши fn make_greeter(lang: &str) -> Box<dyn Greeter>, которая возвращает Spanish для "es" и English для всего остального. Поприветствуй "Mia" для каждого кода из ["en", "es", "fr"]. Подсказка: обе ветки if должны возвращать один и тот же тип, поэтому оберни каждую в Box::new(...).

Приветствия здесь остаются на английском и испанском — в этом и смысл задачи.

Показать решение
trait Greeter {
fn hello(&self, name: &str) -> String;
}
struct English;
struct Spanish;
impl Greeter for English {
fn hello(&self, name: &str) -> String {
format!("Hello, {name}!")
}
}
impl Greeter for Spanish {
fn hello(&self, name: &str) -> String {
format!("¡Hola, {name}!")
}
}
fn make_greeter(lang: &str) -> Box<dyn Greeter> {
if lang == "es" {
Box::new(Spanish)
} else {
Box::new(English)
}
}
fn main() {
for lang in ["en", "es", "fr"] {
let greeter = make_greeter(lang);
println!("{lang}: {}", greeter.hello("Mia"));
}
}

Вывод:

en: Hello, Mia!
es: ¡Hola, Mia!
fr: Hello, Mia!
▶ Попробуй сам
  • Vec может хранить только один тип. Чтобы смешать типы, используй трейт-объект, например Box<dyn Trait>.
  • Box<dyn Trait> владеет значением, &dyn Trait заимствует его. Трейт-объект всегда живёт за указателем.
  • Обобщения и impl Trait используют статическую диспетчеризацию (быстро, решается при компиляции). dyn Trait использует динамическую (поиск в vtable во время работы).
  • Возвращай Box<dyn Trait>, когда функция выбирает между разными типами во время работы.
  • Трейт-объектами могут быть только трейты, совместимые с dyn: без методов, возвращающих Self, и без обобщённых методов (если у них нет where Self: Sized).

Проверь себя

1. Почему Circle и Square нельзя положить в один обычный Vec?

2. Что говорит Rust слово dyn в Box<dyn Shape>?

3. Что из этого использует статическую диспетчеризацию?

4. Почему Vec<dyn Shape> (без Box) — ошибка?

5. Какой метод трейта делает трейт несовместимым с dyn?

Результат: 0 / 5