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

Трэйты

Important

В русскоязычной литературе по Rust слово trait переводят по-разному: примесь, типаж. Мы будем использовать англицизм “трэйт”, потому что так говорят даже в русскоязычной среде при обсуждении языка Rust.

Трэйты служат для определения интерфейса взаимодействия с типом путём описания методов, которые должны у него присутствовать. Если для некого типа необходимо реализовать трэйт, то для этого типа нужно определить реализации всех методов, объявленных в трэйте.

Tip

Программисты на Java и C# могут провести параллели с интерфейсами. Для программистов на C++ самой близкой аналогией будет абстрактный класс.

Синтаксис объявления трэйта:

trait Имя {
    fn метод_1(&self) -> Тип1;
    ...
    fn метод_N(&self) -> ТипN;
}

Синтаксис реализации трэйта для структуры:

impl Трэйт for Структура {
    fn метод_1(&self) -> Тип1 { ... }
    ...
    fn метод_N(&self) -> ТипN { ... }
}

Пример:

// трэйт, который говорит о том, что тип реализующий его, умеет представляться
trait CanIntroduce {
    // метод "представиться"
    fn introduce(&self) -> String;
}

struct Person {
    name: String
}

impl CanIntroduce for Person {
    fn introduce(&self) -> String {
        // Человек представляется называя своё имя
        format!("Hello, I'm {}", self.name)
    }
}

fn main() {
    let person = Person { name: String::from("John") };

    println!("{}", person.introduce ()); // Hello, I'm John
}

Полиморфизм

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

В Rust существует два принципиально разных подхода к передаче аргументов на основе трейтов:

  • Статическая диспетчеризация — когда тип аргумента указывается как impl Трэйт
  • Динамическая диспетчеризация — когда тип аргумента указывается как dyn Трэйт

Если вы программируете на C++, то, скорее всего, вы уже поняли, что это означает. В противном случае давайте рассмотрим каждый из этих двух типов.

Статическая диспетчеризация

Сначала мы рассмотрим синтаксис передачи аргументов со статической диспетчеризацией, а потом разберём, как этот механизм работает внутри.

Для того чтобы функция принимала аргумент по трэйту со статической диспетчеризацией, надо указать тип аргумента как impl Трэйт.

Рассмотрим на примере. Напишем функцию, которая принимает любой тип, реализующий трэйт CanIntroduce из примера выше, и печатает “представление” на консоль :

fn print_introduction(v: &impl CanIntroduce) {
    // Всё что мы знаем о v: его тип реализует трэйт CanIntroduce
    println!("Value says: {}", v.introduce());
}

Разумеется, так как мы принимаем аргумент по его трэйту, единственное, что мы можем с ним делать — то, что объявлено в трэйте. Но это как раз то, что нам нужно.

Для того чтобы продемонстрировать, как эта функция может принимать аргументы разных типов, давайте в дополнение к структуре Person создадим еще структуру Dog и также реализуем для неё трэйт CanIntroduce.

struct Dog {
    name: String
}

impl CanIntroduce for Dog {
    fn introduce(&self) -> String {
        // Вне зависимости от своего имени, собака может только погавкать
        String::from("Waf-waf")
    }
}

Теперь посмотрим, как можно вызвать функцию print_introduction и для Person, и для Dog.

trait CanIntroduce {
    fn introduce(&self) -> String;
}

struct Person {
    name: String
}

impl CanIntroduce for Person {
    fn introduce(&self) -> String {
        format!("Hello, I'm {}", self.name)
    }
}

struct Dog {
    name: String
}

impl CanIntroduce for Dog {
    fn introduce(&self) -> String {
        // Вне зависимости от своего имени, собака может только погавкать
        String::from("Waf-waf")
    }
}

fn print_introduction(v: &impl CanIntroduce) {
    // Всё что мы знаем о v: его тип реализует трэйт CanIntroduce
    println!("Value says: {}", v.introduce());
}

fn main() {
    let person = Person { name: String::from("John") };
    let dog    = Dog    { name: String::from("Bark") };

    print_introduction(&person); // Value says: Hello, I'm John
    print_introduction(&dog);    // Value says: Waf-waf
}

Всё работает: мы написали полиморфную функцию, которая принимает аргумент любого типа, реализующего трэйт.


