Раздел 23 · Rust

Стандартные трейты

senior~45 мин

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

Стандартные трейты

Экскурсия по трейтам, на которых держится стандартная библиотека: печать, конверсии, сравнение, операторы, Deref и Drop. Это словарь языка: реализуешь нужные трейты, и твой тип ведёт себя как родной. У каждого трейта, кроме сигнатуры, есть закон, и законы здесь важнее сигнатур.

Идея

Прошлый урок закончился советом: проектируя трейт, начинай с законов. Стандартная библиотека спроектирована ровно так, и в этом уроке мы пройдём её главный словарь. Хороший тип в Rust интероперабелен: его можно напечатать, сравнить, сконвертировать, сложить оператором плюс, положить ключом в HashMap. Ничего из этого не магия компилятора. За каждой способностью стоит конкретный трейт, а за каждым трейтом контракт, который компилятор не проверяет, но на который молча рассчитывает вся экосистема.

Сквозным примером будут деньги. Хранить их во float нельзя, десятичные дроби там не точны, поэтому возьмём newtype-подход из урока про структуры и будем держать копейки в целом числе:

struct Money {
    kopecks: u64,
}

Сейчас этот тип не умеет ничего: ни напечататься, ни сравниться, ни сложиться. К концу урока он станет полноправным гражданином языка.

Display и Debug

Первая способность: рассказать о себе. Печатей в Rust две, и они сознательно разведены. Display отвечает за {} и пишет для пользователя программы, Debug отвечает за {:?} и пишет для разработчика:

use std::fmt;

#[derive(Debug)]
struct Money {
    kopecks: u64,
}

impl fmt::Display for Money {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}.{:02} руб.", self.kopecks / 100, self.kopecks % 100)
    }
}

fn main() {
    let price = Money { kopecks: 125_000 };
    println!("{price}");    // 1250.00 руб.
    println!("{price:?}");  // Money { kopecks: 125000 }
}

Разделение труда видно по способу реализации. Debug выводится через derive и должен стоять на каждом твоём типе рефлексом: без него не работают ни assert_eq!, ни логирование структуры целиком. Display пишется руками, потому что только автор знает, как тип выглядит для человека: где поставить точку, в какой валюте, что скрыть. Стандартная библиотека следует той же логике: Debug есть у Vec, HashMap и кортежей, а Display у них отсутствует принципиально, у списка нет единственно правильного человеческого вида.

Реализовав Display, ты бесплатно получаешь to_string(): в прошлом уроке мы видели blanket impl impl<T: Display + ?Sized> ToString for T, и Money уже попал под его раздачу. Реализовывать ToString руками не нужно никогда.

И маленький подарок для отладки: тип с Debug работает в макросе dbg!. Он печатает в stderr и само выражение, и его значение, а главное, забирает владение аргументом и возвращает его обратно, поэтому встраивается в середину любого выражения:

if dbg!(price.kopecks > 100_000) {  // [src/main.rs:14] price.kopecks > 100000 = true
    // ...
}

Единственный минус: из release-сборки dbg! сам не исчезает, вычищать перед релизом придётся руками.

Default, Clone, Copy

Default это осмысленное пустое значение типа. Контракт здесь смысловой: дефолт обязан быть полезным нулём, а не случайной заглушкой. Если все поля умеют Default, реализация выводится:

#[derive(Debug, Default)]
struct Account {
    balance: Money,     // Money { kopecks: 0 }, для этого Default нужен и ему
    is_blocked: bool,   // false
    note: String,       // пустая строка
}

Сила Default в комбинациях. Синтаксис обновления структуры из пятого урока позволяет заполнить пару полей и забрать остальное из дефолта, а у Option есть unwrap_or_default, закрывающий «нет значения, возьми нулевое» одной строкой:

let acc = Account { note: String::from("vip"), ..Account::default() };
let balance = maybe_balance.unwrap_or_default();

Clone это явная копия значения, та самая, которой урок про владение чинил move-ошибки. Контракт: копия эквивалентна оригиналу и независима от него. Про цену контракт молчит сознательно: клон Vec на гигабайт скопирует гигабайт. Отсюда практический совет, который стоит принять без чувства вины: на раннем этапе клонировать, чтобы не воевать с borrow checker, нормально. Приоритеты здорового проекта: сначала корректность, потом элегантность, потом производительность, и оптимизировать клоны стоит после профилирования, а не до.

Copy поднимает ставку: это маркерный трейт поверх Clone, обещающий, что копия типа это дешёвое побитовое копирование. Ты уже знаешь его семантику из урока про владение: присваивание в Rust всегда копирует биты, разница лишь в том, инвалидирует ли borrow checker источник. Move это копия с запретом на оригинал, copy это та же копия без запрета. Пометка #[derive(Copy, Clone)] переключает тип из первого режима во второй.

