Использование типаж-объектов, допускающих значения разных типов
В главе 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
{{#rustdoc_include ../listings/ch17-oop/listing-17-03/src/lib.rs}}
Этот синтаксис должен выглядеть знакомым из наших дискуссий о том, как определять типажи в главе 10. Далее следует новый синтаксис: в листинге 17.4 определена структура с именем Screen
, которая содержит вектор с именем components
. Этот вектор имеет тип Box<dyn Draw>
, который и является типаж-объектом; это замена для любого типа внутри Box
который реализует типаж Draw
.
Файл: src/lib.rs
{{#rustdoc_include ../listings/ch17-oop/listing-17-04/src/lib.rs:here}}
В структуре Screen
, мы определим метод run
, который будет вызывать метод draw
каждого элемента вектора components
, как показано в листинге 17-5:
Файл: src/lib.rs
{{#rustdoc_include ../listings/ch17-oop/listing-17-05/src/lib.rs:here}}
Это работает иначе, чем определение структуры, которая использует параметр общего типа с ограничениями типажа. Обобщённый параметр типа может быть заменён только одним конкретным типом, тогда как типаж-объекты позволяют нескольким конкретным типам замещать типаж-объект во время выполнения. Например, мы могли бы определить структуру Screen
используя общий тип и ограничение типажа, как показано в листинге 17-6:
Файл: src/lib.rs
{{#rustdoc_include ../listings/ch17-oop/listing-17-06/src/lib.rs:here}}
Это вариант ограничивает нас экземпляром Screen
, который имеет список компонентов всех типов Button
или всех типов TextField
. Если у вас когда-либо будут только однородные коллекции, использование обобщений и ограничений типажа является предпочтительным, поскольку определения будут мономорфизированы во время компиляции для использования с конкретными типами.
С другой стороны, с помощью метода, использующего типаж-объекты, один экземпляр Screen
может содержать Vec<T>
который содержит Box<Button>
, также как и Box<TextField>
. Давайте посмотрим как это работает, а затем поговорим о влиянии на производительность во время выполнения.
Реализации типажа
Теперь мы добавим несколько типов, реализующих типаж Draw
. Мы объявим тип Button
. Опять же, фактическая реализация библиотеки GUI выходит за рамки этой книги, поэтому тело метода draw
не будет иметь никакой полезной реализации. Чтобы представить, как может выглядеть такая реализация, структура Button
может иметь поля для width
, height
и label
, как показано в листинге 17-7:
Файл: src/lib.rs
{{#rustdoc_include ../listings/ch17-oop/listing-17-07/src/lib.rs:here}}
Поля width
, height
и label
структуры Button
будут отличаться от, например, полей других компонентов вроде типа TextField
, которая могла бы иметь те же поля плюс поле placeholder
. Каждый из типов, который мы хотим нарисовать на экране будет реализовывать типаж Draw
, но будет использовать отличающийся код метода draw
для определения как именно рисовать конкретный тип, например Button
в этом примере (без фактического кода GUI, который выходит за рамки этой главы). Например, тип Button
может иметь дополнительный блок impl
, содержащий методы, относящиеся к тому, что происходит, когда пользователь нажимает кнопку. Эти варианты методов не будут применяться к типам вроде TextField
.
Если кто-то использующий нашу библиотеку решает реализовать структуру SelectBox
, которая имеет width
, height
и поля options
, он реализует также и типаж Draw
для типа SelectBox
, как показано в листинге 17-8:
Файл: src/main.rs
{{#rustdoc_include ../listings/ch17-oop/listing-17-08/src/main.rs:here}}
Пользователь нашей библиотеки теперь может написать свою функцию main
для создания экземпляра Screen
. К экземпляру Screen
он может добавить SelectBox
и Button
, поместив каждый из них в Box<T>
, чтобы он стал типаж-объектом. Затем он может вызвать метод run
у экземпляра Screen
, который вызовет draw
для каждого из компонентов. Листинг 17-9 показывает эту реализацию:
Файл: src/main.rs
{{#rustdoc_include ../listings/ch17-oop/listing-17-09/src/main.rs:here}}
Когда мы писали библиотеку, мы не знали, что кто-то может добавить тип 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
{{#rustdoc_include ../listings/ch17-oop/listing-17-10/src/main.rs}}
Мы получим ошибку, потому что String
не реализует типаж Draw
:
{{#include ../listings/ch17-oop/listing-17-10/output.txt}}
Эта ошибка даёт понять, что либо мы передаём в компонент Screen
что-то, что мы не собирались передавать и мы тогда должны передать другой тип, либо мы должны реализовать типаж Draw
у типа String
, чтобы Screen
мог вызывать draw
у него.
Типаж-объекты выполняют динамическую диспетчеризацию (связывание)
Вспомните, в разделе «Производительность кода, использующего обобщённые типы» в главе 10 наше обсуждение процесса мономорфизации, выполняемого компилятором, когда мы используем ограничения типажей для обобщённых типов: компилятор генерирует частные реализации функций и методов для каждого конкретного типа, который мы применяем для параметра обобщённого типа. Код, который получается в результате мономорфизации, выполняет статическую диспетчеризацию , то есть когда компилятор знает, какой метод вы вызываете во время компиляции. Это противоположно динамической диспетчеризации, когда компилятор не может определить во время компиляции, какой метод вы вызываете. В случае динамической диспетчеризации компилятор формирует код, который во время выполнения определит, какой метод нужно вызвать.
Когда мы используем типаж-объекты, Rust должен использовать динамическую диспетчеризацию. Компилятор не знает всех типов, которые могут быть использованы с кодом, использующим типаж-объекты, поэтому он не знает, какой метод реализован для какого типа при вызове. Вместо этого, во время выполнения, Rust использует указатели внутри типаж-объекта, чтобы узнать какой метод вызвать. Такой поиск вызывает дополнительные затраты во время исполнения, которые не требуются при статической диспетчеризации. Динамическая диспетчеризация также не позволяет компилятору выбрать встраивание кода метода, что в свою очередь делает невозможными некоторые оптимизации. Однако мы получили дополнительную гибкость в коде, который мы написали в листинге 17-5, и которую смогли поддержать в листинге 17-9, поэтому все "за" и "против" нужно рассматривать в комплексе.