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

В главе 8 мы упоминали, что одним из ограничений векторов является то, что они могут хранить элементы только одного типа. Мы создали обходное решение в листинге 8-10, где мы определили перечисление 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, а затем определяем соответствующий типаж. (Мы будем говорить о причине того, что типаж-объекты должны использовать указатель в главе 19 раздела "Типы динамического размера и Sized типаж" <!-- --> ). Мы можем использовать типаж-объекты вместо универсального или конкретного типа. Везде, где мы используем типаж-объект, система типов Rust гарантирует во время компиляции, что любое значение используемое в этом контексте будет реализовывать нужный типаж у типаж-объекта. Следовательно, нам не нужно знать все возможные типы во время компиляции.

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

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

Файл: src/lib.rs


#![allow(unused)]
fn main() {
pub trait Draw {
    fn draw(&self);
}
}

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

Этот синтаксис должен выглядеть знакомым из наших дискуссий о том, как определять типажи в главе 10. Далее следует новый синтаксис: в листинге 17.4 определена структура с именем Screen, которая содержит вектор с именем components. Этот вектор имеет тип Box<dyn Draw>, который и является типаж-объектом; это замена для любого типа внутри Box который реализует типаж Draw.

Файл: src/lib.rs


#![allow(unused)]
fn main() {
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


#![allow(unused)]
fn main() {
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


#![allow(unused)]
fn main() {
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


#![allow(unused)]
fn main() {
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) демонстрирует, что случится если мы попытаемся добавить 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`
  |
  = note: required for the cast to the object type `dyn Draw`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0277`.
error: could not compile `gui`

To learn more, run the command again with --verbose.

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

Типаж-объекты выполняют динамическую диспетчеризацию (связывание)

Напомним, в разделе «Производительность кода с использованием обобщений» главы 10 обсуждается процесс мономорфизации выполняемый компилятором, когда мы используем ограничения типажей для обобщённых типов: компилятор генерирует конкретные реализации функций и методов для каждого конкретного типа, который мы используем вместо параметра обобщённого типа. Код, полученный в результате мономорфизации, выполняет статическую диспетчеризацию, когда компилятор знает какой метод вы вызываете во время компиляции. Это противоположно подходу динамической диспетчеризации, когда компилятор не может сказать во время компиляции, какой метод вы вызываете. В случаях динамической диспетчеризации компилятор генерирует код, который во время выполнения определяет, какой метод необходимо вызывать.

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

Безопасность объекта необходима для типаж-объектов

Вы можете превратить только объектно-безопасные типажи в типаж-объекты. Некоторые сложные правила управляют всеми свойствами, которые делают типаж-объект безопасным, но на практике имеют значение только два правила. Типаж является объектно-безопасным, если все методы определённые в нем имеют следующие свойства:

  • Тип возвращаемого значения не является Self.
  • Нет обобщённых параметров типа.

Ключевое слово Self является псевдонимом для типа, для которого мы реализуем типажи или его методы. Типаж-объекты должны быть объектно-безопасными, потому что как только вы использовали типаж-объект Rust больше не знает точный тип, реализующий типаж. Если метод типажа возвращает тип Self, но типаж-объект забывает чем является этот точный тип Self, то у метода нет возможности использовать исходный точный тип. То же самое верно для параметров обобщённого типа, которые заполняются параметрами конкретного типа при использовании типажа: конкретные типы становятся частью типа, который реализует типаж. Когда тип забыт из-за использования типаж-объекта, невозможно узнать какими типами нужно заполнять параметры обобщённого типа.

Примером типажа методы которого не являются объектно-безопасными, является типаж Clone из стандартной библиотеки. Сигнатура для метода clone в типаже Clone выглядит следующим образом:


#![allow(unused)]
fn main() {
pub trait Clone {
    fn clone(&self) -> Self;
}
}

Тип String реализует типаж Clone и когда мы вызываем метод clone у экземпляра String, мы получаем экземпляр String. Точно так же, если мы вызываем clone для экземпляра Vec<T> то, мы возвращаем экземпляр Vec<T>. Сигнатура clone должна знать, какой тип будет заменять Self, потому что это тип возвращаемого значения.

Компилятор укажет, когда вы пытаетесь сделать что-то, что нарушает правила безопасности объекта в отношении свойств объекта. Например, допустим, мы попытались реализовать структуру Screen в листинге 17-4 для хранения типов, которые реализуют типаж Clone вместо типажа Draw, например:

pub struct Screen {
    pub components: Vec<Box<dyn Clone>>,
}

Мы получим ошибку:

$ cargo build
   Compiling gui v0.1.0 (file:///projects/gui)
error[E0038]: the trait `Clone` cannot be made into an object
 --> src/lib.rs:2:21
  |
2 |     pub components: Vec<Box<dyn Clone>>,
  |                     ^^^^^^^^^^^^^^^^^^^ `Clone` cannot be made into an object
  |
  = note: the trait cannot be made into an object because it requires `Self: Sized`
  = note: for a trait to be "object safe" it needs to allow building a vtable to allow the call to be resolvable dynamically; for more information visit <https://doc.rust-lang.org/reference/items/traits.html#object-safety>

error: aborting due to previous error

For more information about this error, try `rustc --explain E0038`.
error: could not compile `gui`

To learn more, run the command again with --verbose.

Эта ошибка означает, что таким способом вы не можете использовать этот типаж как типаж-объект. Если вы заинтересованы в более подробной информации о безопасности объектов, см. Rust RFC 255.