Продвинутые типажи

Мы познакомились с трейтами в разделе "Трейты: Определение общего поведения" в главе 10, но там мы не обсуждали более сложные детали. Теперь, когда вы больше знаете о Rust, мы можем перейти к более подробному рассмотрению.

Указание типов-заполнителей в определениях трейтов с ассоциированными типами

Ассоциированные типы связывают тип-заполнитель с типажом таким образом, что определения методов типажа могут использовать эти типы-заполнители в своих сигнатурах. Для конкретной реализации типажа вместо типа-заполнителя указывается конкретный тип, который будет использоваться. Таким образом, мы можем определить типажи, использующие некоторые типы, без необходимости точно знать, что это за типы, пока типажи не будут реализованы.

Мы назвали большинство продвинутых возможностей в этой главе редко востребованными. Ассоциированные типы находятся где-то посередине: они используются реже чем возможности описанные в остальной части книги, но чаще чем многие другие возможности обсуждаемые в этой главе.

Одним из примеров трейта с ассоциированным типом является типаж Iterator из стандартной библиотеки. Ассоциированный тип называется Item и символизирует тип значений, по которым итерируется тип, реализующий типаж Iterator. Определение трейта Iterator показано в листинге 19-12.

{{#rustdoc_include ../listings/ch19-advanced-features/listing-19-12/src/lib.rs}}

Листинг 19-12: Определение трейта Iterator, который имеет ассоциированный тип Item

Тип 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 используя обобщённые типы

Разница в том, что при использовании обобщений, как показано в листинге 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}}
}

Листинг 19-14: Реализация типажа Add для перегрузки оператора + для экземпляров Point

Метод 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}}

Листинг 19-15: Реализация типажа Add для структуры Millimeters, чтобы складывать Millimeters и Meters

Чтобы сложить 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}}
}

Листинг 19-16: Два типажа определены с методом fly и реализованы у типа Human, а также метод fly реализован непосредственно у Human

Когда мы вызываем fly у экземпляра Human, то компилятор по умолчанию вызывает метод, который непосредственно реализован для типа, как показано в листинге 19-17.

Файл: src/main.rs


#![allow(unused)]
fn main() {
{{#rustdoc_include ../listings/ch19-advanced-features/listing-19-17/src/main.rs:here}}
}

Листинг 19-17: Вызов fly у экземпляра Human

Запуск этого кода напечатает *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}}
}

Листинг 19-18: Указание какой метода fly мы хотим вызвать

Указание имени типажа перед именем метода проясняет компилятору 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}}
}

Листинг 19-19: Типаж с ассоциированной функцией и тип с ассоциированной функцией с тем же именем, которая тоже реализует типаж

Мы реализовали код для приюта для животных, который хочет назвать всех щенков именем 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}}

Листинг 19-20. Попытка вызвать функцию baby_name из типажа Animal, но Rust не знает какую реализацию использовать

Поскольку 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}}
}

Листинг 19-21: Использование полного синтаксиса для указания, что мы мы хотим вызвать функцию baby_name у типажа Animal реализованную в Dog

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

Листинг 19-22: Реализация типажа OutlinePrint которая требует функциональности типажа Display

Поскольку мы указали, что типаж 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}}
}

Листинг 19-23. Создание типа Wrapper Vec<String> для реализации Display

Реализация 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.