А теперь давайте поговорим о том, как это работает, и почему диспетчеризация называется статической.

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

Note

Такой процесс генерации функции с конкретным типом вместо трэйта называется мономорфизацией.

То есть, найдя вызов функции print_introduction для Person, а потом для Dog, компилятор сгенерирует нечто наподобие следующего (разумеется, имена будут не такими):

fn print_introduction_$Person(v: &Person) {
    println!("Value says: {}", v.introduce());
}
fn print_introduction_$Dog(v: &Dog) {
    println!("Value says: {}", v.introduce());
}

А далее компилятор изменит вызовы полиморфной функции print_introduction на вызовы конкретных вариантов:

fn main() {
    let person = Person { name: String::from("John") };
    let dog    = Dog    { name: String::from("Bark") };

    print_introduction_$Person(&person);
    print_introduction_$Dog(&dog);
}

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

Адреса методов статичны (известны на момент сборки программы), поэтому диспетчеризация и называется статической.

Динамическая диспетчеризация

В противовес статической диспетчеризации существует и динамическая. На первый взгляд, различие в коде между статической диспетчеризацией и динамической — минимально: нужно просто заменить тип аргумента с impl Трэйт на dyn Трэйт. Однако разница в реализации очень существенна. Как и в прошлый раз, сначала мы разберём синтаксис, а потом уже внутреннее устройство.

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

fn print_introduction(v: &dyn CanIntroduce) {
    println!("Value says: {}", v.introduce());
}

При этом вызовы этой функции для типов Person и Dog вообще не изменились:

trait CanIntroduce {
    fn introduce(&self) -> String;
}

struct Person {
    name: String
}

impl CanIntroduce for Person {
    fn introduce(&self) -> String {
        format!("Hello, I'm {}", self.name)
    }
}

struct Dog {
    name: String
}

impl CanIntroduce for Dog {
    fn introduce(&self) -> String {
        String::from("Waf-waf")
    }
}

fn print_introduction(v: &dyn CanIntroduce) {
    println!("Value says: {}", v.introduce());
}

fn main() {
    let person = Person { name: String::from("John") };
    let dog    = Dog    { name: String::from("Bark") };

    print_introduction(&person); // Value says: Hello, I'm John
    print_introduction(&dog);    // Value says: Waf-waf
}

Как мы уже сказали, разница кажется минимальной только внешне. Если при статической диспетчеризации компилятор генерирует столько вариантов функции, сколько имеется различных типов для которых функция вызывается, то при динамической диспетчеризации, функция всего одна.

Каким же тогда образом print_introduction понимает для какой реализации CanIntroduce она вызвана и какую реализацию метода introduce ей следует использовать? Дело в том, что аргумент &dyn CanIntroduce — это не просто ссылка на объект, переданный в качестве аргумента, это пара ссылок: первая — на сам объект, а вторая — на vtable (таблица виртуальных вызовов) для конкретного типа аргумента. В англоязычной литературе эту пару ссылок называют fat pointer — толстый указатель.

Когда компилятор видит в коде места, где к объекту конкретного типа обращаются посредством dyn Трэйт, то для этого типа он генерирует специальную таблицу vtable. В этой таблице хранятся имена методов, которые тип реализует для трэйта, и адреса реализаций этих методов в сегменте кода. Фактически, vtable является картотекой, которая хранит отображение имён методов из трэйта на реализации этих методов для конкретного типа.

Далее во всех местах вызова функции с dyn Трэйт аргументом компилятор генерирует код, который в качестве аргумента в функцию передаёт пару: адрес объекта-аргумента и адрес его vtable.

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

  1. по имени метода ищёт в таблице vtable адрес реализации этого метода
  2. вызывает этот метод

Объект типа dyn Трэйт называется трэйт объектом (trait object).

Tip

Не путайте:

  • dyn Трэйт (трэйт-объект) — значение неизвестного типа и неизвестного размера
  • &dyn Трэйт (ссылка на трэйт-объект) — пара указателей: на значение и на vtable

То есть точно так же, как слайс-ссылка &[] — это не просто адрес начала последовательности, а адрес + размер последовательности, так же и &dyn — это не просто адрес, а адрес значения + адрес vtable.

impl vs dyn

