SOLID принципы в Rust: теория и практика от А до Я

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

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

Начать курс

Введение: Почему SOLID важен в Rust?

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

1. Single Responsibility Principle (SRP): Принцип единственной ответственности

Суть: Каждый модуль, структура или функция должны иметь только одну причину для изменения.

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

Плохой пример (нарушение SRP)

struct User {
    name: String,
    email: String,
}

impl User {
    fn save_to_db(&self) {
        // логика сохранения в базу
    }

    fn send_email(&self, message: &str) {
        // логика отправки email
    }
}

Здесь User отвечает и за хранение, и за отправку — две причины для изменения.

Хороший пример (соблюдение SRP)

struct User {
    name: String,
    email: String,
}

struct UserRepository;
impl UserRepository {
    fn save(&self, user: &User) { /* ... */ }
}

struct EmailService;
impl EmailService {
    fn send(&self, user: &User, message: &str) { /* ... */ }
}

Теперь каждая структура имеет одну ответственность: User — данные, UserRepository — сохранение, EmailService — отправка.

2. Open-Closed Principle (OCP): Принцип открытости/закрытости

Суть: Программные сущности должны быть открыты для расширения, но закрыты для модификации.

В Rust это достигается с помощью трейтов и дженериков. Вы можете добавлять новое поведение, не меняя существующий код.

Пример: Система скидок

Предположим, у нас есть функция расчёта цены со скидкой. Вместо того чтобы добавлять новые типы скидок через if-else, используем трейт:

trait Discount {
    fn apply(&self, price: f64) -> f64;
}

struct NoDiscount;
impl Discount for NoDiscount {
    fn apply(&self, price: f64) -> f64 { price }
}

struct SeasonalDiscount(f64);
impl Discount for SeasonalDiscount {
    fn apply(&self, price: f64) -> f64 { price * (1.0 - self.0) }
}

fn calculate_price(price: f64, discount: &dyn Discount) -> f64 {
    discount.apply(price)
}

Теперь, чтобы добавить новую скидку (например, BlackFridayDiscount), нам не нужно менять calculate_price — просто реализуем трейт Discount.

3. Liskov Substitution Principle (LSP): Принцип подстановки Барбары Лисков

Суть: Объекты подтипов (в Rust — типы, реализующие трейт) должны быть взаимозаменяемы с объектами базового типа без нарушения корректности программы.

В Rust это означает, что если у вас есть трейт и несколько его реализаций, то код, работающий с dyn Trait или дженериками, не должен ломаться при подстановке любой из них.

Пример нарушения LSP

trait Bird {
    fn fly(&self) -> String;
}

struct Sparrow;
impl Bird for Sparrow {
    fn fly(&self) -> String { "Sparrow flies".to_string() }
}

struct Penguin;
impl Bird for Penguin {
    fn fly(&self) -> String { panic!("Penguins can't fly!") }
}

Если мы заменим Sparrow на Penguin, код сломается. Решение — не включать методы, которые не могут быть реализованы всеми подтипами. Лучше разделить трейты:

trait CanFly {
    fn fly(&self) -> String;
}
trait Swim {
    fn swim(&self) -> String;
}

struct Penguin;
impl Swim for Penguin { /* ... */ }

struct Sparrow;
impl CanFly for Sparrow { /* ... */ }

4. Interface Segregation Principle (ISP): Принцип разделения интерфейса

Суть: Клиенты не должны зависеть от методов, которые они не используют. Лучше несколько маленьких, специализированных трейтов, чем один "толстый".

Плохой пример (нарушение ISP)

trait Worker {
    fn work(&self);
    fn eat(&self);
    fn sleep(&self);
}

struct Robot;
impl Worker for Robot {
    fn work(&self) { /* работает */ }
    fn eat(&self) { panic!("Robot doesn't eat"); }
    fn sleep(&self) { panic!("Robot doesn't sleep"); }
}

Хороший пример (соблюдение ISP)

trait Workable {
    fn work(&self);
}
trait Eatable {
    fn eat(&self);
}
trait Sleepable {
    fn sleep(&self);
}

struct Robot;
impl Workable for Robot { /* ... */ }

struct Human;
impl Workable for Human { /* ... */ }
impl Eatable for Human { /* ... */ }
impl Sleepable for Human { /* ... */ }

Теперь Robot реализует только то, что ему нужно.

5. Dependency Inversion Principle (DIP): Принцип инверсии зависимостей

Суть: Модули верхнего уровня не должны зависеть от модулей нижнего уровня. Оба должны зависеть от абстракций (трейтов).

В Rust это реализуется через внедрение зависимостей (dependency injection) через трейты.

Пример: Приложение, отправляющее уведомления

trait Notifier {
    fn send(&self, message: &str);
}

struct EmailNotifier;
impl Notifier for EmailNotifier {
    fn send(&self, message: &str) { /* отправка email */ }
}

struct SmsNotifier;
impl Notifier for SmsNotifier {
    fn send(&self, message: &str) { /* отправка SMS */ }
}

struct NotificationService {
    notifier: Box<dyn Notifier>,
}

impl NotificationService {
    fn new(notifier: Box<dyn Notifier>) -> Self {
        NotificationService { notifier }
    }

    fn notify(&self, message: &str) {
        self.notifier.send(message);
    }
}

Теперь NotificationService

Блоги

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