Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

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

Трейты с ассоциированными типами

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

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

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

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 со значением этого конкретного типа.

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

Файл: src/lib.rs
struct Counter {
    count: u32,
}

impl Counter {
    fn new() -> Counter {
        Counter { count: 0 }
    }
}

impl Iterator for Counter {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        // --пропуск--
        if self.count < 5 {
            self.count += 1;
            Some(self.count)
        } else {
            None
        }
    }
}

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

pub trait Iterator<T> {
    fn next(&mut self) -> Option<T>;
}
Листинг 20-14: Гипотетическое определение трейта Iterator через обобщения

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

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

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

Параметры по умолчанию и перегрузка операторов

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

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

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

Файл: 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. Если при реализации трейта Add не указать конкретный тип для Rhs, по умолчанию Rhs будет Self — то есть тем типом, для которого мы реализуем Add.

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

У нас есть две структуры, Millimeters и Meters, хранящие значения в разных единицах измерения. Такая тонкая обёртка существующего типа в другую структуру называется паттерном newtype (newtype pattern); подробнее о нём — в разделе «Реализация внешних трейтов с помощью паттерна newtype». Мы хотим складывать значения в миллиметрах со значениями в метрах, чтобы реализация Add правильно выполняла перевод. Можно реализовать Add для Millimeters с Meters в качестве Rhs, как в листинге 20-16.

Файл: 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))
    }
}
Листинг 20-16: Реализуем трейт Add для Millimeters, чтобы складывать Millimeters и Meters

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

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

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

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

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

Различаем одноимённые методы

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

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

Файл: 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*");
    }
}

fn main() {}
Листинг 20-17: Два трейта с методом fly реализованы для типа Human, и ещё один метод fly реализован прямо для Human

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

Файл: 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*");
    }
}

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

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: Трейт с ассоциированной функцией и тип с одноимённой ассоциированной функцией, который ещё и реализует этот трейт

Код, называющий всех щенков Спотами, находится в ассоциированной функции baby_name, определённой для Dog. Тип Dog также реализует трейт Animal, описывающий черты, общие для всех животных. Детёныши собак называются щенками (puppy), и это выражено в реализации трейта 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
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 {}", 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, а не для какого-то другого типа, нужен полностью квалифицированный синтаксис. Как им пользоваться, показано в листинге 20-22.

Файл: 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 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

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

<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. Это похоже на добавление ограничения трейтом к самому трейту. Реализация трейта OutlinePrint — в листинге 20-23.

Файл: 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));
    }
}

fn main() {}
Листинг 20-23: Реализуем трейт OutlinePrint, которому нужна функциональность Display

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

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

Файл: 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));
    }
}

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

impl OutlinePrint for Point {}

fn main() {
    let p = Point { x: 1, y: 3 };
    p.outline_print();
}

Получим ошибку: 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 {}
   |                       ^^^^^ unsatisfied trait bound
   |
help: the trait `std::fmt::Display` is not implemented for `Point`
  --> src/main.rs:15:1
   |
15 | struct Point {
   | ^^^^^^^^^^^^
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();
   |       ^^^^^^^^^^^^^ unsatisfied trait bound
   |
help: the trait `std::fmt::Display` is not implemented for `Point`
  --> src/main.rs:15:1
   |
15 | struct Point {
   | ^^^^^^^^^^^^
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
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));
    }
}

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

impl OutlinePrint for Point {}

use std::fmt;

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

fn main() {
    let p = Point { x: 1, y: 3 };
    p.outline_print();
}

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

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

В разделе «Реализация трейта для типа» главы 10 мы упоминали правило сироты: реализовать трейт для типа можно, только если трейт, тип или оба они локальны для нашего крейта. Обойти это ограничение можно с помощью паттерна 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<String>, чтобы реализовать Display

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

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

Паттерн newtype полезен и тогда, когда трейты здесь ни при чём. Сменим тему и посмотрим на продвинутые способы работы с системой типов Rust.