Использование типаж-объектов, допускающих значения разных типов

В главе 8 мы упоминали, что одним из ограничений векторов является то, что они могут хранить элементы только одного типа. Мы создали обходное решение в листинге 8-9, где мы определили перечисление SpreadsheetCell в котором были варианты для хранения целых чисел, чисел с плавающей точкой и текста. Это означало, что мы могли хранить разные типы данных в каждой ячейке и при этом иметь вектор, представляющий строку из ячеек. Это очень хорошее решение, когда наши взаимозаменяемые элементы вектора являются типами с фиксированным набором, известным при компиляции кода.

Однако иногда мы хотим, чтобы пользователь нашей библиотеки мог расширить набор типов, которые допустимы в конкретной ситуации. Чтобы показать как этого добиться, мы создадим пример инструмента с графическим интерфейсом пользователя (GUI), который просматривает список элементов, вызывает метод draw для каждого из них, чтобы нарисовать его на экране - это обычная техника для инструментов GUI. Мы создадим библиотечный крейт с именем gui, содержащий структуру библиотеки GUI. Этот крейт мог бы включать некоторые готовые типы для использования, такие как Button или TextField. Кроме того, пользователи такого крейта gui захотят создавать свои собственные типы, которые могут быть нарисованы: например, кто-то мог бы добавить тип Image, а кто-то другой добавить тип SelectBox.

Мы не будем реализовывать полноценную библиотеку GUI для этого примера, но покажем, как её части будут подходить друг к другу. На момент написания библиотеки мы не можем знать и определить все типы, которые могут захотеть создать другие программисты. Но мы знаем, что gui должен отслеживать множество значений различных типов и ему нужно вызывать метод draw для каждого из этих значений различного типа. Ему не нужно точно знать, что произойдёт, когда вызывается метод draw, просто у значения будет доступен такой метод для вызова.

Чтобы сделать это на языке с наследованием, можно определить класс с именем Component у которого есть метод с названием draw. Другие классы, такие как Button, Image и SelectBox наследуются от Component и следовательно, наследуют метод draw. Каждый из них может переопределить реализацию метода draw, чтобы определить своё пользовательское поведение, но платформа может обрабатывать все типы, как если бы они были экземплярами Component и вызывать draw у них. Но поскольку в Rust нет наследования, нам нужен другой способ структурировать gui библиотеку, чтобы позволить пользователям расширять её новыми типами.

Определение типажа для общего поведения

Чтобы реализовать поведение, которое мы хотим иметь в gui, определим типаж с именем Draw, который будет содержать один метод с названием draw. Затем мы можем определить вектор, который принимает типаж-объект. Типаж-объект указывает как на экземпляр типа, реализующего указанный типаж, так и на внутреннюю таблицу, используемую для поиска методов типажа указанного типа во время выполнения. Мы создаём типаж-объект в таком порядке: используем какой-нибудь вид указателя, например ссылку & или умный указатель Box<T>, затем ключевое слово dyn, а затем указываем соответствующий типаж. (Мы будем говорить о причине того, что типаж-объекты должны использовать указатель в разделе "Типы динамического размера и типаж Sized " главы 19). Мы можем использовать типаж-объекты вместо универсального или конкретного типа. Везде, где мы используем типаж-объект, система типов Rust проверит во время компиляции, что любое значение, используемое в этом контексте, будет реализовывать нужный типаж у типаж-объекта. Следовательно, нам не нужно знать все возможные типы во время компиляции.

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

В листинге 17.3 показано, как определить типаж с именем Draw с помощью одного метода с именем draw:

Файл: src/lib.rs

pub trait Draw {
    fn draw(&self);
}

Листинг 17-3: Определение типажа Draw

Этот синтаксис должен выглядеть знакомым из наших дискуссий о том, как определять типажи в главе 10. Далее следует новый синтаксис: в листинге 17.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>>,
}

Листинг 17-4: Определение структуры Screen с полем components, которое является вектором типаж-объектов, которые реализуют типаж Draw

В структуре Screen, мы определим метод run, который будет вызывать метод draw каждого элемента вектора components, как показано в листинге 17-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();
        }
    }
}

Листинг 17-5: Реализация метода run у структуры Screen, который вызывает метод draw каждого компонента из вектора

Это работает иначе, чем определение структуры, которая использует параметр общего типа с ограничениями типажа. Обобщённый параметр типа может быть заменён только одним конкретным типом, тогда как типаж-объекты позволяют нескольким конкретным типам замещать типаж-объект во время выполнения. Например, мы могли бы определить структуру Screen используя общий тип и ограничение типажа, как показано в листинге 17-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();
        }
    }
}

