Идиоматичный и защищённый Rust
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Идиоматичный и защищённый Rust
Хорошая идиома переносит проверку из головы ревьюера в компилятор.
Идея
Вот строчка из чужого кода. Попробуй понять, что она делает:
process_data(&data, true, false, true);
Не выйдет: смысл флагов знает только сигнатура. Открываешь её, запоминаешь порядок, возвращаешься. А через месяц кто-то поменяет порядок двух флагов. Вызовы продолжат компилироваться, но смысл аргументов изменится. Теперь та же строчка, переписанная по правилам этого урока:
process_data(&data, Compression::Strong, Encryption::None, Validation::Enabled);
Читается без сигнатуры, перепутать аргументы местами не даст компилятор, а новый вариант в любом из enum подсветит все места, где его забыли разобрать.
В этом весь урок. Защищённый код это не код с проверками на каждый чих. Это код, где неверное использование не компилируется, а изменение требований вызывает каскад понятных ошибок сборки, а не тихие баги. Кусочки у тебя уже есть: исчерпывающий match из урока про enum, законы From из урока про стандартные трейты, приватность из урока про модули. Сегодня собираем из них систему и добираем недостающие идиомы.
Типы вместо договорённостей
Начнём с уже показанного приёма. Bool в сигнатуре почти всегда заслуживает enum, даже одинокий:
// было: что значит true, знает только читатель сигнатуры
fn save(path: &Path, overwrite: bool) -> io::Result<()> { /* ... */ }
// стало: вызов save(path, Overwrite::Yes) читается сам
enum Overwrite {
Yes,
No,
}
fn save(path: &Path, overwrite: Overwrite) -> io::Result<()> { /* ... */ }
Цена: три строки. Выгода тройная. Вызовы читаются без сигнатуры. Аргументы разных смыслов не перепутать местами: два bool для компилятора взаимозаменяемы, два разных enum нет. А когда завтра понадобится третий режим, Overwrite::KeepBackup, компилятор сам перечислит все match, которые надо дополнить; bool такой эволюции не переживает. Rust API Guidelines зовут это правилом C-CUSTOM-TYPE, а clippy ловит нарушения линтом fn_params_excessive_bools.
Следующий уровень той же идеи: newtype с проверкой при создании. Newtype ты уже знаешь: обёртка делает метры неперепутываемыми с секундами. Добавим к обёртке инвариант:
pub struct Email(String); // поле приватное
impl Email {
pub fn parse(raw: &str) -> Result<Self, EmailError> {
let normalized = raw.trim().to_ascii_lowercase();
if !normalized.contains('@') {
return Err(EmailError::MissingAt);
}
Ok(Self(normalized))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
Поле приватное, поэтому снаружи модуля нельзя создать Email напрямую. В показанном API значение создаётся через parse: строка уже обрезана по краям, её буквы ASCII приведены к нижнему регистру, и в ней есть @. Функция в глубине кода принимает Email и не повторяет эти проверки. Полную корректность адреса этот учебный разбор не доказывает. Внутри модуля поле доступно, поэтому все способы создания и изменения Email должны сохранять те же гарантии.
Этот приём называют правилом parse, don’t validate: не «проверь и забудь», а «разбери и сохрани факт разбора в типе». Валидация возвращает bool, и знание испаряется на следующей строчке, поэтому глубокие слои перепроверяют заново. Разбор возвращает новый тип, и знание едет дальше вместе со значением. Стандартная библиотека сама живёт по этому правилу: String::from_utf8(bytes) это разбор Vec<u8> в String, и весь код после него знает, что байты валидный UTF-8, без единой повторной проверки.
Добавь TryFrom, который вызывает parse, чтобы не дублировать проверку:
impl TryFrom<&str> for Email {
type Error = EmailError;
fn try_from(raw: &str) -> Result<Self, Self::Error> {
Self::parse(raw)
}
}
Именно TryFrom, не From: закон тебе известен, From не имеет права отказывать, а разбор почты отказывает. impl From с паникой внутри или с тихой подменой мусора на значение по умолчанию это бомба под чужим ?; clippy зовёт такое fallible_impl_from.
Сохрани доказательство в результате разбора
Представь, что CONFIG_DIRS хранит список каталогов конфигурации.
Функция проверила непустоту и вернула Vec<PathBuf>. Ты вызываешь
directories.first(), но получаешь Option<&PathBuf> и снова разбираешь
None. Компилятор не читал проверку внутри другой функции: тип Vec
по-прежнему допускает пустой список.
В статье Rusty thoughts on “Parse, don’t validate” Eli Bendersky показывает, как сохранить такие гарантии в типах Rust. Это развитие принципа Alexis King: проверка нужна, но её результат должен пережить возврат из функции.
Непустота по устройству типа
Непустой список можно представить как обязательный первый элемент и
возможно пустой хвост. Так устроен NonEmpty<T> из крейта nonempty.
Вот минимальный вариант без внешних зависимостей:
use std::path::PathBuf;
pub struct NonEmpty<T> {
pub head: T,
pub tail: Vec<T>,
}
impl<T> NonEmpty<T> {
pub fn first(&self) -> &T {
&self.head
}
}
fn parse_config_dirs(raw: &str) -> Result<NonEmpty<PathBuf>, &'static str> {
let mut parts = raw.split(',').map(str::trim);
let first = parts.next().ok_or("нет каталогов")?;
if first.is_empty() {
return Err("пустой каталог");
}
let head = PathBuf::from(first);
let mut tail = Vec::new();
for part in parts {
if part.is_empty() {
return Err("пустой каталог");
}
tail.push(PathBuf::from(part));
}
Ok(NonEmpty { head, tail })
}
fn primary_config_dir(directories: &NonEmpty<PathBuf>) -> &std::path::Path {
directories.first().as_path()
}
Теперь основной код получает ссылку без Option, потому что значение без
head не собрать. Можно заменить голову и очистить хвост, но коллекция
останется непустой. Мы сразу строим голову и хвост из итератора:
не собираем промежуточный вектор и не сдвигаем все элементы через remove(0).
Есть ловушка в исходном примере статьи: raw.split(',') для "" даёт
один элемент "". Преобразование его в PathBuf создаст пустой путь,
а проверка !directories.is_empty() пройдёт. Поэтому наш разбор отвергает
пустые элементы, в том числе "", " " и "/etc/app,,/opt/app".
Это отдельный инвариант, который одна непустота коллекции не доказывает.
PathBuf здесь также не обещает, что каталог абсолютный, существует или
доступен для чтения. Сигнатура должна обещать только то, что разбор доказал.
Проверка существования в момент разбора не гарантирует существование
при следующем обращении к файловой системе.
Практический пример из статьи: конвейер команд оболочки в posixutils-rs
хранит commands: NonEmpty<Command>. Парсер либо не находит конвейер,
либо строит его с первой командой. После этого исполняющий код не
обрабатывает «конвейер без команд», такого значения нет в модели.
Уточняй тип постепенно
Другой пример Bendersky берёт из rust-analyzer. Обычный PathBuf
не гарантирует ни UTF-8, ни абсолютность. Крейта camino хватает для
первой гарантии: Utf8PathBuf хранит путь, который можно представить
строкой UTF-8. Тип AbsPathBuf в rust-analyzer добавляет вторую:
путь обязан быть абсолютным.
PathBuf
↓ преобразование с проверкой UTF-8
Utf8PathBuf
↓ TryFrom с проверкой is_absolute()
AbsPathBuf
Каждый шаг может отказать. Успех возвращает более узкий тип, а не
старое значение с комментарием «уже проверили». Для UTF-8-пути as_str()
возвращает &str, не Option<&str>, как Path::to_str().
Код, принимающий AbsPathBuf, не повторяет is_absolute().
Но более узкий тип полезен только пока его API сохраняет инвариант.
Приватное поле не спасёт, если метод отдаёт &mut PathBuf и позволяет
заменить абсолютный путь относительным. Проверяй все способы создания
и изменения значения, а не только new.
NonZero: гарантия из стандартной библиотеки
std::thread::available_parallelism() возвращает
io::Result<NonZeroUsize>. Если вызов успешен, число доступных
исполнителей не равно нулю:
use std::num::NonZeroUsize;
fn jobs_per_worker(jobs: usize, workers: NonZeroUsize) -> usize {
jobs / workers
}
let workers = NonZeroUsize::new(4).ok_or("нужен хотя бы один исполнитель")?;
assert_eq!(jobs_per_worker(12, workers), 3);
assert!(NonZeroUsize::new(0).is_none());
Деление usize на NonZeroUsize не паникует из-за нулевого знаменателя.
Если заменить параметр на usize, гарантия пропадёт, даже если вызывающий
код где-то раньше проверял workers > 0.
У типа есть и преимущество в представлении: нулевой битовый шаблон запрещён
для самого значения, поэтому Option<NonZeroUsize> использует его для
None. Стандартная библиотека гарантирует тот же размер и выравнивание,
что у usize. Не обобщай это на любой newtype: гарантия относится к
конкретным типам, перечисленным в документации Option.
JSON должен пройти ту же границу
Разбор JSON через serde ты видел в 23-rust/16 · Экосистема: serde, clap, tokio и docs.rs. Теперь сохраним в результате не только форму данных, но и ограничения. serde_json может разбирать JSON сразу в типы с нужными гарантиями:
use serde::Deserialize;
use std::num::NonZeroUsize;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
enum Mode {
Fast,
Safe,
}
#[derive(Debug, Deserialize)]
struct Config {
name: String,
workers: NonZeroUsize,
mode: Mode,
}
let raw = r#"{"name":"compiler","workers":4,"mode":"fast"}"#;
let config: Config = serde_json::from_str(raw)?;
Для запуска нужны serde с возможностью derive и serde_json.
После успешного разбора workers ненулевой, mode содержит только
Fast или Safe, а name это строка. JSON с workers: 0,
mode: "turbo" или массивом вместо имени не пройдёт разбор.
Непустоту имени тип String не обещает, для неё нужен отдельный тип.
Особенно внимательно с собственным Email(String) выше. Обычный
#[derive(Deserialize)] на такой обёртке разберёт внутреннюю строку
напрямую и не вызовет Email::parse. Приватность поля не закрывает
этот путь: сгенерированный код десериализации находится в том же модуле.
Чтобы JSON проходил проверку, направь разбор через TryFrom<String>:
#[derive(Debug, Deserialize)]
#[serde(try_from = "String")]
pub struct Username(String);
impl TryFrom<String> for Username {
type Error = &'static str;
fn try_from(raw: String) -> Result<Self, Self::Error> {
if raw.is_empty() {
return Err("пустое имя");
}
Ok(Self(raw))
}
}
Этот учебный Username обещает только непустоту, не нормализацию и не
правила имени из домашки. Проверь обе двери: прямой TryFrom и
serde_json::from_str::<Username>. Пустая строка должна быть отвергнута
обоими способами.
Дальше эту дисциплину применим к идентификаторам брони и диапазонам дат в уроке про доменные типы. Главный вопрос при выборе API: какую проверку вызывающий код больше не должен повторять, потому что результат уже хранит её доказательство?
Разбор без дыр
Про match без _ мы договорились ещё в уроке про enum: подстраховка снизу выключает исчерпываемость, и новый вариант молча уезжает в общую ветвь. Правило прежнее: по собственному enum перечисляй варианты явно, _ оставь типам с бесконечным числом значений.
Менее известно, что тот же приём защищает и структуры. Сравни две реализации PartialEq:
// молчаливая: новое поле тихо выпадет из сравнения
impl PartialEq for User {
fn eq(&self, other: &Self) -> bool {
self.id == other.id && self.email == other.email
}
}
// защищённая: новое поле сломает компиляцию ровно здесь
impl PartialEq for User {
fn eq(&self, other: &Self) -> bool {
let Self { id, email, display_cache: _ } = self;
let Self { id: other_id, email: other_email, display_cache: _ } = other;
id == other_id && email == other_email
}
}
Полная деструктуризация let Self { ... } обязана перечислить все поля. Добавишь в User поле role, и защищённая версия откажется компилироваться: реши, участвует ли роль в равенстве. Молчаливая продолжит работать и будет считать равными пользователей с разными ролями. Поле, которое игнорируешь сознательно, помечай по имени: display_cache: _ читается как решение, а безымянное .. как забывчивость. Тот же трюк уместен в Hash, в Debug и при конструировании: литерал со всеми полями вместо ..Default::default() заставит новое поле пройти через твои глаза.
Третья дыра разбора: индексация. parts[0] паникует на пустом срезе, а страхующая проверка parts.len() >= 2 живёт отдельно от использования и отстаёт от него при рефакторинге. Паттерны по срезам соединяют проверку и использование в одну конструкцию:
match parts.as_slice() {
[] => Err(ParseError::Empty),
[cmd] => run_bare(cmd),
[cmd, args @ ..] => run_with_args(cmd, args),
}
Пустой срез, один элемент, голова и хвост: каждый случай назван, паниковать негде, а пропущенный случай это ошибка компиляции, а не строка в логах прода.
Атрибуты-предохранители
#[must_use] превращает проигнорированный результат в предупреждение. Ты сталкиваешься с ним с первого урока: Result помечен им в std, поэтому забытый Result и шумит. Помечай и свои функции:
#[must_use]
pub fn normalized(&self) -> Email { /* ... */ }
Классический сценарий: метод не мутирует, а возвращает новое значение. Вызвал email.normalized(); без присваивания, решил, что нормализовал на месте, а результат улетел в никуда. С атрибутом компилятор переспросит.
#[non_exhaustive] на публичном enum или структуре говорит чужому коду: список не окончен. Чужой match обязан завести ветвь _, чужой код не может построить структуру литералом. Зачем это нужно, объясняет таблица semver из урока про cargo: добавление варианта в публичный enum это major, чужие исчерпывающие match сломаются. С атрибутом вариант можно добавлять в minor: чужие match уже готовы к неизвестному.
Заметь сделку: атрибут ограничивает исчерпывающий разбор, чтобы чужой код пережил добавление вариантов. В крейте, где объявлен тип, ограничения атрибута не действуют: твои match остаются исчерпывающими. В другом крейте, даже из того же workspace, нужна ветвь _. Если при добавлении варианта ты хочешь получить ошибки сборки во всех крейтах своего проекта, не ставь атрибут. Если публичный enum библиотеки должен расти без мажорных скачков, ставь.
Закрой обходные пути
Бывает трейт, который чужой код должен использовать, но не реализовывать: например, набор бэкендов фиксирован автором. Для этого есть приём sealed:
mod private {
pub trait Sealed {}
}
pub trait Backend: private::Sealed {
fn execute(&self, query: &str) -> Vec<Row>;
}
pub struct Postgres;
impl private::Sealed for Postgres {}
impl Backend for Postgres { /* ... */ }
Модуль private не виден снаружи, реализовать private::Sealed чужой крейт не может, а без него недоступен и Backend. Список реализаций закрыт, и это развязывает руки: автор может добавлять в Backend методы, не ломая чужой код, ведь чужих реализаций не существует. В Rust API Guidelines паттерн описан как C-SEALED.
Тот же ход для структур: если поля публичные, но строить значение мимо конструктора нельзя, добавь приватное поле-заглушку _private: (). Литерал снаружи перестанет компилироваться, останется единственная дверь, твой конструктор с проверками.
Без лишних клонов
Следующая группа идиом про владение. Запах, с которого она начинается: clone() не потому, что нужна копия, а чтобы успокоить borrow checker.
enum Editor {
Draft { text: String },
Published { text: String },
}
fn publish(state: &mut Editor) {
if let Editor::Draft { text } = state {
let text = std::mem::take(text);
*state = Editor::Published { text };
}
}
Попробуй написать publish без mem::take, и упрёшься: за &mut Editor нельзя вынести text наружу, enum нельзя оставить без поля даже на мгновение. Наивный выход text.clone() копирует всю строку, хотя нужно лишь передать владение. std::mem::take позволяет обойтись без копии. Проиграй переход по шагам:
takeоставляет вDraft.textпустую строку. Она не выделяет память, а состояние по-прежнему содержит все поля.takeвозвращает прежнюю строку вместе с её буфером.- Эта строка переезжает в
Published, который заменяет старое состояние.
take подставляет значение Default. Для типов без Default есть std::mem::replace, которому замену передаёшь явно.
Второй инструмент: Cow, clone on write, для функций, которые чаще всего ничего не меняют:
use std::borrow::Cow;
fn normalize(input: &str) -> Cow<'_, str> {
if input.contains('\t') {
Cow::Owned(input.replace('\t', " "))
} else {
Cow::Borrowed(input)
}
}
Cow это enum с двумя вариантами: заём Borrowed или владение Owned. Без табуляций функция возвращает исходную строку без выделения памяти. Если табуляции есть, создаёт новую строку; её буфер может увеличиваться по мере замены, поэтому число выделений памяти здесь не гарантировано. Вызывающему разница не мешает: Cow разыменовывается в &str через Deref из урока про стандартные трейты. Если девяносто девять строк из ста не содержат табуляций, для них не придётся создавать копии. Сигнатура показывает оба возможных результата: заимствованную строку и строку во владении.
Третья идиома: изменяемость только на время настройки. Значение надо один раз собрать и дальше не трогать:
let config = {
let mut config = load_config();
config.apply_overrides(&args);
config
};
// дальше config неизменяемый: мутация не скомпилируется
Блок собирает, наружу выходит неизменяемое имя; то же самое даёт повторное связывание let config = config; без mut. Мелочь, но из таких мелочей складывается код, где mut означает «это ещё меняется», а его отсутствие гарантирует «это уже зафиксировано».
Компилятор и clippy как чек-лист
Все идиомы выше объединяет одна привычка: отдать проверку машине. Последний слой этой привычки, предупреждения и линты.
У clippy сотни линтов, собранных в группы по убыванию доверия. Группа correctness включена как deny: срабатывание почти наверняка баг. Группы suspicious, style, complexity и perf включены как warn. Группа pedantic выключена: линты строгие, со спорными случаями и ложными срабатываниями. Группа restriction выключена и целиком не включается никогда: это запреты под конкретную политику проекта, из них выбирают поштучно. Рабочая стратегия: включи pedantic целиком и гаси точечно, а из restriction возьми осознанные запреты, например уже знакомые тебе по уроку. Конфиг живёт в Cargo.toml:
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
indexing_slicing = "warn"
fn_params_excessive_bools = "warn"
wildcard_enum_match_arm = "warn"
Гасить тоже надо правильно. Вместо #[allow(...)] бери #[expect(...)]: он подавляет ожидаемое предупреждение. Когда линт перестанет срабатывать, компилятор выдаст unfulfilled_lint_expectations. Это сигнал убрать #[expect], сам атрибут никуда не исчезает. #[allow] такой сигнал не даёт и может пережить причину, по которой его поставили:
#[expect(clippy::indexing_slicing, reason = "len проверена строкой выше")]
let first = parts[0];
И предохранитель от чрезмерного рвения: не пиши #![deny(warnings)] в коде. Новая версия Rust приносит новые предупреждения, и сборка, которая вчера работала, упадёт после rustup update, хотя код не менялся. Жёсткости место в CI: флаг RUSTFLAGS="-D warnings" в пайплайне ловит то же самое, не прибивая локальную разработку к версии компилятора.
Вспомни различие между паникой и Result из урока про ошибки. Паника это утверждение «здесь баг программиста», Result это ожидаемый отказ. Поэтому unwrap допустим там, где None или Err означали бы ошибку в твоей логике, а ещё лучше expect: его сообщение документирует, почему нарушение невозможно. Недопустима паника как обработка ожидаемого отказа: файл не нашёлся, сеть моргнула, пользователь ввёл мусор, всё это Result и только Result.
Что забрать с собой
Тип хранит проверенную гарантию: принимающий его код не повторяет проверку.
Исчерпывающий разбор обнаруживает изменения модели: новое поле или вариант заставляет пересмотреть зависимый код.
Предупреждения ловят проигнорированные результаты и ненужные подавления линтов. Не держи эти проверки в голове, передай их компилятору.
ДЗ
Дальше
Блок про язык и проекты закрыт: владение, трейты, модули, cargo, экосистема, идиомы. Следующий блок спускается на уровень ниже, к битам и машине. Начнём с представления информации: как число лежит в байтах, почему порядок байтов делит мир на little и big endian и зачем это тому, кто собирается писать эмулятор, сетевой протокол и блокчейн.