Сравнение, операторы и newtype
Ирма Окунева · совет Гильдии
Гроши мастерская считает просто числами. Сегодня ты сложишь цену с качеством, завтра клеймо с углём, и никто не заметит. Деньги должны быть деньгами: складываться с деньгами, сравниваться с деньгами и печататься как деньги.
Ирма описала приём newtype: обёртка из одного поля, которая ничего не стоит в рантайме, но делает значение отдельным типом. Сквозной пример прошлого урока уже такой: struct Money { kopecks: u64 }. Сегодня научим его сравниваться, хешироваться и складываться операторами, а в конце разберём два трейта, которые компилятор вызывает сам: Deref и Drop.
Сравнение и хеш
Четыре трейта сравнения выглядят бюрократией, пока не увидишь, что это лестница из обещаний, и каждая ступенька добавляет закон:
#[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.
Мастерская, этап 14
Деньги Гильдии становятся типом Grosh(u64): складываются оператором +, умножаются на количество, сравниваются, хешируются и печатаются по-русски, с правильным словом «грош», «гроша» или «грошей». Ценники изделий переходят на новый тип.
Доска заказов
На доске Гильдии висит заказ без подсказок: клейма без регистра.
ДЗ
Дальше
Словарь собран, и весь он работал на этапе компиляции: derive, blanket impl, мономорфизация, ни одного решения в рантайме. Гроши мастерской стали настоящими деньгами. Тем временем Гильдия собирает в одном зале кузню, ювелирку и бронника, и каждый продаёт своё. В уроке «Трейт-объекты и диспетчеризация» появится второй режим: dyn Trait и гетерогенные коллекции, где конкретный тип стирается, а метод выбирается во время исполнения. Это цена, которую платят за список изделий разных мастерских, и платить её нужно осознанно.