Что такое трейт
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Что такое трейт
Главный урок блока. Трейт это не наследование и не интерфейс из 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.