Печать и конверсии
Ферапонт · кладовщик
Команды ты мне пишешь строками, а я строк не храню. Мне нужна запись: что за команда, сколько корзин, какое клеймо. Разбери строку один раз, на входе, а дальше говори со мной записями. И печатай их так, чтобы человек прочитал.
Ферапонт просит то, на чём держится стандартная библиотека: тип должен уметь рассказать о себе и уметь появиться из другого типа, в том числе из строки. В этом уроке пройдём первую половину словаря стандартных трейтов: печать, пустое значение и копии, конверсии From и TryFrom и разбор строки через FromStr. Вторую половину, сравнение и операторы, разберём в следующем уроке.
Идея
Прошлый урок закончился советом: проектируя трейт, начинай с законов. Стандартная библиотека спроектирована ровно так, и в этом уроке мы пройдём её главный словарь. Хороший тип в 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 превращает каждую такую точку в мину.
FromStr: строка как тип
Последняя конверсия словаря встречается чаще всех остальных: из строки. Пользователь, файл и сеть дают текст, а программе нужен тип. Метод parse на &str ты видел ещё в уроке про значения: "42".parse::<u32>(). Работает он через трейт FromStr, и свой тип подключается к нему так же, как к TryFrom:
use std::str::FromStr;
#[derive(Debug, PartialEq)]
struct BadAmount;
impl FromStr for Money {
type Err = BadAmount;
fn from_str(text: &str) -> Result<Money, BadAmount> {
let (rubles, kopecks) = text.split_once('.').unwrap_or((text, "0"));
let rubles: u64 = rubles.parse().map_err(|_| BadAmount)?;
let kopecks: u64 = kopecks.parse().map_err(|_| BadAmount)?;
Ok(Money { kopecks: rubles * 100 + kopecks })
}
}
let price: Money = "12.50".parse().unwrap(); // Money { kopecks: 1250 }
Закон у FromStr тот же, что у TryFrom: разбор может отказать, и отказ это значение, а не паника. Хорошая пара для FromStr это Display: если тип печатается в строку, которую он же умеет разобрать обратно, тестировать его можно круговым прогоном «напечатал, разобрал, сравнил».
Мастерская, этап 13
Команды мастерской перестают быть строками в глубине кода. Строка разбирается один раз, на входе, в перечисление Command через FromStr, а всё дальше работает с вариантами. Заодно изделие учится печататься через Display, а слиток превращается в заготовку через From. Снаружи ни один ответ не меняется.
ДЗ
Дальше
Тип умеет напечататься, скопироваться и появиться из другого типа или из строки. Мастерская говорит с Ферапонтом записями. Но деньги Гильдии пока просто числа, и гроши легко сложить с качеством или с клеймом. В следующем уроке займёмся второй половиной словаря: сравнением и хешем, операторами, newtype и трейтами Deref и Drop.