Почему Rust не раздаёт Copy автоматически всем подходящим типам? Потому что пометка меняет семантику каждого присваивания в чужом коде, и делать такое молча опасно: сегодня твоя структура из двух u32 копируется, завтра ты добавил в неё String, и все места, где её передавали «по копии», превратились бы в move-ошибки. Copy это обещание на публичном API, и его дают явно. Компилятор со своей стороны следит за правом на обещание: тип со String внутри или с Drop-логикой Copy не получит.

From и TryFrom

Конверсии между типами в Rust тоже трейт, и у него самый известный закон стандартной библиотеки. From<T> объявляет: из T можно сделать Self, и эта операция не может провалиться:

impl From<u64> for Money {
    fn from(kopecks: u64) -> Self {
        Money { kopecks }
    }
}

let price = Money::from(990);
let price: Money = 990.into();

Откуда взялся into, которого мы не писали? Из blanket impl impl<T, U: From<T>> Into<U> for T: реализовал From, получил зеркальный Into бесплатно. Правило экосистемы: реализуй только From, Into сам не трогай. Эти двое нужны парой только ради удобной записи ограничений в дженериках, по смыслу это одна конверсия с двух сторон.

Теперь закон: From не имеет права ни паниковать, ни терять данные. Стандартная библиотека блюдёт его педантично: u64::from(x: u8) существует, расширение всегда безопасно, а вот u8::from(x: u64) не существует вовсе, сужение может потерять старшие байты. Для конверсий, которые могут отказать, есть честный близнец TryFrom, возвращающий Result:

#[derive(Debug)]
struct NegativeAmount;

impl TryFrom<i64> for Money {
    type Error = NegativeAmount;

    fn try_from(value: i64) -> Result<Self, Self::Error> {
        u64::try_from(value)
            .map(|kopecks| Money { kopecks })
            .map_err(|_| NegativeAmount)
    }
}

Сумма из внешнего мира бывает отрицательной, и тип говорит об этом в сигнатуре, а не паникой на проде. Нарушенный закон From бьёт по чужому коду: твою конверсию зовут не только явно, её молча вызывает оператор ? при прокидывании ошибок, как ты видел в уроке про ошибки. Паникующий From превращает каждую такую точку в мину.

Сравнение и хеш

Четыре трейта сравнения выглядят бюрократией, пока не увидишь, что это лестница из обещаний, и каждая ступенька добавляет закон:

#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
struct Money {
    kopecks: u64,
}

PartialEq включает оператор == и требует симметрии и транзитивности: a == b влечёт b == a, и равенство передаётся по цепочке. Eq не добавляет ни одного метода, это чистый маркер с обещанием рефлексивности: каждое значение равно самому себе. Звучит как тавтология, пока не вспомнить float: NaN != NaN по стандарту IEEE 754, поэтому f64 остановился на PartialEq, и слово partial в имени трейта существует ради него.

С порядком та же пара. PartialOrd включает < и > через partial_cmp, возвращающий Option<Ordering>: для некоторых пар порядок может не существовать. Ord обещает полный порядок: любые два значения сравнимы, ровно одно из «меньше, равно, больше» истинно. И снова float за бортом: NaN < 0.0 ложь и NaN >= 0.0 тоже ложь, так что f64 не Ord. Последствие ты встретишь в первый же день работы: vec.sort() требует Ord и отказывается сортировать Vec<f64>. Выход: sort_by(|a, b| a.total_cmp(b)), где ты явно выбираешь, куда класть NaN.

У derive-версии порядка есть ружьё на стене: поля сравниваются лексикографически в порядке объявления в исходнике. Для struct Version { major: u32, minor: u32, patch: u32 } это ровно то, что нужно. Но переставь поля местами, и сортировка молча изменится: derive читает твой исходник как спецификацию.

Hash замыкает компанию и связан с Eq главным законом раздела: если a == b, то и хеши обязаны совпадать. На этой паре держатся ключи HashMap и HashSet из урока про коллекции: поиск ведра идёт по хешу, проверка кандидата по ==, и если трейты разошлись, карта ломается тихо. Вставил ключ, ищешь его же, не находишь. Отсюда правило: Hash, Eq и PartialEq выводи через derive все вместе либо пиши руками все вместе, смешивать нельзя.

Операторы и Index

Оператор + в Rust не привилегия чисел, а вызов метода трейта std::ops::Add: запись a + b развернётся в a.add(b). Деньги складывать осмысленно, складываем:

use std::ops::{Add, Mul};

impl Add for Money {
    type Output = Money;

    fn add(self, rhs: Money) -> Money {
        Money { kopecks: self.kopecks + rhs.kopecks }
    }
}

impl Mul<u32> for Money {
    type Output = Money;

    fn mul(self, qty: u32) -> Money {
        Money { kopecks: self.kopecks * u64::from(qty) }
    }
}

let total = price + delivery;  // Money + Money
let bill = price * 3;          // Money * u32: цена на количество

