Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Трейт-объекты: абстракция над общим поведением

В главе 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.

Файл: src/lib.rs
pub trait Draw {
    fn draw(&self);
}
Листинг 18-3: Определение трейта Draw

Этот синтаксис должен быть знаком по главе 10, где мы обсуждали определение трейтов. А дальше — новый синтаксис: в листинге 18-4 определена структура Screen (экран) с вектором components. Тип вектора — Box<dyn Draw>, то есть трейт-объект: заменитель для любого типа внутри Box, реализующего трейт Draw.

Файл: src/lib.rs
pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}
Листинг 18-4: Определение структуры Screen с полем components — вектором трейт-объектов, реализующих трейт Draw

Определим для структуры Screen метод run, который вызывает метод draw у каждого элемента components, как в листинге 18-5.

Файл: src/lib.rs
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();
        }
    }
}
Листинг 18-5: Метод run у Screen, вызывающий метод draw у каждого компонента

Это работает иначе, чем структура с обобщённым параметром типа и ограничениями трейтами. Вместо обобщённого параметра подставляется ровно один конкретный тип сразу для всех значений, а на место трейт-объекта во время выполнения можно подставлять разные конкретные типы. Например, структуру Screen можно было определить с обобщённым типом и ограничением трейтом, как в листинге 18-6.

Файл: src/lib.rs
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();
        }
    }
}
Листинг 18-6: Альтернативная реализация структуры Screen и её метода run через обобщения и ограничения трейтами

Тогда экземпляр Screen может хранить список компонентов, которые все либо типа Button, либо все типа TextField. Если коллекции у вас всегда однородные, лучше использовать обобщения и ограничения трейтами: определения будут мономорфизированы при компиляции под конкретные типы.

А с методом на трейт-объектах один экземпляр Screen может хранить Vec<T>, в котором есть и Box<Button>, и Box<TextField>. Посмотрим, как это работает, а потом поговорим о влиянии на производительность.

Реализация трейта

Теперь добавим несколько типов, реализующих трейт Draw. Сделаем тип Button. Опять же, настоящая реализация GUI-библиотеки выходит за рамки этой книги, поэтому в теле метода draw ничего полезного не будет. Чтобы представить, как могла бы выглядеть реализация, пусть у структуры Button будут поля width, height и label (подпись), как в листинге 18-7.

Файл: src/lib.rs
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) {
        // код, который на самом деле рисует кнопку
    }
}
Листинг 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
use gui::Draw;

struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        // код, который на самом деле рисует выпадающий список
    }
}

fn main() {}
Листинг 18-8: Другой крейт использует gui и реализует трейт Draw для структуры SelectBox

Теперь пользователь библиотеки может написать функцию main, создающую экземпляр Screen, и добавить в него SelectBox и Button, поместив каждый в Box<T>, чтобы получить трейт-объекты. Затем он вызывает у экземпляра Screen метод run, который вызовет draw у каждого компонента. Эта реализация — в листинге 18-9.

Файл: src/main.rs
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();
}
Листинг 18-9: С помощью трейт-объектов храним значения разных типов, реализующих один и тот же трейт

Когда мы писали библиотеку, мы не знали, что кто-то добавит тип 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 в качестве компонента.

Файл: 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`
  --> 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, — так что это компромисс, который стоит взвешивать.