Подведём итог. Есть два способа обратиться к типу посредством трэйта, который он реализует:

  • impl Трэйт — заменяется на конкретный тип при компиляции
  • dyn Трэйт — заменяется на трэйт объект, который проксирует вызовы методов на реальный тип при помощи динамической диспетчеризации

Note

Если вы не знакомы с C++, детали динамической диспетчеризации, скорее всего, будут плохо понятны. Не пытайтесь понять всё досконально за раз, просто вернитесь к этой теме потом, после того как освоитесь с Rust на базовом уровне и будете готовы к более глубокому изучению.

На данном этапе мы еще не имеем достаточно знаний, чтобы рассмотреть все аспекты использования трэйт объектов, но пока что можем сказать следующее:

  • Вызовы методов посредством статической диспетчеризации работают гораздо быстрее, потому что вызов осуществляется напрямую по адресу метода, в то время как при динамической диспетчеризации сначала нужно найти адрес метода в vtable.
  • При компиляции impl Трэйт просто подменяется на конкретный тип, поэтому аргументы такого типа можно передавать как по ссылке &impl Трэйт, так и по значению — impl Трэйт.
  • В отличие от impl Трэйт, dyn Трэйт нельзя передать по значению. Дело в том, что при компиляции функции в машинный код должен быть известен точный размер всех её аргументов. Но при динамической диспетчеризации конкретный тип аргумента не известен, а следовательно, неизвестен и его размер. Поэтому трэйт объекты всегда передаются либо по ссылке — &dyn Трэйт, либо через умный указатель Box<dyn Трэйт> (о котором мы поговорим позже).

Реализация трэйта для “чужих” типов

В отличие от ООП языков, где методы класса определяются непосредственно в теле класса, в Rust методы структуры определяются за пределами тела структуры. Это даёт возможность реализовать свой трэйт для “чужих” структур (находящихся в других библиотеках).

trait CanIntroduce {
    fn introduce(&self) -> String;
}

impl CanIntroduce for &str {
    fn introduce(&self) -> String {
        String::from("I am string slice")
    }
}

impl CanIntroduce for i32 {
    fn introduce(&self) -> String {
        String::from("I am integer")
    }
}

fn print_introduction(v: impl CanIntroduce) {
    println!("Value says: {}", v.introduce());
}

fn main() {
    print_introduction("a"); // Value says: I am string slice
    print_introduction(5);   // Value says: I am integer
}

Однако в Rust существует правило “Orphan rule” (правило сирот), которое гласит:

Трэйт можно реализовать для типа только в том случае, если либо трэйт, либо тип (либо оба) принадлежит библиотеке в которой осуществляется реализация.

То есть, не смотря на то, что тип i32 принадлежит стандартной библиотеке, мы смогли реализовать для него трэйт CanIntroduce только потому, что трэйт CanIntroduce объявлен нами же в нашей программе. Мы не можем определить трэйт из “чужой” библиотеки для типа из “чужой” библиотеки. Либо трэйт, либо тип должен принадлежать нашему main.rs или его модулям.

В главе Newtype паттенр мы рассмотрим способ обхода ограничений Orphan rule.

Возврат трэйта из функции

Rust позволяет не только передавать аргументы посредством трэйта, но и возвращать трэйт из функции.

Принцип такой же, как и с передачей аргументов:

  • если возвращаемый тип impl Trait, то компилятор проведёт замену на конкретный тип
  • если возвращаемый тип dyn Trait, то компилятор создаст трэйт объект

Например:

fn make_person() -> impl CanIntroduce {
    Person { name: String::from("John") }
}

Однако есть нюанс: поскольку компилятор просто заменяет impl Трэйт на конкретный тип, из такой функции нельзя возвращать два разных типа.

Рассмотрим пример:

trait CanIntroduce {
    fn introduce(&self) -> String;
}

struct Person {
    name: String
}

impl CanIntroduce for Person {
    fn introduce(&self) -> String {
        format!("Hello, I'm {}", self.name)
    }
}

struct Dog {
    name: String
}

impl CanIntroduce for Dog {
    fn introduce(&self) -> String {
        // Вне зависимости от своего имени, собака может только погавкать
        String::from("Waf-waf")
    }
}