В сигнатуре два новых механизма. Mul<u32> показывает, что тип справа это параметр трейта: один тип может умножаться на разные правые стороны, а без параметра подразумевается Mul<Self>. type Output это ассоциированный тип, результат операции; подробно мы разберём ассоциированные типы в уроке про итераторы, где живёт самый известный из них. И заметь u64::from(qty) в теле: словарь конверсий уже работает на нас.

Закон у операторов негласный, но строгий: перегруженный оператор должен значить то же, что у чисел. Плюс складывает, сравнение сравнивает. Перегрузка, которая в + прячет запись в базу, делает код нечитаемым ровно тем способом, от которого Rust обычно защищает.

Квадратные скобки тоже трейт: v[i] это Index, и у него поучительная сигнатура fn index(&self, index: Idx) -> &Self::Output. Возврат не Option<&Output>, а голая ссылка: вернуть «ничего» этой сигнатуре нечем, поэтому Index на несуществующем ключе обязан паниковать. Скобки в Rust это всегда заявление «я уверен, что элемент есть», для неуверенности существует .get() с Option, мы говорили об этом в уроке про коллекции. Параметр Idx дженерик, и это открывает простор: Vec индексируется и usize, и диапазонами вроде v[1..4], а свой тип можно индексировать чем угодно осмысленным, хоть enum:

use std::ops::Index;

impl Index<Weekday> for Schedule {
    type Output = Vec<Lesson>;

    fn index(&self, day: Weekday) -> &Vec<Lesson> {
        &self.days[day as usize]
    }
}

let lessons = &schedule[Weekday::Mon];

Deref и Drop

Остались два трейта, которые компилятор зовёт сам, без видимого вызова в коде.

Deref отвечает на вопрос, который висит с урока про заимствование: почему &String проходит в функцию, ожидающую &str, а &Vec<T> туда, где ждут срез &[T]. Потому что String реализует Deref<Target = str>, Vec<T> реализует Deref<Target = [T]>, а компилятор применяет deref coercion: при несовпадении типов в вызове он пробует разыменовать аргумент через цепочку Deref, пока не совпадёт. Та же механика даёт методам сквозной доступ: у Box<Money> можно вызывать методы Money напрямую, ящик прозрачен.

Из прозрачности следует и закон: Deref реализуют только умные указатели, то есть типы, чья суть «владею значением Target и являюсь способом до него добраться». Box, Rc, String, Vec подходят. А вот популярный соблазн новичка из ООП-мира: сделать struct Soldier { human: Human, weapon: Weapon }, реализовать Deref<Target = Human> и получить «наследование», где &Soldier проходит всюду вместо &Human. Сначала это даже работает, потом ломается по всем швам: коэрсия действует только на ссылки, передать Soldier по значению как Human нельзя; дженерик-ограничения её не видят, fn rest<T: Rest> не примет солдата, даже если Human: Rest; а методы-тёзки начинают тихо перекрывать друг друга. Наследования в Rust нет и через чёрный ход, состав показывай честными методами вроде as_human(&self) -> &Human.

Drop закрывает урок и круг, начатый уроком про владение: RAII, о котором там шла речь, это в точности трейт Drop. Метод drop вызывается, когда значение покидает область видимости:

struct TempFile {
    path: std::path::PathBuf,
}

impl Drop for TempFile {
    fn drop(&mut self) {
        let _ = std::fs::remove_file(&self.path);
    }
}

Пока TempFile жив, файл существует; умер, файл удалён, и забыть об этом невозможно. Стандартная библиотека построена на этом приёме: BufWriter в своём Drop сбрасывает буфер на диск, а у Mutex нет метода unlock, потому что lock() возвращает MutexGuard, чей Drop отпускает замок сам. Утечка замка как класс ошибок просто не существует.

Две детали напоследок. Вызвать value.drop() руками нельзя, компилятор откажет с E0040: иначе значение умерло бы дважды. Если нужно убить значение досрочно, есть std::mem::drop, и это самая короткая функция стандартной библиотеки: fn drop<T>(_x: T) {}. Тело пустое, весь фокус в сигнатуре: она забирает владение, и значение умирает на выходе из неё. И порядок смерти строго определён: локальные переменные умирают в порядке, обратном объявлению, а поля структуры в порядке объявления. Теперь понятно и почему Copy и Drop несовместимы: у побитовых копий нет понятия «последний владелец», и кому из копий чистить ресурс, ответить нельзя.

Практика

Словарь запоминается руками. Шесть задач, по одной на каждую полку словаря, от derive до Deref.

ДЗ

Дальше

Словарь собран, и весь он работал на этапе компиляции: derive, blanket impl, мономорфизация, ни одного решения в рантайме. В уроке 12 появится второй режим: dyn Trait, толстые указатели и vtable, где конкретный тип стирается, а метод выбирается во время исполнения. Это цена, которую платят за гетерогенные коллекции и «верни либо то, либо это», и платить её нужно осознанно.