Трейти: Визначення Спільної Поведінки

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

Note: Traits are similar to a feature often called interfaces in other languages, although with some differences.

Визначення Трейту

Поведінка типу визначається тими методами, які ми можемо викликати у цього типу. Різні типи розділяють однакову поведінку, якщо ми можемо викликати одні й ті самі методи цих типів. Визначення трейтів - це спосіб згрупувати сигнатури методів разом, заради того, щоб описати загальну поведінку, необхідну для досягнення певної мети.

For example, let’s say we have multiple structs that hold various kinds and amounts of text: a NewsArticle struct that holds a news story filed in a particular location and a Tweet that can have at most 280 characters along with metadata that indicates whether it was a new tweet, a retweet, or a reply to another tweet.

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

Реалізація Поведінки за Замовчуванням

Іноді корисно мати поведінку за замовчуванням для деяких чи всіх методів трейту замість того, щоб вимагати реалізації всіх методів у кожному типі, що реалізує цей трейт. Потім, коли ми реалізуємо трейт для певного типу, можна зберегти чи перевизначити поведінку кожного методу за замовчуванням вже всередині типів.

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

Файл: src/lib.rs

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 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-14: Визначення трейту 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 для типу 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())
    }
}

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!("(Read more from {}...)", 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(
            "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 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 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!("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 після сигнатури функції. Тому замість того, щоб писати ось так:

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

Сигнатура цієї функції менш захаращена: назва функції, список параметрів, та тип значення, що повертається, знаходяться поруч, а сигнатура не містить в собі множину обмежень трейту.

Повернення Значень Типу, що Реалізує Певний Трейт

We can also use the impl Trait syntax in the return position to return a value of some type that implements a trait, as shown here:

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 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(
            "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, не буде працювати:

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 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(
                "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 в компіляторі. Ми подивимося, як написати функцію з такою поведінкою у секції “Використання об'єктів трейтів, які дозволяють використовувати значення різних типів” розділу 17.

Використання Обмежень Трейту для Умовної Реалізації Методів

Використовуючи обмеження трейту з блоком impl, який використовує параметри узагальненого типу, можна реалізувати методи умовно, для тих типів, які реалізують вказаний трейт. Наприклад, тип Pair<T> у Блоці коду 10-15 завжди реалізує функцію new для повертання нового екземпляру Pair<T> (нагадаємо з секції “Визначення методів” розділу 5, що 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);
        }
    }
}

Лістинг 10-15: Умовна реалізація методів в узагальнених типів в залежності від обмежень трейту

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