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