Продвинутые типажи
Мы познакомились с трейтами в разделе "Трейты: Определение общего поведения" в главе 10, но там мы не обсуждали более сложные детали. Теперь, когда вы больше знаете о Rust, мы можем перейти к более подробному рассмотрению.
Указание типов-заполнителей в определениях трейтов с ассоциированными типами
Ассоциированные типы связывают тип-заполнитель с типажом таким образом, что определения методов типажа могут использовать эти типы-заполнители в своих сигнатурах. Для конкретной реализации типажа вместо типа-заполнителя указывается конкретный тип, который будет использоваться. Таким образом, мы можем определить типажи, использующие некоторые типы, без необходимости точно знать, что это за типы, пока типажи не будут реализованы.
Мы назвали большинство продвинутых возможностей в этой главе редко востребованными. Ассоциированные типы находятся где-то посередине: они используются реже чем возможности описанные в остальной части книги, но чаще чем многие другие возможности обсуждаемые в этой главе.
Одним из примеров трейта с ассоциированным типом является типаж Iterator
из стандартной библиотеки. Ассоциированный тип называется Item
и символизирует тип значений, по которым итерируется тип, реализующий типаж Iterator
. Определение трейта Iterator
показано в листинге 19-12.
{{#rustdoc_include ../listings/ch19-advanced-features/listing-19-12/src/lib.rs}}
Тип Item
является заполнителем и определение метода next
показывает, что он будет возвращать значения типа Option<Self::Item>
. Разработчики типажа Iterator
определят конкретный тип для Item
, а метод next
вернёт Option
содержащий значение этого конкретного типа.
Ассоциированные типы могут показаться концепцией похожей на обобщения, поскольку последние позволяют нам определять функцию, не указывая, какие типы она может обрабатывать. Чтобы изучить разницу между этими двумя концепциями, мы рассмотрим реализацию типажа Iterator
для типа с именем Counter
, который указывает, что тип Item
равен u32
:
Файл: src/lib.rs
{{#rustdoc_include ../listings/ch19-advanced-features/no-listing-22-iterator-on-counter/src/lib.rs:ch19}}
Этот синтаксис весьма напоминает обобщённые типы. Так почему же типаж Iterator
не определён обобщённым типом, как показано в листинге 19-13?
{{#rustdoc_include ../listings/ch19-advanced-features/listing-19-13/src/lib.rs}}
Разница в том, что при использовании обобщений, как показано в листинге 19-13, мы должны аннотировать типы в каждой реализации; потому что мы также можем реализовать Iterator<String> for Counter
или любого другого типа, мы могли бы иметь несколько реализации Iterator
для Counter
. Другими словами, когда типаж имеет обобщённый параметр, он может быть реализован для типа несколько раз, каждый раз меняя конкретные типы параметров обобщённого типа. Когда мы используем метод next
у Counter
, нам пришлось бы предоставить аннотации типа, указывая какую реализацию Iterator
мы хотим использовать.
С ассоциированными типами не нужно аннотировать типы, потому что мы не можем реализовать типаж у типа несколько раз. В листинге 19-12 с определением, использующим ассоциированные типы можно выбрать только один тип Item
, потому что может быть только одно объявление impl Iterator for Counter
. Нам не нужно указывать, что нужен итератор значений типа u32
везде, где мы вызываем next
у Counter
.
Ассоциированные типы также становятся частью контракта типажа: разработчики типажа должны предоставить тип, который заменит ассоциированный заполнитель типа. Связанные типы часто имеют имя, описывающее то, как будет использоваться тип, и хорошей практикой является документирование связанного типа в документации по API.
Параметры обобщённого типа по умолчанию и перегрузка операторов
Когда мы используем параметры обобщённого типа, мы можем указать конкретный тип по умолчанию для обобщённого типа. Это устраняет необходимость разработчикам указывать конкретный тип, если работает тип по умолчанию. Тип по умолчанию указывается при объявлении обобщённого типа с помощью синтаксиса <PlaceholderType=ConcreteType>
.
Отличным примером, когда этот метод полезен, является перегрузка оператора (operator overloading), когда вы настраиваете поведение оператора (например, +
) для определённых ситуаций.
Rust не позволяет создавать собственные операторы или перегружать произвольные операторы. Но можно перегрузить перечисленные операции и соответствующие им типажи из std::ops
путём реализации типажей, связанных с этими операторами. Например, в листинге 19-14 мы перегружаем оператор +
, чтобы складывать два экземпляра Point
. Мы делаем это реализуя типаж Add
для структуры Point
:
Файл: src/main.rs
#![allow(unused)] fn main() { {{#rustdoc_include ../listings/ch19-advanced-features/listing-19-14/src/main.rs}} }
Метод add
складывает значения x
двух экземпляров Point
и значения y
у Point
для создания нового экземпляра Point
. Типаж Add
имеет ассоциированный тип с именем Output
, который определяет тип, возвращаемый из метода add
.
Обобщённый тип по умолчанию в этом коде находится в типаже Add
. Вот его определение:
#![allow(unused)] fn main() { trait Add<Rhs = Self> { type Output; fn add(self, rhs: Rhs) -> Self::Output; } }
Этот код должен выглядеть знакомым: типаж с одним методом и ассоциированным типом. Новый синтаксис это RHS=Self
. Такой синтаксис называется параметры типа по умолчанию (default type parameters). Параметр обобщённого типа RHS
(сокращённо “right hand side”) определяет тип параметра rhs
в методе add
. Если мы не укажем конкретный тип для RHS
при реализации типажа Add
, то типом для RHS
по умолчанию будет Self
, который будет типом для которого реализуется типаж Add
.
Когда мы реализовали Add
для структуры Point
, мы использовали стандартное значение для RHS
, потому что хотели сложить два экземпляра Point
. Давайте посмотрим на пример реализации типажа Add
, где мы хотим пользовательский тип RHS
вместо использования типа по умолчанию.
У нас есть две разные структуры Millimeters
и Meters
, хранящие значения в разных единицах измерения. Это тонкое обёртывание существующего типа в другую структуру известно как шаблон newtype, который мы более подробно опишем в разделе "Шаблон Newtype для реализация внешних типажей у внешних типов" . Мы хотим добавить значения в миллиметрах к значениям в метрах и хотим иметь реализацию типажа Add
, которая делает правильное преобразование единиц. Можно реализовать Add
для Millimeters
с типом Meters
в качестве Rhs
, как показано в листинге 19-15.
Файл: src/lib.rs
{{#rustdoc_include ../listings/ch19-advanced-features/listing-19-15/src/lib.rs}}
Чтобы сложить Millimeters
и Meters
, мы указываем impl Add<Meters>
, чтобы указать значение параметра типа RHS
(Meters) вместо использования значения по умолчанию Self
(Millimeters).
Параметры типа по умолчанию используются в двух основных случаях:
- Чтобы расширить тип без внесения изменений ломающих существующий код
- Чтобы позволить пользовательское поведение в специальных случаях, которые не нужны большинству пользователей
Типаж Add
из стандартной библиотеки является примером второй цели: обычно вы складываете два одинаковых типа, но типаж Add
позволяет сделать больше. Использование параметра типа по умолчанию в объявлении типажа Add
означает, что не нужно указывать дополнительный параметр большую часть времени. Другими словами, большая часть кода реализации не нужна, что делает использование типажа проще.
Первая цель похожа на вторую, но используется наоборот: если вы хотите добавить параметр типа к существующему типажу, можно дать ему значение по умолчанию, чтобы разрешить расширение функциональности типажа без нарушения кода существующей реализации.
Полностью квалифицированный синтаксис для устранения неоднозначности: вызов методов с одинаковым именем
В Rust ничего не мешает типажу иметь метод с одинаковым именем, таким же как метод другого типажа и Rust не мешает реализовывать оба таких типажа у одного типа. Также возможно реализовать метод с таким же именем непосредственно у типа, такой как и методы у типажей.
При вызове методов с одинаковыми именами в Rust нужно указать, какой из трёх возможных вы хотите использовать. Рассмотрим код в листинге 19-16, где мы определили два типажа: Pilot
и Wizard
, у обоих есть метод fly
. Затем мы реализуем оба типажа у типа Human
в котором уже реализован метод с именем fly
. Каждый метод fly
делает что-то своё.
Файл: src/main.rs
#![allow(unused)] fn main() { {{#rustdoc_include ../listings/ch19-advanced-features/listing-19-16/src/main.rs:here}} }
Когда мы вызываем fly
у экземпляра Human
, то компилятор по умолчанию вызывает метод, который непосредственно реализован для типа, как показано в листинге 19-17.
Файл: src/main.rs
#![allow(unused)] fn main() { {{#rustdoc_include ../listings/ch19-advanced-features/listing-19-17/src/main.rs:here}} }
Запуск этого кода напечатает *waving arms furiously*
, показывая, что Rust называется метод fly
реализованный непосредственно у Human
.
Чтобы вызвать методы fly
у типажа Pilot
или типажа Wizard
нужно использовать более явный синтаксис, указывая какой метод fly
мы имеем в виду. Листинг 19-18 демонстрирует такой синтаксис.
Файл: src/main.rs
#![allow(unused)] fn main() { {{#rustdoc_include ../listings/ch19-advanced-features/listing-19-18/src/main.rs:here}} }
Указание имени типажа перед именем метода проясняет компилятору Rust, какую именно реализацию fly
мы хотим вызвать. Мы могли бы также написать Human::fly(&person)
, что эквивалентно используемому нами person.fly()
в листинге 19-18, но это писание немного длиннее, когда нужна неоднозначность.
Выполнение этого кода выводит следующее:
{{#include ../listings/ch19-advanced-features/listing-19-18/output.txt}}
Поскольку метод fly
принимает параметр self
, если у нас было два типа оба реализующих один типаж, то Rust может понять, какую реализацию типажа использовать в зависимости от типа self
.
Однако, ассоциированные функции, не являющиеся методами, не имеют параметра self
. Когда существует несколько типов или типажей, определяющих функции, не являющиеся методами, с одним и тем же именем функции, Rust не всегда знает, какой тип вы имеете в виду, если только вы не используете полный синтаксис. Например, в листинге 19-19 мы создаём типаж для приюта животных, который хочет назвать всех маленьких собак Spot. Мы создаём типаж Animal
со связанной с ним функцией baby_name
, не являющейся методом. Типаж Animal
реализован для структуры Dog
, для которой мы также напрямую предоставляем связанную функцию baby_name
, не являющуюся методом.
Файл: src/main.rs
#![allow(unused)] fn main() { {{#rustdoc_include ../listings/ch19-advanced-features/listing-19-19/src/main.rs}} }
Мы реализовали код для приюта для животных, который хочет назвать всех щенков именем Spot, в ассоциированной функции baby_name
, которая определена для Dog
. Тип Dog
также реализует типаж Animal
, который описывает характеристики, которые есть у всех животных. Маленьких собак называют щенками, и это выражается в реализации Animal
у Dog
в функции baby_name
ассоциированной с типажом Animal
.
В main
мы вызываем функцию Dog::baby_name
, которая вызывает ассоциированную функцию определённую напрямую у Dog
. Этот код печатает следующее:
{{#include ../listings/ch19-advanced-features/listing-19-19/output.txt}}
Этот вывод не является тем, что мы хотели бы получить. Мы хотим вызвать функцию baby_name
, которая является частью типажа Animal
реализованного у Dog
, так чтобы код печатал A baby dog is called a puppy
. Техника указания имени типажа использованная в листинге 19-18 здесь не помогает; если мы изменим main
код как в листинге 19-20, мы получим ошибку компиляции.
Файл: src/main.rs
{{#rustdoc_include ../listings/ch19-advanced-features/listing-19-20/src/main.rs:here}}
Поскольку Animal::baby_name
не имеет параметра self
, и могут быть другие типы, реализующие типаж Animal
, Rust не может понять, какую реализацию Animal::baby_name
мы хотим использовать. Мы получим эту ошибку компилятора:
{{#include ../listings/ch19-advanced-features/listing-19-20/output.txt}}
Чтобы устранить неоднозначность и сказать Rust, что мы хотим использовать реализацию Animal
для Dog
, нужно использовать полный синтаксис. Листинг 19-21 демонстрирует, как использовать полный синтаксис.
Файл: src/main.rs
#![allow(unused)] fn main() { {{#rustdoc_include ../listings/ch19-advanced-features/listing-19-21/src/main.rs:here}} }
Мы указываем аннотацию типа в угловых скобках, которая указывает на то что мы хотим вызвать метод baby_name
из типажа Animal
реализованный в Dog
, также указывая что мы хотим рассматривать тип Dog
в качестве Animal
для вызова этой функции. Этот код теперь напечатает то, что мы хотим:
{{#include ../listings/ch19-advanced-features/listing-19-21/output.txt}}
В общем, полный синтаксис определяется следующим образом:
<Type as Trait>::function(receiver_if_method, next_arg, ...);
Для ассоциированных функций, которые не являются методами, будет отсутствовать receiver
(объект приёмника): будет только список аргументов. Вы можете использовать полный синтаксис везде, где вызываете функции или методы. Тем не менее, разрешается опустить любую часть этого синтаксиса, которую Rust может понять из другой информации в программе. Вам нужно использовать более подробный синтаксис только в тех случаях, когда существует несколько реализаций, использующих одно и то же название, и Rust нужно помочь определить, какую реализацию вы хотите вызвать.
Использование супер типажей для требования функциональности одного типажа в рамках другого типажа
Иногда вы можете написать определение типажа, которое зависит от другого типажа: для типа, реализующего первый типаж, вы хотите потребовать, чтобы этот тип также реализовал второй типаж. Вы должны сделать это, чтобы ваше определение типажа могло использовать связанные элементы второго типажа. Типаж, на который опирается ваше определение типажа, называется supertrait вашего типажа.
Например, мы хотим создать типаж OutlinePrint
с методом outline_print
, который будет печатать значение обрамлённое звёздочками. Мы хотим чтобы структура Point
, реализующая типаж стандартной библиотеки Display
, вывела на печать (x, y)
при вызове outline_print
у экземпляра Point
, который имеет значение 1
для x
и значение 3
для y
. Она должна напечатать следующее:
**********
* *
* (1, 3) *
* *
**********
В реализации outline_print
мы хотим использовать функциональность типажа Display
. Поэтому нам нужно указать, что типаж OutlinePrint
будет работать только для типов, которые также реализуют Display
и предоставляют функциональность, которая нужна в OutlinePrint
. Мы можем сделать это в объявлении типажа, указав OutlinePrint: Display
. Этот метод похож на добавление ограничения в типаж. В листинге 19-22 показана реализация типажа OutlinePrint
.
Файл: src/main.rs
#![allow(unused)] fn main() { {{#rustdoc_include ../listings/ch19-advanced-features/listing-19-22/src/main.rs:here}} }
Поскольку мы указали, что типаж OutlinePrint
требует типажа Display
, мы можем использовать функцию to_string
, которая автоматически реализована для любого типа реализующего Display
. Если бы мы попытались использовать to_string
не добавляя двоеточие и не указывая типаж Display
после имени типажа, мы получили бы сообщение о том, что метод с именем to_string
не был найден у типа &Self
в текущей области видимости.
Давайте посмотрим что происходит, если мы пытаемся реализовать типаж OutlinePrint
для типа, который не реализует Display
, например структура Point
:
Файл: src/main.rs
{{#rustdoc_include ../listings/ch19-advanced-features/no-listing-02-impl-outlineprint-for-point/src/main.rs:here}}
Мы получаем сообщение о том, что требуется реализация Display
, но её нет:
{{#include ../listings/ch19-advanced-features/no-listing-02-impl-outlineprint-for-point/output.txt}}
Чтобы исправить, мы реализуем Display
у структуры Point
и выполняем требуемое ограничение OutlinePrint
, вот так:
Файл: src/main.rs
#![allow(unused)] fn main() { {{#rustdoc_include ../listings/ch19-advanced-features/no-listing-03-impl-display-for-point/src/main.rs:here}} }
Тогда реализация типажа OutlinePrint
для структуры Point
будет скомпилирована успешно и мы можем вызвать outline_print
у экземпляра Point
для отображения значения обрамлённое звёздочками.
Шаблон Newtype для реализация внешних типажей у внешних типов
В разделе "Реализация типажа у типа" главы 10, мы упоминали "правило сироты" (orphan rule), которое гласит, что разрешается реализовать типаж у типа, если либо типаж, либо тип являются локальными для нашего крейта. Можно обойти это ограничение, используя шаблон нового типа (newtype pattern), который включает в себя создание нового типа в кортежной структуре. (Мы рассмотрели кортежные структуры в разделе "Использование структур кортежей без именованных полей для создания различных типов" главы 5.) Структура кортежа будет иметь одно поле и будет тонкой оболочкой для типа которому мы хотим реализовать типаж. Тогда тип оболочки является локальным для нашего крейта и мы можем реализовать типаж для локальной обёртки. Newtype это термин, который происходит от языка программирования Haskell. В нем нет ухудшения производительности времени выполнения при использовании этого шаблона и тип оболочки исключается во время компиляции.
В качестве примера, мы хотим реализовать типаж Display
для типа Vec<T>
, где "правило сироты" (orphan rule) не позволяет нам этого делать напрямую, потому что типаж Display
и тип Vec<T>
объявлены вне нашего крейта. Мы можем сделать структуру Wrapper
, которая содержит экземпляр Vec<T>
; тогда мы можем реализовать Display
у структуры Wrapper
и использовать значение Vec<T>
как показано в листинге 19-23.
Файл: src/main.rs
#![allow(unused)] fn main() { {{#rustdoc_include ../listings/ch19-advanced-features/listing-19-23/src/main.rs}} }
Реализация Display
использует self.0
для доступа к внутреннему Vec<T>
, потому что Wrapper
это структура кортежа, а Vec<T>
это элемент с индексом 0 в кортеже. Затем мы можем использовать функциональные возможности типа Display
у Wrapper
.
Недостатком использования этой техники является то, что Wrapper
является новым типом, поэтому он не имеет методов для значения, которое он держит в себе. Мы должны были бы реализовать все методы для Vec<T>
непосредственно во Wrapper
, так чтобы эти методы делегировались внутреннему self.0
, что позволило бы нам обращаться с Wrapper
точно так же, как с Vec<T>
. Если бы мы хотели, чтобы новый тип имел каждый метод имеющийся у внутреннего типа, реализуя типаж Deref
(обсуждается в разделе "Работа с умными указателями как с обычными ссылками с помощью Deref
типажа" главы 15) у Wrapper
для возвращения внутреннего типа, то это было бы решением. Если мы не хотим, чтобы тип Wrapper
имел все методы внутреннего типа, например, для ограничения поведения типа Wrapper
, то пришлось бы вручную реализовать только те методы, которые нам нужны.
Этот шаблон newtype также полезен, даже когда типажи не задействованы. Давайте переключим внимание и рассмотрим некоторые продвинутые способы взаимодействия с системой типов Rust.