Паттерны проектирования в Rust
Паттерн проектирования (design pattern) — это проверенный способ решить задачу, которая встречается снова и снова. Паттерны — это не библиотеки, которые устанавливают. Скорее рецепты: «когда у тебя такая проблема, устрой код вот так».
Многие паттерны из Java или C# в Rust выглядят иначе или вообще не нужны. У Rust есть свои любимцы, построенные на его строгой системе типов, перечислениях, трейтах и владении. Главная идея большинства из них одна: пусть компилятор ловит ошибки за тебя, чтобы они никогда не дошли до пользователей. Каждый раздел ниже начинается с проблемы, а потом показывает паттерн, который её решает.
Newtype: разный смысл — разные типы
Заголовок раздела «Newtype: разный смысл — разные типы»Проблема. Обычный f64 может значить что угодно: метры, футы, секунды, евро. Если функция принимает f64 в метрах, а ты передаёшь футы, компилятор помочь не может — и то и другое просто f64. (Как раз из-за такой путаницы с единицами NASA в 1999 году потеряло аппарат Mars Climate Orbiter.)
Паттерн. Оберни значение в крошечную структуру с одним полем. Теперь Meters и Feet — разные типы, и Rust не даст их перепутать:
#[derive(Debug, Clone, Copy)]struct Meters(f64);
#[derive(Debug, Clone, Copy)]struct Feet(f64);
impl Feet { fn to_meters(self) -> Meters { Meters(self.0 * 0.3048) }}
fn safe_altitude(height: Meters) -> bool { height.0 >= 300.0}
fn main() { let tower = Meters(330.0); let plane = Feet(900.0);
println!("Башня на безопасной высоте? {}", safe_altitude(tower)); println!("900 футов — это {:.1} м", plane.to_meters().0); println!("Самолёт на безопасной высоте? {}", safe_altitude(plane.to_meters()));}Вывод:
Башня на безопасной высоте? true900 футов — это 274.3 мСамолёт на безопасной высоте? falseЕсли забыть перевести единицы и написать safe_altitude(plane), программа даже не скомпилируется:
error[E0308]: mismatched types --> src/main.rs:13:34 |13 | println!("{}", safe_altitude(plane)); | ------------- ^^^^^ expected `Meters`, found `Feet` | | | arguments to this function are incorrectКомпилятор сообщает о несовпадении типов: ожидался Meters, а получен Feet. Во время работы программы newtype ничего не стоит: в памяти Meters — это ровно f64. Используй его для идентификаторов (UserId(u32) против OrderId(u32)), единиц измерения и проверенных строк. Ещё он помогает обойти правило сироты (orphan rule) — см. Продвинутые трейты.
Builder: много настроек, один понятный результат
Заголовок раздела «Builder: много настроек, один понятный результат»Проблема. У некоторых структур много полей, и большинство необязательные. Конструктор вроде Email::new("[email protected]", "Hi", "", vec![], false) трудно читать — какой false за что отвечает? А что, если адреса нет или он неправильный?
Паттерн. Отдельная структура-строитель (builder) собирает настройки через маленькие методы с понятными именами. Каждый метод возвращает строителя, так что вызовы можно выстраивать в цепочку. В конце build() проверяет всё и возвращает Result:
struct Email { to: String, subject: String, body: String, urgent: bool,}
#[derive(Default)]struct EmailBuilder { to: Option<String>, subject: Option<String>, body: String, urgent: bool,}
impl EmailBuilder { fn new() -> Self { EmailBuilder::default() }
fn to(mut self, address: &str) -> Self { self.to = Some(address.to_string()); self }
fn subject(mut self, subject: &str) -> Self { self.subject = Some(subject.to_string()); self }
fn body(mut self, text: &str) -> Self { self.body = text.to_string(); self }
fn urgent(mut self) -> Self { self.urgent = true; self }
fn build(self) -> Result<Email, String> { let to = self.to.ok_or("у письма должен быть получатель")?; if !to.contains('@') { return Err(format!("«{}» — это не адрес электронной почты", to)); } let subject = self.subject.unwrap_or_else(|| String::from("(без темы)")); Ok(Email { to, subject, body: self.body, urgent: self.urgent }) }}
fn show(result: Result<Email, String>) { match result { Ok(e) => println!("Готово: to={} subject={} body={:?} urgent={}", e.to, e.subject, e.body, e.urgent), Err(msg) => println!("Ошибка: {}", msg), }}
fn main() { let email = EmailBuilder::new() .subject("Обедаем?") .body("Пицца в 12?") .urgent() .build(); show(email);
show(EmailBuilder::new().subject("Привет").build()); show(EmailBuilder::new().to("ana.example.com").build());}Вывод:
Готово: [email protected] subject=Обедаем? body="Пицца в 12?" urgent=trueГотово: [email protected] subject=(без темы) body="" urgent=falseОшибка: у письма должен быть получательОшибка: «ana.example.com» — это не адрес электронной почтыКаждый сеттер принимает mut self (забирает владение строителем), меняет одно поле и возвращает строителя обратно. Именно поэтому цепочка работает. Вызывающий код задаёт только то, что ему важно, имена методов объясняют каждое значение, а build() — единственное место, где проверяются правила. Внутри него ok_or превращает None (получателя нет) в Err, а ? сразу возвращает эту ошибку. Строителей ты увидишь во многих крейтах — например, std::thread::Builder и std::process::Command (см. Окружение и процессы).
Default и обновление структуры: лёгкий вариант
Заголовок раздела «Default и обновление структуры: лёгкий вариант»Проблема. Строитель — это много кода. Если в структуре нечего проверять, а нужны просто разумные начальные значения, это перебор.
Паттерн. Реализуй Default с хорошими начальными значениями, а потом используй синтаксис обновления структуры .., чтобы поменять только нужные поля:
#[derive(Debug, Clone)]struct ServerConfig { host: String, port: u16, workers: u32, verbose: bool,}
impl Default for ServerConfig { fn default() -> Self { ServerConfig { host: String::from("127.0.0.1"), port: 8080, workers: 4, verbose: false, } }}
fn main() { let dev = ServerConfig { verbose: true, ..Default::default() }; let prod = ServerConfig { host: String::from("0.0.0.0"), workers: 16, ..ServerConfig::default() }; // Начинаем с другой конфигурации, а не со значений по умолчанию: let staging = ServerConfig { port: 9090, ..prod.clone() };
println!("{:?}", dev); println!("{:?}", prod); println!("{:?}", staging);}Вывод:
ServerConfig { host: "127.0.0.1", port: 8080, workers: 4, verbose: true }ServerConfig { host: "0.0.0.0", port: 8080, workers: 16, verbose: false }ServerConfig { host: "0.0.0.0", port: 9090, workers: 16, verbose: false }..something значит «все поля, которые я не перечислил, возьми из something». Зачем prod.clone()? Обновление структуры перемещает оставшиеся поля, а host — это String. Без клонирования prod оказался бы частично перемещён, и напечатать его потом было бы нельзя. Хорошее правило: Default + .. — когда допустима любая комбинация значений, строитель — когда некоторые комбинации недопустимы.
Typestate: конечный автомат, проверяемый при компиляции
Заголовок раздела «Typestate: конечный автомат, проверяемый при компиляции»Проблема. Некоторые вещи должны происходить по порядку. Веб-запросу нужен URL, прежде чем его можно отправить. С обычной структурой ты проверял бы это во время работы («URL задан? если нет — вернуть ошибку») и надеялся, что каждый вызывающий код эту ошибку обработает.
Паттерн. Сделай состояние частью типа (typestate). У Request<NoUrl> просто нет метода send. Только установка URL превращает его в Request<HasUrl>, у которого этот метод есть:
// Два состояния. Они не хранят данных (или только то, что нужно этому состоянию).struct NoUrl;struct HasUrl(String);
struct Request<U> { url: U, headers: Vec<(String, String)>,}
impl Request<NoUrl> { fn new() -> Self { Request { url: NoUrl, headers: Vec::new() } }
// Меняет тип: Request<NoUrl> -> Request<HasUrl> fn url(self, url: &str) -> Request<HasUrl> { Request { url: HasUrl(url.to_string()), headers: self.headers } }}
// Доступно в любом состоянииimpl<U> Request<U> { fn header(mut self, key: &str, value: &str) -> Self { self.headers.push((key.to_string(), value.to_string())); self }}
// Доступно только после того, как задан URLimpl Request<HasUrl> { fn send(self) -> String { format!("GET {}, заголовков: {}", self.url.0, self.headers.len()) }}
fn main() { let response = Request::new() .header("Accept", "text/html") .url("https://example.com") .header("User-Agent", "rust-tutorial") .send();
println!("{}", response);}Вывод:
GET https://example.com, заголовков: 2А теперь попробуй отправить запрос без URL — Request::new().send() — и компилятор тебя остановит:
error[E0599]: no method named `send` found for struct `Request<NoUrl>` in the current scope --> src/main.rs:22:35 |22 | let response = Request::new().send(); | ^^^^ method not found in `Request<NoUrl>` | = note: the method was found for `Request<HasUrl>`Компилятор не находит метод send у Request<NoUrl> и замечает, что такой метод есть у Request<HasUrl>. В программе, которая компилируется, этой ошибки просто не может быть. Никаких проверок во время работы и никакой лишней цены: NoUrl занимает ноль байт. Поскольку url() принимает self по значению, старый Request<NoUrl> расходуется, и никто не сможет случайно продолжить пользоваться состоянием «до». Здесь обобщения используются хитрым способом; попробуй внести небольшое изменение в Playground, чтобы почувствовать, как это работает.
RAII и Drop-охрана: уборка, о которой невозможно забыть
Заголовок раздела «RAII и Drop-охрана: уборка, о которой невозможно забыть»Проблема. Ресурсы нужно освобождать: закрыть файл, снять блокировку, удалить временную папку, напечатать «готово». Если уборка — это функция, которую надо не забыть вызвать, кто-нибудь обязательно забудет, особенно при раннем return или ошибке.
Паттерн. Положи уборку в Drop. Rust вызывает drop автоматически, когда значение выходит из области видимости, на любом пути — при обычном завершении, раннем return, ? и даже панике. Это называется RAII («Resource Acquisition Is Initialization», «получение ресурса есть инициализация»: получить ресурс и владеть значением — один и тот же шаг). Значение, чья единственная работа — прибрать за собой, называют охраной (guard):
struct Guard { name: String,}
impl Guard { fn new(name: &str) -> Guard { println!("начало: {}", name); Guard { name: name.to_string() } }}
impl Drop for Guard { fn drop(&mut self) { println!("уборка: {}", self.name); }}
fn risky(fail: bool) -> Result<(), String> { let _guard = Guard::new("рискованная работа"); if fail { return Err(String::from("что-то пошло не так")); // охрана всё равно приберёт } println!("работа сделана"); Ok(())}
fn main() { { let _block = Guard::new("блок"); println!("внутри блока"); } // здесь _block удаляется
println!("{:?}", risky(false)); println!("{:?}", risky(true));}Вывод:
начало: блоквнутри блокауборка: блокначало: рискованная работаработа сделанауборка: рискованная работаOk(())начало: рискованная работауборка: рискованная работаErr("что-то пошло не так")Ты и так постоянно пользуешься этим паттерном. File закрывается сам, когда его удаляют. MutexGuard (из mutex.lock(), см. Многопоточность) снимает блокировку мьютекса, когда его удаляют. Box освобождает свою память, когда его удаляют (Умные указатели). Поэтому в коде на Rust почти никогда не встречаются вызовы «close» или «free».
From и Into: удобные аргументы функций
Заголовок раздела «From и Into: удобные аргументы функций»Проблема. Твоя функция принимает String, и вызывающим с &str каждый раз приходится писать String::from("Аня") или "Аня".to_string(). Или функция принимает UserId, а у вызывающих — обычный u32. Мелочь, но она добавляет шума повсюду.
Паттерн. Реализуй From для своих типов и принимай в функциях impl Into<T>. Тогда передавать можно всё, что преобразуется:
#[derive(Debug)]struct UserId(u32);
impl From<u32> for UserId { fn from(n: u32) -> Self { UserId(n) }}
#[derive(Debug)]struct User { id: UserId, name: String,}
impl User { fn new(id: impl Into<UserId>, name: impl Into<String>) -> Self { User { id: id.into(), name: name.into() } }}
fn main() { let ana = User::new(1, "Аня"); // u32 и &str let ben = User::new(UserId(2), String::from("Бен")); // уже нужные типы
println!("{:?}", ana); println!("{:?}", ben);}Вывод:
User { id: UserId(1), name: "Аня" }User { id: UserId(2), name: "Бен" }Реализуй From, а не Into напрямую — Into ты получишь бесплатно. Тот же трюк работает в обработке ошибок: ? вызывает From::from для ошибки, так что реализация From<io::Error> for MyError позволяет ? преобразовывать ошибки за тебя (см. Свои типы ошибок). Используй это для преобразований, которые не могут не удаться. Если преобразование может не получиться, используй TryFrom, который возвращает Result.
Конечные автоматы на enum и недопустимые состояния
Заголовок раздела «Конечные автоматы на enum и недопустимые состояния»Проблема. Заказ проходит путь от «в корзине» до «оплачен» и «отправлен». Первая попытка часто выглядит так:
struct Order { paid: bool, shipped: bool, tracking_number: Option<String>,}Но так можно записать бессмыслицу: shipped: true при paid: false или трек-номер у заказа, который ещё не отправлен. Каждой функции приходится проверять эти невозможные комбинации.
Паттерн. Сделай недопустимые состояния непредставимыми. Используй перечисление с вариантом на каждое состояние и дай каждому варианту только те данные, которые у этого состояния действительно есть. Переходы — это методы, которые принимают старое состояние и возвращают новое:
#[derive(Debug)]enum Order { Cart { items: Vec<String> }, Paid { items: Vec<String>, amount: u32 }, Shipped { tracking: String }, Cancelled,}
impl Order { fn pay(self, amount: u32) -> Order { match self { Order::Cart { items } => Order::Paid { items, amount }, other => other, // оплатить можно только корзину; всё остальное не меняется } }
fn ship(self, tracking: &str) -> Order { match self { Order::Paid { .. } => Order::Shipped { tracking: tracking.to_string() }, other => other, } }
fn cancel(self) -> Order { match self { Order::Shipped { .. } => self, // отменять уже поздно _ => Order::Cancelled, } }
fn status(&self) -> String { match self { Order::Cart { items } => format!("в корзине, товаров: {}", items.len()), Order::Paid { items, amount } => format!("оплачено {}, товаров: {}", amount, items.len()), Order::Shipped { tracking } => format!("отправлен, трек-номер {}", tracking), Order::Cancelled => String::from("отменён"), } }}
fn main() { let order = Order::Cart { items: vec![String::from("книга"), String::from("ручка")] }; println!("{}", order.status());
let order = order.ship("XYZ"); // ещё не оплачен: ничего не происходит println!("{}", order.status());
let order = order.pay(25).ship("TRACK-42"); println!("{}", order.status());
let order = order.cancel(); // уже отправлен println!("{}", order.status());
let other = Order::Cart { items: vec![] }.cancel(); println!("{}", other.status());}Вывод:
в корзине, товаров: 2в корзине, товаров: 2отправлен, трек-номер TRACK-42отправлен, трек-номер TRACK-42отменёнУ отправленного заказа не может не быть трек-номера — у варианта Shipped он есть всегда. У корзины не может быть трек-номера — для него просто нет поля. А поскольку match обязан покрыть все варианты, добавление нового состояния (скажем, Refunded) заставит компилятор указать на каждое место, которое нужно обновить.
Перечисления или трейт-объекты? В объектно-ориентированных языках часто делают интерфейс «State» и по классу на каждое состояние. В Rust так можно сделать с помощью трейт-объектов (Box<dyn State>), и книга Rust показывает, как именно. Вот как выбрать:
- Перечисление — ты заранее знаешь все состояния. Получаешь исчерпывающие проверки
match, и вся логика собрана в одном месте. В Rust это обычный выбор. - Трейт-объекты — другим людям (или другим крейтам) нужно добавлять новые состояния, не меняя твой код.
- Typestate (из раздела выше) — ты хочешь, чтобы неправильный порядок вызовов был ошибкой компиляции, а не просто игнорировался во время работы. Это строже, но состояние должно быть известно при компиляции.
Частые ошибки
Заголовок раздела «Частые ошибки»Писать let _ = guard вместо let _guard = guard. Выглядит почти одинаково, а работает по-разному. Имя, начинающееся с подчёркивания (например, _guard), держит значение живым до конца области видимости. А просто _ — вообще не переменная, поэтому значение удаляется сразу:
struct Guard;
impl Drop for Guard { fn drop(&mut self) { println!("охрана удалена"); }}
fn main() { let _ = Guard; println!("работа с `let _`");
let _g = Guard; println!("работа с `let _g`");}Вывод:
охрана удаленаработа с `let _`работа с `let _g`охрана удаленаС let _ уборка прошла до работы — для блокировки или таймера это ровно то, чего быть не должно. Для охраны всегда давай переменной имя.
Использовать паттерн только потому, что он существует. Строитель для структуры с двумя полями или typestate для того, что никто никогда не использует неправильно, добавляет код, но ничего не делает безопаснее. Начинай с простого. Берись за паттерн, когда чувствуешь проблему, которую он решает.
- Newtype (
struct Meters(f64)) даёт одним и тем же данным разные типы, чтобы компилятор ловил путаницу, — и это бесплатно во время работы. - Builder даёт читаемую настройку цепочкой вызовов и один
build(), который проверяет данные и возвращаетResult. Default +..— лёгкий вариант, когда проверять нечего. - Typestate кладёт состояние в тип (
Request<NoUrl>противRequest<HasUrl>), так что вызовы в неправильном порядке не компилируются. - RAII / Drop-охрана автоматически прибирает при любом выходе из области видимости.
- From/Into позволяют функциям принимать много типов на входе через
impl Into<T>. - Перечисления описывают конечные автоматы и делают недопустимые состояния непредставимыми; трейт-объекты нужны, только когда новые состояния должны добавлять другие.
Проверь себя
Заголовок раздела «Проверь себя»Проверь себя
1. Что даёт newtype struct Meters(f64) по сравнению с обычным f64?
2. Что возвращает build() в паттерне builder на этой странице?
3. Почему в примере с typestate нельзя вызвать send() у Request::new()?
4. Когда срабатывает уборка Drop-охраны?
5. Что делает let _ = Guard;?
Результат: 0 / 5
Что почитать
Заголовок раздела «Что почитать»- Rust Design Patterns (на английском): бесплатная книга сообщества об идиомах, паттернах и антипаттернах Rust, включая newtype, builder и RAII-охрану.
- Реализация шаблона объектно-ориентированного проектирования — книга «Язык программирования Rust» (перевод на русский): паттерн «Состояние» на трейт-объектах, а затем он же, переделанный с кодированием состояний в типах.
- Rust API Guidelines (на английском): официальные советы по проектированию удобных идиоматичных API (преобразования, строители, именование).
- RAII — Rust на примерах (перевод на русский): как владение и
Dropдают автоматическую уборку. - The Typestate Pattern in Rust — Cliff L. Biffle (на английском): более глубокое и очень понятное руководство по typestate с дополнительными примерами.