fn make_someone(is_person: bool) -> impl CanIntroduce {
    if is_person {
        Person { name: String::from("John") }
    } else {
        Dog { name: String::from("Bark") }
    }
}

fn main() {
    let p = make_someone(true);
}

Как мы знаем, impl Трэйт просто заменяется компилятором на конкретный тип. Поэтому, если возвращаемый тип impl CanIntroduce будет заменён, например, на Person, то возврат объекта типа Dog станет невозможным. И наоборот.

Возврат dyn Трэйт в этой ситуации работает прекрасно:

fn make_someone(is_person: bool) -> Box<dyn CanIntroduce> {
    if is_person {
        Box::new(Person { name: String::from("John") })
    } else {
        Box::new(Dog { name: String::from("Bark") })
    }
}

fn main() {
    let person = make_someone(true);
    let dog    = make_someone(false);

    print_introduction(person.as_ref());
    print_introduction(dog.as_ref());
}

В этом примере присутствует тип, с которым мы пока еще не знакомы — Box. Фактически это просто безопасная обёртка над указателем. Конструктор Box::new(значение) переносит значение со стека в кучу и возвращает объект Box, внутри которого содержится указатель с адресом объекта в куче. Подробнее мы разберём Box в главе Умные указатели.

Tip

Если вы знакомы с C++, то можете считать, что Box<T> — это то же самое, что std::unique_ptr<T>

Главная причина, по которой мы используем Box<dyn Трэйт>, а не &dyn Трэйт, заключается в том, что мы не можем создать в функции объект на стеке, а потом вернуть ссылку на него. Ведь при выходе из функции её стэк-фрэйм будет очищен вместе со всеми находящимися в нём объектами, и ссылка на любой из этих объектов станет недействительной. Именно поэтому мы переносим объект в кучу и возвращаем из функции указатель (Box) на этот объект в куче.

Дефолтные имплементации методов

Методы в трэйте могут иметь реализации по умолчанию.

trait CanIntroduce {
    fn say_name(&self) -> String;
    fn introduce(&self) -> String { // реализация по умолчанию
        format!("Hello, I am {}", self.say_name())
    }
}

struct Person {
    name: String
}

impl CanIntroduce for Person {
    fn say_name(&self) -> String {
        self.name.clone()
    }
}

fn main() {
    let person = Person { name: String::from("John") };
    // Вызываем дефолтную реализацию метода introduce
    println!("{}", person.introduce()); // Hello, I am John
}

Методы с реализацией по умолчанию переопределяются так же, как и обычные методы трэйтов:

trait CanIntroduce {
    fn say_name(&self) -> String;
    fn introduce(&self) -> String { // реализация по умолчанию
        format!("Hello, I am {}", self.say_name())
    }
}

struct Person {
    name: String
}

impl CanIntroduce for Person {
    fn say_name(&self) -> String {
        self.name.clone()
    }
    fn introduce(&self) -> String { // переопределяем
        format!("Hi, I'am {}", self.say_name())
    }
}

fn main() {
    let person = Person { name: String::from("John") };
    println!("{}", person.introduce());
}

“Наследование” трэйта

В Rust один трэйт может “наследовать” другой трэйт.

trait A : B { ... } // Трэйт A "наследует" трэйт B

На практике это означает, что если мы хотим реализовать для нашего типа трэйт A, то мы обязательно должны реализовать и трэйт B для него.

Например:

trait HasName {
    fn say_name(&self) -> String;
}

// Все кто реализуют CanIntroduce, должны реализовать и HasName
trait CanIntroduce : HasName {
    fn introduce(&self) -> String;
}

struct Person {
    name: String
}

impl CanIntroduce for Person {
    fn introduce(&self) -> String {
        format!("Hello, I am {}", self.say_name())
    }
}

// Компилятор обяжет сделать реализацию для HasName
// после того как найдёт реализацию для CanIntroduce
impl HasName for Person {
    fn say_name(&self) -> String {
        self.name.clone()
    }
}

fn main() {
    let person = Person { name: String::from("John") };
    println!("{}", person.introduce()); // Hello, I am John
}

Ограничение несколькими трэйтами

Давайте посмотрим на передачу аргументов по impl Трэйт под другим углом. Когда мы указываем трэйт в качестве аргумента функции, то мы тем самым накладываем ограничение на типы, которые можно передавать в эту функцию.

