Продвинутые трейты
Впервые мы познакомились с трейтами в разделе «Трейты: общее поведение» главы 10, но в продвинутые детали не вдавались. Теперь, когда вы знаете о Rust больше, можно разобраться в тонкостях.
Трейты с ассоциированными типами
Ассоциированные типы (associated types) связывают с трейтом тип-заполнитель, который определения методов трейта могут использовать в своих сигнатурах. Тот, кто реализует трейт, указывает конкретный тип, который будет использоваться вместо заполнителя в данной реализации. Так можно определить трейт, который использует какие-то типы, и не знать точно, что это за типы, пока трейт не реализован.
Большинство продвинутых возможностей в этой главе мы описывали как редко нужные. Ассоциированные типы — где-то посередине: они встречаются реже, чем возможности из остальной книги, но чаще многих других возможностей из этой главы.
Пример трейта с ассоциированным типом — трейт 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> {
// --пропуск--
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, и
для любого другого типа, — то есть у 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.
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: такой синтаксис называется параметрами типа по
умолчанию (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.
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, какой из них вы имеете
в виду. Посмотрите на код в листинге 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());
}
Код, называющий всех щенков Спотами, находится в ассоциированной функции
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, будет ошибка
компиляции.
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, а не для какого-то другого типа, нужен полностью квалифицированный
синтаксис. Как им пользоваться, показано в листинге 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 нужна помощь,
чтобы понять, какую из них вызвать.
Супертрейты
Иногда определение трейта зависит от другого трейта: чтобы тип мог реализовать первый трейт, нужно, чтобы он реализовывал и второй. Так делают, чтобы определение трейта могло пользоваться ассоциированными элементами второго трейта. Трейт, на который опирается определение вашего трейта, называется супертрейтом (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.
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 после имени трейта, получили бы ошибку: для типа
&Self в текущей области видимости метод to_string не найден.
Посмотрим, что будет, если попытаться реализовать 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 {}
| ^^^^^ 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:
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 обращается к внутреннему 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.