Трейт-объекты и диспетчеризация
Ирма Окунева · совет Гильдии
В одном зале ярмарки теперь три мастерские: кузня, ювелирка и бронник. Каждая делает своё, а витрина у Гильдии одна. Покупателю всё равно, кто ковал: он видит ценник. Как положить в один список подкову, кольцо и кольчугу?
Дженерик здесь не поможет: Vec<T> хранит значения одного типа, а у Ирмы три разных. Нужен второй режим трейтов, где конкретный тип стирается, а метод находится во время исполнения: трейт-объекты dyn Trait. Этот урок про то, как они устроены, чем за них платят и когда их выбирать вместо дженериков.
Дженерик разворачивается в копии кода на этапе компиляции,
dyn Traitвыбирает метод в рантайме через vtable. Урок про обе диспетчеризации: толстые указатели, type erasure и правила dyn-совместимости. Цель: выбирать междуBox<dyn Trait>и дженериком осознанно, а не по привычке.
Идея
Вернёмся к мониторингу из урока про трейты: трейт Report, структуры Deploy и Alert, и каждая умеет рассказать о себе. Теперь задача, на которой инструменты двух прошлых уроков застревают: лента событий, где деплои и алерты лежат вперемешку, в порядке прихода.
let feed = vec![
Deploy { service: String::from("api"), version: String::from("2.4.1") },
Alert { message: String::from("диск заполнен"), is_critical: true }, // ❌ expected Deploy, found Alert
];
Vec<T> мономорфизируется в вектор одного конкретного типа, смешивать ему нечем. И impl Report из урока про трейты не спасёт: ты помнишь его ограничение, все ветки функции обязаны вернуть один и тот же тип. Корень общий, и стоит увидеть его ясно: вся машинерия дженериков работает на этапе компиляции, а вопрос «деплой сейчас пришёл или алерт» существует только в рантайме. Компилятор не может знать, что прилетит в ленту, значит, нужен механизм, откладывающий выбор метода до момента исполнения. Это и есть динамическая диспетчеризация, второй режим полиморфизма в Rust.
Толстый указатель и vtable
Механизм включается словом dyn:
let deploy = Deploy { service: String::from("api"), version: String::from("2.4.1") };
let event: &dyn Report = &deploy;
println!("{}", event.title());
dyn Report читается как «какое-то значение, умеющее Report». Конкретный тип в этой ссылке стёрт: глядя на event, ни ты, ни компилятор больше не знаете, что внутри Deploy. Этот приём называется type erasure, и он сразу ставит вопрос: если тип стёрт, как вызов event.title() находит код именно Deploy::title?
Ответ лежит внутри самой ссылки. &dyn Report это толстый указатель: два машинных слова вместо одного. Первое слово, как у обычной ссылки, адрес данных. Второе слово, адрес vtable, таблицы, которую компилятор собрал заранее для пары «тип Deploy, трейт Report»: указатели на Deploy::title, Deploy::severity, плюс служебные данные о типе, его размер, выравнивание и деструктор. Вызов event.title() это два шага в рантайме: прочитать нужный слот vtable, прыгнуть по указателю. Таблицы компилятор складывает в статическую память один раз на пару тип-трейт: сколько бы трейт-объектов ни родилось в рантайме, vtable для (Deploy, Report) одна на всю программу.
Как именно устроен толстый указатель, сколько в нём слов у среза и у трейт-объекта и почему &str нельзя превратить в &dyn Display, разберём в уроке про DST и толстые указатели. Для этого урока хватит главного: за dyn стоит указатель на данные плюс указатель на таблицу методов.
dyn живёт за указателем
У dyn Report нет размера на этапе компиляции: Deploy с двумя String занимает 48 байт, Alert 32, а «что-то, умеющее Report» может быть любого размера. Поэтому положить dyn Report в переменную или в Vec напрямую нельзя, безразмерному значению негде жить на стеке. Трейт-объект всегда обитает за указателем: &dyn Report, Box<dyn Report>, Rc<dyn Report>. Для ленты событий это Box, владеющий указатель в кучу:
let feed: Vec<Box<dyn Report>> = vec![
Box::new(Deploy { service: String::from("api"), version: String::from("2.4.1") }),
Box::new(Alert { message: String::from("диск заполнен"), is_critical: true }),
];
for event in &feed {
println!("{}", event.log_line());
}
Гетерогенная коллекция собрана. В самом векторе лежат одинаковые двухсловные Box-указатели, события живут в куче каждый своего размера, и каждая итерация цикла уходит через vtable своего типа: первая строчка через таблицу Deploy, вторая через таблицу Alert. Заметь, что log_line мы не писали ни для одного из них, это метод по умолчанию из урока «Что такое трейт», и в vtable он попадает наравне с обязательными.
Второй сценарий, где без dyn никак, тебе обещан с урока про дженерики: вернуть «либо то, либо это»:
fn latest_event(critical: bool) -> Box<dyn Report> {
if critical {
Box::new(Alert { message: String::from("диск заполнен"), is_critical: true })
} else {
Box::new(Deploy { service: String::from("api"), version: String::from("2.4.1") })
}
}
С impl Report эта функция не компилировалась, мономорфизации нужен был один тип на все ветки. С Box<dyn Report> тип стёрт, и ветки свободны. Та же дверь открывается для плагинной архитектуры: ядро объявляет трейт, чужой код приносит свои типы, и ядро зовёт их, не зная о них ничего, кроме vtable.
Dyn-совместимость
Попробуем стереть тип у чего-нибудь из прошлого урока:
let value: Box<dyn Clone> = Box::new(42); // ❌ E0038: the trait `Clone` is not dyn compatible
Не каждый трейт превращается в трейт-объект. Свойство называется dyn-совместимостью, в старых текстах object safety, и его правила перестают казаться произвольными, если держать в голове один вопрос: можно ли построить vtable, не зная конкретного типа?
Возьми Clone и его fn clone(&self) -> Self. В мире dyn тип за ссылкой стёрт, и Self означает «неизвестно что неизвестного размера»: компилятор не знает, сколько байт выделить под возврат. Слот для такого метода в vtable построить нельзя. Тот же приговор дженерик-методам вроде fn process<T: Display>(&self, item: T): мономорфизация штампует копию метода на каждый T, а vtable это конечная таблица, собранная заранее, все будущие T в неё не влезут. Итого правила: методы принимают &self или &mut self, не возвращают и не принимают Self по значению, не имеют своих дженерик-параметров, и сам трейт не требует Self: Sized.
Что делать, если трейту нужен и метод с Self, и dyn-совместимость? Выключить метод из vtable точечно:
trait Report {
fn title(&self) -> String;
fn severity(&self) -> u8;
fn into_archived(self) -> ArchivedReport
where
Self: Sized, // метода нет в vtable, dyn Report остаётся законным
{
ArchivedReport { title: self.title() }
}
}
Пометка where Self: Sized означает: метод существует только там, где конкретный тип известен, а для dyn Report его нет. Стандартная библиотека пользуется этим постоянно: у Iterator все адаптеры вроде map и filter забирают self по значению и помечены where Self: Sized, поэтому dyn Iterator существует и работает, хоть и без части методов.
Последний штрих: в типе трейт-объекта может жить только один содержательный трейт. Box<dyn Report + Display> не скомпилируется: это потребовало бы две vtable и снова трёхсловный указатель. Добавлять можно лишь маркерные auto-трейты без методов, Box<dyn Report + Send + Sync> законен, у Send и Sync нет vtable. Если нужны два словаря методов сразу, заведи супертрейт trait FullReport: Report + Display {} и стирай до него. Дорога к супертрейту тоже открыта: &dyn FullReport приводится к &dyn Report обычным as или передачей в функцию, компилятор сам подменит vtable на таблицу попроще. Этот апкаст стабилизировали в Rust 1.86, в более старых учебниках вместо него встретишь обходной метод вроде fn as_report(&self) -> &dyn Report, теперь он не нужен.
Вернуть тип обратно: Any
Type erasure играет в одну сторону: из Deploy сделать &dyn Report легко, а спросить у &dyn Report, не Deploy ли он, нечем, в vtable пары (Deploy, Report) нет ответа на такой вопрос. Когда конкретный тип всё же нужен обратно, стандартная библиотека предлагает специальный трейт Any: его vtable хранит TypeId, и по нему можно проверить догадку:
use std::any::Any;
fn inspect(event: &dyn Any) {
if let Some(deploy) = event.downcast_ref::<Deploy>() {
println!("деплой сервиса {}", deploy.service);
} else {
println!("что-то другое, и мы не узнаем что");
}
}
downcast_ref::<Deploy> возвращает Option<&Deploy>: либо тип угадан и ссылка на конкретный Deploy снова в руках, либо None. Заметь честность интерфейса: никакого «приведения вслепую» как в языках с кастами, только проверяемая догадка.
И сразу предупреждение из практики: downcast_ref в прикладном коде почти всегда запах. Если ты ловишь себя на цепочке «а не Deploy ли ты, а не Alert ли ты», то ты вручную собрал плохой match по закрытому набору вариантов, и нужен enum, к которому мы придём через раздел. Настоящая ниша Any узкая: контейнеры разнотипных значений вроде хранилищ расширений в веб-фреймворках, передача произвольной нагрузки через панику, плагинные системы. Встретишь его там, поймёшь, что происходит, но сам тянись к нему в последнюю очередь.
Дженерик против dyn
Оба режима у тебя в руках, осталась развилка. Сведём цены. Дженерик платит на компиляции: копия кода на каждый тип, толще бинарник, дольше сборка, зато в рантайме вызов прямой, инлайнится и оптимизируется насквозь, ты видел это в уроке про дженерики. dyn платит в рантайме: косвенный вызов через vtable и, что важнее, стена для оптимизатора, сквозь указатель на функцию не работают ни инлайн, ни векторизация, ни специализация. Зато код один на все типы, и решение о типе можно принимать во время исполнения.
Развилка касается не только коллекций и возвратов, она есть у каждой функции-параметра. fn print_report<T: Report>(r: &T) и fn print_report(r: &dyn Report) решают одну задачу, и заметь, во втором варианте нет ни Box, ни кучи: обычная ссылка на стековое значение, просто толстая. Дженерик-версия размножится по типам и впустит инлайн, dyn-версия останется в бинарнике одним экземпляром и сможет лечь в Vec указателей на функции, в поле структуры без типового параметра, в границу плагина. Цены те же, что и выше, масштаб мельче.
| Ситуация | Выбор |
|---|---|
| Гетерогенная коллекция, типы вперемешку | dyn, дженерику нечем |
| Вернуть разные типы из веток функции | dyn или enum |
| Горячий цикл, счётный код | дженерик, инлайн решает |
| Плагины, типы появятся после компиляции ядра | dyn |
| Библиотечный API общего назначения | дженерик по умолчанию |
| Бинарник распух, сборка ползёт | точечно перевести жирное на dyn |
И одна альтернатива, о которой забывают, потому что она не похожа на «полиморфизм»: если набор вариантов закрытый и весь твой, тебе, возможно, не нужны ни дженерик, ни dyn, а нужен enum из урока про перечисления. enum Event { Deploy(Deploy), Alert(Alert) } решает и ленту, и возврат из веток: без кучи, без vtable, с прямыми вызовами через match, и компилятор проверит полноту разбора. Трейт-объект выигрывает, когда набор типов открытый: чужой код сможет добавить свой тип события, не трогая твой enum. Закрытый мир, enum; открытый мир, dyn; горячий обобщённый код, дженерик. Это и есть вся таблица, сжатая в одно предложение.
Практика
Толстые указатели и ?Sized лучше один раз пощупать, чем три раза перечитать.
Мастерская, этап 15
У Гильдии три мастерские, и каждая это Box<dyn Workshop>: кузня делает подковы, ювелирка кольца, бронник кольчуги. Изделия разных мастерских лежат в одном Vec<Box<dyn Craft>>, и одна функция считает их общую цену. Команда мастерские покажет, что и почём делает каждая.
ДЗ
Дальше
Витрина Гильдии собрана из трёх мастерских, и цены на ней честные. Хозяин ярмарки объявляет торги: покупатели встают в очередь, а в конце дня считают выручку и лучшего покупателя. Вадим уже набрал заказов больше всех.
Две диспетчеризации в руках: статическая со своей платой за компиляцию и динамическая со своей платой за вызов. В уроке «Итераторы и замыкания» обе встретятся внутри одного трейта: Iterator это дженерики и мономорфизация в каждом map, ассоциированный тип Item в сигнатуре и Box<dyn Iterator> там, где цепочку нужно стереть до поведения. Заодно разберём ленивые адаптеры и замыкания, на которых держится вся обработка коллекций в Rust.