Трейты: определение схожего поведения

Трейт определяет функциональность, которой обладает конкретный тип и которая может использоваться с другими типами. Мы можем использовать трейты для определения общего поведения абстрактным образом. Мы можем использовать ограничение по трейтам, чтобы указать, что обобщённый тип может быть заменён на любой тип с некоторым известным поведением.

Примечание: Трейты схожи с таким механизмом как интерфейсы, но всё же они различаются.

Определение трейта

Поведение типа определяется теми методами, которые мы можем вызвать на данном типе. Различные типы разделяют одинаковое поведение, если мы можем вызвать одни и те же методы у этих типов. Определение трейтов — это способ сгруппировать сигнатуры методов вместе для того, чтобы описать общее поведение, необходимое для достижения определённой цели.

Например, пусть есть несколько структур, которые имеют различные структуру и объём текста: структура NewsArticle, которая содержит новость, напечатанную в каком-то месте мира, и структура Tweet, которая содержит 280 символов текста твита плюс мета-данные, обозначающие, является ли твит новым или ответом на другой твит.

Мы хотим создать библиотечный крейт новостного агрегатора aggregator, который может отображать сводку данных, сохранённых в экземплярах структур NewsArticle или Tweet. Чтобы реализовать это, нам необходимо иметь возможность получить сводку на основе данных структуры, и мы можем запросить эту сводку, вызвав метод 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 заголовок, автора и место публикации статьи. Для структуры Tweet мы определяем реализацию 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!("{} ({} из {})", self.headline, self.author, self.location)
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

Реализация трейта для типа аналогична реализации обычных методов. Разница в том, что после impl мы указываем имя трейта, который мы хотим реализовать, затем используем ключевое слово for, а затем указываем имя типа, для которого мы хотим сделать реализацию трейта. Внутри блока impl мы помещаем сигнатуру метода, объявленную в трейте. Вместо точки с запятой в конце, после каждой сигнатуры пишутся фигурные скобки, и тело метода заполняется конкретным кодом, реализующим поведение, которое мы хотим получить от методов трейта для конкретного типа.

Теперь, когда наш библиотечный крейт реализовывает трейт Summary для NewsArticle и Tweet, программисты, использующие наш крейт, могут вызывать методы трейта у экземпляров типов NewsArticle и Tweet точно так же, как если бы это были обычные методы. Единственное отличие состоит в том, что программист должен ввести трейт в область видимости точно так же, как и типы. Вот пример того как бинарный крейт может использовать наш новостной агрегатор:

use aggregator::{Summary, Tweet};

fn main() {
    let tweet = Tweet {
        username: String::from("horse_ebooks"),
        content: String::from(
            "конечно, вы уже наверное знаете, народ, ...",
        ),
        reply: false,
        retweet: false,
    };

    println!("1 новый твит: {}", tweet.summarize());
}

Данный код напечатает: 1 новый твит: horse_ebooks: конечно, вы уже наверное знаете, народ, ....

Другие крейты, которые зависят от aggregator, также могут включать трейт Summary в область видимости для реализации Summary для своих собственных типов. Одно ограничение, на которое следует обратить внимание, заключается в том, что мы можем реализовать трейт для типа только в том случае, если хотя бы или трейт, или тип определяются нашим крейтом. Например, мы можем реализовать стандартный библиотечный трейт Display на собственном типе Tweet как часть функциональности нашего крейта aggregator, потому что тип Tweet является локальным для крейта 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("(Читать далее...)")
    }
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

Для использования реализации по умолчанию при создании сводки экземпляров NewsArticle, мы указываем пустой блок при impl Summary for NewsArticle {}.

Хотя мы больше не определяем метод summarize непосредственно на NewsArticle, мы предоставили реализацию по умолчанию и указали, что NewsArticle реализует трейт Summary. В результате мы всё ещё можем вызвать метод summarize у экземпляра NewsArticle; например, вот так:

use aggregator::{self, NewsArticle, Summary};

fn main() {
    let article = NewsArticle {
        headline: String::from("«Питтсбург Пингвинз» выиграли Кубок Стэнли!"),
        location: String::from("Питтсбург, штат Пенсильвания, США"),
        author: String::from("Пингвин Айсбург"),
        content: String::from(
            "«Питтсбург Пингвинз» вновь оказалась лучшей \
             хоккейной командой в НХЛ.",
        ),
    };

    println!("Новости! {}", article.summarize());
}

Этот код печатает Новости! (Читать далее...).

Создание реализации по умолчанию не требует от нас изменений чего-либо в реализации Summary для Tweet из Листинга 10-13. Причина заключается в том, что синтаксис для переопределения реализации по умолчанию является таким же, как синтаксис для реализации метода трейта, который не имеет реализации по умолчанию.

Реализации по умолчанию могут вызывать другие методы в том же трейте, даже если эти другие методы не имеют реализации по умолчанию. Таким образом, трейт может предоставить много полезной функциональности, а от разработчиков требует указывать только небольшую его часть. Например, мы могли бы определить трейт Summary, имеющий метод summarize_author без реализации по умолчанию, а затем определить метод summarize который имеет реализацию по умолчанию, вызывающую метод summarize_author:

pub trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!("(Читать далее, от {}...)", self.summarize_author())
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}

Чтобы использовать такую версию трейта Summary, при реализации трейта для типа нужно определить только метод summarize_author:

pub trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!("(Читать далее, от {}...)", self.summarize_author())
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}

После того, как мы определим summarize_author, можно вызвать summarize для экземпляров структуры Tweet, и реализация по умолчанию метода summarize будет вызывать определение summarize_author, которое мы уже предоставили. Так как мы реализовали метод summarize_author трейта Summary, то трейт даёт нам поведение метода summarize без необходимости писать ещё какой-либо код. Вот как это выглядит:

