Продвинутые Traits

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

Определение трейтов с ассоциированными типами

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

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

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

#![allow(unused)]
fn main() {
pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;
}
}

Листинг 20-13: Определение трейта Iterator с ассоциированным типом Item

Тип Item является заполнителем, и определение метода next показывает, что он будет возвращать значения типа Option<Self::Item>. Реализаторы трейта Iterator укажут конкретный тип для Item, и метод next будет возвращать Option, содержащий значение этого конкретного типа.

Ассоциированные типы могут показаться похожими на обобщённые типы (generics), поскольку последние позволяют нам определить функцию без указания типов, с которыми она может работать. Чтобы исследовать разницу между этими двумя концепциями, рассмотрим реализацию трейта Iterator для типа с именем Counter, который указывает, что тип Item — это u32:

Файл: src/lib.rs

#![allow(unused)]
fn main() {
impl Iterator for Counter {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        // --пропуск--
}

Этот синтаксис кажется сопоставимым с синтаксисом обобщённых типов. Так почему бы просто не определить трейт Iterator с обобщёнными типами, как показано в листинге 20-14?

#![allow(unused)]
fn main() {
pub trait Iterator<T> {
    fn next(&mut self) -> Option<T>;
}
}

Листинг 20-14: Гипотетическое определение трейта Iterator с использованием обобщённых типов

Разница заключается в том, что при использовании обобщённых типов, как в листинге 20-14, мы должны аннотировать типы в каждой реализации; поскольку мы также можем реализовать Iterator<String> для Counter или любого другого типа, у нас может быть несколько реализаций Iterator для Counter. Другими словами, когда трейт имеет обобщённый параметр, он может быть реализован для типа несколько раз, каждый раз изменяя конкретные типы параметров обобщённого типа. Когда мы используем метод next на Counter, нам пришлось бы предоставлять аннотации типов, чтобы указать, какую реализацию Iterator мы хотим использовать.

С ассоциированными типами нам не нужно аннотировать типы, потому что мы не можем реализовать трейт для типа несколько раз. В листинге 20-13 с определением, использующим ассоциированные типы, мы можем выбрать, каким будет тип Item, только один раз, потому что может быть только одна реализация impl Iterator for Counter. Нам не нужно указывать, что мы хотим итератор значений u32 везде, где мы вызываем next на Counter.

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

Использование параметров обобщённого типа по умолчанию и перегрузка операторов

При использовании параметров обобщённого типа мы можем указать конкретный тип по умолчанию для обобщённого типа. Это устраняет необходимость для реализаторов трейта указывать конкретный тип, если тип по умолчанию подходит. Вы указываете тип по умолчанию при объявлении обобщённого типа с помощью синтаксиса <PlaceholderType=ConcreteType>.

Отличным примером ситуации, где полезна эта техника, является перегрузка операторов (operator overloading), когда вы настраиваете поведение оператора (например, +) в определённых ситуациях.

Rust не позволяет создавать собственные операторы или перегружать произвольные операторы. Но вы можете перегружать операции и соответствующие трейты, перечисленные в std::ops, реализуя трейты, связанные с оператором. Например, в листинге 20-15 мы перегружаем оператор + для сложения двух экземпляров Point. Мы делаем это, реализуя трейт Add для структуры Point.

Файл: src/main.rs

use std::ops::Add;

#[derive(Debug, Copy, Clone, PartialEq)]
struct Point {
    x: i32,
    y: i32,
}

impl Add for Point {
    type Output = Point;

    fn add(self, other: Point) -> Point {
        Point {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

fn main() {
    assert_eq!(
        Point { x: 1, y: 0 } + Point { x: 2, y: 3 },
        Point { x: 3, y: 3 }
    );
}

Листинг 20-15: Реализация трейта 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, как показано в листинге 20-16.

Файл: src/lib.rs

#![allow(unused)]
fn main() {
use std::ops::Add;

struct Millimeters(u32);
struct Meters(u32);

impl Add<Meters> for Millimeters {
    type Output = Millimeters;

    fn add(self, other: Meters) -> Millimeters {
        Millimeters(self.0 + (other.0 * 1000))
    }
}
}

Листинг 20-16: Реализация трейта Add для Millimeters для сложения Millimeters и Meters

Чтобы сложить Millimeters и Meters, мы указываем impl Add<Meters>, чтобы установить значение параметра типа Rhs вместо использования значения по умолчанию Self.

Вы будете использовать параметры типа по умолчанию двумя основными способами:

  1. Для расширения типа без нарушения существующего кода
  2. Чтобы разрешить настройку в конкретных случаях, которые большинству пользователей не понадобятся

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

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

Разрешение неоднозначности между методами с одинаковыми именами

Ничто в Rust не мешает трейту иметь метод с тем же именем, что и метод другого трейта, и Rust не мешает вам реализовать оба трейта на одном типе. Также возможно реализовать метод непосредственно на типе с тем же именем, что и методы из трейтов.

При вызове методов с одинаковыми именами вам нужно указать Rust, какой из них вы хотите использовать. Рассмотрим код в листинге 20-17, где мы определили два трейта, Pilot и Wizard, оба с методом fly. Затем мы реализуем оба трейта на типе Human, у которого уже реализован метод fly. Каждый метод fly делает что-то разное.

Файл: src/main.rs

#![allow(unused)]
fn main() {
trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}
}

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

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

Файл: src/main.rs

fn main() {
    let person = Human;
    person.fly();
}

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

Запуск этого кода выведет *waving arms furiously*, показывая, что Rust вызвал метод fly, реализованный непосредственно на Human.

Чтобы вызвать методы fly из трейта Pilot или Wizard, нам нужно использовать более явный синтаксис для указания, какой метод fly мы имеем в виду. Листинг 20-19 демонстрирует этот синтаксис.

Файл: src/main.rs

fn main() {
    let person = Human;
    Pilot::fly(&person);
    Wizard::fly(&person);
    person.fly();
}

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

Указание имени трейта перед именем метода проясняет Rust, какую реализацию fly мы хотим вызвать. Мы также могли бы написать Human::fly(&person), что эквивалентно person.fly(), который мы использовали в листинге 20-19, но это писать немного дольше, если нам не нужно разрешать неоднозначность.

Запуск этого кода выводит следующее:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.46s
     Running `target/debug/traits-example`
This is your captain speaking.
Up!
*waving arms furiously*

Поскольку метод fly принимает параметр self, если бы у нас было два типа, которые оба реализуют один трейт, Rust мог бы определить, какую реализацию трейта использовать, на основе типа self.

Однако ассоциированные функции, которые не являются методами, не имеют параметра self. Когда есть несколько типов или трейтов, определяющих функции-не-методы с одинаковым именем, Rust не всегда знает, какой тип вы имеете в виду, если вы не используете полностью квалифицированный синтаксис. Например, в листинге 20-20 мы создаём трейт для приюта животных, который хочет называть всех щенков Spot. Мы создаём трейт Animal с ассоциированной функцией-не-методом baby_name. Трейт Animal реализован для структуры Dog, для которой мы также предоставляем ассоциированную функцию-не-метод baby_name непосредственно.

Файл: src/main.rs

trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", Dog::baby_name());
}

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

Мы реализуем код для именования всех щенков Spot в ассоциированной функции baby_name, определённой на Dog. Тип Dog также реализует трейт Animal, который описывает характеристики, присущие всем животным. Детёныши собак называются щенками (puppies), и это выражено в реализации трейта Animal для Dog в функции baby_name, связанной с трейтом Animal.

В main мы вызываем функцию Dog::baby_name, которая вызывает ассоциированную функцию, определённую непосредственно на Dog. Этот код выводит следующее:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.54s
     Running `target/debug/traits-example`
A baby dog is called a Spot

Этот вывод не тот, который мы хотели. Мы хотим вызвать функцию baby_name, которая является частью трейта Animal, который мы реализовали на Dog, чтобы код вывел A baby dog is called a puppy. Техника указания имени трейта, которую мы использовали в листинге 20-19, здесь не помогает; если мы изменим main на код в листинге 20-21, мы получим ошибку компиляции.

Файл: src/main.rs

// [Этот код не компилируется!]
fn main() {
    println!("A baby dog is called a {}", Animal::baby_name());
}

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

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

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0790]: cannot call associated function on trait without specifying the corresponding `impl` type
  --> src/main.rs:20:43
   |
2  |     fn baby_name() -> String;
   |     ------------------------- `Animal::baby_name` defined here
...
20 |     println!("A baby dog is called a {}", Animal::baby_name());
   |                                           ^^^^^^^^^^^^^^^^^^^ cannot call associated function of trait
   |
help: use the fully-qualified path to the only available implementation
   |
20 |     println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
   |                                           +++++++       +

For more information about this error, try `rustc --explain E0790`.
error: could not compile `traits-example` (bin "traits-example") due to 1 previous error

Чтобы разрешить неоднозначность и сказать Rust, что мы хотим использовать реализацию Animal для Dog, а не реализацию Animal для какого-либо другого типа, нам нужно использовать полностью квалифицированный синтаксис. Листинг 20-22 демонстрирует, как использовать полностью квалифицированный синтаксис.

Файл: src/main.rs

fn main() {
    println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
}

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

Мы предоставляем Rust аннотацию типа в угловых скобках, которая указывает, что мы хотим вызвать метод baby_name из трейта Animal, реализованного на Dog, говоря, что мы хотим рассматривать тип Dog как Animal для этого вызова функции. Теперь этот код выведет то, что мы хотим:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/traits-example`
A baby dog is called a puppy

В общем случае полностью квалифицированный синтаксис определяется следующим образом:

#![allow(unused)]
fn main() {
<Type as Trait>::function(receiver_if_method, next_arg, ...);
}

Для ассоциированных функций, которые не являются методами, не будет получателя (receiver): будет только список других аргументов. Вы можете использовать полностью квалифицированный синтаксис везде, где вы вызываете функции или методы. Однако вам разрешено опускать любую часть этого синтаксиса, которую Rust может определить из другой информации в программе. Вам нужно использовать этот более многословный синтаксис только в случаях, когда есть несколько реализаций, использующих одно и то же имя, и Rust нужна помощь, чтобы определить, какую реализацию вы хотите вызвать.

Использование супертрейтов

Иногда вы можете написать определение трейта, которое зависит от другого трейта: для того чтобы тип реализовал первый трейт, вы хотите потребовать, чтобы этот тип также реализовал второй трейт. Вы делаете это, чтобы ваше определение трейта могло использовать ассоциированные элементы второго трейта. Трейт, от которого зависит ваше определение трейта, называется супертрейтом (supertrait) вашего трейта.

Например, предположим, что мы хотим создать трейт OutlinePrint с методом outline_print, который будет выводить заданное значение в формате, обрамлённом звёздочками. То есть, для структуры Point, которая реализует трейт Display из стандартной библиотеки с результатом (x, y), когда мы вызываем outline_print на экземпляре Point со значениями x = 1 и y = 3, должно быть выведено следующее:

**********
*        *
* (1, 3) *
*        *
**********

В реализации метода outline_print мы хотим использовать функциональность трейта Display. Поэтому нам нужно указать, что трейт OutlinePrint будет работать только для типов, которые также реализуют Display и предоставляют функциональность, необходимую OutlinePrint. Мы можем сделать это в определении трейта, указав OutlinePrint: Display. Этот метод аналогичен добавлению ограничения трейта (trait bound) к трейту. Листинг 20-23 показывает реализацию трейта OutlinePrint.

Файл: src/main.rs

#![allow(unused)]
fn main() {
use std::fmt;

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {output} *");
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}
}

Листинг 20-23: Реализация трейта OutlinePrint, который требует функциональность из Display

Поскольку мы указали, что OutlinePrint требует трейт Display, мы можем использовать функцию to_string, которая автоматически реализуется для любого типа, реализующего Display. Если бы мы попытались использовать to_string без добавления двоеточия и указания трейта Display после имени трейта, мы получили бы ошибку о том, что метод to_string не найден для типа &Self в текущей области видимости.

Давайте посмотрим, что произойдёт, если мы попытаемся реализовать OutlinePrint для типа, который не реализует Display, например, для структуры Point:

Файл: src/main.rs

#![allow(unused)]
fn main() {
// [Этот код не компилируется!]
struct Point {
    x: i32,
    y: i32,
}

impl OutlinePrint for Point {}
}

Мы получаем ошибку о том, что Display требуется, но не реализован:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0277]: `Point` doesn't implement `std::fmt::Display`
  --> src/main.rs:20:23
   |
20 | impl OutlinePrint for Point {}
   |                       ^^^^^ `Point` cannot be formatted with the default formatter
   |
   = help: the trait `std::fmt::Display` is not implemented for `Point`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
note: required by a bound in `OutlinePrint`
  --> src/main.rs:3:21
   |
3  | trait OutlinePrint: fmt::Display {
   |                     ^^^^^^^^^^^^ required by this bound in `OutlinePrint`

error[E0277]: `Point` doesn't implement `std::fmt::Display`
  --> src/main.rs:24:7
   |
24 |     p.outline_print();
   |       ^^^^^^^^^^^^^ `Point` cannot be formatted with the default formatter
   |
   = help: the trait `std::fmt::Display` is not implemented for `Point`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
note: required by a bound in `OutlinePrint::outline_print`
  --> src/main.rs:3:21
   |
3  | trait OutlinePrint: fmt::Display {
   |                     ^^^^^^^^^^^^ required by this bound in `OutlinePrint::outline_print`
4  |     fn outline_print(&self) {
   |        ------------- required by a bound in this associated function

For more information about this error, try `rustc --explain E0277`.
error: could not compile `traits-example` (bin "traits-example") due to 2 previous errors

Чтобы исправить это, мы реализуем Display для Point и удовлетворим ограничение, которое требует OutlinePrint, следующим образом:

Файл: src/main.rs

#![allow(unused)]
fn main() {
use std::fmt;

impl fmt::Display for Point {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "({}, {})", self.x, self.y)
    }
}
}

Тогда реализация трейта OutlinePrint для Point будет успешно компилироваться, и мы сможем вызвать outline_print на экземпляре Point, чтобы отобразить его в рамке из звёздочек.

Реализация внешних трейтов с помощью шаблона Newtype

В разделе «Реализация трейта для типа» в главе 10 мы упоминали правило сироты (orphan rule), которое гласит, что мы можем реализовать трейт для типа только если либо трейт, либо тип, либо оба являются локальными для нашего крейта. Можно обойти это ограничение с помощью шаблона newtype, который предполагает создание нового типа в виде кортежной структуры. (Мы рассматривали кортежные структуры в разделе «Создание различных типов с помощью кортежных структур» в главе 5.) Кортежная структура будет иметь одно поле и представлять собой тонкую обёртку вокруг типа, для которого мы хотим реализовать трейт. Тогда тип-обёртка является локальным для нашего крейта, и мы можем реализовать трейт для обёртки. Термин «newtype» происходит из языка программирования Haskell. Использование этого шаблона не влечёт накладных расходов во время выполнения, и тип-обёртка устраняется во время компиляции.

В качестве примера предположим, что мы хотим реализовать Display для Vec<T>, что запрещено правилом сироты, поскольку трейт Display и тип Vec<T> определены вне нашего крейта. Мы можем создать структуру Wrapper, которая содержит экземпляр Vec<T>; затем мы можем реализовать Display для Wrapper и использовать значение Vec<T>, как показано в листинге 20-24.

Файл: src/main.rs

use std::fmt;

struct Wrapper(Vec<String>);

impl fmt::Display for Wrapper {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "[{}]", self.0.join(", "))
    }
}

fn main() {
    let w = Wrapper(vec![String::from("hello"), String::from("world")]);
    println!("w = {w}");
}

Листинг 20-24: Создание типа Wrapper вокруг Vec для реализации Display

Реализация Display использует self.0 для доступа к внутреннему Vec<T>, потому что Wrapper является кортежной структурой и Vec<T> — это элемент с индексом 0 в кортеже. Затем мы можем использовать функциональность трейта Display на Wrapper.

Недостаток этого подхода в том, что Wrapper — это новый тип, поэтому он не имеет методов содержащегося в нём значения. Нам пришлось бы реализовать все методы Vec<T> непосредственно для Wrapper таким образом, чтобы методы делегировали вызовы self.0, что позволило бы нам обращаться с Wrapper точно так же, как с Vec<T>. Если бы мы хотели, чтобы новый тип имел все методы внутреннего типа, решением была бы реализация трейта Deref для Wrapper с возвратом внутреннего типа (мы обсуждали реализацию трейта Deref в разделе «Обращение с умными указателями как с обычными ссылками» в главе 15). Если бы мы не хотели, чтобы тип Wrapper имел все методы внутреннего типа — например, чтобы ограничить поведение типа Wrapper — нам пришлось бы вручную реализовать только те методы, которые нам нужны.

Шаблон newtype также полезен даже когда трейты не задействованы. Давайте переключим фокус и рассмотрим некоторые продвинутые способы взаимодействия с системой типов Rust.