Раздел 23 · Rust

Трейт-объекты и диспетчеризация

senior~40 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Трейт-объекты и диспетчеризация

Дженерик разворачивается в копии кода на этапе компиляции, 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.