Паттерны проектирования в Rust: не просто мода, а необходимость
Когда мы говорим о Rust, многие представляют себе систему типов, borrow checker и отсутствие сборщика мусора. Но за этим фасадом строгости скрывается мощный инструмент для проектирования архитектуры. Паттерны проектирования (design patterns) в Rust — это не просто дань традициям Java или C++. Это способ заставить компилятор работать на вас, а не против вас.
В этой статье мы разберём, почему классические паттерны из GoF (Gang of Four) в Rust выглядят иначе, какие из них действительно полезны, а какие — антипаттерны. И самое главное — увидим реальный код, который можно сразу применить в своём проекте.
Почему Rust меняет правила игры?
Классические паттерны проектирования часто решают проблемы, связанные с управлением памятью, наследованием и полиморфизмом. Rust же предлагает совершенно другой подход:
- Владение и заимствование — вместо сборки мусора вы явно управляете памятью. Паттерны, которые в других языках решают проблему "кто владеет объектом", в Rust становятся тривиальными или вообще не нужны.
- Черты (Traits) — замена наследованию. Rust не поддерживает классическое ООП-наследование, но трейты дают гибкость без проблем ромбовидного наследования.
- Enum-ы и сопоставление с образцом — мощный инструмент, который заменяет многие паттерны, связанные с состоянием и ветвлением.
- Функциональный стиль — итераторы, замыкания и комбинаторы позволяют писать лаконичный код без явных паттернов.
Поэтому многие паттерны в Rust выглядят не как "класс и интерфейс", а как трейт + структура + enum. Давайте посмотрим на конкретные примеры.
Паттерн Builder: конструируем сложные объекты
Паттерн Builder — один из самых популярных в Rust. Он идеально подходит для создания объектов с большим количеством параметров, особенно когда часть из них опциональна.
Проблема: конструктор с 10 параметрами
Представьте, что вам нужно создать конфигурацию HTTP-клиента. В традиционном подходе вы бы написали:
let client = HttpClient::new(
"https://api.example.com",
Duration::from_secs(30),
5,
true,
None,
vec!["Accept: application/json".to_string()],
...
);
Это нечитаемо и легко ошибиться порядком аргументов. Builder решает эту проблему.
Реализация на Rust
В Rust паттерн Builder реализуется через отдельную структуру с методами-сеттерами, которые возвращают self:
#[derive(Debug)]
pub struct HttpClient {
base_url: String,
timeout: Duration,
max_retries: u32,
verify_ssl: bool,
headers: Vec<String>,
}
pub struct HttpClientBuilder {
base_url: String,
timeout: Duration,
max_retries: u32,
verify_ssl: bool,
headers: Vec<String>,
}
impl HttpClientBuilder {
pub fn new(base_url: &str) -> Self {
HttpClientBuilder {
base_url: base_url.to_string(),
timeout: Duration::from_secs(30),
max_retries: 3,
verify_ssl: true,
headers: Vec::new(),
}
}
pub fn timeout(mut self, duration: Duration) -> Self {
self.timeout = duration;
self
}
pub fn max_retries(mut self, n: u32) -> Self {
self.max_retries = n;
self
}
pub fn no_ssl(mut self) -> Self {
self.verify_ssl = false;
self
}
pub fn add_header(mut self, header: &str) -> Self {
self.headers.push(header.to_string());
self
}
pub fn build(self) -> Result<HttpClient, String> {
if self.base_url.is_empty() {
return Err("base_url cannot be empty".to_string());
}
Ok(HttpClient {
base_url: self.base_url,
timeout: self.timeout,
max_retries: self.max_retries,
verify_ssl: self.verify_ssl,
headers: self.headers,
})
}
}
Использование:
let client = HttpClientBuilder::new("https://api.example.com")
.timeout(Duration::from_secs(60))
.max_retries(5)
.no_ssl()
.add_header("Authorization: Bearer token123")
.build()
.expect("Failed to build client");
println!("{:?}", client);
Обратите внимание: мы используем mut self и возвращаем Self. Это позволяет создавать цепочки вызовов. Финальный метод build потребляет билдер и возвращает Result — это позволяет валидировать параметры.
Когда использовать: когда у объекта больше 3-4 параметров, особенно с опциональными значениями. В Rust это стандарт для библиотек (например, reqwest, tokio).
Паттерн Strategy: выбираем алгоритм на лету
Strategy позволяет переключать алгоритмы во время выполнения. В Rust это делается через трейты и замыкания.
Классический подход через трейты
Допустим, у нас есть система сжатия данных. Разные алгоритмы (Gzip, Brotli, Zstd) реализуют общий интерфейс:
trait Compression {
fn compress(&self, data: &[u8]) -> Vec<u8>;
fn decompress(&self, data: &[u8]) -> Vec<u8>;
}
struct GzipCompressor;
impl Compression for GzipCompressor {
fn compress(&self, data: &[u8]) -> Vec<u8> {
// Реализация Gzip
data.to_vec()
}
fn decompress(&self, data: &[u8]) -> Vec<u8> {
data.to_vec()
}
}
struct BrotliCompressor;
impl Compression for BrotliCompressor {
fn compress(&self, data: &[u8]) -> Vec<u8> {
// Реализация Brotli
data.to_vec()
}
fn decompress(&self, data: &[u8]) -> Vec<u8> {
data.to_vec()
}
}
struct DataProcessor<T: Compression> {
compressor: T,
}
impl<T: Compression> DataProcessor<T> {
fn process(&self, data: &[u8]) -> Vec<u8> {
self.compressor.compress(data)
}
}
Использование:
let gzip = DataProcessor { compressor: GzipCompressor };
let brotli = DataProcessor { compressor: BrotliCompressor };
let data = b"Hello, world!";
println!("Gzip: {:?}", gzip.process(data));
println!("Brotli: {:?}", brotli.process(data));
Но есть нюанс: здесь тип стратегии фиксируется на этапе компиляции. Если вам нужно переключать стратегию динамически (например, на основе конфигурации), используйте Box<dyn Trait>:
struct DynamicProcessor {
compressor: Box<dyn Compression>,
}
impl DynamicProcessor {
fn process(&self, data: &[u8]) -> Vec<u8> {
self.compressor.compress(data)
}
}
// Использование
let strategy: Box<dyn Compression> = if use_b