Трейты: общее поведение
Трейт (trait) описывает функциональность, которая может быть общей для нескольких типов. С помощью трейтов можно абстрактно описывать общее поведение. А с помощью ограничений трейтами (trait bounds) можно указать, что обобщённый тип — это любой тип с определённым поведением.
Примечание: трейты похожи на то, что в других языках часто называют интерфейсами, хотя есть и отличия.
Определение трейта
Поведение типа — это методы, которые можно у него вызвать. Разные типы ведут себя одинаково, если у всех этих типов можно вызвать одни и те же методы. Определение трейта — способ собрать вместе сигнатуры методов и описать набор поведения, необходимый для какой-то цели.
Допустим, у нас есть несколько структур, хранящих текст разного вида и объёма:
структура NewsArticle хранит новостную заметку из определённого места, а
SocialPost — пост длиной не больше 280 символов плюс метаданные: новый ли
это пост, репост или ответ на другой пост.
Мы хотим сделать библиотечный крейт aggregator — агрегатор медиа, который
умеет показывать краткое изложение (summary) данных из экземпляра
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 и реализовать его для своих типов. Есть одно ограничение: реализовать
трейт для типа можно, только если трейт, тип или оба они локальны для нашего
крейта. Например, мы можем реализовать трейты стандартной библиотеки, вроде
Display, для собственного типа вроде SocialPost в рамках крейта
aggregator, потому что тип SocialPost локален для нашего крейта. Можем мы
реализовать и Summary для Vec<T> в крейте aggregator, потому что
локален трейт Summary.
А вот реализовать внешний трейт для внешнего типа нельзя. Например, нельзя в
крейте aggregator реализовать трейт Display для Vec<T>: и Display, и
Vec<T> определены в стандартной библиотеке и не локальны для нашего крейта.
Это ограничение — часть свойства под названием согласованность
(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: 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 хорош в простых случаях, но на самом деле это
синтаксический сахар для более длинной формы — ограничения трейтом (trait
bound). Выглядит она так:
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 {
можно написать так:
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 метод cmp_display реализован для Pair<T>, только если
внутренний тип 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 implementations), и в
стандартной библиотеке Rust они используются повсеместно. Например,
стандартная библиотека реализует трейт ToString для любого типа, который
реализует трейт Display. Блок impl в стандартной библиотеке выглядит
примерно так:
impl<T: Display> ToString for T {
// --snip--
}
Благодаря этой обобщённой реализации у любого типа, реализующего Display,
можно вызвать метод to_string из трейта ToString. Например, так можно
превратить целое число в соответствующее значение String — ведь целые числа
реализуют Display:
#![allow(unused)]
fn main() {
let s = 3.to_string();
}
Обобщённые реализации перечислены в документации трейта в разделе «Implementors».
Трейты и ограничения трейтами позволяют писать код с обобщёнными параметрами типа, избавляясь от дублирования, и при этом сообщать компилятору, что у обобщённого типа должно быть определённое поведение. Компилятор по информации из ограничений проверяет, что все конкретные типы, с которыми используется наш код, ведут себя правильно. В динамически типизированных языках мы получили бы ошибку во время выполнения, вызвав у типа метод, которого у него нет. Rust переносит такие ошибки на этап компиляции, и исправить проблемы приходится ещё до того, как код вообще сможет запуститься. Кроме того, не нужно писать код, проверяющий поведение во время выполнения, — всё уже проверено при компиляции. Это повышает производительность и не лишает нас гибкости обобщений.