Введение: Почему 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