Трейт-объекты и диспетчеризация
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Трейт-объекты и диспетчеризация
Дженерик разворачивается в копии кода на этапе компиляции,
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, прыгнуть по указателю. Вот вся картинка целиком:
обычная ссылка трейт-объект
&deploy: &Deploy &deploy as &dyn Report
┌──────────────┐ ┌──────────────┬──────────────┐
│ адрес данных │ │ адрес данных │ адрес vtable │
└──────┬───────┘ └──────┬───────┴──────┬───────┘
│ │ │
▼ ▼ ▼
Deploy { service, version } Deploy { ... } vtable пары (Deploy, Report)
┌──────────────────────────┐
│ drop · size · align │
│ title → Deploy::title │
│ severity → Deploy::severity
│ log_line → Deploy::log_line
└──────────────────────────┘
Сами таблицы компилятор складывает в статическую память один раз на пару тип-трейт: сколько бы трейт-объектов ни родилось в рантайме, vtable для (Deploy, Report) одна на всю программу, и второе слово каждого такого указателя смотрит в неё. Размеры можно пощупать руками:
use std::mem::size_of;
// на 64-битной машине
assert_eq!(size_of::<&Deploy>(), 8); // обычная ссылка: одно слово
assert_eq!(size_of::<&dyn Report>(), 16); // данные + vtable: два слова
assert_eq!(size_of::<&str>(), 16); // данные + длина: тоже два
Третья строчка не случайна. Толстый указатель ты уже встречал в уроке про срезы: &str тоже двухсловный, только вторым словом едет длина. Приём один и тот же: когда компилятор чего-то не знает о данных статически, длины или типа, недостающее знание прикручивают к указателю и возят с собой. Из этой арифметики следует и неожиданный запрет: &str нельзя превратить в &dyn Display, хотя str реализует Display. Такой ссылке понадобились бы три слова, данные, длина и vtable, а указателей шире двух слов в Rust нет.
| Ссылка | адрес данных | длина | адрес vtable | слов |
|---|---|---|---|---|
&String | да | нет | нет | 1 |
&str | да | да | нет | 2 |
&String as &dyn Display | да | нет | да | 2 |
&str as &dyn Display | да | да | да | 3, запрещено |
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 лучше один раз пощупать, чем три раза перечитать.
ДЗ
Дальше
Две диспетчеризации в руках: статическая со своей платой за компиляцию и динамическая со своей платой за вызов. В уроке 13 обе встретятся внутри одного трейта: Iterator это дженерики и мономорфизация в каждом map, ассоциированный тип Item в сигнатуре и Box<dyn Iterator> там, где цепочку нужно стереть до поведения. Заодно разберём ленивые адаптеры и замыкания, на которых держится вся обработка коллекций в Rust.