Типажи: Определение общей функциональности
Типаж определяет функциональность, которой обладает конкретный тип и которой он может делиться с другими типами. Мы можем использовать типажи для абстрактного определения общего поведения. Мы можем использовать ограничения типажей (trait bounds), чтобы указать, что обобщённый тип может быть любым типом, обладающим определённым поведением.
Примечание: Типажи похожи на особенность, часто называемую интерфейсами в других языках, хотя и имеют некоторые отличия.
Определение типажа
Поведение типа состоит из методов, которые мы можем вызывать для этого типа. Разные типы разделяют одно и то же поведение, если мы можем вызывать одни и те же методы для всех этих типов. Определения типажей — это способ сгруппировать сигнатуры методов, чтобы определить набор поведений, необходимых для достижения определённой цели.
Например, предположим, у нас есть несколько структур, которые хранят различные виды и объёмы текста: структура 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 с 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.
Синтаксис ограничения типажа (Trait Bound Syntax)
Синтаксис 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 перемещает эти ошибки на этап компиляции, так что мы вынуждены исправить проблемы, прежде чем наш код сможет вообще запуститься. Кроме того, нам не нужно писать код, который проверяет поведение во время выполнения, потому что мы уже проверили это на этапе компиляции. Это улучшает производительность, не жертвуя гибкостью обобщений.