Использование трейт-объектов для абстракции над общим поведением
В Главе 8 мы упоминали, что одно из ограничений векторов заключается в том, что они могут хранить элементы только одного типа. Мы создали обходное решение в Листинге 8-9, где определили перечисление SpreadsheetCell с вариантами для хранения целых чисел, чисел с плавающей точкой и текста. Это означало, что мы могли хранить разные типы данных в каждой ячейке и при этом иметь вектор, представляющий строку ячеек. Это совершенно хорошее решение, когда наши взаимозаменяемые элементы представляют собой фиксированный набор типов, известный на момент компиляции кода.
Однако иногда мы хотим, чтобы пользователь нашей библиотеки мог расширять набор типов, допустимых в определенной ситуации. Чтобы показать, как мы можем достичь этого, мы создадим пример инструмента графического пользовательского интерфейса (GUI), который проходит по списку элементов, вызывая метод draw для каждого из них, чтобы отрисовать его на экране — распространенная техника для инструментов GUI. Мы создадим библиотечный крейт с именем gui, который содержит структуру библиотеки GUI. Этот крейт может включать некоторые типы для использования людьми, такие как Button или TextField. Кроме того, пользователи gui захотят создавать свои собственные типы, которые можно отрисовывать: например, один программист может добавить Image, а другой — SelectBox.
Во время написания библиотеки мы не можем знать и определять все типы, которые другие программисты могут захотеть создать. Но мы знаем, что gui должен отслеживать множество значений разных типов, и ему нужно вызывать метод draw для каждого из этих значений разных типов. Ему не нужно знать точно, что произойдет, когда мы вызовем метод draw, только то, что значение будет иметь этот метод доступным для вызова.
Чтобы сделать это в языке с наследованием, мы могли бы определить класс с именем Component, который имеет метод draw. Другие классы, такие как Button, Image и SelectBox, наследовались бы от Component и, таким образом, наследовали бы метод draw. Они могли бы каждый переопределить метод draw, чтобы определить свое собственное поведение, но фреймворк мог бы рассматривать все типы как экземпляры Component и вызывать draw на них. Но поскольку в Rust нет наследования, нам нужен другой способ структурировать библиотеку gui, чтобы позволить пользователям создавать новые типы, совместимые с библиотекой.
Определение трейта для общего поведения
Чтобы реализовать поведение, которое мы хотим иметь в gui, мы определим трейт с именем Draw, который будет иметь один метод с именем draw. Затем мы можем определить вектор, который принимает трейт-объект. Трейт-объект указывает как на экземпляр типа, реализующего наш указанный трейт, так и на таблицу, используемую для поиска методов трейта для этого типа во время выполнения. Мы создаем трейт-объект, указывая какой-либо указатель, такой как ссылка или умный указатель Box<T>, затем ключевое слово dyn, а затем указывая соответствующий трейт. (Мы поговорим о причине, по которой трейт-объекты должны использовать указатель, в разделе "Динамически sized типы и трейт Sized" в Главе 20.) Мы можем использовать трейт-объекты вместо универсального или конкретного типа. Где бы мы ни использовали трейт-объект, система типов Rust гарантирует во время компиляции, что любое значение, используемое в этом контексте, будет реализовывать трейт трейт-объекта. Следовательно, нам не нужно знать все возможные типы во время компиляции.
Мы упоминали, что в Rust мы избегаем называть структуры и перечисления «объектами», чтобы отличать их от объектов других языков. В структуре или перечислении данные в полях структуры и поведение в блоках impl разделены, тогда как в других языках данные и поведение, объединенные в одно понятие, часто называются объектом. Трейт-объекты отличаются от объектов в других языках тем, что мы не можем добавлять данные в трейт-объект. Трейт-объекты не так универсально полезны, как объекты в других языках: их конкретная цель — позволить абстракцию над общим поведением.
Листинг 18-3 показывает, как определить трейт с именем Draw с одним методом draw.
Файл: src/lib.rs
#![allow(unused)] fn main() { pub trait Draw { fn draw(&self); } }
Листинг 18-3: Определение трейта Draw
Этот синтаксис должен выглядеть знакомым из наших обсуждений о том, как определять трейты в Главе 10. Далее следует новый синтаксис: Листинг 18-4 определяет структуру с именем Screen, которая содержит вектор с именем components. Этот вектор имеет тип Box<dyn Draw>, который является трейт-объектом; это заместитель для любого типа внутри Box, который реализует трейт Draw.
Файл: src/lib.rs
#![allow(unused)] fn main() { pub struct Screen { pub components: Vec<Box<dyn Draw>>, } }
Листинг 18-4: Определение структуры Screen с полем components, содержащим вектор трейт-объектов, реализующих трейт Draw
Для структуры Screen мы определим метод с именем run, который будет вызывать метод draw для каждого из своих компонентов, как показано в Листинге 18-5.
Файл: src/lib.rs
#![allow(unused)] fn main() { impl Screen { pub fn run(&self) { for component in self.components.iter() { component.draw(); } } } }
Листинг 18-5: Метод run на Screen, который вызывает метод draw для каждого компонента
Это работает иначе, чем определение структуры, которая использует параметр обобщенного типа с границами трейта. Параметр обобщенного типа может быть заменен только одним конкретным типом за раз, тогда как трейт-объекты позволяют нескольким конкретным типам подставляться вместо трейт-объекта во время выполнения. Например, мы могли бы определить структуру Screen с использованием обобщенного типа и границы трейта, как в Листинге 18-6.
Файл: src/lib.rs
#![allow(unused)] fn main() { pub struct Screen<T: Draw> { pub components: Vec<T>, } impl<T> Screen<T> where T: Draw, { pub fn run(&self) { for component in self.components.iter() { component.draw(); } } } }
Листинг 18-6: Альтернативная реализация структуры Screen и ее метода run с использованием дженериков и границ трейтов
Это ограничивает нас экземпляром Screen, который имеет список компонентов, все типа Button или все типа TextField. Если у вас всегда будут только однородные коллекции, использование дженериков и границ трейтов предпочтительнее, потому что определения будут моно morphизированы во время компиляции для использования конкретных типов.
С другой стороны, с методом, использующим трейт-объекты, один экземпляр Screen может содержать Vec<T>, который содержит Box<Button>, а также Box<TextField>. Давайте посмотрим, как это работает, а затем поговорим о последствиях для производительности во время выполнения.
Реализация трейта
Теперь мы добавим некоторые типы, которые реализуют трейт Draw. Мы предоставим тип Button. Опять же, фактическая реализация библиотеки GUI выходит за рамки этой книги, поэтому метод draw не будет иметь никакой полезной реализации в своем теле. Чтобы представить, как может выглядеть реализация, структура Button может иметь поля для width, height и label, как показано в Листинге 18-7.
Файл: src/lib.rs
#![allow(unused)] fn main() { pub struct Button { pub width: u32, pub height: u32, pub label: String, } impl Draw for Button { fn draw(&self) { // код для фактической отрисовки кнопки } } }
Листинг 18-7: Структура Button, реализующая трейт Draw
Поля width, height и label на Button будут отличаться от полей других компонентов; например, тип TextField может иметь те же поля плюс поле placeholder. Каждый из типов, которые мы хотим отрисовать на экране, будет реализовывать трейт Draw, но будет использовать разный код в методе draw для определения того, как рисовать этот конкретный тип, как здесь у Button (без фактического кода GUI, как упоминалось). Тип Button, например, может иметь дополнительный блок impl, содержащий методы, связанные с тем, что происходит, когда пользователь нажимает кнопку. Такие методы не будут применяться к типам вроде TextField.
Если кто-то, использующий нашу библиотеку, решит реализовать структуру SelectBox, которая имеет поля width, height и options, он также реализует трейт Draw для типа SelectBox, как показано в Листинге 18-8.
Файл: src/main.rs
#![allow(unused)] fn main() { use gui::Draw; struct SelectBox { width: u32, height: u32, options: Vec<String>, } impl Draw for SelectBox { fn draw(&self) { // код для фактической отрисовки select box } } }
Листинг 18-8: Другой крейт, использующий gui и реализующий трейт Draw на структуре SelectBox
Теперь пользователь нашей библиотеки может написать свою функцию main, чтобы создать экземпляр Screen. К экземпляру Screen они могут добавить SelectBox и Button, поместив каждый в Box<T>, чтобы стать трейт-объектом. Затем они могут вызвать метод run на экземпляре Screen, который вызовет draw для каждого из компонентов. Листинг 18-9 показывает эту реализацию.
Файл: src/main.rs
use gui::{Button, Screen}; fn main() { let screen = Screen { components: vec![ Box::new(SelectBox { width: 75, height: 10, options: vec![ String::from("Yes"), String::from("Maybe"), String::from("No"), ], }), Box::new(Button { width: 50, height: 10, label: String::from("OK"), }), ], }; screen.run(); }
Листинг 18-9: Использование трейт-объектов для хранения значений разных типов, реализующих один и тот же трейт
Когда мы писали библиотеку, мы не знали, что кто-то может добавить тип SelectBox, но наша реализация Screen смогла работать с новым типом и рисовать его, потому что SelectBox реализует трейт Draw, что означает, что он реализует метод draw.
Эта концепция — забота только о сообщениях, на которые отвечает значение, а не о конкретном типе значения — похожа на концепцию утиной типизации в динамически типизированных языках: если оно ходит как утка и крякает как утка, то это должно быть уткой! В реализации run для Screen в Листинге 18-5, run не нужно знать, какой конкретный тип у каждого компонента. Он не проверяет, является ли компонент экземпляром Button или SelectBox, он просто вызывает метод draw на компоненте. Указав Box<dyn Draw> в качестве типа значений в векторе components, мы определили, что Screen нуждается в значениях, для которых мы можем вызвать метод draw.
Преимущество использования трейт-объектов и системы типов Rust для написания кода, похожего на код с утиной типизацией, заключается в том, что нам никогда не приходится проверять, реализует ли значение определенный метод во время выполнения, или беспокоиться о получении ошибок, если значение не реализует метод, но мы все равно его вызываем. Rust не скомпилирует наш код, если значения не реализуют трейты, которые нужны трейт-объектам.
Например, Листинг 18-10 показывает, что происходит, если мы пытаемся создать Screen со String в качестве компонента.
Файл: src/main.rs
// Этот код не компилируется! use gui::Screen; fn main() { let screen = Screen { components: vec![Box::new(String::from("Hi"))], }; screen.run(); }
Листинг 18-10: Попытка использовать тип, который не реализует трейт трейт-объекта
Мы получим эту ошибку, потому что String не реализует трейт Draw:
$ cargo run
Compiling gui v0.1.0 (file:///projects/gui)
error[E0277]: the trait bound `String: Draw` is not satisfied
--> src/main.rs:5:26
|
5 | components: vec![Box::new(String::from("Hi"))],
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is not implemented for `String`
|
= help: the trait `Draw` is implemented for `Button`
= note: required for the cast from `Box<String>` to `Box<dyn Draw>`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `gui` (bin "gui") due to 1 previous error
Эта ошибка позволяет нам знать, что либо мы передаем что-то в Screen, что мы не собирались передавать, и поэтому должны передать другой тип, либо мы должны реализовать Draw для String, чтобы Screen мог вызвать draw на нем.
Выполнение динамической диспетчеризации
Вспомните в разделе "Производительность кода с использованием дженериков" в Главе 10 наше обсуждение процесса моно morphизации, выполняемого компилятором для дженериков: компилятор генерирует необобщенные реализации функций и методов для каждого конкретного типа, который мы используем вместо параметра обобщенного типа. Код, полученный в результате моно morphизации, выполняет статическую диспетчеризацию, когда компилятор знает, какой метод вы вызываете во время компиляции. Это противоположно динамической диспетчеризации, когда компилятор не может определить во время компиляции, какой метод вы вызываете. В случаях динамической диспетчеризации компилятор генерирует код, который во время выполнения будет знать, какой метод вызвать.
Когда мы используем трейт-объекты, Rust должен использовать динамическую диспетчеризацию. Компилятор не знает все типы, которые могут быть использованы с кодом, использующим трейт-объекты, поэтому он не знает, какой метод, реализованный на каком типе, вызывать. Вместо этого во время выполнения Rust использует указатели внутри трейт-объекта, чтобы узнать, какой метод вызвать. Этот поиск влечет за собой затраты времени выполнения, которых нет при статической диспетчеризации. Динамическая диспетчеризация также предотвращает возможность компилятора встраивать код метода, что, в свою очередь, предотвращает некоторые оптимизации, и у Rust есть некоторые правила о том, где можно и где нельзя использовать динамическую диспетчеризацию, называемые совместимостью с dyn. Эти правила выходят за рамки данного обсуждения, но вы можете прочитать о них больше в справочнике. Однако мы получили дополнительную гибкость в коде, который мы написали в Листинге 18-5 и смогли поддержать в Листинге 18-9, так что это компромисс, который стоит учитывать.