Трейты и чем они отличаются от интерфейсов
Trait в Rust — это не «интерфейс, который тип обязан объявить при рождении», а отдельный контракт, который можно навесить на тип позже, в том числе на чужой.
Trait — именованный набор сигнатур методов, ассоциированных типов и констант, который реализуется для типа отдельным блоком
impl Trait for Type.
Вопрос: «Что такое trait и чем он отличается от интерфейса в Java или Go?»
Вопрос звучит безобидно, и почти все начинают с «ну это как интерфейс». Так отвечать можно, но только если сразу же назвать три отличия, иначе интервьюер решит, что вы пишете на Rust как на Java.
Что на самом деле проверяет интервьюер
- Понимаете ли вы, что реализация трейта живёт отдельно от объявления типа — это ключ ко всей архитектуре Rust.
- Знаете ли, что trait умеет больше, чем список методов: методы по умолчанию, ассоциированные типы и константы.
- Осознаёте ли, почему в Rust реализация явная, а в Go — структурная (по форме).
Trait — это не только сигнатуры
Внутри трейта можно задать реализацию по умолчанию, ассоциированный тип и константу. Реализатору остаётся дописать только то, что действительно специфично для его типа.
trait Storage {
// ассоциированный тип: реализатор сам решает, чем он оперирует
type Key;
// ассоциированная константа
const NAME: &'static str;
fn get(&self, key: &Self::Key) -> Option<String>;
// метод по умолчанию: выражен через обязательный get
fn has(&self, key: &Self::Key) -> bool {
self.get(key).is_some()
}
}
В Java до появления default-методов такого не было вовсе, а ассоциированных типов нет и сейчас — там их роль частично играют дженерик-параметры интерфейса, что заметно многословнее.
Реализация отделена от типа
В Java класс обязан написать implements Comparable в своём объявлении. В Rust объявление типа и объявление реализации — два независимых элемента, и второй можно добавить в любой момент и в любом модуле вашего крейта.
struct User { name: String }
// позже, возможно в другом файле
impl Storage for User {
type Key = u32;
const NAME: &'static str = "user";
fn get(&self, _key: &u32) -> Option<String> {
Some(self.name.clone())
}
}
Вопрос: «Можно ли добавить метод к чужому типу?»
Правильный ответ — да, и это одна из самых приятных возможностей языка. Приём называется extension trait: вы объявляете свой trait и реализуете его для типа из стандартной библиотеки или чужого крейта.
Что на самом деле проверяет интервьюер
Знаете ли вы, что «свой trait для чужого типа» разрешён, а «чужой trait для чужого типа» — нет. Кандидаты обычно помнят одну половину правила.
trait Stats {
fn mean(&self) -> f64;
}
impl Stats for Vec<f64> {
fn mean(&self) -> f64 {
if self.is_empty() {
return 0.0;
}
self.iter().sum::<f64>() / self.len() as f64
}
}
fn main() {
let v = vec![1.0, 2.0, 4.0];
println!("{:.2}", v.mean());
}
Вывод:
2.33
Точно так же можно реализовать свой trait для i32, String или Option<T>. В Java эквивалента нет: чтобы добавить поведение к ArrayList, придётся наследоваться или писать статические утилиты.
Вопрос: «Что такое orphan rule?»
Попробуем перейти границу — реализовать чужой trait для чужого типа.
use std::fmt;
impl fmt::Display for Vec<u8> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{} байт", self.len())
}
}
Ошибка компиляции:
error[E0117]: only traits defined in the current crate can be implemented for types defined outside of the crate
--> src/main.rs:3:1
|
3 | impl fmt::Display for Vec<u8> {
| ^^^^^^^^^^^^^^^^^^^^^^-------
| | |
| | `Vec` is not defined in the current crate
| impl doesn't use only types from inside the current crate
|
= note: define and implement a trait or new type instead
Что на самом деле проверяет интервьюер
Понимаете ли вы, что orphan rule (правило сироты) существует не из вредности, а ради когерентности: для пары «trait + тип» во всей программе должна существовать ровно одна реализация. Если бы два независимых крейта реализовали Display for Vec<u8> по-своему, то при их одновременном подключении компилятору пришлось бы выбирать между ними — а подключение крейта не должно ломать чужой код. Правило формулируется так: либо trait, либо тип обязан принадлежать вашему крейту.
Обход через newtype
use std::fmt;
struct Bytes(Vec<u8>);
impl fmt::Display for Bytes {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{} байт", self.0.len())
}
}
fn main() {
println!("{}", Bytes(vec![1, 2, 3]));
}
Вывод:
3 байт
Bytes — тип нашего крейта, поэтому правило соблюдено. Обёртка не стоит ничего в рантайме: у структуры с одним полем то же представление в памяти.
derive: компилятор пишет impl за вас
Атрибут #[derive(...)] — процедурный макрос, который разворачивается в обычный блок impl ещё до проверки типов. Debug даёт форматирование через {:?}, Clone — глубокое копирование поле за полем, PartialEq — сравнение всех полей, Default — конструктор из значений по умолчанию каждого поля.
#[derive(Debug, Clone, PartialEq, Default)]
struct Config {
retries: u32,
verbose: bool,
}
fn main() {
let c = Config::default();
println!("{:?} {}", c, c == c.clone());
}
Вывод:
Config { retries: 0, verbose: false } true
Важная деталь: derive добавляет bound на каждый параметр типа. Для #[derive(Clone)] struct Wrap<T>(T) сгенерируется impl<T: Clone> Clone for Wrap<T> — иногда это строже, чем нужно, и тогда impl пишут руками.
Supertrait
Trait может требовать другой trait как предусловие — это и есть supertrait. Это не наследование реализации, а требование к реализатору.
trait Named {
fn name(&self) -> String;
}
trait Greet: Named {
fn greet(&self) -> String {
format!("Привет, {}!", self.name())
}
}
Чем отличается от Go
В Go интерфейс реализуется неявно: если у типа есть подходящие методы, он уже удовлетворяет интерфейсу. В Rust требуется явный impl Trait for Type. Это осознанный выбор: случайного совпадения имён недостаточно, чтобы считать контракт выполненным, а компилятор получает возможность мономорфизировать вызовы и проверять когерентность.
Типичные ошибки кандидатов
- Отвечать «trait — это интерфейс» и на этом останавливаться, не упомянув отделённую реализацию.
- Утверждать, что чужой тип расширить нельзя, — можно, если trait ваш.
- Объяснять orphan rule как «чтобы не мусорили», а не как гарантию единственности реализации.
- Считать supertrait наследованием и ждать, что методы «унаследуются» автоматически.
- Думать, что
derive— рантайм-рефлексия; на самом деле это генерация кода на этапе компиляции, рефлексии в Rust нет. - Путать
DebugиDisplay: первый для разработчика и derive-абелен, второй пишется руками.
Как ответить кратко
«Trait — это контракт: сигнатуры методов плюс методы по умолчанию, ассоциированные типы и константы. Главное отличие от интерфейсов Java — реализация отделена от типа: impl Trait for Type можно написать где угодно в крейте, поэтому свой trait разрешено реализовать даже для i32 или Vec<T> — это extension trait. Ограничение — orphan rule: либо trait, либо тип должен принадлежать моему крейту, иначе E0117. Она нужна для когерентности, чтобы на пару trait–тип во всей программе была ровно одна реализация; обходится через newtype. В отличие от Go реализация явная, а не структурная, и часть impl-ов пишет за нас derive.»