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

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

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

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

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

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

pub trait Iterator {
    type Item;

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

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

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

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

Давайте рассмотрим разницу между этими двумя понятиями на примере из главы 13, которая реализует типаж Iterator у структуры Counter. В листинге 13-21 мы указали, что тип для 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> {
        // --snip--
        if self.count < 5 {
            self.count += 1;
            Some(self.count)
        } else {
            None
        }
    }
}

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

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

Листинг 19-13: Гипотетическое определение типажа Iterator используя обобщённые типы

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

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

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

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

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

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

Файл: src/main.rs

use std::ops::Add;

#[derive(Debug, 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 }
    );
}

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

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

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


#![allow(unused)]
fn main() {
trait Add {
    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, хранящие значения в разных единицах измерения. Мы хотим добавить значения в миллиметрах к значениям в метрах и хотим иметь реализацию типажа Add, которая делает правильное преобразование единиц. Можно реализовать Add для Millimeters с типом Meters в качестве RHS, как показано в листинге 19-15.

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

Листинг 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

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

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

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

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

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

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

Чтобы вызвать методы fly у типажа Pilot или типажа Wizard нужно использовать более явный синтаксис, указывая какой метод fly мы имеем в виду. Листинг 19-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;
    Pilot::fly(&person);
    Wizard::fly(&person);
    person.fly();
}

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

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

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

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished dev [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 не может выяснить, какой тип вы имеете в виду если вы не используете полностью квалифицированный синтаксис (fully qualified). Например, типаж Animal в листинге 19-19 имеет: ассоциированную функцию baby_name, реализацию типажа Animal для структуры Dog и ассоциированную функцию baby_name, объявленную напрямую у структуры Dog.

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

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

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

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

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished dev [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. Техника указания имени типажа использованная в листинге 19-18 здесь не помогает; если мы изменим main код как в листинге 19-20, мы получим ошибку компиляции.

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

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

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

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0283]: type annotations needed
  --> src/main.rs:20:43
   |
2  |     fn baby_name() -> String;
   |     ------------------------- required by `Animal::baby_name`
...
20 |     println!("A baby dog is called a {}", Animal::baby_name());
   |                                           ^^^^^^^^^^^^^^^^^ cannot infer type
   |
   = note: cannot satisfy `_: Animal`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0283`.
error: could not compile `traits-example`

To learn more, run the command again with --verbose.

Чтобы устранить неоднозначность и сказать Rust, что мы хотим использовать реализацию Animal для Dog, нужно использовать полный синтаксис. Листинг 19-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 {}", <Dog as Animal>::baby_name());
}

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

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

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished dev [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 нуждается в помощи для определения, какой вариант реализации вы хотите вызвать.

Использование супер типажей для требования функциональности одного типажа в рамках другого типажа

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

Например, мы хотим создать типаж 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

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

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

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

Давайте посмотрим что происходит, если мы пытаемся реализовать типаж 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:6
   |
3  | trait OutlinePrint: fmt::Display {
   |                     ------------ required by this bound in `OutlinePrint`
...
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

error: aborting due to previous error

For more information about this error, try `rustc --explain E0277`.
error: could not compile `traits-example`

To learn more, run the command again with --verbose.

Чтобы исправить, мы реализуем 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, мы упоминали "правило сироты" (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

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

Листинг 19-23. Создание типа Wrapper Vec для реализации 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.