Трейт-объекты в Rust
На странице Трейты ты писал функции, которые принимают «любой тип, реализующий Speak». На странице Обобщения ты видел, как Rust делает отдельную копию обобщённой функции для каждого типа. Оба способа отлично работают, когда тип известен во время компиляции. А что, если нужен один список, в котором лежат значения разных типов?
Для этого и существует трейт-объект (trait object). Представь учителя музыки и класс, полный учеников. Один играет на пианино, другой — на скрипке, третий — на барабанах. Учителю не нужно знать, у кого какой инструмент: он говорит «играйте!», и каждый играет по-своему. Трейт-объект позволяет коду делать то же самое: «точный тип я не знаю, но знаю, что он умеет вот это».
Проблема: один Vec, разные типы
Заголовок раздела «Проблема: один Vec, разные типы»Допустим, у тебя есть две фигуры, и ты хочешь хранить их в одном 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<dyn Trait>: список разных фигур
Заголовок раздела «Box<dyn Trait>: список разных фигур»Решение состоит из двух частей. Во-первых, дай обеим фигурам общий трейт. Во-вторых, положи каждую фигуру в 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») и называется трейт-объектом.
&dyn Trait: заимствуем вместо коробки
Заголовок раздела «&dyn Trait: заимствуем вместо коробки»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, когда упрёшься в задачу «разные типы в одном списке». О разнице в скорости особо не переживай: в большинстве программ ты её никогда не заметишь.
Возвращаем Box<dyn Trait> из функции
Заголовок раздела «Возвращаем Box<dyn Trait> из функции»Иногда функция должна решить во время работы, какой тип вернуть. Можно попробовать -> 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(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»Не каждый трейт может стать трейт-объектом. Трейт должен быть совместимым с dyn (dyn compatible; в старых книгах и статьях это называют объектной безопасностью, object safety). Полные правила длинные, но большинство реальных случаев покрывают два:
- Метод не может возвращать
Self. (ЗаdynRust не знает, что такое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 для случаев, когда действительно нужно смешивать типы.
Практика
Заголовок раздела «Практика»1. Зоопарк в одном Vec
Заголовок раздела «1. Зоопарк в одном Vec»Объяви трейт 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()); }}Вывод:
Собака говорит: гавКорова говорит: муУтка говорит: кря2. Выбираем приветствие во время работы
Заголовок раздела «2. Выбираем приветствие во время работы»Объяви трейт 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
Что почитать
Заголовок раздела «Что почитать»- Использование трейт-объектов, допускающих значения разных типов — книга «Язык программирования Rust» (перевод на русский): официальная глава, в которой строится список компонентов интерфейса с
Box<dyn Draw>. - Возврат типажа с dyn — Rust на примерах (перевод на русский): короткий запускаемый пример функции, которая возвращает
Box<dyn Trait>. - Trait objects — The Rust Reference (на английском): точные правила для типов
dyn Trait, включая времена жизни и дополнительные маркерные трейты вродеSend. - Dyn compatibility — The Rust Reference (на английском): полный список правил, которым должен следовать трейт, чтобы работать с
dyn. - Ключевое слово
dyn— документация std (на английском): короткое объяснениеdyn, vtable и компромиссов по сравнению с обобщениями.