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. Затем мы можем определить вектор, принимающий трейт-объект. Трейт-объект указывает и на экземпляр типа, реализующего указанный нами трейт, и на таблицу, которая используется для поиска методов трейта у этого типа во время выполнения. Мы создаем трейт-объект, указывая некоторый вид указателя, например ссылку или умный указатель Box<T>, затем ключевое слово dyn, а затем соответствующий трейт. (О причине, по которой трейт-объекты должны использовать указатель, мы поговорим в разделе «Динамически размерные типы и трейт Sized» главы 20.) Мы можем использовать трейт-объекты вместо обобщенного или конкретного типа. Везде, где мы используем трейт-объект, система типов Rust во время компиляции гарантирует, что любое значение, используемое в этом контексте, будет реализовывать трейт этого трейт-объекта. Следовательно, нам не нужно знать все возможные типы во время компиляции.

Мы упоминали, что в Rust мы воздерживаемся от называния структур и перечислений «объектами», чтобы отличать их от объектов в других языках. В структуре или перечислении данные в полях структуры и поведение в блоках impl разделены, тогда как в других языках данные и поведение, объединенные в одну концепцию, часто называются объектом. Трейт-объекты отличаются от объектов в других языках тем, что мы не можем добавить данные в трейт-объект. Трейт-объекты не столь универсально полезны, как объекты в других языках: их конкретная цель — позволить абстрагироваться над общим поведением.

Листинг 18-3 показывает, как определить трейт с именем Draw с одним методом draw.

Имя файла: src/lib.rs
pub trait Draw {
    fn draw(&self);
}
Listing 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>>,
}
Listing 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();
        }
    }
}
Listing 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();
        }
    }
}
Listing 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) {
        // code to actually draw a button
    }
}
Listing 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) {
        // code to actually draw a select box
    }
}

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

Пользователь нашей библиотеки теперь может написать свою функцию main, чтобы создать экземпляр Screen. В экземпляр Screen он может добавить SelectBox и Button, поместив каждый из них в Box<T>, чтобы они стали трейт-объектами. Затем он может вызвать метод run у экземпляра Screen, и тот вызовет 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) {
        // code to actually draw a select box
    }
}

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();
}
Listing 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();
}
Listing 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: компилятор генерирует необобщенные реализации функций и методов для каждого конкретного типа, который мы используем вместо обобщенного параметра типа. Код, получающийся в результате мономорфизации, выполняет статическую диспетчеризацию: компилятор знает во время компиляции, какой метод вы вызываете. Это противопоставляется динамической диспетчеризации, когда компилятор не может сказать во время компиляции, какой метод вы вызываете. В случаях динамической диспетчеризации компилятор генерирует код, который во время выполнения узнает, какой метод вызвать.

Когда мы используем трейт-объекты, Rust должен использовать динамическую диспетчеризацию. Компилятор не знает все типы, которые могут использоваться с кодом, использующим трейт-объекты, поэтому он не знает, реализацию какого метода у какого типа нужно вызвать. Вместо этого во время выполнения Rust использует указатели внутри трейт-объекта, чтобы узнать, какой метод вызвать. Этот поиск создает накладные расходы во время выполнения, которых нет при статической диспетчеризации. Динамическая диспетчеризация также не позволяет компилятору выбрать встраивание кода метода, что, в свою очередь, препятствует некоторым оптимизациям. Кроме того, в Rust есть правила о том, где можно и где нельзя использовать динамическую диспетчеризацию; они называются dyn-совместимостью. Эти правила выходят за рамки этого обсуждения, но вы можете подробнее прочитать о них в справочнике. Однако мы получили дополнительную гибкость в коде, который написали в листинге 18-5 и смогли поддержать в листинге 18-9, так что это компромисс, который стоит учитывать.