Паттерны проектирования в Rust: когда и зачем их применять

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Паттерны проектирования в 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                

Блоги

Рекомендаций по книгам