Що таке принципи SOLID? (П'ять принципів об'єктно-орієнтованого проєктування SOLID)
Принципи SOLID - це набір із п'яти фундаментальних принципів об'єктно-орієнтованого проєктування (OOD), запропонованих Робертом К. Мартіном.
Принципи SOLID містять рекомендації щодо створення програмного забезпечення, яке легше підтримувати й масштабувати в міру зростання проєкту. Ці принципи допомагають позбутися «запахів коду», спростити рефакторинг і підтримати практики гнучкої (Agile) або адаптивної розробки.
Цей гайд по черзі знайомить із кожним принципом і пояснює, як вони допоможуть вам удосконалити навички розробки та писати кращий і надійніший код на Java.
Що таке принципи SOLID?
Абревіатура SOLID означає:
- S: принцип єдиної відповідальності (Single Responsibility Principle)
- O: принцип відкритості/закритості (Open-Closed Principle)
- L: принцип підстановки Лісков (Liskov Substitution Principle)
- I: принцип розділення інтерфейсів (Interface Segregation Principle)
- D: принцип інверсії залежностей (Dependency Inversion Principle)
Принцип єдиної відповідальності
Принцип єдиної відповідальності означає, що кожен клас має зосереджуватися лише на одному конкретному завданні чи призначенні, тобто мати лише одну причину для змін.
Технічно це означає, що на структуру класу має впливати лише один тип змін, наприклад логіка роботи з базою даних або логіка журналювання. Наприклад, якщо клас представляє сутність даних, як-от Book чи Student, його слід оновлювати лише тоді, коли змінюється структура або атрибути цієї сутності.
Дотримання цього принципу дає суттєві переваги. Воно знижує ризик того, що кілька команд працюватимуть над одним класом із не пов'язаними між собою цілями, що інакше могло б призвести до суперечливих або несумісних змін.
Крім того, це спрощує контроль версій. Наприклад, якщо оновлено клас, призначений для операцій із базою даних, ми легко визначимо зміни, пов'язані з функціональністю бази даних.
Цей принцип також мінімізує конфлікти злиття під час спільної розробки. Коли кожен клас має єдине призначення, зміни рідше перетинаються, тож конфлікти виникають рідше і їх легше розв'язувати.
Приклад
Ось приклад, що демонструє принцип єдиної відповідальності. Розгляньмо клас Student:
До застосування SRP (принципу єдиної відповідальності) клас Student відповідає і за функції, пов'язані зі студентом, і за надсилання сповіщень, що порушує SRP.
public class Student {
public String getDetails(int studentID) {
// Logic to fetch student details
}
public void updateGrade(int studentID, int grade) {
// Logic to update student grade
}
public void sendNotification(String message) {
// Logic to send notifications to the student
}
}Після застосування принципу єдиної відповідальності ми виносимо відповідальність за сповіщення в новий клас NotificationService:
public class Student {
public String getDetails(int studentID) {
// Logic to fetch student details
}
public void updateGrade(int studentID, int grade) {
// Logic to update student grade
}
}
public class NotificationService {
public void sendNotification(String message) {
// Logic to send notifications to students
}
}Тепер клас Student зосереджується лише на поведінці, пов'язаній зі студентом.
Логіку сповіщень інкапсульовано в класі NotificationService, що спрощує керування кодом і його зміну.
Кожен клас має чітку і єдину відповідальність, що зменшує зв'язаність і покращує підтримуваність.
Принцип відкритості/закритості
Принцип відкритості/закритості стверджує, що «програмні сутності (як-от класи, модулі чи функції) мають бути відкриті для розширення, але закриті для модифікації». Це означає, що ви можете додавати нову функціональність до системи, не змінюючи наявний код.
Розгляньмо цей принцип на прикладі
Уявіть, що у вас є клас Shape, який використовується для обчислення площі різних фігур у геометричному застосунку. Спочатку клас підтримує лише обчислення площі прямокутників. Згодом ви хочете додати підтримку обчислення площі кіл.
Замість того щоб змінювати наявний клас Shape, додаючи до нього логіку для кіл, ви можете створити новий клас, наприклад Circle, який розширює поведінку. Так клас Shape залишається незмінним, а нову функціональність можна додати.
Ось як це можна реалізувати:
// Base class
public abstract class Shape {
public abstract double calculateArea();
}
// Rectangle class
public class Rectangle extends Shape {
private double length;
private double width;
public Rectangle(double length, double width) {
this.length = length;
this.width = width;
}
@Override
public double calculateArea() {
return length * width;
}
}
// Circle class
public class Circle extends Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double calculateArea() {
return Math.PI * radius * radius;
}
}
У наведеному коді відповідно до принципу відкритості/закритості:
Клас Shape не змінюється, що забезпечує стабільність наявного коду. Нові фігури (як-от Triangle чи Square) легко додати, розширивши клас Shape. Такий підхід сприяє повторному використанню коду, гнучкості та підтримуваності.
Принцип підстановки Лісков
Принцип підстановки Лісков, сформульований Барбарою Лісков у 1987 році, стверджує, що «підтипи мають бути здатні заміняти свої базові типи». Це означає, що будь-який дочірній клас має бути здатним замінити свій батьківський клас, не спричиняючи неочікуваної поведінки.
Щоб зрозуміти цей принцип, розгляньмо приклад класу Bird (птах). Клас Bird зазвичай має таку поведінку, як політ. Конкретний вид птаха, наприклад пінгвін (Penguin), може успадковувати клас Bird. Однак пінгвіни не вміють літати, тож якщо поведінка польоту успадковується безпосередньо, це призведе до порушення принципу підстановки Лісков.
Приклад
Ось як це можна пояснити на коді:
Нижче наведено порушення принципу підстановки Лісков:
// Parent class
public class Bird {
public void fly() {
System.out.println("Flying...");
}
}
// Child class
public class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("Penguins can't fly");
}
}
У цьому прикладі підстановка Penguin замість Bird призводить до неочікуваної поведінки, оскільки клас Penguin перевизначає метод fly, викидаючи виняток.
Щоб застосувати принцип підстановки Лісков, можна переструктурувати код, винісши поведінку польоту в окрему абстракцію:
// Base class
public abstract class Bird {
public abstract void eat();
}
// Flying behavior
public interface Flyable {
void fly();
}
// Subclass for birds that can fly
public class Sparrow extends Bird implements Flyable {
@Override
public void eat() {
System.out.println("Sparrow is eating...");
}
@Override
public void fly() {
System.out.println("Sparrow is flying...");
}
}
// Subclass for birds that cannot fly
public class Penguin extends Bird {
@Override
public void eat() {
System.out.println("Penguin is eating...");
}
}
Тепер клас Bird зосереджується лише на спільній поведінці, як-от харчуванні.
Поведінку польоту відокремлено, і лише птахи, які вміють літати, реалізують інтерфейс Flyable.
Підстановка будь-якого птаха замість класу Bird працює так, як очікується, що зберігає узгодженість і запобігає помилкам під час виконання.
Наведений приклад коду гарантує, що дочірні класи можна підставити замість батьківського без порушення принципу підстановки Лісков.
Принцип розділення інтерфейсів
Принцип розділення інтерфейсів стосується інтерфейсів, а не класів, і тісно пов'язаний із принципом єдиної відповідальності. Він стверджує: «Жоден клієнт не повинен бути змушений реалізовувати інтерфейс, який він не використовує».
Ідея полягає в тому, щоб не створювати великі універсальні інтерфейси, а натомість розбивати їх на менші, конкретніші, що відповідають потребам окремих клієнтів.
Простіше кажучи, згідно з принципом розділення інтерфейсів клас або сутність має реалізовувати лише ті методи, які стосуються її конкретного призначення. Це зменшує непотрібні залежності й забезпечує гнучкість проєктування.
Приклад
Уявіть систему керування бібліотекою. Є загальний інтерфейс LibraryServices, що містить методи для видачі книжок, повернення книжок, сплати штрафів за прострочення та бронювання електронних книжок.
Тепер припустімо, що є два типи користувачів: відвідувачі, які приходять особисто, та онлайн-користувачі.
Відвідувачам, які приходять особисто, потрібен лише доступ до видачі та повернення книжок.
Онлайн-користувачі зосереджені на бронюванні електронних книжок і можуть не користуватися видачею чи поверненням.
Єдиний інтерфейс LibraryServices для обох типів користувачів змушує кожен із них реалізовувати методи, які йому не потрібні, що порушує принцип розділення інтерфейсів.
Порушення принципу розділення інтерфейсів
// Fat interface with unrelated methods
public interface LibraryServices {
void borrowBook();
void returnBook();
void payLateFee();
void reserveEBook();
}
// In-person visitor implements all methods but uses only a few
public class InPersonVisitor implements LibraryServices {
@Override
public void borrowBook() {
System.out.println("Borrowing a book...");
}
@Override
public void returnBook() {
System.out.println("Returning a book...");
}
@Override
public void payLateFee() {
System.out.println("Paying a late fee...");
}
@Override
public void reserveEBook() {
// Not applicable for in-person visitors
throw new UnsupportedOperationException("Not applicable for in-person visitors");
}
}
Дотримуючись принципу розділення інтерфейсів, єдиний великий інтерфейс розділяють на менші інтерфейси, призначені для конкретних клієнтів.
// Smaller, focused interfaces
public interface BookBorrowing {
void borrowBook();
void returnBook();
}
public interface FeePayment {
void payLateFee();
}
public interface EBookReservation {
void reserveEBook();
}
// In-person visitors only implement relevant interfaces
public class InPersonVisitor implements BookBorrowing, FeePayment {
@Override
public void borrowBook() {
System.out.println("Borrowing a book...");
}
@Override
public void returnBook() {
System.out.println("Returning a book...");
}
@Override
public void payLateFee() {
System.out.println("Paying a late fee...");
}
}
// Online users implement only their relevant interface
public class OnlineUser implements EBookReservation {
@Override
public void reserveEBook() {
System.out.println("Reserving an e-book...");
}
}
Наведений підхід гарантує, що архітектура залишається чистою і відповідає принципу розділення інтерфейсів.
Переваги дотримання принципу
- Кожен клієнт працює лише з тими інтерфейсами, які його стосуються.
- Зменшує непотрібні залежності.
- Спрощує підтримку та розширення системи.
- Запобігає потенційним помилкам під час виконання, як-от виняткам, які викидають нереалізовані методи.
Принцип інверсії залежностей
Принцип інверсії залежностей наголошує:
«Сутності мають залежати від абстракцій, а не від конкретних реалізацій».
Це означає, що модулі високого рівня (які відповідають за загальну функціональність) не повинні безпосередньо залежати від модулів низького рівня (які відповідають за деталі реалізації). Натомість обидва мають спиратися на абстракції (як-от інтерфейси чи абстрактні класи).
Дотримуючись цього принципу, ви досягаєте слабкої зв'язаності, завдяки чому система стає гнучкішою, легшою в підтримці та простішою для змін без порушення роботи інших компонентів.
Приклад
Розгляньмо NotificationService, який має надсилати повідомлення різними каналами, як-от SMS та електронною поштою. Якщо NotificationService безпосередньо залежить від конкретних класів для надсилання SMS чи листів, він стає тісно пов'язаним із цими реалізаціями. Будь-які зміни в класах низького рівня (наприклад, додавання сповіщень у WhatsApp) вимагатимуть змін у NotificationService, що порушує принцип.
// High-level module directly depends on low-level modules
public class NotificationService {
private EmailSender emailSender;
private SMSSender smsSender;
public NotificationService() {
this.emailSender = new EmailSender(); // Tight coupling
this.smsSender = new SMSSender(); // Tight coupling
}
public void sendNotification(String message) {
emailSender.sendEmail(message);
smsSender.sendSMS(message);
}
}
class EmailSender {
public void sendEmail(String message) {
System.out.println("Sending Email: " + message);
}
}
class SMSSender {
public void sendSMS(String message) {
System.out.println("Sending SMS: " + message);
}
}
У цьому прикладі NotificationService тісно пов'язаний і з EmailSender, і з SMSSender. Додавання нового способу сповіщення (наприклад, WhatsAppSender) вимагатиме зміни класу NotificationService, що порушує принцип інверсії залежностей.
Щоб дотриматися принципу інверсії залежностей, введіть абстракцію (інтерфейс), від якої залежатимуть і модуль високого рівня (NotificationService), і модулі низького рівня (EmailSender, SMSSender).
// Abstraction for sending notifications
public interface NotificationSender {
void send(String message);
}
// Low-level module: EmailSender
public class EmailSender implements NotificationSender {
@Override
public void send(String message) {
System.out.println("Sending Email: " + message);
}
}
// Low-level module: SMSSender
public class SMSSender implements NotificationSender {
@Override
public void send(String message) {
System.out.println("Sending SMS: " + message);
}
}
// High-level module: NotificationService
public class NotificationService {
private final NotificationSender notificationSender;
// Dependency injection via constructor
public NotificationService(NotificationSender notificationSender) {
this.notificationSender = notificationSender;
}
public void sendNotification(String message) {
notificationSender.send(message);
}
}
Приклад використання
Тепер NotificationService може працювати з будь-якою реалізацією інтерфейсу NotificationSender, не залежачи безпосередньо від конкретних класів.
public class Main {
public static void main(String[] args) {
NotificationSender emailSender = new EmailSender();
NotificationService emailService = new NotificationService(emailSender);
emailService.sendNotification("Your email message!");
NotificationSender smsSender = new SMSSender();
NotificationService smsService = new NotificationService(smsSender);
smsService.sendNotification("Your SMS message!");
}
}Переваги принципу інверсії залежностей
- Зменшує зв'язаність між модулями високого та низького рівня.
- Спрощує додавання нових реалізацій (наприклад, WhatsAppSender) без зміни наявного коду.
- Сприяє впровадженню залежностей, що спрощує тестування й підвищує гнучкість.
Залежачи від абстракцій, а не від конкретних реалізацій, ви можете створити модульну та масштабовану систему.
Висновок
У цій статті ми розглянули п'ять принципів SOLID для написання чистого й підтримуваного коду. Якщо дотримуватися цих принципів, проєкти стає легше передавати членам команди, розширювати новими функціями, змінювати, тестувати й рефакторити, мінімізуючи можливі труднощі.
Шукаєте найкращий VPS-хостинг? У BlueVPS ми пропонуємо потужні, масштабовані та налаштовувані хостинг-рішення з виділеним середовищем і необмеженим трафіком. Почніть уже сьогодні і виведіть свій хостинг на новий рівень!
Blog