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-библиотеку для этого примера, но покажем, как части будут сочетаться. На момент написания библиотеки мы не можем знать и определять все типы, которые другие программисты могут захотеть создать. Но мы знаем, что 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.

Filename: src/lib.rs
pub trait Draw {
    fn draw(&self);
}
Listing 18-3: Определение типажа Draw

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

Filename: 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.

Filename: 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:

Filename: 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:

Filename: 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.

Filename: 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 показывает эту реализацию:

Filename: 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 для написания кода, похожего на код с утиной типизацией, в том, что нам никогда не нужно проверять во время выполнения, реализует ли значение particular метод, или беспокоиться о получении ошибок, если значение не реализует метод, но мы всё равно вызываем его. Rust не скомпилирует наш код, если значения не реализуют типажи, требуемые объектами типажа.

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

Filename: 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 для него.

Объекты типажа и вывод типов

Один из недостатков использования объектов типажа — как они взаимодействуют с выводом типов. Например, рассмотрим вывод типов для Vec<T>. Когда T не является объектом типажа, Rustу просто нужно знать тип одного элемента в векторе, чтобы вывести T. Поэтому пустой вектор вызывает ошибку вывода типов:

fn main() {
let v = vec![];
// error[E0282]: type annotations needed for `Vec<T>`
}

Но добавление элемента позволяет Rust вывести тип вектора:

fn main() {
let v = vec!["Hello world"];
// ok, v : Vec<&str>
}

Вывод типов сложнее для объектов типажа. Например, скажем, мы попытались вынести массив components в Листинге 17-9 в отдельную переменную, вот так:

fn main() {
    let components = vec![
        Box::new(SelectBox { /* .. */ }),
        Box::new(Button { /* .. */ }),
    ];
    let screen = Screen { components };
    screen.run();
}

Листинг 17-11: Вынос массива components вызывает ошибку типа

Этот рефакторинг заставляет программу больше не компилироваться! Компилятор отвергает эту программу со следующей ошибкой:

error[E0308]: mismatched types
   --> test.rs:55:14
    |
55  |       Box::new(Button {
    |  _____--------_^
    | |     |
    | |     arguments to this function are incorrect
56  | |       width: 50,
57  | |       height: 10,
58  | |       label: String::from("OK"),
59  | |     }),
    | |_____^ expected `SelectBox`, found `Button`

В Листинге 17-09 компилятор понимает, что вектор components должен иметь тип Vec<Box<dyn Draw>>, потому что это указано в определении структуры Screen. Но в Листинге 17-11 компилятор теряет эту информацию в точке, где определяется components. Чтобы исправить проблему, вы должны дать подсказку алгоритму вывода типов. Это может быть либо через явное приведение любого элемента вектора, вот так:

  let components = vec![
        Box::new(SelectBox { /* .. */ }) as Box<dyn Draw>,
        Box::new(Button { /* .. */ }),
  ];

Либо через аннотацию типа для привязки let, вот так:

  let components: Vec<Box<dyn Draw>> = vec![
        Box::new(SelectBox { /* .. */ }),
        Box::new(Button { /* .. */ }),
  ];

В целом, стоит знать, что использование объектов типажа может ухудшить опыт разработки для клиентов API в случае вывода типов.

Объекты типажа выполняют динамическое диспетчеризацию

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

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