Трейт-объекты: абстракция над общим поведением
В главе 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. Затем можно определить вектор, хранящий трейт-объекты.
Трейт-объект (trait object) указывает и на экземпляр типа, реализующего
заданный трейт, и на таблицу, по которой во время выполнения ищутся методы
трейта для этого типа. Трейт-объект создаётся так: указывается какой-нибудь
указатель — ссылка или умный указатель Box<T>, — затем ключевое слово dyn и
нужный трейт. (Почему трейт-объекты обязательно используют указатель, мы
поговорим в разделе «Типы с динамическим размером и трейт
Sized» главы 20.) Трейт-объекты можно
использовать вместо обобщённого или конкретного типа. Где бы мы ни
использовали трейт-объект, система типов Rust при компиляции проверит, что
любое значение в этом контексте реализует трейт трейт-объекта. Поэтому все
возможные типы при компиляции знать не нужно.
Мы уже говорили, что в Rust структуры и перечисления не называют «объектами»,
чтобы отличать их от объектов других языков. В структуре или перечислении данные
в полях и поведение в блоках impl разделены, а в других языках данные и
поведение, объединённые в одно понятие, часто и называют объектом.
Трейт-объекты отличаются от объектов других языков тем, что добавить в
трейт-объект данные нельзя. Трейт-объекты не так универсальны, как объекты в
других языках: их конкретное назначение — абстракция над общим поведением.
В листинге 18-3 — определение трейта Draw с одним методом draw.
pub trait Draw {
fn draw(&self);
}
DrawЭтот синтаксис должен быть знаком по главе 10, где мы обсуждали определение
трейтов. А дальше — новый синтаксис: в листинге 18-4 определена структура
Screen (экран) с вектором components. Тип вектора — Box<dyn Draw>, то есть
трейт-объект: заменитель для любого типа внутри Box, реализующего трейт
Draw.
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
Screen с полем components — вектором трейт-объектов, реализующих трейт DrawОпределим для структуры Screen метод run, который вызывает метод draw у
каждого элемента components, как в листинге 18-5.
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
run у Screen, вызывающий метод draw у каждого компонентаЭто работает иначе, чем структура с обобщённым параметром типа и ограничениями
трейтами. Вместо обобщённого параметра подставляется ровно один конкретный тип
сразу для всех значений, а на место трейт-объекта во время выполнения можно
подставлять разные конкретные типы. Например,
структуру Screen можно было определить с обобщённым типом и ограничением
трейтом, как в листинге 18-6.
pub trait Draw {
fn draw(&self);
}
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();
}
}
}
Screen и её метода run через обобщения и ограничения трейтамиТогда экземпляр Screen может хранить список компонентов, которые все либо
типа Button, либо все типа TextField. Если коллекции у вас всегда однородные,
лучше использовать обобщения и ограничения трейтами: определения будут
мономорфизированы при компиляции под конкретные типы.
А с методом на трейт-объектах один экземпляр Screen может хранить Vec<T>, в
котором есть и Box<Button>, и Box<TextField>. Посмотрим, как это работает, а
потом поговорим о влиянии на производительность.
Реализация трейта
Теперь добавим несколько типов, реализующих трейт Draw. Сделаем тип Button.
Опять же, настоящая реализация GUI-библиотеки выходит за рамки этой книги,
поэтому в теле метода draw ничего полезного не будет. Чтобы представить, как
могла бы выглядеть реализация, пусть у структуры Button будут поля width,
height и label (подпись), как в листинге 18-7.
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
pub struct Button {
pub width: u32,
pub height: u32,
pub label: String,
}
impl Draw for Button {
fn draw(&self) {
// код, который на самом деле рисует кнопку
}
}
Button, реализующая трейт DrawПоля width, height и label у Button отличаются от полей других
компонентов: например, у TextField могут быть те же поля плюс поле
placeholder. Каждый тип, который мы хотим рисовать на экране, реализует трейт
Draw, но в методе draw у каждого свой код — как рисовать именно этот тип, как
здесь у Button (только, как сказано, без настоящего кода GUI). У Button,
например, может быть ещё один блок impl с методами о том, что происходит, когда
пользователь нажимает кнопку. К типам вроде TextField такие методы не
относятся.
Если кто-то, пользуясь нашей библиотекой, решит реализовать структуру
SelectBox с полями width, height и options (варианты), он реализует трейт
Draw и для типа SelectBox, как в листинге 18-8.
use gui::Draw;
struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}
impl Draw for SelectBox {
fn draw(&self) {
// код, который на самом деле рисует выпадающий список
}
}
fn main() {}
gui и реализует трейт Draw для структуры SelectBoxТеперь пользователь библиотеки может написать функцию main, создающую
экземпляр Screen, и добавить в него SelectBox и Button, поместив каждый в
Box<T>, чтобы получить трейт-объекты. Затем он вызывает у экземпляра Screen
метод run, который вызовет draw у каждого компонента. Эта реализация — в
листинге 18-9.
use gui::Draw;
struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}
impl Draw for SelectBox {
fn draw(&self) {
// код, который на самом деле рисует выпадающий список
}
}
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();
}
Когда мы писали библиотеку, мы не знали, что кто-то добавит тип SelectBox, но
наша реализация Screen смогла работать с новым типом и нарисовать его,
потому что SelectBox реализует трейт Draw, а значит, и метод draw.
Такой подход — интересоваться только тем, на какие сообщения отвечает
значение, а не его конкретным типом, — похож на утиную типизацию (duck
typing) в динамически типизированных языках: если что-то ходит как утка и
крякает как утка, значит, это утка! В реализации run для Screen из листинга
18-5 методу run не нужно знать конкретный тип каждого компонента. Он не
проверяет, экземпляр ли это Button или SelectBox, а просто вызывает у
компонента метод draw. Указав тип значений вектора components как
Box<dyn Draw>, мы определили, что Screen нужны значения, у которых можно
вызвать метод draw.
Преимущество трейт-объектов и системы типов Rust перед утиной типизацией в том, что во время выполнения никогда не нужно проверять, реализует ли значение определённый метод, и не нужно бояться ошибки, если значение метод не реализует, а мы его всё равно вызвали. Rust просто не скомпилирует код, если значения не реализуют трейты, которые нужны трейт-объектам.
Например, листинг 18-10 показывает, что будет, если попытаться создать Screen
со String в качестве компонента.
use gui::Screen;
fn main() {
let screen = Screen {
components: vec![Box::new(String::from("Hi"))],
};
screen.run();
}
Получим ошибку, потому что 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`
--> src/lib.rs:23:1
|
23 | impl Draw 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, где мы обсуждали мономорфизацию, которую компилятор выполняет для обобщений: для каждого конкретного типа, подставленного вместо обобщённого параметра, компилятор генерирует необобщённые реализации функций и методов. Код, получающийся в результате мономорфизации, использует статическую диспетчеризацию (static dispatch): компилятор при компиляции знает, какой метод вы вызываете. Противоположность — динамическая диспетчеризация (dynamic dispatch): компилятор при компиляции не может сказать, какой метод вызывается. В этом случае он генерирует код, который узнает нужный метод во время выполнения.
С трейт-объектами Rust вынужден использовать динамическую диспетчеризацию. Компилятор не знает всех типов, которые могут использоваться с кодом на трейт-объектах, поэтому не знает, метод какого типа вызывать. Вместо этого во время выполнения Rust по указателям внутри трейт-объекта определяет, какой метод вызвать. Этот поиск стоит времени во время выполнения — при статической диспетчеризации таких затрат нет. Кроме того, динамическая диспетчеризация не даёт компилятору встроить (inline) код метода, а значит, мешает некоторым оптимизациям. Ещё в Rust есть правила о том, где можно и где нельзя использовать динамическую диспетчеризацию, — dyn-совместимость (dyn compatibility). Эти правила выходят за рамки обсуждения, но подробнее о них можно прочитать в справочнике. Зато код из листинга 18-5 получился гибче, и мы смогли поддержать сценарий из листинга 18-9, — так что это компромисс, который стоит взвешивать.