use aggregator::{self, Summary, Tweet};

fn main() {
    let tweet = Tweet {
        username: String::from("horse_ebooks"),
        content: String::from(
            "конечно, вы уже наверное знаете, народ, ...",
        ),
        reply: false,
        retweet: false,
    };

    println!("1 новый твит: {}", tweet.summarize());
}

Этот код печатает 1 новый твит: (Читать далее, от @horse_ebooks...)

Обратите внимание, что невозможно вызвать реализацию по умолчанию из переопределённой реализации того же метода.

Трейты как параметры

Теперь, когда вы знаете, как определять и реализовывать трейты, мы можем изучить, как использовать трейты, чтобы определить функции, которые принимают много различных типов. Мы используем трейт Summary (реализованный для типов NewsArticle и Tweet) в Листинге 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!("{} ({} из {})", self.headline, self.author, self.location)
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

pub fn notify(item: &impl Summary) {
    println!("Срочные новости! {}", item.summarize());
}

Вместо конкретного типа у параметра item указывается ключевое слово impl и имя трейта. Этот параметр может принимать любой тип, который реализует указанный трейт. В теле notify мы можем вызывать любые методы у экземпляра item, которые приходят с трейтом Summary, такие как метод summarize. Мы можем вызвать notify и передать в него любой экземпляр NewsArticle или Tweet. Код, который вызывает данную функцию с любым другим типом, таким как String или i32, не будет компилироваться, потому что эти типы не реализуют трейт Summary.

Ограничение по трейтам

Синтаксис impl Trait работает для простых случаев, но на самом деле является синтаксическим сахаром для более длинной конструкции — ограничения по трейтам:

pub fn notify<T: Summary>(item: &T) {
    println!("Срочные новости! {}", 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 должен реализовывать два трейта: 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!("{} ({} из {})", self.headline, self.author, self.location)
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

fn returns_summarizable() -> impl Summary {
    Tweet {
        username: String::from("horse_ebooks"),
        content: String::from(
            "конечно, вы уже наверное знаете, народ, ...",
        ),
        reply: false,
        retweet: false,
    }
}

Используя impl Summary для типа возвращаемого значения, мы указываем, что функция returns_summarizable возвращает некоторый тип, который реализует трейт Summary, не обозначая конкретный тип. В этом случае returns_summarizable возвращает Tweet, но код, вызывающий эту функцию, этого не знает.

Возможность возвращать тип, который определяется только реализуемым им трейтом, особенно полезна в контексте замыканий и итераторов, которые мы рассмотрим в Главе 13. Замыкания и итераторы создают типы, которые знает только компилятор, или же типы, которые очень долго указывать. Запись impl Trait позволяет кратко указать, что функция возвращает некоторый тип, который реализует трейт Iterator без необходимости писать очень длинный тип.

Однако, impl Trait можно использовать, только если функция всегда возвращает значение одного типа. Например, данный код, который возвращает значение или типа NewsArticle, или типа Tweet, но в качестве возвращаемого типа объявляет 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!("{} ({} из {})", self.headline, self.author, self.location)
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

fn returns_summarizable(switch: bool) -> impl Summary {
    if switch {
        NewsArticle {
            headline: String::from(
                "«Питтсбург Пингвинз» выиграли Кубок Стэнли!",
            ),
            location: String::from("Питтсбург, штат Пенсильвания, США"),
            author: String::from("Пингвин Айсбург"),
            content: String::from(
                "«Питтсбург Пингвинз» вновь оказалась лучшей \
                 хоккейной командой в НХЛ.",
            ),
        }
    } else {
        Tweet {
            username: String::from("horse_ebooks"),
            content: String::from(
                "конечно, вы уже наверное знаете, народ, ...",
            ),
            reply: false,
            retweet: false,
        }
    }
}

Неоднозначность возвращаемого типа не допустима из-за того, как синтаксис 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!("Слева ({}) — наибольшее", self.x);
        } else {
            println!("Справа ({}) — наибольшее", self.y);
        }
    }
}

Мы также можем избирательно реализовать трейт для всех типов, которые реализуют другой трейт. Реализация трейта для всех типов, которые удовлетворяют ограничениям по трейтам, называются сплошной реализацией, и она широко используется в стандартной библиотеке Rust. Например, стандартная библиотека реализует трейт ToString для всех типов, которые реализуют трейт Display. Блок impl, делающий это, выглядит примерно так:

impl<T: Display> ToString for T {
    // --код сокращён--
}

Благодаря такой сплошной реализации можно вызвать метод to_string, определённый трейтом ToString, для любого типа, который реализует трейт Display. Например, мы можем превратить целые числа в их соответствующие значения String, потому что целые числа реализуют трейт Display:

#![allow(unused)]
fn main() {
let s = 3.to_string();
}

В документации к трейтам, их сплошные реализации можно увидеть в разделе "Implementors".

Трейты и ограничения по трейтам позволяют нам писать более абстрактный код с помощью параметров обобщённого типа, а также позволяют указать компилятору, что мы хотим, чтобы у обобщённого типа было опредёленное поведение. Затем компилятор может использовать информацию об ограничениях по трейтам, чтобы проверить, что все конкретные типы, используемые в нашем коде, реализуют нужное поведение. В языках с динамической типизацией мы получили бы ошибку во время выполнения, если бы вызвали метод для типа, в котором этот метод не определен. Но Rust переносит эти ошибки на время компиляции, а потому он обязывает нас устранять проблемы ещё до того, как код будет запущен. Кроме того, нам не нужно писать код, который проверяет поведение во время выполнения, потому что мы уже проверили его во время компиляции. Это повышает производительность без необходимости отказываться от гибкости обобщённых типов, функций и методов.