SOLID принципы в Rust: теория и практика (продвинутый уровень)

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

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

Начать курс

Введение: SOLID в мире Rust

Принципы SOLID — это фундамент гибкой и поддерживаемой архитектуры. Однако их реализация в Rust имеет свою специфику, обусловленную уникальной системой владения, трейтами и отсутствием классического ООП. В этой статье мы разберем, как применять каждый из пяти принципов на продвинутом уровне, используя идиоматичный Rust. Мы не просто перечислим правила, а покажем, как они трансформируются в контексте системы типов и заимствования.

Эта статья предназначена для разработчиков, уже знакомых с основами Rust и SOLID. Мы сосредоточимся на тонкостях: как избежать нарушения LSP при работе с обобщенными типами, как ISP влияет на проектирование трейтов, и почему DIP в Rust часто требует использования типажей-объектов или Box.

1. Принцип единственной ответственности (SRP) и владение

В Rust SRP приобретает дополнительное измерение из-за строгих правил владения. Класс (или структура) должен иметь только одну причину для изменения. Но как это соотносится с передачей владения?

Проблема: структура, управляющая ресурсами и логикой

Рассмотрим пример, где структура одновременно управляет файловым дескриптором и парсит данные. Это нарушает SRP.

use std::fs::File;
use std::io::Read;

// ❌ Нарушение SRP: структура и управляет ресурсом, и обрабатывает данные
struct DataProcessor {
    file: File,
}

impl DataProcessor {
    fn new(path: &str) -> Self {
        DataProcessor {
            file: File::open(path).unwrap(),
        }
    }

    fn process(&mut self) -> Vec<u8> {
        let mut buffer = Vec::new();
        self.file.read_to_end(&mut buffer).unwrap();
        // ... сложная логика обработки
        buffer
    }
}

Решение: разделение через передачу владения

Разделяем ответственность: одна структура отвечает за чтение, другая — за обработку. Владение передается явно.

use std::fs::File;
use std::io::Read;

// ✅ Первая ответственность: управление файлом
struct FileReader {
    file: File,
}

impl FileReader {
    fn new(path: &str) -> Self {
        FileReader {
            file: File::open(path).unwrap(),
        }
    }

    fn read(self) -> Vec<u8> {
        let mut buffer = Vec::new();
        // self.file потребляется (moved), так как после чтения она не нужна
        self.file.read_to_end(&mut buffer).unwrap();
        buffer
    }
}

// ✅ Вторая ответственность: обработка данных
struct DataProcessor {
    data: Vec<u8>,
}

impl DataProcessor {
    fn new(data: Vec<u8>) -> Self {
        DataProcessor { data }
    }

    fn process(&self) -> Vec<u8> {
        // Иммутабельная обработка
        self.data.iter().map(|&b| b + 1).collect()
    }
}

fn main() {
    let reader = FileReader::new("data.bin");
    let raw_data = reader.read(); // reader больше не доступен
    let processor = DataProcessor::new(raw_data);
    let result = processor.process();
}

Ключевой момент: мы использовали self (владение) вместо &self в read(), чтобы явно указать, что после чтения FileReader больше не нужен. Это делает поток данных прозрачным и предотвращает утечки ресурсов.

2. Принцип открытости/закрытости (OCP) и трейты

OCP гласит: сущности должны быть открыты для расширения, но закрыты для модификации. В Rust это идеально реализуется через трейты и обобщения без использования виртуальных таблиц (vtable), если это не требуется.

Продвинутый пример: стратегия с нулевыми накладными расходами

Допустим, у нас есть система логирования. Вместо того чтобы модифицировать ядро приложения для каждого нового бэкенда, мы используем полиморфизм через трейты.

// Закрыто для модификации
trait Logger {
    fn log(&self, msg: &str);
}

// Открыто для расширения
struct ConsoleLogger;
impl Logger for ConsoleLogger {
    fn log(&self, msg: &str) {
        println!("{}", msg);
    }
}

struct FileLogger {
    // ...
}
impl Logger for FileLogger {
    fn log(&self, msg: &str) {
        // запись в файл
    }
}

// Функция, работающая с любым Logger
fn process_data<L: Logger>(logger: &L, data: &[u8]) {
    // ... логика
    logger.log("Processing started");
}

Здесь process_data использует обобщение <L: Logger>. Это статический полиморфизм (мономорфизация). Компилятор создаст отдельную копию функции для каждого типа L. Нет накладных расходов на динамическую диспетчеризацию. Если же вам нужна гибкость во время выполнения (например, выбор логгера из конфига), используйте Box<dyn Logger>.

fn process_data_dyn(logger: &dyn Logger, data: &[u8]) {
    logger.log("Dynamic dispatch");
}

Важно: Выбор между &dyn Trait и <T: Trait> — это компромисс между производительностью и гибкостью. В Rust нет наследования, поэтому OCP реализуется через композицию трейтов.

3. Принцип подстановки Лисков (LSP) и границы трейтов

LSP в Rust не так очевиден, как в языках с наследованием. Он сводится к правильному контракту трейтов. Если тип B реализует трейт A, то код, ожидающий A, должен работать с B без сюрпризов.

Типичная ошибка: нарушение контракта через панику

Представьте трейт Shape с методом area. Если реализация для Circle паникует при отрицательном радиусе, а для Square — нет, это нарушение LSP.

trait Shape {
    fn area(&self) -> f64;
}

struct Circle {
    radius: f64,
}

impl Shape for Circle {
    fn area(&self) -> f64 {
        if self.radius <                

Блоги

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