Дополнительное исследование трейтов Rust

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

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

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

Введение

Добро пожаловать в Advanced Traits. Этот лабиринт является частью Rust Book. Вы можете практиковать свои навыки Rust в LabEx.

В этом лабиринте мы углубимся в более продвинутые аспекты трейтов, которые были рассмотрены ранее в разделе "Traits: Defining Shared Behavior", теперь, когда у вас лучше понимается Rust.


Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL rust(("Rust")) -.-> rust/DataStructuresandEnumsGroup(["Data Structures and Enums"]) rust(("Rust")) -.-> rust/BasicConceptsGroup(["Basic Concepts"]) rust(("Rust")) -.-> rust/DataTypesGroup(["Data Types"]) rust(("Rust")) -.-> rust/FunctionsandClosuresGroup(["Functions and Closures"]) 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/DataTypesGroup -.-> rust/type_casting("Type Conversion and Casting") 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-100448{{"Дополнительное исследование трейтов Rust"}} rust/integer_types -.-> lab-100448{{"Дополнительное исследование трейтов Rust"}} rust/string_type -.-> lab-100448{{"Дополнительное исследование трейтов Rust"}} rust/type_casting -.-> lab-100448{{"Дополнительное исследование трейтов Rust"}} rust/function_syntax -.-> lab-100448{{"Дополнительное исследование трейтов Rust"}} rust/expressions_statements -.-> lab-100448{{"Дополнительное исследование трейтов Rust"}} rust/method_syntax -.-> lab-100448{{"Дополнительное исследование трейтов Rust"}} rust/traits -.-> lab-100448{{"Дополнительное исследование трейтов Rust"}} rust/operator_overloading -.-> lab-100448{{"Дополнительное исследование трейтов Rust"}} end

Advanced Traits

Мы впервые рассмотрели трейты в разделе "Traits: Defining Shared Behavior", но не затрагивали более продвинутые аспекты. Теперь, когда вы знаете больше о Rust, мы можем углубиться в детали.

Associated Types

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

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

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

pub trait Iterator {
    type Item;

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

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

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

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

Filename: src/lib.rs

impl Iterator for Counter {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        --snip--

Этот синтаксис похож на синтаксис обобщений. Почему же не определить трейт Iterator с использованием обобщений, как показано в Listing 19-13?

pub trait Iterator<T> {
    fn next(&mut self) -> Option<T>;
}

Listing 19-13: Гипотетическое определение трейта Iterator с использованием обобщений

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

При использовании ассоциированных типов мы не должны аннотировать типы, потому что мы не можем реализовать трейт для типа несколько раз. В Listing 19-12 с определением, которое использует ассоциированные типы, мы можем выбрать, какой будет тип Item только один раз, потому что может быть только одна реализация Iterator для Counter. Мы не должны указывать, что мы хотим итератор значений u32 везде, где мы вызываем next для Counter.

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

Default Generic Type Parameters and Operator Overloading

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

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

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

Filename: 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 }
    );
}

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

Метод add складывает значения x двух экземпляров Point и значения y двух экземпляров Point для создания нового Point. Трейт Add имеет ассоциированный тип с именем Output, который определяет тип, возвращаемый методом add.

Обобщенный тип по умолчанию в этом коде находится внутри трейта Add. Вот его определение:

trait Add<Rhs=Self> {
    type Output;

    fn add(self, rhs: Rhs) -> Self::Output;
}

Этот код, вероятно, будет выглядеть знакомым: трейт с одним методом и ассоциированным типом. Новая часть - это Rhs=Self: этот синтаксис называется параметрами типа по умолчанию. Обобщенный тип параметр Rhs (краткая форма от "right-hand side") определяет тип параметра rhs в методе add. Если мы не укажем конкретный тип для Rhs при реализации трейта Add, тип Rhs будет по умолчанию равен Self, который будет типом, для которого мы реализуем Add.

Когда мы реализовывали Add для Point, мы использовали значение по умолчанию для Rhs, потому что мы хотели сложить два экземпляра Point. Посмотрим на пример реализации трейта Add, где мы хотим настроить тип Rhs вместо использования значения по умолчанию.

