Объекты-примеси для значений различных типов

RustRustBeginner
Практиковаться сейчас

This tutorial is from open-source community. Access the source code

💡 Этот учебник переведен с английского с помощью ИИ. Чтобы просмотреть оригинал, вы можете перейти на английский оригинал

Введение

Добро пожаловать в Использование объектов-примесей, которые допускают значения разных типов. Эта лабораторная работа является частью Rust Book. Вы можете практиковать свои навыки Rust в LabEx.

В этой лабораторной работе мы исследуем, как использовать объекты-примеси для допуска значений разных типов в библиотеке, конкретно в контексте графического интерфейса пользователя (GUI) инструмента.


Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL rust(("Rust")) -.-> rust/ControlStructuresGroup(["Control Structures"]) rust(("Rust")) -.-> rust/FunctionsandClosuresGroup(["Functions and Closures"]) rust(("Rust")) -.-> rust/BasicConceptsGroup(["Basic Concepts"]) rust(("Rust")) -.-> rust/DataTypesGroup(["Data Types"]) rust(("Rust")) -.-> rust/DataStructuresandEnumsGroup(["Data Structures and Enums"]) rust(("Rust")) -.-> rust/AdvancedTopicsGroup(["Advanced Topics"]) rust/BasicConceptsGroup -.-> rust/variable_declarations("Variable Declarations") rust/DataTypesGroup -.-> rust/integer_types("Integer Types") rust/DataTypesGroup -.-> rust/string_type("String Type") rust/ControlStructuresGroup -.-> rust/for_loop("for Loop") rust/FunctionsandClosuresGroup -.-> rust/function_syntax("Function Syntax") rust/FunctionsandClosuresGroup -.-> rust/expressions_statements("Expressions and Statements") rust/DataStructuresandEnumsGroup -.-> rust/method_syntax("Method Syntax") rust/AdvancedTopicsGroup -.-> rust/traits("Traits") rust/AdvancedTopicsGroup -.-> rust/operator_overloading("Traits for Operator Overloading") subgraph Lab Skills rust/variable_declarations -.-> lab-100442{{"Объекты-примеси для значений различных типов"}} rust/integer_types -.-> lab-100442{{"Объекты-примеси для значений различных типов"}} rust/string_type -.-> lab-100442{{"Объекты-примеси для значений различных типов"}} rust/for_loop -.-> lab-100442{{"Объекты-примеси для значений различных типов"}} rust/function_syntax -.-> lab-100442{{"Объекты-примеси для значений различных типов"}} rust/expressions_statements -.-> lab-100442{{"Объекты-примеси для значений различных типов"}} rust/method_syntax -.-> lab-100442{{"Объекты-примеси для значений различных типов"}} rust/traits -.-> lab-100442{{"Объекты-примеси для значений различных типов"}} rust/operator_overloading -.-> lab-100442{{"Объекты-примеси для значений различных типов"}} end

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

В главе 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".) Мы можем использовать объекты-примеси вместо обобщенного или конкретного типа. В любом месте, где мы используем объект-примесь, типовая система 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 struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

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

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

Имя файла: src/lib.rs

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 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 struct Button {
    pub width: u32,
    pub height: u32,
    pub label: String,
}

impl Draw for Button {
    fn draw(&self) {
        // код для фактического рисования кнопки
    }
}

Листинг 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) {
        // код для фактического рисования выпадающего списка
    }
}

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

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

Имя файла: src/main.rs

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.

Этот концепт - быть concerned только с теми сообщениями, на которые отвечает значение, а не с конкретным типом значения - похож на концепцию 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:

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`

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

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

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

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

Резюме

Поздравляем! Вы завершили лабораторную работу по теме "Использование объектов-примесей, которые допускают значения разных типов". Вы можете выполнить больше лабораторных работ в LabEx, чтобы улучшить свои навыки.