Определение общего поведения с помощью трейтов
Трейт определяет функциональность, которой обладает конкретный тип и которую он может разделять с другими типами. Трейты позволяют определять общее поведение абстрактным способом. Мы можем использовать ограничения трейтов, чтобы указать, что обобщенный тип может быть любым типом, обладающим определенным поведением.
Примечание: трейты похожи на возможность, которую в других языках часто называют интерфейсами, хотя между ними есть некоторые различия.
Определение трейта
Поведение типа состоит из методов, которые можно вызвать для этого типа. Разные типы разделяют одно и то же поведение, если мы можем вызывать одни и те же методы для всех этих типов. Определения трейтов – это способ сгруппировать сигнатуры методов вместе, чтобы определить набор поведений, необходимых для достижения некоторой цели.
Например, допустим, у нас есть несколько структур, которые хранят разные виды
и объемы текста: структура NewsArticle, хранящая новостную историю,
отправленную из определенного места, и SocialPost, который может иметь не
более 280 символов вместе с метаданными, указывающими, был ли это новый пост,
репост или ответ на другой пост.
Мы хотим создать библиотечный крейт медиаагрегатора с именем aggregator,
который может отображать краткие сводки данных, хранящихся в экземпляре
NewsArticle или SocialPost. Для этого нам нужна сводка от каждого типа, и
мы будем запрашивать ее вызовом метода summarize для экземпляра. Листинг
10-12 показывает определение публичного трейта Summary, выражающего это
поведение.
pub trait Summary {
fn summarize(&self) -> String;
}
Summary, состоящий из поведения, предоставляемого методом summarizeЗдесь мы объявляем трейт с помощью ключевого слова trait, а затем указываем
имя трейта, в данном случае Summary. Мы также объявляем трейт как pub,
чтобы крейты, зависящие от этого крейта, тоже могли использовать этот трейт,
как мы увидим в нескольких примерах. Внутри фигурных скобок мы объявляем
сигнатуры методов, описывающие поведение типов, реализующих этот трейт; в
данном случае это fn summarize(&self) -> String.
После сигнатуры метода вместо предоставления реализации в фигурных скобках мы
используем точку с запятой. Каждый тип, реализующий этот трейт, должен
предоставить собственное поведение для тела метода. Компилятор проследит, что
у любого типа с трейтом Summary метод summarize будет определен именно с
такой сигнатурой.
Трейт может содержать несколько методов в своем теле: сигнатуры методов перечисляются по одной на строку, и каждая строка заканчивается точкой с запятой.
Реализация трейта для типа
Теперь, когда мы определили желаемые сигнатуры методов трейта Summary, мы
можем реализовать его для типов в нашем медиаагрегаторе. Листинг 10-13
показывает реализацию трейта Summary для структуры NewsArticle, которая
использует заголовок, автора и место, чтобы создать возвращаемое значение
summarize. Для структуры SocialPost мы определяем summarize как имя
пользователя, за которым следует весь текст поста, предполагая, что содержимое
поста уже ограничено 280 символами.
pub trait Summary {
fn summarize(&self) -> String;
}
pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}
impl Summary for NewsArticle {
fn summarize(&self) -> String {
format!("{}, by {} ({})", self.headline, self.author, self.location)
}
}
pub struct SocialPost {
pub username: String,
pub content: String,
pub reply: bool,
pub repost: bool,
}
impl Summary for SocialPost {
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
Summary для типов NewsArticle и SocialPostРеализация трейта для типа похожа на реализацию обычных методов. Разница в том,
что после impl мы пишем имя трейта, который хотим реализовать, затем
используем ключевое слово for, а затем указываем имя типа, для которого
хотим реализовать трейт. Внутри блока impl мы помещаем сигнатуры методов,
которые определены в определении трейта. Вместо добавления точки с запятой
после каждой сигнатуры мы используем фигурные скобки и заполняем тело метода
конкретным поведением, которое методы трейта должны иметь для данного типа.
Теперь, когда библиотека реализовала трейт Summary для NewsArticle и
SocialPost, пользователи крейта могут вызывать методы трейта для экземпляров
NewsArticle и SocialPost так же, как мы вызываем обычные методы.
Единственное отличие в том, что пользователь должен ввести в область видимости
и трейт, и типы. Вот пример того, как бинарный крейт мог бы использовать наш
библиотечный крейт aggregator:
use aggregator::{SocialPost, Summary};
fn main() {
let post = SocialPost {
username: String::from("horse_ebooks"),
content: String::from(
"of course, as you probably already know, people",
),
reply: false,
repost: false,
};
println!("1 new post: {}", post.summarize());
}
Этот код печатает 1 new post: horse_ebooks: of course, as you probably already know, people.
Другие крейты, зависящие от крейта aggregator, также могут ввести трейт
Summary в область видимости, чтобы реализовать Summary для собственных
типов. Важно отметить одно ограничение: мы можем реализовать трейт для типа
только если трейт, тип или они оба являются локальными для нашего крейта.
Например, мы можем реализовать трейты стандартной библиотеки вроде Display
для пользовательского типа вроде SocialPost как часть функциональности
нашего крейта aggregator, потому что тип SocialPost локален для нашего
крейта aggregator. Мы также можем реализовать Summary для Vec<T> в нашем
крейте aggregator, потому что трейт Summary локален для нашего крейта
aggregator.
Но мы не можем реализовывать внешние трейты для внешних типов. Например, мы не
можем реализовать трейт Display для Vec<T> внутри нашего крейта
aggregator, потому что и Display, и Vec<T> определены в стандартной
библиотеке и не являются локальными для нашего крейта aggregator. Это
ограничение является частью свойства, называемого согласованностью
(coherence), а точнее правилом сиротства (orphan rule), названным так
потому, что родительский тип отсутствует. Это правило гарантирует, что чужой
код не сможет сломать ваш код и наоборот. Без этого правила два крейта могли
бы реализовать один и тот же трейт для одного и того же типа, и Rust не знал
бы, какую реализацию использовать.
Использование реализаций по умолчанию
Иногда полезно иметь поведение по умолчанию для некоторых или всех методов трейта вместо того, чтобы требовать реализации всех методов для каждого типа. Тогда при реализации трейта для конкретного типа мы можем сохранить или переопределить поведение каждого метода по умолчанию.
В листинге 10-14 мы задаем строку по умолчанию для метода summarize трейта
Summary, вместо того чтобы только определить сигнатуру метода, как мы делали
в листинге 10-12.
pub trait Summary {
fn summarize(&self) -> String {
String::from("(Read more...)")
}
}
pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}
impl Summary for NewsArticle {}
pub struct SocialPost {
pub username: String,
pub content: String,
pub reply: bool,
pub repost: bool,
}
impl Summary for SocialPost {
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
Summary с реализацией метода summarize по умолчаниюЧтобы использовать реализацию по умолчанию для краткого описания экземпляров
NewsArticle, мы указываем пустой блок impl Summary for NewsArticle {}.
Даже несмотря на то, что мы больше не определяем метод summarize для
NewsArticle напрямую, мы предоставили реализацию по умолчанию и указали, что
NewsArticle реализует трейт Summary. В результате мы все еще можем вызвать
метод summarize для экземпляра NewsArticle, вот так:
use aggregator::{self, NewsArticle, Summary};
fn main() {
let article = NewsArticle {
headline: String::from("Penguins win the Stanley Cup Championship!"),
location: String::from("Pittsburgh, PA, USA"),
author: String::from("Iceburgh"),
content: String::from(
"The Pittsburgh Penguins once again are the best \
hockey team in the NHL.",
),
};
println!("New article available! {}", article.summarize());
}
Этот код печатает New article available! (Read more...).
Создание реализации по умолчанию не требует ничего менять в реализации
Summary для SocialPost из листинга 10-13. Причина в том, что синтаксис
переопределения реализации по умолчанию такой же, как синтаксис реализации
метода трейта, у которого нет реализации по умолчанию.
Реализации по умолчанию могут вызывать другие методы того же трейта, даже если
у этих других методов нет реализации по умолчанию. Таким образом, трейт может
предоставлять много полезной функциональности и требовать от реализующих типов
указать лишь небольшую ее часть. Например, мы могли бы определить трейт
Summary так, чтобы в нем был метод summarize_author, реализация которого
обязательна, а затем определить метод summarize с реализацией по умолчанию,
которая вызывает метод summarize_author:
pub trait Summary {
fn summarize_author(&self) -> String;
fn summarize(&self) -> String {
format!("(Read more from {}...)", self.summarize_author())
}
}
pub struct SocialPost {
pub username: String,
pub content: String,
pub reply: bool,
pub repost: bool,
}
impl Summary for SocialPost {
fn summarize_author(&self) -> String {
format!("@{}", self.username)
}
}
Чтобы использовать эту версию Summary, нам нужно определить только
summarize_author, когда мы реализуем трейт для типа:
pub trait Summary {
fn summarize_author(&self) -> String;
fn summarize(&self) -> String {
format!("(Read more from {}...)", self.summarize_author())
}
}
pub struct SocialPost {
pub username: String,
pub content: String,
pub reply: bool,
pub repost: bool,
}
impl Summary for SocialPost {
fn summarize_author(&self) -> String {
format!("@{}", self.username)
}
}
После определения summarize_author мы можем вызвать summarize для
экземпляров структуры SocialPost, и реализация summarize по умолчанию
вызовет предоставленное нами определение summarize_author. Поскольку мы
реализовали summarize_author, трейт Summary дал нам поведение метода
summarize без необходимости писать дополнительный код. Вот как это выглядит:
use aggregator::{self, SocialPost, Summary};
fn main() {
let post = SocialPost {
username: String::from("horse_ebooks"),
content: String::from(
"of course, as you probably already know, people",
),
reply: false,
repost: false,
};
println!("1 new post: {}", post.summarize());
}
Этот код печатает 1 new post: (Read more from @horse_ebooks...).
Обратите внимание, что вызвать реализацию по умолчанию из переопределяющей реализации того же метода невозможно.
Использование трейтов как параметров
Теперь, когда вы знаете, как определять и реализовывать трейты, мы можем
рассмотреть, как использовать трейты для определения функций, принимающих много
разных типов. Мы используем трейт Summary, реализованный для типов
NewsArticle и SocialPost в листинге 10-13, чтобы определить функцию
notify, вызывающую метод summarize для своего параметра item, который
имеет некоторый тип, реализующий трейт Summary. Для этого мы используем
синтаксис impl Trait, вот так:
pub trait Summary {
fn summarize(&self) -> String;
}
pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}
impl Summary for NewsArticle {
fn summarize(&self) -> String {
format!("{}, by {} ({})", self.headline, self.author, self.location)
}
}
pub struct SocialPost {
pub username: String,
pub content: String,
pub reply: bool,
pub repost: bool,
}
impl Summary for SocialPost {
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
pub fn notify(item: &impl Summary) {
println!("Breaking news! {}", item.summarize());
}
Вместо конкретного типа для параметра item мы указываем ключевое слово
impl и имя трейта. Этот параметр принимает любой тип, который реализует
указанный трейт. В теле notify мы можем вызывать для item любые методы,
предоставляемые трейтом Summary, например summarize. Мы можем вызвать notify
и передать любой экземпляр NewsArticle или SocialPost. Код, который
вызывает функцию с любым другим типом, например String или i32, не
скомпилируется, потому что эти типы не реализуют Summary.
Синтаксис ограничений трейтов
Синтаксис impl Trait работает для простых случаев, но на самом деле это
синтаксический сахар для более длинной формы, известной как ограничение
трейта; она выглядит так:
pub fn notify<T: Summary>(item: &T) {
println!("Breaking news! {}", item.summarize());
}
Эта более длинная форма эквивалентна примеру из предыдущего раздела, но более многословна. Мы помещаем ограничения трейтов вместе с объявлением параметра обобщенного типа после двоеточия и внутри угловых скобок.
Синтаксис impl Trait удобен и делает код в простых случаях более кратким, а
полный синтаксис ограничений трейтов позволяет выражать более сложные случаи.
Например, у нас могут быть два параметра, реализующих Summary. С синтаксисом
impl Trait это выглядит так:
pub fn notify(item1: &impl Summary, item2: &impl Summary) {
Использование impl Trait подходит, если мы хотим, чтобы эта функция
разрешала item1 и item2 иметь разные типы (при условии, что оба типа
реализуют Summary). Однако если мы хотим заставить оба параметра иметь один
и тот же тип, нужно использовать ограничение трейта, вот так:
pub fn notify<T: Summary>(item1: &T, item2: &T) {
Обобщенный тип T, указанный как тип параметров item1 и item2,
ограничивает функцию так, что конкретный тип значения, переданного как
аргумент для item1 и item2, должен быть одним и тем же.
Несколько ограничений трейтов с синтаксисом +
Мы также можем указать более одного ограничения трейта. Допустим, мы хотим,
чтобы notify использовала для item форматирование для отображения, а также
summarize: в определении notify мы указываем, что item должен
реализовывать и Display, и Summary. Это можно сделать с помощью синтаксиса
+:
pub fn notify(item: &(impl Summary + Display)) {
Синтаксис + также допустим с ограничениями трейтов для обобщенных типов:
pub fn notify<T: Summary + Display>(item: &T) {
Когда указаны эти два ограничения трейтов, тело notify может вызывать
summarize и использовать {} для форматирования item.
Более понятные ограничения трейтов с предложениями where
У использования слишком большого количества ограничений трейтов есть свои
недостатки. У каждого обобщения есть собственные ограничения трейтов, поэтому
функции с несколькими параметрами обобщенных типов могут содержать много
информации об ограничениях трейтов между именем функции и списком параметров,
из-за чего сигнатуру функции становится трудно читать. По этой причине в Rust
есть альтернативный синтаксис для указания ограничений трейтов внутри
предложения where после сигнатуры функции. Поэтому вместо того чтобы писать
так:
fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {
можно использовать предложение where, вот так:
fn some_function<T, U>(t: &T, u: &U) -> i32
where
T: Display + Clone,
U: Clone + Debug,
{
unimplemented!()
}
Сигнатура этой функции менее загромождена: имя функции, список параметров и возвращаемый тип находятся рядом, как у функции без большого количества ограничений трейтов.
Возврат типов, реализующих трейты
Мы также можем использовать синтаксис impl Trait в позиции возвращаемого
типа, чтобы вернуть значение некоторого типа, реализующего трейт, как показано
здесь:
pub trait Summary {
fn summarize(&self) -> String;
}
pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}
impl Summary for NewsArticle {
fn summarize(&self) -> String {
format!("{}, by {} ({})", self.headline, self.author, self.location)
}
}
pub struct SocialPost {
pub username: String,
pub content: String,
pub reply: bool,
pub repost: bool,
}
impl Summary for SocialPost {
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
fn returns_summarizable() -> impl Summary {
SocialPost {
username: String::from("horse_ebooks"),
content: String::from(
"of course, as you probably already know, people",
),
reply: false,
repost: false,
}
}
Используя impl Summary для возвращаемого типа, мы указываем, что функция
returns_summarizable возвращает некоторый тип, реализующий трейт Summary,
не называя конкретный тип. В этом случае returns_summarizable возвращает
SocialPost, но коду, вызывающему эту функцию, не нужно это знать.
Возможность указать возвращаемый тип только по трейту, который он реализует,
особенно полезна в контексте замыканий и итераторов, которые мы рассмотрим в
главе 13. Замыкания и итераторы создают типы, известные только компилятору,
или типы, которые очень долго записывать. Синтаксис impl Trait позволяет
кратко указать, что функция возвращает некоторый тип, реализующий трейт
Iterator, без необходимости выписывать очень длинный тип.
Однако использовать impl Trait можно только если вы возвращаете один
единственный тип. Например, этот код, возвращающий либо NewsArticle, либо
SocialPost с возвращаемым типом, указанным как impl Summary, не будет
работать:
pub trait Summary {
fn summarize(&self) -> String;
}
pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}
impl Summary for NewsArticle {
fn summarize(&self) -> String {
format!("{}, by {} ({})", self.headline, self.author, self.location)
}
}
pub struct SocialPost {
pub username: String,
pub content: String,
pub reply: bool,
pub repost: bool,
}
impl Summary for SocialPost {
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
fn returns_summarizable(switch: bool) -> impl Summary {
if switch {
NewsArticle {
headline: String::from(
"Penguins win the Stanley Cup Championship!",
),
location: String::from("Pittsburgh, PA, USA"),
author: String::from("Iceburgh"),
content: String::from(
"The Pittsburgh Penguins once again are the best \
hockey team in the NHL.",
),
}
} else {
SocialPost {
username: String::from("horse_ebooks"),
content: String::from(
"of course, as you probably already know, people",
),
reply: false,
repost: false,
}
}
}
Возвращать либо NewsArticle, либо SocialPost нельзя из-за ограничений того,
как синтаксис impl Trait реализован в компиляторе. Мы рассмотрим, как
написать функцию с таким поведением, в разделе «Использование трейт-объектов
для абстракции над общим поведением» главы 18.
Использование ограничений трейтов для условной реализации методов
Используя ограничение трейта с блоком impl, который применяет параметры
обобщенных типов, мы можем условно реализовывать методы для типов, реализующих
указанные трейты. Например, тип Pair<T> в листинге 10-15 всегда реализует
функцию new, возвращающую новый экземпляр Pair<T> (вспомните из раздела
«Синтаксис методов» главы 5, что Self – это
псевдоним типа для типа блока impl, которым в этом случае является
Pair<T>). Но в следующем блоке impl Pair<T> реализует метод
cmp_display только если его внутренний тип T реализует трейт PartialOrd,
который включает сравнение, и трейт Display, который включает печать.
use std::fmt::Display;
struct Pair<T> {
x: T,
y: T,
}
impl<T> Pair<T> {
fn new(x: T, y: T) -> Self {
Self { x, y }
}
}
impl<T: Display + PartialOrd> Pair<T> {
fn cmp_display(&self) {
if self.x >= self.y {
println!("The largest member is x = {}", self.x);
} else {
println!("The largest member is y = {}", self.y);
}
}
}
Мы также можем условно реализовать трейт для любого типа, реализующего другой
трейт. Реализации трейта для любого типа, удовлетворяющего ограничениям
трейтов, называются blanket-реализациями и широко используются в стандартной
библиотеке Rust. Например, стандартная библиотека реализует трейт ToString
для любого типа, реализующего трейт Display. Блок impl в стандартной
библиотеке выглядит примерно так:
impl<T: Display> ToString for T {
// --snip--
}
Поскольку в стандартной библиотеке есть такая blanket-реализация, мы можем
вызывать метод to_string, определенный трейтом ToString, для любого типа,
реализующего трейт Display. Например, мы можем превратить целые числа в
соответствующие значения String вот так, потому что целые числа реализуют
Display:
#![allow(unused)]
fn main() {
let s = 3.to_string();
}
Blanket-реализации появляются в документации для трейта в разделе “Implementors”.
Трейты и ограничения трейтов позволяют писать код, который использует параметры обобщенных типов для уменьшения дублирования, но также указывать компилятору, что мы хотим, чтобы обобщенный тип обладал определенным поведением. Затем компилятор может использовать информацию об ограничениях трейтов, чтобы проверить, что все конкретные типы, используемые с нашим кодом, предоставляют правильное поведение. В языках с динамической типизацией мы получили бы ошибку во время выполнения, если бы вызвали метод для типа, у которого этот метод не определен. Но Rust переносит эти ошибки во время компиляции, чтобы мы были вынуждены исправить проблемы еще до того, как наш код сможет выполниться. Кроме того, нам не нужно писать код, который проверяет поведение во время выполнения, потому что мы уже проверили его во время компиляции. Это повышает производительность без необходимости отказываться от гибкости обобщений.