Трейты: Определение общих поведений

RustRustBeginner
Практиковаться сейчас

This tutorial is from open-source community. Access the source code

💡 Этот учебник переведен с английского с помощью ИИ. Чтобы просмотреть оригинал, вы можете перейти на английский оригинал

Введение

Добро пожаловать в Traits: Defining Shared Behavior. Эта лабораторная работа является частью Rust Book. Вы можете практиковать свои навыки Rust в LabEx.

В этой лабораторной работе мы исследуем трейты как способ определения общих поведений в типе и указания ограничений трейтов для обобщенных типов.

Traits: Defining Shared Behavior

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

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

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

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

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

Мы хотим создать библиотечный крейт aggregator для агрегации медиа, который может отображать сводки данных, которые могут быть сохранены в экземпляре NewsArticle или Tweet. Для этого нам нужна сводка от каждого типа, и мы запросим эту сводку, вызвав метод summarize для экземпляра. В листинге 10-12 показано определение публичного трейта Summary, который выражает это поведение.

Имя файла: src/lib.rs

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

Листинг 10-12: Трейт Summary, состоящий из поведения, предоставляемого методом summarize

Здесь мы объявляем трейт с использованием ключевого слова trait, а затем имя трейта, которое в этом случае - Summary. Мы также объявляем трейт как pub, чтобы зависимости этого крейта могли также использовать этот трейт, как мы увидим в нескольких примерах. Внутри фигурных скобок мы объявляем сигнатуры методов, которые описывают поведение типов, реализующих этот трейт, а в этом случае это fn summarize(&self) -> String.

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

В теле трейта может быть несколько методов: сигнатуры методов перечисляются по одной в строке, и каждая строка заканчивается точкой с запятой.

Реализация трейта для типа

Теперь, когда мы определили желаемые сигнатуры методов трейта Summary, мы можем реализовать его для типов в нашей медиа-агрегаторе. В листинге 10-13 показана реализация трейта Summary для структуры NewsArticle, которая использует заголовок, автора и местонахождение для создания возвращаемого значения метода summarize. Для структуры Tweet мы определяем summarize как имя пользователя, за которым следует весь текст твита, предполагая, что содержание твита уже ограничено 280 символами.

Имя файла: src/lib.rs

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 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)
    }
}

Листинг 10-13: Реализация трейта Summary для типов NewsArticle и Tweet

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

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

use aggregator::{Summary, Tweet};

fn main() {
    let tweet = Tweet {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        retweet: false,
    };

    println!("1 new tweet: {}", tweet.summarize());
}

Этот код выводит 1 new tweet: horse_ebooks: of course, as you probably already know, people.

Другие зависимости от крейта aggregator также могут импортировать трейт Summary в область видимости, чтобы реализовать Summary для своих собственных типов. Одно ограничение, о котором нужно помнить, заключается в том, что мы можем реализовать трейт для типа только в том случае, если либо трейт, либо тип, или оба, локальны для нашего крейта. Например, мы можем реализовать стандартные библиотеки трейты, такие как Display, для пользовательского типа, такого как Tweet, как часть функциональности нашего крейта aggregator, потому что тип Tweet локален для нашего крейта aggregator. Мы также можем реализовать Summary для Vec<T> в нашем крейте aggregator, потому что трейт Summary локален для нашего крейта aggregator.

Но мы не можем реализовать внешние трейты для внешних типов. Например, мы не можем реализовать трейт Display для Vec<T> внутри нашего крейта aggregator, потому что Display и Vec<T> определены в стандартной библиотеке и не являются локальными для нашего крейта aggregator. Это ограничение является частью свойства, называемого согласованностью, и более конкретно правилом сироты, так называемым потому, что родительский тип отсутствует. Это правило гарантирует, что код других людей не может сломать ваш код и наоборот. Без этого правила два крейта могли бы реализовать один и тот же трейт для одного и того же типа, и Rust не знал бы, какой имплементации использовать.

Стандартные реализации

Иногда полезно определить стандартное поведение для некоторых или всех методов в трейте, вместо того чтобы требовать реализацию для всех методов для каждого типа. Затем, когда мы реализуем трейт для конкретного типа, мы можем сохранить или переопределить стандартное поведение каждого метода.

