Раздел 23 · Rust

Что такое трейт

middle~35 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Что такое трейт

Главный урок блока. Трейт это не наследование и не интерфейс из Java один в один, а набор поведения с законами, который тип обещает выполнять. Почти всё в стандартной библиотеке держится на трейтах: Iterator, From, операторы, печать. Понял трейты, понял язык.

Идея

У каждого типа в Rust есть два независимых вопроса. Что тип содержит: это поля, ты описал их структурой или enum. И что тип умеет: сравниваться, печататься, хешироваться, превращаться в другой тип. Вся прошлая неделя намекала, что второй вопрос живёт отдельной жизнью: PartialOrd в ограничениях урока про дженерики, Hash + Eq у ключей HashMap, From под капотом оператора ?. Пришло время назвать вещи: способность типа это трейт, и трейты в Rust полностью разведены с данными.

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

trait Report {
    fn title(&self) -> String;
    fn severity(&self) -> u8;
}

Трейт это контракт: имена методов, их сигнатуры, и ни байта данных. Он не знает и не хочет знать, какие поля будут у типов, которые его реализуют. Теперь сами типы, обычные структуры из пятого урока, и для каждого свой impl Trait for Type:

struct Deploy {
    service: String,
    version: String,
}

struct Alert {
    message: String,
    is_critical: bool,
}

impl Report for Deploy {
    fn title(&self) -> String {
        format!("деплой {} {}", self.service, self.version)
    }

    fn severity(&self) -> u8 {
        1
    }
}

impl Report for Alert {
    fn title(&self) -> String {
        self.message.clone()
    }

    fn severity(&self) -> u8 {
        if self.is_critical { 5 } else { 3 }
    }
}

Разнесённость данных и поведения даёт свойство, которого нет ни в классах, ни в Java-интерфейсах: способности добавляются задним числом. Тип объявлен в одном месте, а impl-блоки могут жить в другом, дописываться позже, и, что важнее всего, твой трейт можно реализовать для чужого типа:

impl Report for String {
    fn title(&self) -> String {
        self.clone()
    }

    fn severity(&self) -> u8 {
        0
    }
}

String из стандартной библиотеки, автор которой про твой мониторинг не слышал, теперь умеет Report. В Java для этого пришлось бы писать обёртку: класс String финален и интерфейсов задним числом не получает.

У свободы есть один закон, без которого она превратилась бы в хаос. Представь, что два крейта реализовали один трейт для одного типа по-разному: чью реализацию брать? Rust запрещает саму возможность конфликта правилом, известным как orphan rule: в impl Trait for Type хотя бы один из двух, трейт или тип, должен быть твоим. Report for String законно, трейт твой. А вот impl Display for Vec<i32> компилятор отвергнет с ошибкой E0117: оба чужие. Лазейка для таких случаев тебе уже знакома из пятого урока: заверни чужой тип в newtype, и тип станет твоим.

Методы по умолчанию

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

trait Report {
    fn title(&self) -> String;
    fn severity(&self) -> u8;

    fn log_line(&self) -> String {
        format!("[sev:{}] {}", self.severity(), self.title())
    }
}

log_line написан один раз и выражен через обязательные методы: каждый тип, давший title и severity, автоматически умеет и log_line. Реализация вправе переопределить умолчание, если знает способ лучше, а вправе и не трогать. Это рабочая лошадка дизайна API в Rust: трейт Iterator, к которому мы придём в уроке про итераторы, требует один метод next, а дарит за него больше семидесяти, от map до sum. Контракт маленький, польза большая.

Trait bounds и impl Trait

Способность объявлена, реализации есть, осталось писать функции «для всего, что умеет Report». Это ограничения из прошлого урока, теперь со своим трейтом:

fn send_digest<T: Report>(events: &[T]) {
    for event in events {
        println!("{}", event.log_line());
    }
}

Для простых случаев с одним использованием параметра есть сахар, читающийся почти по-английски:

fn print_one(event: &impl Report) {
    println!("{}", event.log_line());
}

&impl Report означает «ссылка на что-нибудь, умеющее Report» и разворачивается компилятором в тот же дженерик с ограничением. Когда параметр упоминается один раз, сахар читается лучше; когда нужно сказать «два аргумента одного и того же типа», без явного <T: Report> не обойтись: два impl Report в сигнатуре это два независимых анонимных параметра.

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

fn latest_event() -> impl Report {
    Deploy {
        service: String::from("api"),
        version: String::from("2.4.1"),
    }
}

