Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Визначення спільної поведінки за допомогою трейтів

Трейт визначає функціональність, яку має певний тип і яку він може ділити з іншими типами. Ми можемо використовувати трейти для визначення спільної поведінки абстрактним способом. Ми можемо використовувати обмеження трейтів (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 працює для простих випадків, але насправді це синтаксичний цукор (syntactic sugar) для довшої форми, відомої як обмеження трейту (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 переносить ці помилки на час компіляції, щоб ми були змушені виправити проблеми ще до того, як наш код узагалі зможе запуститися. Крім того, нам не потрібно писати код, який перевіряє поведінку під час виконання, тому що ми вже перевірили це під час компіляції. Це підвищує продуктивність без необхідності відмовлятися від гнучкості узагальнених типів.