В листинге 10-14 мы задаем стандартную строку для метода summarize трейта Summary, вместо того чтобы только определить сигнатуру метода, как мы это делали в листинге 10-12.

Имя файла: src/lib.rs

pub trait Summary {
    fn summarize(&self) -> String {
        String::from("(Read more...)")
    }
}

Листинг 10-14: Определение трейта Summary с стандартной реализацией метода summarize

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

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

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 для Tweet в листинге 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()
        )
    }
}

Для использования этой версии Summary, нам нужно только определить summarize_author, когда мы реализуем трейт для типа:

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

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

let tweet = Tweet {
    username: String::from("horse_ebooks"),
    content: String::from(
        "of course, as you probably already know, people",
    ),
    reply: false,
    retweet: false,
};

println!("1 new tweet: {}", tweet.summarize());

Этот код выводит 1 new tweet: (Read more from @horse_ebooks...).

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

Трейты в качестве параметров

Теперь, когда вы знаете, как определить и реализовать трейты, мы можем изучить, как использовать трейты для определения функций, которые принимают множество различных типов. Мы будем использовать трейт Summary, который мы реализовали для типов NewsArticle и Tweet в листинге 10-13, чтобы определить функцию notify, которая вызывает метод summarize для параметра item, который является некоторым типом, реализующим трейт Summary. Для этого мы используем синтаксис impl Trait, вот так:

pub fn notify(item: &impl Summary) {
    println!("Breaking news! {}", item.summarize());
}

Вместо конкретного типа для параметра item мы указываем ключевое слово impl и имя трейта. Этот параметр принимает любой тип, реализующий указанный трейт. В теле функции notify мы можем вызывать любые методы для item, которые относятся к трейту Summary, например, summarize. Мы можем вызвать функцию notify и передать любой экземпляр NewsArticle или Tweet. Код, который вызывает функцию с любым другим типом, таким как 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 использовала форматирование для отображения, а также метод 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 子句中指定 trait 约束。因此,与其这样写:

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,
{

此函数的签名不那么杂乱:函数名、参数列表和返回类型靠得很近,类似于没有很多 trait 约束的函数。

Возвращение типов, реализующих трейты

Мы также можем использовать синтаксис impl Trait в позиции возврата, чтобы вернуть значение некоторого типа, который реализует трейт, как показано здесь:

fn returns_summarizable() -> impl Summary {
    Tweet {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        retweet: false,
    }
}

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

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

Однако вы можете использовать impl Trait только если возвращаете один тип. Например, этот код, который возвращает либо NewsArticle, либо Tweet с типом возврата, заданным как impl Summary, не сработает:

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 {
        Tweet {
            username: String::from("horse_ebooks"),
            content: String::from(
                "of course, as you probably already know, people",
            ),
            reply: false,
            retweet: false,
        }
    }
}

Возвращение либо NewsArticle, либо Tweet не допускается из-за ограничений, связанных с тем, как синтаксис impl Trait реализован в компиляторе. Мы рассмотрим, как написать функцию с таким поведением в разделе "Использование объектов трейта, которые допускают значения разных типов".

Использование ограничений трейта для условного реализации методов

С использованием ограничения трейта с блоком impl, который использует обобщенные типы параметров, мы можем условно реализовывать методы для типов, которые реализуют указанные трейты. Например, тип Pair<T> в Listing 10-15 всегда реализует функцию new, чтобы вернуть новый экземпляр Pair<T> (помните из раздела "Определение методов", что Self является псевдонимом типа для типа блока impl, который в этом случае - Pair<T>). Но в следующем блоке impl Pair<T> реализует метод cmp_display только если его внутренний тип T реализует трейт PartialOrd, который позволяет сравнивать, и трейт Display, который позволяет печатать.

Имя файла: src/lib.rs

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);
        }
    }
}

Listing 10-15: Условная реализация методов для обобщенного типа в зависимости от ограничений трейта

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

impl<T: Display> ToString for T {
    --snip--
}

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

let s = 3.to_string();

Общие реализации появляются в документации по трейту в разделе "Реализаторы".

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

Резюме

Поздравляем! Вы завершили лабораторную работу "Трейты: Определение общих поведений". Вы можете практиковаться в других лабораторных работах в LabEx, чтобы улучшить свои навыки.