SOLID-принципы
SOLID — это набор пяти принципов объектно-ориентированного проектирования, которые помогают писать код, легко поддающийся поддержке, тестированию и расширению. Эти принципы были сформулированы Робертом Мартином (Robert C. Martin), известным также как «Дядюшка Боб».
Single Responsibility Principle (SRP)
Принцип единственной ответственности
Каждый класс или модуль должен иметь единственную обязанность и, соответственно, единственную причину для изменения.
Зачем?
- Упрощает понимание кода, т.к. класс/модуль отвечает за одну задачу.
- Если требования меняются, корректировки вносятся в строго определённом месте.
Пример (JS)
// Плохо: класс делает слишком много вещей — сохраняет данные и логирует
class UserService {
saveUser(user) {
// ...код для сохранения в базу
Logger.log(`User ${user.name} saved`);
}
}
// Лучше: выносим логику логирования в отдельный класс/сервис
class UserService {
constructor(logger) {
this.logger = logger;
}
saveUser(user) {
// ...код для сохранения в базу
this.logger.log(`User ${user.name} saved`);
}
}
class Logger {
log(message) {
console.log(`[LOG]: ${message}`);
}
}
Open-Closed Principle (OCP)
Принцип открытости/закрытости
Классы и модули должны быть открыты для расширения, но закрыты для изменения.
Простыми словами — модули надо проектировать так, чтобы их требовалось менять как можно реже, а расширять функциональность можно было с помощью создания новых сущностей и композиции их со старыми.
Зачем?
- Позволяет добавлять новую функциональность без изменения уже существующего кода.
- Снижает риск внесения багов в стабильные части системы.
// Плохо: каждый раз добавляем новое условие в код
function getDiscount(user) {
if (user.type === 'regular') {
return 5;
}
if (user.type === 'vip') {
return 10;
}
// ...
}
// Лучше
class RegularUser {
getDiscount() {
return 5;
}
}
class VipUser {
getDiscount() {
return 10;
}
}
function showDiscount(user) {
// Не изменяем логику, а просто вызываем метод
return user.getDiscount();
}
Здесь мы можем легко добавить нового пользователя, реализовав его класс со своим методом getDiscount
. При этом функцию showDiscount
не придётся менять.
Liskov Substitution Principle (LSP)
Принцип подстановки Барбары Лисков
Классы-наследники должны быть полностью заменяемы своими родительскими классами. Если где-то ожидается объект родительского типа, мы должны иметь возможность подставить объект дочернего типа без нарушения работы программы.
Простыми словами — классы-наследники не должны противоречить базовому классу. Например, они не могут предоставлять интерфейс ýже базового. Поведение наследников должно быть ожидаемым для функций, которые используют базовый класс.
Зачем?
- Обеспечивает корректное и предсказуемое поведение при использовании наследования.
- Помогает избегать ошибок, связанных с неожиданной логикой у наследников.
// Плохо: Square ломает поведение Rectangle
class Rectangle {
constructor(width, height) {
this.width = width;
this.height = height;
}
setWidth(width) {
this.width = width;
}
setHeight(height) {
this.height = height;
}
getArea() {
return this.width * this.height;
}
}
class Square extends Rectangle {
// Нарушаем LSP: при изменении ширины или высоты должно
// меняться и другое измерение, но это ломает логику родителя
setWidth(width) {
this.width = width;
this.height = width;
}
setHeight(height) {
this.width = height;
this.height = height;
}
}
В этом случае, если мы в коде ожидаем Rectangle и вызываем методы setWidth и setHeight, для Square получим не то поведение, которое предполагает базовый класс (у квадрата всегда ширина равна высоте).
Лучше — не наследовать Square
от Rectangle
, а либо использовать общий абстрактный класс (например, Shape
), либо применить композицию. Таким образом, мы избегаем ломки базовой логики наследованием.
Interface Segregation Principle (ISP)
Принцип разделения интерфейса
Клиенты (классы, модули) не должны зависеть от интерфейсов (или контрактов), которые они не используют. Если интерфейс «жирный» и содержит слишком много методов, лучше разбить его на несколько специализированных интерфейсов.
Зачем?
- Избегает «захламления» контрактов методами, не нужными в каждом месте.
- Делает код более гибким и понятным: каждая часть программы зависит только от того, что ей действительно важно.
// Плохо: один класс, который содержит все методы
// (например, Animal с eat, walk, swim, fly)
class Animal {
eat() {}
walk() {}
swim() {}
fly() {}
}
// Класс Dog может не плавать, а класс Bird не ходить по земле
// и т.д. - получаются пустые или неиспользуемые методы.
// Лучше: разделяем ответственность
class Eatable {
eat() {}
}
class Walkable {
walk() {}
}
class Flyable {
fly() {}
}
// Теперь классы, которым надо летать, просто расширяют Flyable,
// а те, которым надо ходить, — Walkable, и так далее
class Dog extends Eatable {
// Дополнительно, если хотим, мы можем насладовать Dog от Walkable
// но не добавлять методы Flyable и Swimable, которые ему не нужны
}
Dependency Inversion Principle (DIP)
Принцип инверсии зависимостей
Зависимости должны строиться на уровне абстракций, а не конкретных реализаций. Модули верхнего уровня не должны зависеть от модулей нижнего уровня напрямую — оба типа модулей зависят от абстракций.
Зачем?
- Упрощает замену реализаций (например, работу с разными базами данных).
- Улучшает тестируемость: можно легко подменять зависимости (например, на моки).
// Плохо: класс напрямую зависит от конкретной реализации
class UserService {
constructor() {
this.db = new MySQLDatabase();
}
getUsers() {
return this.db.query('SELECT * FROM users');
}
}
// Лучше: через абстракцию (интерфейс/контракт)
class UserService {
constructor(database) {
// database — это абстрактный контракт, который должен уметь .query()
this.database = database;
}
getUsers() {
return this.database.query('SELECT * FROM users');
}
}
// Теперь мы можем подменять конкретную реализацию
class MySQLDatabase {
query(sql) {
// запрос в MySQL
}
}
class MongoDatabase {
query(sql) {
// запрос в Mongo
}
}
// В разных окружениях/приложениях
// мы можем передавать нужную реализацию в конструктор UserService
const userService = new UserService(new MySQLDatabase());
Здесь UserService
не знает конкретных деталей реализации базы данных, он работает через абстракцию, которую предоставляют классы MySQLDatabase
или MongoDatabase
.
Заключение
- SRP – Один модуль, одна ответственность.
- OCP – Открыт для расширения, закрыт для изменения.
- LSP – Наследники должны быть подставляемы на место родительских типов без проблем.
- ISP – Разделяйте «жирные» интерфейсы, чтобы зависимости были минимальными.
- DIP – Зависите от абстракций, а не от конкретных реализаций.