Сьогоднішній новачок — це завтрашній майстер. – Джордан Б. Пітерсон.
Вступ
Принцип SOLID складається з п’яти фундаментальних принципів. Мета SOLID — зменшити сильну залежність між класами в коді (тобто зробити класи менш залежними один від одного), перетворивши її на слабку залежність. Це дозволяє вносити зміни в код без впливу на інші класи, які відповідають за різні завдання.
SOLID складається з наступних принципів:
Принцип Єдиної Відповідальності (Single Responsibility Principle)
Клас повинен мати лише одну причину для зміни.
Цей принцип говорить нам, що клас повинен мати лише одну відповідальність або основне завдання, для якого він існує, а не кілька відповідальностей. Коли клас має декілька відповідальностей, зміна однієї може негативно вплинути на інші.
Приклад:
Уявімо, що ми маємо інтернет-магазин і клас під назвою OrderProcessor
, чия відповідальність повинна полягати в обробці замовлення. Проте ми визначили цей клас наступним чином:
- Валідація даних замовлення
- Розрахунок загальної суми оплати за замовлення
- Генерація рахунка-фактури за замовленням
- Надсилання підтвердження електронною поштою клієнту
Хоча ці завдання є частиною обробки замовлення, кожне з них має свою конкретну відповідальність. У результаті наш основний клас бере на себе не лише відповідальність за обробку замовлення, а й валідацію замовлень, розрахунок загальної суми, генерацію рахунків, надсилання листів тощо. Чим більше відповідальностей ми додаємо, тим складнішим стає клас.
Рисунок 1. Клас із кількома відповідальностями.
Ми можемо застосувати принцип і реорганізувати систему, створивши класи, які виконують конкретні завдання:
- Валідація даних замовлення (
OrderValidator
) - Розрахунок загальної суми (
OrderCalculator
) - Генерація рахунка-фактури (
InvoiceGenerator
) - Надсилання підтвердження електронною поштою клієнту (
EmailSender
)
_Рисунок 2.
Класи з Єдиною Відповідальністю.
В коді:
Без використання SRP
public class OrderProcessor {
// Просто обробляє замовлення
public void processOrder(Order order) {
validateOrder(order);
double total = calculateTotal(order);
generateInvoice(order, total);
sendEmail(order, total);
}
// Завдання #1
private void validateOrder(Order order) {
// Бізнес-логіка для валідації замовлення
}
// Завдання #2
private double calculateTotal(Order order) {
// Бізнес-логіка для розрахунку загальної вартості замовлення
}
// Завдання #3
private void generateInvoice(Order order, double total) {
// Бізнес-логіка для генерації рахунка-фактури
}
// Завдання #4
private void sendEmail(Order order, double total) {
// Бізнес-логіка для надсилання електронного листа
}
}
З використанням SRP
public class OrderProcessor {
private OrderValidator validator;
private OrderCalculator calculator;
private InvoiceGenerator invoiceGenerator;
private EmailSender emailSender;
public OrderProcessor(OrderValidator validator, OrderCalculator calculator,
InvoiceGenerator invoiceGenerator, EmailSender emailSender) {
this.validator = validator;
this.calculator = calculator;
this.invoiceGenerator = invoiceGenerator;
this.emailSender = emailSender;
}
// Обробляє замовлення
public void processOrder(Order order) {
validator.validate(order);
double total = calculator.calculateTotal(order);
invoiceGenerator.generateInvoice(order, total);
emailSender.sendEmail(order, total);
}
}
// Завдання #1 Клас
class OrderValidator {
public void validate(Order order) {
// Логіка для валідації замовлення
}
}
// Завдання #2 Клас
class OrderCalculator {
public double calculateTotal(Order order) {
// Логіка для розрахунку загальної вартості замовлення
return 100.0; // Приклад
}
}
// Завдання #3 Клас
class InvoiceGenerator {
public void generateInvoice(Order order, double total) {
// Логіка для генерації рахунка-фактури
}
}
// Завдання #4 Клас
class EmailSender {
public void sendEmail(Order order, double total) {
// Логіка для надсилання електронного листа
}
}
Таким чином, ми досягаємо кращої організації коду. Зміна одного завдання не впливає на інші, оскільки кожне завдання має свою конкретну відповідальність.
Принцип Відкритості/Закритості
Сутності в програмному забезпеченні (класи, модулі, функції тощо) повинні бути відкритими для розширення, але закритими для модифікації.
Цей принцип ґрунтується на ідеї, що ми можемо розширити функціональність класу без зміни його оригінального коду. Таким чином, клас закритий для змін, які можуть призвести до помилок, але відкритий для розширень, тобто ми можемо додавати нові функціональності.
Приклад:
Уявімо, що ми маємо клас під назвою NotificationService
, який надсилає сповіщення користувачам, але на даний момент він робить це лише через електронну пошту. Ми хочемо реалізувати новий метод, додатково до електронної пошти, наприклад, для надсилання сповіщень через SMS чи інші альтернативи.
Щоб реалізувати нові функції, ми створюємо нові класи, які розширюють наш оригінальний клас. Таким чином, ми не змінюємо оригінальний клас; ми просто розширюємо його поведінку.
Рисунок 3. Додавання нових функцій до оригінального класу без змін у ньому
В коді:
Без принципу
Оригінальний (Базовий) клас
Ми маємо лише сповіщення електронною поштою.
class NotificationService {
public void sendNotification(String message, String recipient) {
// Надсилання сповіщення електронною поштою
System.out.println("Sending email to " + recipient + ": " + message);
}
}
Нові функції
Ми хочемо реалізувати кілька типів сповіщень, тому оновлюємо код із новими функціями.
Однак, коли ми продовжуємо додавати нові функції, ми постійно змінюємо оригінальний клас.
class NotificationService {
public void sendNotification(String message, String recipient, String type) {
if (type.equals("email")) {
// Надіслати повідомлення електронною поштою
System.out.println("Sending email to " + recipient + ": " + message);
} else if (type.equals("sms")) {
// Надіслати повідомлення через SMS (нова поведінка, але зміна оригінального класу)
System.out.println("Sending SMS to " + recipient + ": " + message);
} else if (type.equals("push")) {
// Надіслати push-повідомлення (нова поведінка, але зміна оригінального класу)
System.out.println("Sending push notification to " + recipient + ": " + message);
}
}
}
З принципом
Оригінальний (Базовий) клас
Ми маємо лише сповіщення електронною поштою.
class NotificationService {
public void sendNotification(String message, String recipient) {
// Логіка за замовчуванням для надсилання електронної пошти
System.out.println("Sending email to " + recipient + ": " + message);
}
}
Нова функція
Замість того, щоб змінювати базовий код, ми створюємо нові класи, які розширюють його поведінку.
// Клас для надсилання SMS
class SmsNotificationService extends NotificationService {
@Override
public void sendNotification(String message, String recipient) {
// Логіка для надсилання SMS
System.out.println("Sending SMS to " + recipient + ": " + message);
}
}
// Клас для надсилання push-повідомлень
class PushNotificationService extends NotificationService {
@Override
public void sendNotification(String message, String recipient) {
// Логіка для надсилання push-повідомлення
System.out.println("Sending push notification to " + recipient + ": " + message);
}
}
Застосовуючи принцип Відкритості/Закритості, ми уникаємо змін у оригінальному класі і натомість розширюємо його функціональність новими поведінками. Це дозволяє утримувати оригінальний клас закритим для змін і відкритим для розширення.
Принцип Замісності Ліскова
Похідні або дочірні класи повинні бути замінними для своїх базових або батьківських класів.
Цей принцип говорить нам, що дочірні класи повинні поводитися як батьківський клас, не змінюючи оригінальні правила батька. Іншими словами, якщо ми використовуємо батьківський клас у нашій програмі, ми повинні мати можливість замінити його будь-яким дочірнім класом.
Якщо дочірній клас вводить обмеження, залежності або додаткові правила, яких немає в батьківському класі, це порушує принцип.
Приклад:
Уявімо, що ми маємо батьківський клас (або базовий клас) під назвою Vehicle
, який представляє будь-який тип транспорту. Як ми знаємо, всі транспортні засоби мають спільну поведінку: вони можуть рухатися вперед.
Батьківський клас:
// Клас Vehicle
public class Vehicle {
public void move() {
System.out.println("The vehicle is moving");
}
}
Дочірній клас:
// Клас Bicycle
public class Bicycle extends Vehicle {
@Override
public void move() {
System.out.println("The bicycle is moving by pedaling.");
}
}
На даному етапі ми не порушили принцип, оскільки клас Bicycle
може замінити клас Vehicle
. Програма працює нормально, коли ми використовуємо Bicycle
, де очікуємо Vehicle
.
Правильне використання:
public class Main {
public static void main(String[] args) {
Vehicle vehicle = new Vehicle();
// Велосипед може замінити транспортний засіб
Vehicle vehicle2 = new Bicycle();
vehicle.move(); // "The vehicle is moving."
vehicle2.move(); // "The bicycle is moving by pedaling."
}
}
Порушення принципу:
Тепер ми вводимо новий дочірній клас під назвою ElectricCar
.
Цей автомобіль є транспортним засобом, але ми зламаємо програму: тепер замість того, щоб просто рухатися, він спочатку має зарядити акумулятор, перш ніж зможе рухатись.
Дочірній клас:
// Дочірній клас ElectricCar
public class ElectricCar extends Vehicle {
private boolean isBatteryCharged = false;
public void chargeBattery() {
isBatteryCharged = true;
System.out.println("The battery is charged.");
}
@Override
public void move() {
if (!isBatteryCharged) {
System.out.println("The battery is not charged. I can't move.");
return;
}
System.out.println("The electric car is moving.");
}
}
Неправильне використання:
public class Main {
public static void main(String[] args) {
Vehicle vehicle = new Vehicle();
Vehicle electricCar = new ElectricCar(); // Замінювання
vehicle.move(); // "The vehicle is moving."
electricCar.move(); // "The battery is not charged. I can't move."
}
}
Що сталося?
Як було спочатку спроектовано, програма очікує, що будь-який Vehicle
може рухатися, коли викликається метод move()
. Однак, як показано в результатах, клас ElectricCar
змінює правила батьківського класу: тепер перед рухом необхідно зарядити акумулятор. Це порушує принцип, і поведінка дочірнього класу більше не відповідає батьківському класу.
Дочірні класи можуть мати свої методи, але вони не повинні змінювати успадковані методи батьківського класу або додавати обмеження, які змінюють поведінку. Успадковані методи повинні слідувати тим самим цілям та правилам, що визначені в батьківському класі, забезпечуючи тим самим сталість поведінки програми.
Принцип Розділення Інтерфейсів
Жоден клієнт не повинен бути змушений реалізовувати інтерфейс, який є для нього непридатним.
Цей принцип говорить нам, що ми не повинні проектувати великий інтерфейс із усіма методами чи функціональностями, оскільки це змушує різні класи, які реалізують інтерфейс, використовувати методи чи функціональності, які їм не потрібні. Натомість ми повинні поділити великий інтерфейс на менші інтерфейси, орієнтовані на конкретні завдання. Це схоже на перший принцип.
Рисунок 4. Загальний інтерфейс без застосування принципу розділення інтерфейсів.
Рисунок 5. Загальний інтерфейс, поділений на конкретні інтерфейси, із застосуванням принципу розділення інтерфейсів.
Приклад:
Уявімо, що нам потрібна система продажу квитків для парку атракціонів з трьома типами квитків:
- Доступ до механічних ігор
- Водні атракціони
3.
Повний доступ
Ми хочемо розробити ефективну систему, де кожен тип квитка реалізує лише те, що йому потрібно.
В коді:
Без використання принципу
Загальний інтерфейс
interface ParkTicket {
void accessMechanicalGames();
void accessWaterGames();
}
Класи, що реалізують контракт
class MechanicalOnlyTicket implements ParkTicket {
@Override
public void accessMechanicalGames() {
System.out.println("Access to mechanical games allowed.");
}
@Override
public void accessWaterGames() {
// Цей квиток не включає доступ до водних ігор
throw new UnsupportedOperationException("Access to water games not allowed.");
}
}
class WaterOnlyTicket implements ParkTicket {
@Override
public void accessMechanicalGames() {
// Цей квиток не включає доступ до механічних ігор
throw new UnsupportedOperationException("Access to mechanical games not allowed.");
}
@Override
public void accessWaterGames() {
System.out.println("Access to water games allowed.");
}
}
Ці класи квитків змушені реалізовувати методи, які їм не потрібні, що порушує згаданий принцип.
З використанням принципу
Специфічні інтерфейси
interface MechanicalGamesAccess {
void accessMechanicalGames();
}
interface WaterGamesAccess {
void accessWaterGames();
}
Класи, що реалізують контракт
// Реалізація для квитка тільки для механічних ігор
class MechanicalOnlyTicket implements MechanicalGamesAccess {
@Override
public void accessMechanicalGames() {
System.out.println("Access to mechanical games allowed.");
}
}
// Реалізація для квитка тільки для водних ігор
class WaterOnlyTicket implements WaterGamesAccess {
@Override
public void accessWaterGames() {
System.out.println("Access to water games allowed.");
}
}
// Реалізація для квитка з повним доступом
class FullAccessTicket implements MechanicalGamesAccess, WaterGamesAccess {
@Override
public void accessMechanicalGames() {
System.out.println("Access to mechanical games allowed.");
}
@Override
public void accessWaterGames() {
System.out.println("Access to water games allowed.");
}
}
Принцип Інверсії Залежностей
Для цього принципу я написав детальну статтю про те, що це за принцип, як його застосовувати та приклади використання.
Перекладено з: SOLID Principles for Clean and Maintainable Code