Например, объявляя аргумент так:

#![allow(unused)]
fn main() {
fn print_introduction(v: impl CanIntroduce) { ... }
}

мы накладываем ограничение, что функция может быть вызвана только с аргументом, чей тип реализует трэйт CanIntroduce. Но что, если нам нужно, чтобы аргумент реализовывал два трэйта? В таком случае надо просто перечислить необходимые трэйты через знак +.

#![allow(unused)]
fn main() {
trait CanIntroduce { ... }
trait HasJob { ... }

fn print_worker_introduction(v: &(impl CanIntroduce + HasJob)) {
}

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

Self

Нередко в объявлении трэйта необходимо сослаться на конкретный тип, для которого будет реализован трэйт. Для этого используется ключевое слово Self (с заглавной буквы).

Например, создадим трэйт, который декларирует, что у типа должна быть функция-конструктор по умолчанию:

trait HasDefaultConstructor {
    fn make_default() -> Self;
}

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

Давайте теперь реализуем этот трэйт для типа Person из примеров выше:

trait HasDefaultConstructor {
    fn make_default() -> Self;
}

struct Person {
    name: String
}

impl HasDefaultConstructor for Person {
    fn make_default() -> Self {
        Person { name: "Anonymous".to_string() }
    }
}

fn main() {
    let p: Person = Person::make_default();
    println!("Default name: {}", p.name);
}

Здесь в реализации HasDefaultConstructor для Person в функции make_default мы указали тип результата как Self, но могли указать и Person. Эффект был одинаковым.

impl HasDefaultConstructor for Person {
    fn make_default() -> Person { // Person вместо Self
        Person { name: "Anonymous".to_string() }
    }
}

Требования к Трэйт-объектам

Note

Сейчас эта секция будет малопонятной. Её лучше перечитать после прохождения главы про Генерики.

Не для всех трэйтов можно создать трэйт объект. Чтобы компилятор мог сгенерировать трэйт-объект, т.е. dyn Трэйт, трэйт должен удовлетворять следующим требованиям:

  • Трэйт не должен содержать методов, которые возвращают Self или принимают аргумент типа Self.

    #![allow(unused)]
    fn main() {
    trait A {
        fn f(&self, other: &Self) -> Self;
    }
    }
  • Трэйт не должен содержать статических методов:

    trait A {
        fn f() -> i32;
    }
    
    struct B {}
    
    impl A for B {
        fn f() -> i32 {
            5
        }
    }
    
    fn call_f(a: &dyn A) {
        println!("Do nothing");
    }
    
    fn main() {
        let b = B {};
        call_f(&b);
    }
  • Трэйт не должен содержать методов, которые декларируют новый генерик-тип аргумент, не связанный с генерик-тип аргументом, заданным на уровне самого трэйта. О генериках мы поговорим в другой главе. Например, для такого трэйта нельзя создать трэйт-объект.

    #![allow(unused)]
    fn main() {
    trait A {
        fn f<T>();
    }
    }
  • Трэйт не должен содержать ассоциированных типов (которые являются разновидностью генериков):

    #![allow(unused)]
    fn main() {
    trait A {
        type X;
    }
    }

Если трэйт наследует другие трэйты, то все наследуемые трэйты также должны удовлетворять вышеприведённым условиям для преобразования в трэйт-объект.

unsafe trait

Если мы создаём трэйт, содержащий методы, которые предполагают unsafe операции, такие как работа с указателями, то трэйт следует объявить с ключевым словом unsafe.

#![allow(unused)]
fn main() {
unsafe trait MyTrait {
    fn do_something_dangerous();
}
}

Для реализации такого трэйта будет необходимо указать ключевое слово unsafe.

#![allow(unused)]
fn main() {
unsafe impl MyTrait for MyStruct {
    fn do_something_dangerous() {
        ...
    }
}
}

Тот факт, что мы реализуем unsafe трэйт, не означает, что в методах мы обязательно используем unsafe блок. Unsafe трэйт может даже не содержать unsafe методов (как мы это увидим позже для трэйтов Send и Sync). Единственная цель, с которой трэйт помечается как unsafe — сделать акцент на потенциальной опасности трэйта в глазах того, кто будет реализовывать его для своих типов.