Расширенные возможности трейтов
Впервые мы рассматривали трейты в разделе «Определение общего поведения с помощью трейтов» в главе 10, но тогда не обсуждали более продвинутые детали. Теперь, когда вы знаете о Rust больше, мы можем перейти к тонкостям.
Определение трейтов со связанными типами
Связанные типы соединяют заполнитель типа с трейтом так, что определения методов трейта могут использовать эти типы-заполнители в своих сигнатурах. Тот, кто реализует трейт, указывает конкретный тип, который будет использоваться вместо типа-заполнителя для данной конкретной реализации. Благодаря этому мы можем определить трейт, который использует некоторые типы, не зная точно, что это за типы, пока трейт не будет реализован.
Большинство расширенных возможностей в этой главе мы описывали как редко необходимые. Связанные типы находятся где-то посередине: они используются реже, чем возможности, объясненные в остальной части книги, но чаще, чем многие другие возможности, обсуждаемые в этой главе.
Один пример трейта со связанным типом — трейт Iterator, который предоставляет
стандартная библиотека. Связанный тип называется Item и обозначает тип
значений, по которым выполняет итерацию тип, реализующий трейт Iterator.
Определение трейта Iterator показано в листинге 20-13.
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
Iterator, у которого есть связанный тип ItemТип Item является заполнителем, а определение метода next показывает, что
он будет возвращать значения типа Option<Self::Item>. Реализующие трейт
Iterator укажут конкретный тип для Item, и метод next будет возвращать
Option, содержащий значение этого конкретного типа.
Связанные типы могут показаться похожими на обобщения, поскольку последние
позволяют нам определить функцию, не указывая, с какими типами она может
работать. Чтобы рассмотреть различие между этими двумя понятиями, мы посмотрим
на реализацию трейта Iterator для типа с именем Counter, где указано, что
тип Item — это u32:
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 с обобщениями, как показано в листинге
20-14?
pub trait Iterator<T> {
fn next(&mut self) -> Option<T>;
}
Iterator с использованием обобщенийРазличие в том, что при использовании обобщений, как в листинге 20-14, мы
должны указывать типы в каждой реализации; поскольку мы также могли бы
реализовать Iterator<String> for Counter или любой другой тип, у нас могло бы
быть несколько реализаций Iterator для Counter. Другими словами, когда у
трейта есть обобщенный параметр, его можно реализовать для одного типа несколько
раз, каждый раз меняя конкретные типы обобщенных параметров типа. Когда мы
использовали бы метод next для Counter, нам пришлось бы предоставлять
аннотации типов, чтобы указать, какую реализацию Iterator мы хотим
использовать.
Со связанными типами нам не нужно указывать типы, потому что мы не можем
реализовать трейт для одного типа несколько раз. В листинге 20-13, где
определение использует связанные типы, мы можем выбрать, каким будет тип
Item, только один раз, потому что может существовать только один impl Iterator for Counter. Нам не нужно указывать, что мы хотим итератор значений
u32, везде, где мы вызываем next для Counter.
Связанные типы также становятся частью контракта трейта: реализующие трейт должны предоставить тип, который будет стоять на месте заполнителя связанного типа. Связанные типы часто имеют имя, описывающее, как этот тип будет использоваться, и документировать связанный тип в документации API — хорошая практика.
Использование обобщенных параметров типа по умолчанию и перегрузки операторов
Когда мы используем обобщенные параметры типа, мы можем указать конкретный тип
по умолчанию для обобщенного типа. Это избавляет реализующих трейт от
необходимости указывать конкретный тип, если подходит тип по умолчанию. Тип по
умолчанию указывается при объявлении обобщенного типа с помощью синтаксиса
<PlaceholderType=ConcreteType>.
Отличный пример ситуации, где эта техника полезна, — перегрузка операторов,
при которой вы настраиваете поведение оператора (например, +) в отдельных
ситуациях.
Rust не позволяет создавать собственные операторы или перегружать произвольные
операторы. Но вы можете перегружать операции и соответствующие им трейты,
перечисленные в std::ops, реализуя трейты, связанные с оператором. Например,
в листинге 20-15 мы перегружаем оператор +, чтобы складывать два экземпляра
Point. Мы делаем это, реализуя трейт Add для структуры Point.
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 }
);
}
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: этот синтаксис называется параметрами типа по
умолчанию. Обобщенный параметр типа Rhs (сокращение от “right-hand side”,
«правая сторона») определяет тип параметра rhs в методе add. Если при
реализации трейта Add мы не укажем конкретный тип для Rhs, типом Rhs по
умолчанию станет Self, то есть тип, для которого мы реализуем Add.
Когда мы реализовали Add для Point, мы использовали значение по умолчанию
для Rhs, потому что хотели складывать два экземпляра Point. Давайте
посмотрим на пример реализации трейта Add, где мы хотим настроить тип Rhs,
а не использовать значение по умолчанию.
У нас есть две структуры, Millimeters и Meters, которые хранят значения в
разных единицах измерения. Такое тонкое оборачивание существующего типа в
другую структуру известно как шаблон newtype; подробнее мы описываем его в
разделе «Реализация внешних трейтов с помощью шаблона
newtype». Мы хотим складывать значения в миллиметрах
со значениями в метрах и сделать так, чтобы реализация Add выполняла
преобразование корректно. Мы можем реализовать Add для Millimeters, указав
Meters в качестве Rhs, как показано в листинге 20-16.
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))
}
}
Add для Millimeters, чтобы складывать Millimeters и MetersЧтобы сложить Millimeters и Meters, мы указываем impl Add<Meters>, чтобы
задать значение параметра типа Rhs вместо использования значения по умолчанию
Self.
Вы будете использовать параметры типа по умолчанию двумя основными способами:
- Чтобы расширить тип, не ломая существующий код
- Чтобы разрешить настройку в особых случаях, которые большинству пользователей не понадобятся
Трейт Add из стандартной библиотеки — пример второй цели: обычно вы будете
складывать два однотипных значения, но трейт Add предоставляет возможность
настроить поведение шире этого случая. Использование параметра типа по
умолчанию в определении трейта Add означает, что большую часть времени вам не
нужно указывать дополнительный параметр. Другими словами, часть шаблонного кода
реализации становится ненужной, что упрощает использование трейта.
Первая цель похожа на вторую, но в обратную сторону: если вы хотите добавить параметр типа к существующему трейту, вы можете дать ему значение по умолчанию, чтобы расширить функциональность трейта, не ломая существующий код реализаций.
Устранение неоднозначности между методами с одинаковыми именами
Ничто в Rust не запрещает трейту иметь метод с тем же именем, что и метод другого трейта, и Rust также не запрещает реализовать оба трейта для одного типа. Также возможно реализовать метод с тем же именем, что и методы из трейтов, непосредственно для типа.
При вызове методов с одинаковыми именами вам нужно сообщить Rust, какой именно
метод вы хотите использовать. Рассмотрим код в листинге 20-17, где мы
определили два трейта, Pilot и Wizard, и у обоих есть метод с именем fly.
Затем мы реализуем оба трейта для типа Human, у которого уже есть метод с
именем fly, реализованный непосредственно для него. Каждый метод fly
делает что-то свое.
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() {}
fly, и реализованы для типа Human; метод fly также реализован непосредственно для Human.Когда мы вызываем fly для экземпляра Human, компилятор по умолчанию
вызывает метод, реализованный непосредственно для типа, как показано в листинге
20-18.
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();
}
fly для экземпляра HumanЗапуск этого кода напечатает *waving arms furiously*, показывая, что Rust
вызвал метод fly, реализованный непосредственно для Human.
Чтобы вызвать методы fly либо из трейта Pilot, либо из трейта Wizard, нам
нужно использовать более явный синтаксис, указывающий, какой именно метод fly
мы имеем в виду. Листинг 20-19 демонстрирует этот синтаксис.
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();
}
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, не
являющуюся методом.
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());
}
Мы реализуем код для именования всех щенков 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` 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, то получим ошибку компиляции.
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());
}
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 демонстрирует, как использовать полностью квалифицированный синтаксис.
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());
}
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 нужна помощь, чтобы определить, какую реализацию вы хотите
вызвать.
Использование супертрейтов
Иногда вы можете написать определение трейта, которое зависит от другого трейта: чтобы тип мог реализовать первый трейт, вы хотите потребовать, чтобы этот тип также реализовывал второй трейт. Вы делаете это для того, чтобы определение вашего трейта могло использовать связанные элементы второго трейта. Трейт, на который опирается определение вашего трейта, называется супертрейтом вашего трейта.
Например, предположим, что мы хотим создать трейт OutlinePrint с методом
outline_print, который будет печатать переданное значение в таком формате,
чтобы оно было обрамлено звездочками. То есть для структуры Point, которая
реализует трейт стандартной библиотеки Display так, что результатом является
(x, y), при вызове outline_print для экземпляра Point, у которого x
равен 1, а y равен 3, должно быть напечатано следующее:
**********
* *
* (1, 3) *
* *
**********
В реализации метода outline_print мы хотим использовать функциональность
трейта Display. Поэтому нам нужно указать, что трейт OutlinePrint будет
работать только для типов, которые также реализуют Display и предоставляют
функциональность, необходимую OutlinePrint. Мы можем сделать это в
определении трейта, указав OutlinePrint: Display. Эта техника похожа на
добавление ограничения трейта к трейту. Листинг 20-23 показывает реализацию
трейта OutlinePrint.
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() {}
OutlinePrint, которому требуется функциональность из DisplayПоскольку мы указали, что OutlinePrint требует трейт Display, мы можем
использовать функцию to_string, которая автоматически реализована для любого
типа, реализующего Display. Если бы мы попытались использовать to_string,
не добавив двоеточие и не указав трейт Display после имени трейта, мы
получили бы ошибку о том, что метод с именем to_string не найден для типа
&Self в текущей области видимости.
Посмотрим, что произойдет, когда мы попытаемся реализовать OutlinePrint для
типа, который не реализует Display, например для структуры Point:
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 {}
| ^^^^^ the trait `std::fmt::Display` is not implemented for `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();
| ^^^^^^^^^^^^^ the trait `std::fmt::Display` is not implemented for `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, вот так:
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.
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}");
}
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 в разделе
«Использование умных указателей как обычных ссылок» главы 15). Если бы мы не хотели, чтобы тип Wrapper имел все методы
внутреннего типа — например, чтобы ограничить поведение типа Wrapper, — нам
пришлось бы вручную реализовать только те методы, которые нам нужны.
Этот шаблон newtype также полезен даже тогда, когда трейты не участвуют. Давайте сменим фокус и рассмотрим несколько расширенных способов взаимодействия с системой типов Rust.