Первоначально опубликовано в моем блоге: https://kerkour.com/rust-generics-trait-objects
Введение в Rust generics:
- Трейты
- Объекты трейтов (статическая и динамическая диспетчеризация)
Этот пост является отрывком из моего курса Black Hat Rust.
Теперь вы можете задаться вопросом: Как создать коллекцию, которая может содержать различные конкретные типы, удовлетворяющие заданному признаку? Например:
trait UsbModule {
// ...
}
struct UsbCamera {
// ...
}
impl UsbModule for UsbCamera {
// ..
}
impl UsbCamera {
// ...
}
struct UsbMicrophone{
// ...
}
impl UsbModule for UsbMicrophone {
// ..
}
impl UsbMicrophone {
// ...
}
let peripheral_devices: Vec<UsbModule> = vec![
UsbCamera::new(),
UsbMicrophone::new(),
];
К сожалению, в Rust это не так просто. Поскольку модули могут иметь разный размер в памяти, компилятор не позволяет нам создать такую коллекцию. Все элементы вектора не имеют одинаковой формы.
Объекты Traits решают именно эту проблему: когда вы хотите использовать различные конкретные типы (разной формы), придерживающиеся контракта (trait), во время выполнения.
Вместо того чтобы использовать объекты напрямую, мы будем использовать указатели на объекты в нашей коллекции. На этот раз компилятор примет наш код, так как каждый указатель имеет одинаковый размер.
Как это сделать на практике? Мы увидим ниже при добавлении модулей в наш сканер.
Статическая и динамическая диспетчеризация
Итак, в чем техническая разница между общим параметром и объектом трейта?
Когда вы используете общий параметр (здесь для функции process
):
ch_04/snippets/dispatch/src/statik.rs
trait Processor {
fn compute(&self, x: i64, y: i64) -> i64;
}
struct Risc {}
impl Processor for Risc {
fn compute(&self, x: i64, y: i64) -> i64 {
x + y
}
}
struct Cisc {}
impl Processor for Cisc {
fn compute(&self, x: i64, y: i64) -> i64 {
x * y
}
}
fn process<P: Processor>(processor: &P, x: i64) {
let result = processor.compute(x, 42);
println!("{}", result);
}
pub fn main() {
let processor1 = Cisc {};
let processor2 = Risc {};
process(&processor1, 1);
process(&processor2, 2);
}
Компилятор генерирует специализированную версию для каждого типа, с которым вы вызываете функцию, а затем заменяет места вызова вызовами этих специализированных функций.
Это известно как мономорфизация.
Например, приведенный выше код примерно эквивалентен:
fn process_Risc(processor: &Risc, x: i64) {
let result = processor.compute(x, 42);
println!("{}", result);
}
fn process_Cisc(processor: &Cisc, x: i64) {
let result = processor.compute(x, 42);
println!("{}", result);
}
Это то же самое, как если бы вы сами реализовывали эти функции. Это известно как статическая диспетчеризация. Выбор типа осуществляется статически во время компиляции. Это обеспечивает наилучшую производительность во время выполнения.
С другой стороны, когда вы используете объект trait:
ch_04/snippets/dispatch/src/dynamic.rs
trait Processor {
fn compute(&self, x: i64, y: i64) -> i64;
}
struct Risc {}
impl Processor for Risc {
fn compute(&self, x: i64, y: i64) -> i64 {
x + y
}
}
struct Cisc {}
impl Processor for Cisc {
fn compute(&self, x: i64, y: i64) -> i64 {
x * y
}
}
fn process(processor: &dyn Processor, x: i64) {
let result = processor.compute(x, 42);
println!("{}", result);
}
pub fn main() {
let processors: Vec<Box<dyn Processor>> = vec![
Box::new(Cisc {}),
Box::new(Risc {}),
];
for processor in processors {
process(&*processor, 1);
}
}
Компилятор сгенерирует только 1 функцию process
. Именно во время выполнения ваша программа определит, какого типа Processor
является переменная processor
и, следовательно, какой метод compute
нужно вызвать. Это известная динамическая диспетчеризация. Выбор типа осуществляется динамически во время выполнения.
Синтаксис для объектов трейта &dyn Processor
может показаться немного тяжеловесным, особенно если вы пришли из менее многословных языков. Лично мне он нравится! С первого взгляда видно, что функция принимает объект трейта, благодаря dyn Processor
.
Ссылка &
необходима, потому что Rust должен знать точный размер каждой переменной.
Поскольку структуры, реализующие признак Processor
, могут иметь разный размер, единственным решением является передача ссылки. Это также мог быть (умный) указатель, такой как Box
, Rc
или Arc
.
Дело в том, что переменная processor
должна иметь размер, известный во время компиляции.
Обратите внимание, что в этом конкретном примере мы делаем &*processor
, потому что нам сначала нужно разыменовать Box
, чтобы передать ссылку в функцию process
. Это эквивалентно process(&(*processor), 1)
.
При компиляции динамически диспетчеризируемых функций Rust создает под капотом так называемую vtable и использует эту vtable во время выполнения для выбора функции для вызова.
Это сообщение является отрывком из моего курса Black Hat Rust.
Несколько заключительных мыслей
Используйте статическую диспетчеризацию, когда вам нужна абсолютная производительность, и объекты-трейты, когда вам нужна большая гибкость или коллекции объектов с одинаковым поведением.