Листинг 17-6: Альтернативная реализация структуры Screen и метода run, используя обобщённый тип и ограничения типажа

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

С другой стороны, с помощью метода, использующего типаж-объекты, один экземпляр Screen может содержать Vec<T> который содержит Box<Button>, также как и Box<TextField>. Давайте посмотрим как это работает, а затем поговорим о влиянии на производительность во время выполнения.

Реализации типажа

Теперь мы добавим несколько типов, реализующих типаж Draw. Мы объявим тип Button. Опять же, фактическая реализация библиотеки GUI выходит за рамки этой книги, поэтому тело метода draw не будет иметь никакой полезной реализации. Чтобы представить, как может выглядеть такая реализация, структура Button может иметь поля для width, height и label, как показано в листинге 17-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
    }
}

Листинг 17-7: Структура Button реализует типаж Draw

Поля width, height и label структуры Button будут отличаться от, например, полей других компонентов вроде типа TextField, которая могла бы иметь те же поля плюс поле placeholder. Каждый из типов, который мы хотим нарисовать на экране будет реализовывать типаж Draw, но будет использовать отличающийся код метода draw для определения как именно рисовать конкретный тип, например Button в этом примере (без фактического кода GUI, который выходит за рамки этой главы). Например, тип Button может иметь дополнительный блок impl, содержащий методы, относящиеся к тому, что происходит, когда пользователь нажимает кнопку. Эти варианты методов не будут применяться к типам вроде TextField.

Если кто-то использующий нашу библиотеку решает реализовать структуру SelectBox, которая имеет width, height и поля options, он реализует также и типаж Draw для типа SelectBox, как показано в листинге 17-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() {}

Листинг 17-8: Другой крейт, использующий gui и реализующий типаж Draw у структуры SelectBox

Пользователь нашей библиотеки теперь может написать свою функцию main для создания экземпляра Screen. К экземпляру Screen он может добавить SelectBox и Button, поместив каждый из них в Box<T>, чтобы он стал типаж-объектом. Затем он может вызвать метод run у экземпляра Screen, который вызовет draw для каждого из компонентов. Листинг 17-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();
}

Листинг 17-9: Использование типаж-объектов для хранения значений разных типов, реализующих один и тот же типаж

Когда мы писали библиотеку, мы не знали, что кто-то может добавить тип SelectBox, но наша реализация Screen могла работать с новым типом и рисовать его, потому что SelectBox реализует типаж Draw, что означает, что он реализует метод draw.

Эта концепция, касающаяся только сообщений, на которые значение отвечает, в отличие от конкретного типа у значения, аналогична концепции duck typing в динамически типизированных языках: если что-то ходит как утка и крякает как утка, то она должна быть утка! В реализации метода run у Screen в листинге 17-5, run не нужно знать каким будет конкретный тип каждого компонента. Он не проверяет, является ли компонент экземпляром Button или SelectBox, он просто вызывает метод draw компонента. Указав Box<dyn Draw> в качестве типа значений в векторе components, мы определили Screen для значений у которых мы можем вызвать метод draw.

Преимущество использования типаж-объектов и системы типов Rust для написания кода, похожего на код с использованием концепции duck typing состоит в том, что нам не нужно во время выполнения проверять реализует ли значение в векторе конкретный метод или беспокоиться о получении ошибок, если значение не реализует метод, мы все равно вызываем метод. Rust не скомпилирует наш код, если значения не реализуют типаж, который нужен типаж-объектам.

Например, в листинге 17-10 показано, что произойдёт, если мы попытаемся создать Screen с String в качестве его компонента:

Файл: src/main.rs

use gui::Screen;

fn main() {
    let screen = Screen {
        components: vec![Box::new(String::from("Hi"))],
    };

    screen.run();
}

Листинг 17-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 использует указатели внутри типаж-объекта, чтобы узнать какой метод вызвать. Такой поиск вызывает дополнительные затраты во время исполнения, которые не требуются при статической диспетчеризации. Динамическая диспетчеризация также не позволяет компилятору выбрать встраивание кода метода, что в свою очередь делает невозможными некоторые оптимизации. Однако мы получили дополнительную гибкость в коде, который мы написали в листинге 17-5, и которую смогли поддержать в листинге 17-9, поэтому все "за" и "против" нужно рассматривать в комплексе.