Продвинутые типажи
Мы уже знакомились с типажами в разделе «Типажи: определение общего поведения» в главе 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, содержащий значение этого конкретного типа.
Связанные типы могут показаться похожей концепцией на обобщения (generics), поскольку последние позволяют определить функцию без указания типов, с которыми она может работать. Чтобы изучить разницу между этими концепциями, мы рассмотрим реализацию типажа 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 — это хорошая практика.
Параметры типа по умолчанию и перегрузка операторов
При использовании параметров обобщения мы можем указать конкретный тип по умолчанию для обобщённого типа. Это устраняет необходимость для реализаторов типажа указывать конкретный тип, если тип по умолчанию подходит. Вы указываете тип по умолчанию при объявлении обобщённого типа с синтаксисом <ЗаполнительТипа=КонкретныйТип>.
Отличным примером ситуации, где эта техника полезна, является перегрузка операторов, при которой вы настраиваете поведение оператора (например, +) в определённых ситуациях.
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. Если мы не укажем конкретный тип для Rhs при реализации типажа Add, тип 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, который описывает характеристики, общие для всех животных. Щенков называют puppies, и это выражено в реализации типажа 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 с 1 для x и 3 для y, он должен вывести следующее:
**********
* *
* (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 {}
| ^^^^^ `Point` cannot be formatted with the default formatter
|
= help: the trait `std::fmt::Display` is not implemented for `Point`
= note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
note: required by a bound in `OutlinePrint`
--> src/main.rs:3:21
|
3 | trait OutlinePrint: fmt::Display {
| ^^^^^^^^^^^^ required by this bound in `OutlinePrint`
error[E0277]: `Point` doesn't implement `std::fmt::Display`
--> src/main.rs:24:7
|
24 | p.outline_print();
| ^^^^^^^^^^^^^ `Point` cannot be formatted with the default formatter
|
= help: the trait `std::fmt::Display` is not implemented for `Point`
= note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
note: required by a bound in `OutlinePrint::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 в разделе «Обращение с умными указателями как с обычными ссылками с помощью типажа Deref» в главе 15). Если бы мы не хотели, чтобы тип Wrapper имел все методы внутреннего типа — например, чтобы ограничить поведение типа Wrapper — нам пришлось бы реализовать вручную только те методы, которые мы хотим.
Этот паттерн newtype также полезен, даже когда типажи не вовлечены. Давайте сменим фокус и посмотрим на некоторые продвинутые способы взаимодействия с системой типов Rust.