У нас есть две структуры, Millimeters и Meters, хранящие значения в разных единицах измерения. Эта тонкая обертка существующего типа в другой структуре называется шаблоном newtype, о котором мы говорим более подробно в разделе "Using the Newtype Pattern to Implement External Traits on External Types". Мы хотим добавить значения в миллиметрах к значениям в метрах и чтобы реализация Add корректно выполняла преобразование. Мы можем реализовать Add для Millimeters с Meters в качестве Rhs, как показано в Listing 19-15.

Filename: src/lib.rs

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))
    }
}

Listing 19-15: Реализация трейта Add для Millimeters для сложения Millimeters и Meters

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

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

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

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

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

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

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

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

Filename: src/main.rs

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*");
    }
}

Listing 19-16: Определены два трейта, имеющие метод fly, и реализованы для типа Human, а также непосредственно на Human реализован метод fly.

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

Filename: src/main.rs

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

Listing 19-17: Вызов fly для экземпляра Human

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

Для вызова методов fly из трейта Pilot или трейта Wizard нам нужно использовать более явный синтаксис, чтобы указать, какой именно метод fly мы имеем в виду. Listing 19-18 демонстрирует этот синтаксис.

Filename: src/main.rs

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

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

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

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

This is your captain speaking.
Up!
*waving arms furiously*

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

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

Filename: 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());
}

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

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

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

A baby dog is called a Spot

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

Filename: src/main.rs

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

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

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

error[E0283]: type annotations needed
  --> src/main.rs:20:43
   |
20 |     println!("A baby dog is called a {}", Animal::baby_name());
   |                                           ^^^^^^^^^^^^^^^^^ cannot infer
type
   |
   = note: cannot satisfy `_: Animal`

Для разрешения неоднозначности и указания Rust, что мы хотим использовать реализацию Animal для Dog по сравнению с реализацией Animal для какого-то другого типа, нам нужно использовать полный квалифицированный синтаксис. Listing 19-21 демонстрирует, как использовать полный квалифицированный синтаксис.

Filename: src/main.rs

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

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

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

A baby dog is called a puppy

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

<Type as Trait>::function(receiver_if_method, next_arg,...);

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

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

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

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

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

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

Filename: src/main.rs

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));
    }
}

Listing 19-22: Реализация трейта OutlinePrint, который требует функциональности из Display

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

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

Filename: src/main.rs

struct Point {
    x: i32,
    y: i32,
}

impl OutlinePrint for Point {}

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

error[E0277]: `Point` doesn't implement `std::fmt::Display`
  --> src/main.rs:20:6
   |
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`

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

Filename: src/main.rs

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 для реализации внешних трейтов

В разделе "Реализация трейта для типа" мы упоминали правило сироты, согласно которому мы можем реализовать трейт для типа только в том случае, если либо трейт, либо тип, или оба они локальны для нашего пакета. С помощью шаблона newtype можно обойти это ограничение, который заключается в создании нового типа в кортежной структуре. (Мы рассматривали кортежные структуры в разделе "Использование кортежных структур без именованных полей для создания различных типов".) Кортежная структура будет иметь одно поле и будет тонкой оберткой вокруг типа, для которого мы хотим реализовать трейт. Затем обертка типа будет локальной для нашего пакета, и мы сможем реализовать трейт для обертки. Newtype - это термин, который произошел от языка программирования Haskell. Использование этого шаблона не влечет за собой штраф в производительности времени выполнения, и обертка типа удаляется на этапе компиляции.

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

Filename: 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}");
}

Listing 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 для Wrapper для возврата внутреннего типа была бы решением (мы обсуждали реализацию трейта Deref в разделе "Работа с умными указателями как с обычными ссылками с помощью Deref"). Если мы не хотели, чтобы тип Wrapper имел все методы внутреннего типа - например, чтобы ограничить поведение типа Wrapper - мы должны были бы реализовать только те методы, которые мы действительно хотим, вручную.

Этот шаблон newtype также полезен даже в том случае, если не涉及 трейтов. Переключимся на другие аспекты и рассмотрим некоторые более продвинутые способы взаимодействия с типовой системой Rust.

Резюме

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