Вызывающий знает о результате ровно одно: он умеет Report. Какая структура внутри, скрыто, и это сознательное сужение API: завтра вернёшь другой тип, и никто снаружи не заметит. По-настоящему этот приём заиграет с замыканиями и итераторами, чьи типы генерирует компилятор и назвать их в коде буквально невозможно. Одно ограничение знай заранее: все ветки такой функции обязаны возвращать один и тот же конкретный тип. Вернуть «либо Deploy, либо Alert» через impl Report нельзя, мономорфизации нужен один тип на подстановку. Для «либо то, либо это» существует динамическая диспетчеризация и dyn Trait, ей посвящён двенадцатый урок.

Blanket impl и derive

Следующий уровень силы: реализация трейта не для типа, а для целого семейства типов разом. Называется blanket impl:

trait Loggable {
    fn log_line(&self) -> String;
}

impl<T: std::fmt::Display> Loggable for T {
    fn log_line(&self) -> String {
        format!("[log] {self}")
    }
}

impl<T: Display> Loggable for T читается как «каждый тип с Display получает Loggable вот таким способом». Числа, строки, твои будущие типы с Display: все автоматически в деле, включая типы, которых ещё не существует. Именно так стандартная библиотека раздаёт ToString: единственный blanket impl impl<T: Display> ToString for T, и поэтому to_string есть у всего, что печатается, а реализовывать его руками не нужно никогда. Это та же механика, что у ? с From из урока про ошибки: маленькие контракты, из которых библиотека выводит большие следствия.

Остался последний источник реализаций, самый частый в повседневном коде. Для горстки стандартных трейтов реализация настолько механическая, что компилятор пишет её сам по атрибуту derive:

#[derive(Debug, Clone, PartialEq)]
struct Deploy {
    service: String,
    version: String,
}

Debug печатает структуру для отладки, Clone копирует поле за полем, PartialEq сравнивает попарно. Из коробки выводятся Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash и Default, а крейты расширяют список своими: #[derive(Error)] из thiserror в прошлом уроке был ровно этим механизмом. derive это кодогенерация на этапе компиляции, и в бинарнике результат неотличим от рукописного impl.

Чем трейт не является

Теперь можно честно разобрать два сравнения, которые напрашивались весь урок.

Трейт не наследование. Класс-наследник получает от родителя данные, реализацию и место в иерархии «является». Трейт не даёт ничего из этого: данных в нём нет, иерархии типов он не строит, Deploy не «является Report», он «умеет Report». Вместо дерева предков у типа плоский набор способностей, и добавление новой не требует пересматривать чужую иерархию. Это композиция поведения, та самая, которую советует даже классика ООП словами «предпочитай композицию наследованию», только здесь её предпочёл сам язык: наследования в Rust нет.

Трейт не интерфейс из Java, хотя из всех знакомых конструкций интерфейс ближе всего. Различия по существу: методы по умолчанию с телом, реализация задним числом для чужих типов, blanket impl для семейств, и проверка на компиляции без рантайм-каста. Java-интерфейс отвечает на вопрос «кем тип объявил себя при рождении», трейт на вопрос «что для типа реализовано хоть кем-то и где-то».

Если искать настоящего родственника, то это тайпклассы: ты встречал их в уроке про тайпклассы и HKT и щупал руками в Haskell. Та же идея ad-hoc полиморфизма: поведение объявлено отдельно от типа, реализации добавляются задним числом, компилятор подбирает нужную по типу на этапе компиляции. И с тем же негласным довеском: у приличного трейта есть законы. PartialEq обещает симметричность сравнения, Clone обещает, что копия эквивалентна оригиналу, From обещает не падать. Компилятор законы не проверяет, но вся экосистема на них рассчитывает, и трейт с нарушенными законами ломает чужой код тихо. Когда будешь проектировать свои трейты, начинай с законов, а не с сигнатур.

Практика

Трейт перестаёт быть теорией, когда напишешь свой. Три задачи по нарастающей: derive, собственный контракт, orphan rule.

ДЗ

Дальше

Теперь у тебя есть обе половины системы типов Rust: дженерики дают форму «один код для многих типов», трейты наполняют её содержанием «для типов, которые умеют вот это». Дальше урок 11, экскурсия по стандартным трейтам: Display и Debug, From и TryFrom, сравнение и хеширование, перегрузка операторов, Deref с его магией автопревращений и Drop, на котором держится RAII.