Понимание принципов SOLID на примерах практического кода
В этом руководстве подробно раскрыты все пять принципов SOLID с конкретными примерами кода, показывающими, как они применяются в реальных проектах и приложениях на React.
При разработке программного обеспечения правильная работа кода — это лишь половина задачи.
Когда приложение начинает развиваться, его кодовая база часто становится труднее для чтения, изменения и поддержания в рабочем состоянии. Небольшая корректировка в одной части системы может незаметно нарушить работу какого-то другого, не связанного с ней элемента.
Именно такие проблемы предназначены решить принципы SOLID.
SOLID — это группа из пяти принципов проектирования в объектно-ориентированном программировании, которые способствуют созданию кода, обладающего следующими качествами:
- Проще в обслуживании
- Проще в тестировании
- Проще в расширении
- С более слабой связностью компонентов
- Проще для понимания командой разработчиков
Аббревиатура раскрывается следующим образом:
S — Принцип единственной ответственности
O — Принцип открытости/закрытости
L — Принцип замещаемости Лискова
I — Принцип сегрегации интерфейсов
D — Принцип инверсии зависимостей
Рассмотрим каждый из них на простых примерах.
1. S — Принцип единственной ответственности
«У класса должна быть только одна причина для изменений».
Проще говоря, каждый класс или модуль должен быть построен вокруг одной задачи.
Рассмотрим класс User, который отвечает за:
- Хранение данных пользователя
- Взаимодействие с базой данных
- Отправку электронных писем
- Создание отчетов
Это слишком много функций для одного класса.
class User {
createUser() {
// create user
}
saveToDatabase() {
// save user
}
sendEmail() {
// send email
}
generateReport() {
// generate report
}
}
Если требуется изменить логику отправки писем, приходится редактировать класс User.
Если меняется обработка базы данных, снова приходится работать с тем же классом.
Более чистый подход заключается в разделении этих задач.
class User {
createUser() {
// create user
}
}
class UserRepository {
saveToDatabase() {
// database logic
}
}
class EmailService {
sendEmail() {
// email logic
}
}
class ReportService {
generateReport() {
// report logic
}
}
Благодаря такому разделению каждый класс теперь отвечает ровно за одну задачу.
Почему это полезно?
Как только меняются требования, вы сразу понимаете, какую часть кода необходимо изменить.
Одна задача на класс означает одну причину для его изменения.
2. Принцип открытости/закрытости
«Программные сущности должны быть открыты для расширения, но закрыты для изменений».
Формулировка звучит абстрактно, но концепция за ней — нет.
Цель состоит в том, чтобы добавлять новые функции без постоянной переписки уже работающего кода.
Возьмем пример обработки платежей:
function processPayment(type, amount) {
if (type === "card") {
// card payment
} else if (type === "upi") {
// UPI payment
} else if (type === "paypal") {
// PayPal payment
}
}
Теперь предположим, что вам нужно поддерживать:
- Stripe
- Razorpay
- Apple Pay
- Google Pay
Каждый новый вариант делает эту функцию больше и более запутанной.
Лучшей стратегией является присвоение каждому способу оплаты собственного класса.
class CardPayment {
pay(amount) {
console.log(`Card payment: ${amount}`);
}
}
class UpiPayment {
pay(amount) {
console.log(`UPI payment: ${amount}`);
}
}
class PaypalPayment {
pay(amount) {
console.log(`PayPal payment: ${amount}`);
}
}
При такой структуре добавление нового способа оплаты не требует изменения уже написанных классов.
class StripePayment {
pay(amount) {
console.log(`Stripe payment: ${amount}`);
}
}
Исходные реализации остаются прежними.
Идея
Развивайте систему путем добавления нового кода, а не путем постоянной правки уже стабильного кода.
3. L — Принцип замещаемости Лискова
«Подтипы должны быть заменяемы на свои базовые типы».
По сути, этот принцип гласит:
Если
Bявляется подтипомA, то должно быть возможно заменитьBтам, где используетсяA, и приложение должно продолжать работать корректно.
Классическим примером может служить птицы.
Допустим, вы определяете:
class Bird {
fly() {
console.log("Flying");
}
}
Затем вы его расширяете:
class Sparrow extends Bird {
fly() {
console.log("Sparrow is flying");
}
}
Пока всё в порядке.
Но что произойдет с пингвином?
class Penguin extends Bird {
fly() {
throw new Error("Penguins cannot fly");
}
}
Это выявляет недостаток в дизайне.
Если другие части кода предполагают, что каждая запись типа Bird может летать, передача ей экземпляра Penguin нарушит это предположение.
Более разумный дизайн выносит поведение полета в отдельную часть кода.
class Bird {
eat() {
console.log("Eating");
}
}
class FlyingBird extends Bird {
fly() {
console.log("Flying");
}
}
class Sparrow extends FlyingBird {}
class Penguin extends Bird {}
Таким образом, пингвины больше не вынуждены поддерживать поведение, которое к ним не относится.
Урок
Избегайте создания иерархий наследования, которые не имеют логического смысла.
Подкласс должен корректно работать везде, где ожидается работа его родительского класса.
4. I — Принцип сегрегации интерфейсов
«Клиенты не должны вынуждаться использовать методы, которые им не нужны».
Представьте интерфейс, построенный вот так:
print()
scan()
fax()
copy()
Теперь представьте себе простой принтер, который может только печатать.
Почему от этого принтера должны требовать реализации функций scan(), fax() и copy() тоже?
Для этого нет никаких веских причин.
Лучший подход — разделить интерфейс в соответствии с тем, что на самом деле делает каждая функция.
Например:
class Printer {
print() {
console.log("Printing...");
}
}
class Scanner {
scan() {
console.log("Scanning...");
}
}
class FaxMachine {
fax() {
console.log("Faxing...");
}
}
Простой принтер должен реализовывать только те функции, которые он действительно поддерживает.
В современном JavaScript
У JavaScript нет формальных интерфейсов вроде тех, что есть у Java или C#, но основная идея остаётся прежней.
Это можно реализовать с помощью:
- Небольших модулей
- Небольших API
- Композиции
- Отдельных сервисов
- Сфокусированных компонентов React
Вместо того чтобы объединять всё в один огромный сервис:
userService.getUser();
userService.createUser();
userService.deleteUser();
userService.sendEmail();
userService.generateReport();
Разделите обязанности:
userService.getUser();
userService.createUser();
emailService.sendEmail();
reportService.generateReport();
Никогда не заставляйте компонент, класс или модуль использовать функционал, который ему не нужен.
5. D — Принцип обратной зависимости
«Модули высокого уровня не должны зависеть напрямую от модулей низкого уровня. Оба должны зависеть от абстракций».
Этот принцип существует для того, чтобы снизить степень тесной связи.
Возьмем этот пример:
class MongoDB {
save(data) {
console.log("Saving to MongoDB");
}
}
class UserService {
constructor() {
this.database = new MongoDB();
}
saveUser(user) {
this.database.save(user);
}
}
Проблема заключается в том, что UserService напрямую связан с MongoDB.
Если позже решить использовать PostgreSQL, придется возвращаться и изменять сам UserService.
Лучшим подходом будет внедрение зависимости через инъекцию.
class UserService {
constructor(database) {
this.database = database;
}
saveUser(user) {
this.database.save(user);
}
}
Теперь можно свободно передавать различные реализации баз данных.
const mongoDB = new MongoDB();
const userService = new UserService(mongoDB);
А позже замена их будет крайне простой:
const postgresDB = new PostgreSQL();
const userService = new UserService(postgresDB);
UserService никогда не должен знать, какая база данных находится за его спиной.
Почему это полезно?
Это позволяет создавать код, который:
- легче тестировать
- легче заменять
- менее тесно связан с конкретными компонентами
- легче обслуживать со временем
SOLID в реальном проекте
Соблюдение принципов SOLID не означает создания отдельного класса для каждой мелочи.
Это различие имеет огромное значение.
SOLID заключается в принятии обдуманных дизайнерских решений, а не в накоплении абстракций ради самих по себе.
Например, в приложении React эти принципы естественным образом проявляются при разделении функций:
Components
↓
Hooks
↓
Services
↓
API Layer
↓
Database
Основная задача компонента — обработка пользовательского интерфейса.
Собственный хук может содержать логику управления состоянием, которая можно использовать повторно.
Служба API может заниматься коммуникацией по HTTP.
Бэкенд отвечает за бизнес-логику.
Слой базы данных отвечает за сохранение данных.
Такое разделение позволяет поддерживать приложение в управляемом состоянии по мере его роста.
SOLID и React
Хотя принципы SOLID возникли в рамках объектно-ориентированного проектирования, многие из их идей хорошо применимы в работе с React.
Единая ответственность
Вместо создания одного огромного компонента:
Dashboard.jsx
который пытается делать всё сразу, разделим его на:
Dashboard
UserProfile
Statistics
RecentOrders
Notifications
Каждая часть тогда имеет гораздо более чёткую функцию.
Открыто/закрыто
Разрабатывайте повторно используемые компоненты, которые получают новое поведение через параметры, вместо того чтобы каждый раз при появлении новой версии переписывать их внутреннюю структуру.
<Button variant="primary">
Save
</Button>
<Button variant="danger">
Delete
</Button>
Инверсия зависимостей
Вместо прямой связи компонента с конкретным способом получения данных, храните логику API внутри сервиса или хука.
const users = await userService.getUsers();
Самому компоненту не нужно знать, как на самом деле выполняется этот запрос.
Почему важен принцип SOLID
Фактическая польза от принципов SOLID мало связана с тем, насколько элегантно выглядит код.
Речь идет о том, чтобы будущие изменения были менее болезненными.
Представьте проект, в котором участвуют:
10 разработчиков → 100 функций → тысячи файлов → постоянные изменения
Без тщательного проектирования одно небольшое требование может спровоцировать цепную реакцию некорректной работы системы.
Благодаря четкому разделению функций и слабой связности изменения становятся гораздо более предсказуемыми.
Принципы SOLID могут помочь вам:
- Сократить дублирование кода
- Уменьшить степень тесной связности компонентов
- Улучшить возможности тестирования
- Сделать функции более удобными для расширения
- Упростить отладку
- Улучшить сотрудничество в команде
- Сохранить возможность обслуживания крупных приложений
SOLID не означает чрезмерную инженерию
Возможно, это самый важный вывод.
Не используйте принципы SOLID как строгий чек-лист.
Возьмем простую функцию:
function add(a, b) {
return a + b;
}
Ей не нужны пять классов, три интерфейса и контейнер для внедрения зависимостей.
Цель никогда не заключалась в том, чтобы сделать код более сложным.
Цель — сделать действительно сложный код более удобным для работы.
Применяйте принципы SOLID тогда, когда сложность системы действительно требует дополнительной структуры.
Краткое резюме
S — Одна ответственность: класс или модуль должны выполнять одну основную функцию.
O — Открыто/Закрыто: расширяйте поведение, а не постоянно редактируйте уже работающий код.
L — принцип замены Лискова: подклассы должны корректно работать везде там, где ожидается использование родительского класса.
I — принцип сегрегации интерфейсов: не заставляйте клиентов зависеть от функционала, который им не нужен.
D — принцип инверсии зависимостей: опирайтесь на абстракции, а не на конкретные реализации.
SOLID никогда не предназначался для запоминания пяти определений перед собеседованием.
Это способ мышления о проектировании программного обеспечения.
При написании кода полезно на мгновение остановиться и задаться вопросами:
Не пытается ли этот модуль делать слишком много вещей одновременно?
Не приведет ли добавление новой функции к переписке существующего кода?
Не вводятся ли здесь ненужные зависимости?
Можно ли тестировать этот код без особых трудностей?
Принуждают ли какие-то факторы программу поддерживать поведение, которое ей на самом деле не нужно?
Размышления над этими вопросами обычно имеют большее значение, чем умение перечислить, что означает каждая буква принципа SOLID.
Хорошее программное обеспечение — это не просто то, что работает в данный момент.
Хорошее программное обеспечение — это то, которое постепенно совершенствуется без проблем, вместо того чтобы превратиться в настоящий кошмар.
Связанные статьи
- Когда ИИ пишет ваш React-приложение, но игнорирует принципы чистого кода — Узнайте о семи привычках написания чистого кода — DRY, единая ответственность, защитные клозы и другие — которые часто нарушаются в коде React, сгенерированном ИИ, и как их исправить.