Головна / Статті / Принципи SOLID у коді: порушення й виправлення для кожного правила

Принципи SOLID у коді: порушення й виправлення для кожного правила

Практичний розбір пʼяти принципів SOLID у низькорівневому проєктуванні з поганим і добрим прикладом коду для SRP, OCP, LSP, ISP і DIP та коротким підсумком.

2125 слів

Низькорівневе проєктування — це етап, на якому вимоги перетворюються на класи, інтерфейси й звʼязки. Серед ідей, що ним керують, принципи SOLID спливають найчастіше.

SOLID позначає пʼять практичних правил, які зберігають обʼєктно-орієнтований код чистим, гнучким і зручним у підтримці:

  1. S: принцип єдиної відповідальності (Single Responsibility Principle, SRP)
  2. O: принцип відкритості/закритості (Open/Closed Principle, OCP)
  3. L: принцип підстановки Лісков (Liskov Substitution Principle, LSP)
  4. I: принцип розділення інтерфейсів (Interface Segregation Principle, ISP)
  5. D: принцип інверсії залежностей (Dependency Inversion Principle, DIP)

Кожен принцип нижче супроводжують дві версії того самого коду: одна порушує правило, інша йому слідує, тож різницю легко побачити.

S: єдина відповідальність

Клас повинен мати лише одну причину для зміни. Інакше кажучи, він має виконувати одну роботу.

Візьмімо клас Car. Усе, що стосується автомобілів, має місце в ньому, а функціональність, яка не має стосунку до автомобілів, повинна жити деінде.

Спершу версія, що порушує правило:

package SingleResponsibilityWithViolation;
// Violates SRP: This class has multiple responsibilities
// (salary calculation & report generation)
class Employee {
    private String name;
    private double salary;    public Employee(String name, double salary) {
        this.name = name;
        this.salary = salary;
    }    public double calculateSalary() {
        return salary * 1.2; // Example salary calculation
    }    public void generateReport() {
        System.out.println("Generating employee report...");
    }
}public class SRPViolation {
    public static void main(String[] args) {
        Employee emp = new Employee("John", 50000);
        System.out.println("Salary: " + emp.calculateSalary());
        emp.generateReport();
    }
}

Тут клас Employee жонглює трьома обовʼязками: зберігає дані працівника, рахує зарплату й формує звіти. Це дає йому три причини для зміни. Нова формула зарплати, новий макет звіту чи зміна полів працівника — усе це змушує правити той самий клас. З часом клас розбухає, і торкатися його стає ризиковано: правка у звітах може непомітно зламати логіку зарплати.

Тепер чистіша версія:

package SingleResponsibilityWithoutViolation;
// Now, each class has a single responsibility// Responsible only for holding employee details
class Employee {
    private String name;
    private double salary;    public Employee(String name, double salary) {
        this.name = name;
        this.salary = salary;
    }    public String getName() {
        return name;
    }    public double getSalary() {
        return salary;
    }
}// Responsible only for salary calculation
class SalaryCalculator {
    public double calculateSalary(Employee employee) {
        return employee.getSalary() * 1.2;
    }
}// Responsible only for report generation
class ReportGenerator {
    public void generateReport(Employee employee) {
        System.out.println("Generating employee report for " + employee.getName() + "...");
    }
}public class SRPExample {
    public static void main(String[] args) {
        Employee emp = new Employee("John", 10000);
        SalaryCalculator calculator = new SalaryCalculator();
        ReportGenerator reportGenerator = new ReportGenerator();        reportGenerator.generateReport(emp);
        System.out.println("Salary: " + calculator.calculateSalary(emp));
    }
}

Тепер кожен клас має одну причину для зміни. Employee лише зберігає дані, SalaryCalculator лише рахує оплату, а ReportGenerator лише будує звіти. Якщо завтра зміниться формула зарплати, буде зачеплено лише SalaryCalculator, і ніщо інше не опиниться під загрозою. Кожен клас до того ж набагато простіше тестувати окремо.

O: відкритість/закритість

Програмні сутності мають бути відкриті для розширення, але закриті для модифікації. На практиці нова поведінка має зʼявлятися без правки вже робочого коду. Кожна правка стабільного коду ризикує зламати те, що працювало, тому нові можливості мають приходити через розширення наявного.

Порушення:

package OpenClosedWithViolation;
// Violates OCP: Every time a new shape is added,
// we need to modify the existing class.
class AreaCalculator {
    public double calculateArea(String shape, double radius, double length, double breadth) {
        if (shape.equals("circle")) {
            return Math.PI * radius * radius;
        } else if (shape.equals("rectangle")) {
            return length * breadth;
        }
        return 0;
    }
}public class OCPViolation {
    public static void main(String[] args) {
        AreaCalculator calculator = new AreaCalculator();
        System.out.println("Circle Area: " + calculator.calculateArea("circle", 5, 0, 0));
    }
}

Щоб підтримати ще одну фігуру, чи то трикутник, квадрат або шестикутник, комусь доведеться повернутися до AreaCalculator і додати ще одну гілку else-if. Клас ніколи не буває завершеним. Сигнатура методу теж незручна: radius, length і breadth треба передавати щоразу, навіть коли більшість із них не потрібні, тому у виклику й стоять ці нулі. Кожна нова фігура робить метод довшим, важчим для читання й тестування.

Виправлення:

package OpenClosedWithoutViolation;
// Now, new shapes can be added without modifying the existing code
interface Shape {
    double calculateArea();
}class Circle implements Shape {
    private double radius;    public Circle(double radius) {
        this.radius = radius;
    }    public double calculateArea() {
        return Math.PI * radius * radius;
    }
}class Rectangle implements Shape {
    private double length, breadth;    public Rectangle(double length, double breadth) {
        this.length = length;
        this.breadth = breadth;
    }    public double calculateArea() {
        return length * breadth;
    }
}class AreaCalculator {
    public double calculateArea(Shape shape) {
        return shape.calculateArea();
    }
}public class OCPExample {
    public static void main(String[] args) {
        Shape circle = new Circle(5);
        Shape rectangle = new Rectangle(4, 6);
        AreaCalculator calculator = new AreaCalculator();        System.out.println("Circle Area: " + calculator.calculateArea(circle));
        System.out.println("Rectangle Area: " + calculator.calculateArea(rectangle));
    }
}

Тепер кожна фігура сама знає, як обчислити свою площу, а AreaCalculator байдуже, яка фігура йому дісталася. Він просто викликає calculateArea(). Щоб додати Triangle, досить написати новий клас, що реалізує Shape, без змін в AreaCalculator, Circle чи Rectangle. Дизайн відкритий для розширення, бо нові фігури можна додавати вільно, і закритий для модифікації, бо наявні класи лишаються як є.

L: підстановка Лісков

Обʼєкти суперкласу мають замінюватися обʼєктами підкласу без порушення коректності програми.

Цей принцип спирається на успадкування. Батьківський клас має кілька дочірніх, що успадковують його можливості. Принцип каже, що скрізь, де код очікує батьківський тип, будь-який дочірній тип має коректно працювати на його місці.

Приклад порушення:

package LiskovSubstitutionWithViolation;
// Violates LSP: Penguin cannot fly, but it inherits a fly() method.
class Bird {
    public void fly() {
        System.out.println("Flying...");
    }
}class Sparrow extends Bird {
    @Override
    public void fly() {
        System.out.println("Sparrow can fly");
    }
}class Penguin extends Bird {
    @Override
    public void fly() {
        throw new UnsupportedOperationException("Penguins can't fly!");
    }
}public class LSPViolation {
    public static void main(String[] args) {
        Bird penguin = new Penguin();
        Bird sparrow = new Sparrow();
        sparrow.fly();
        penguin.fly(); // This will break at runtime
    }
}

Базовий клас Bird припускає, що всі птахи літають, тому fly() міститься просто в ньому. Пінгвіни літати не вміють, і Penguin доводиться перевизначати fly() лише для того, щоб кинути виняток. Будь-який код, що приймає Bird і викликає fly(), як тут main(), падає під час виконання, отримавши Penguin. Підклас більше не може безпечно підміняти батька, а саме це LSP і забороняє. Корінь проблеми — погано спроєктоване успадкування: політ вбудували в базовий клас так, ніби його вміє будь-який птах.

Виправлений дизайн:

package LiskovSubstitutionWithoutViolation;
abstract class Bird {
    abstract void makeSound();
}interface FlyingBird {
    void fly();
}class Sparrow extends Bird implements FlyingBird {
    public void makeSound() {
        System.out.println("Chirp Chirp!");
    }    public void fly() {
        System.out.println("Sparrow is flying...");
    }
}class Penguin extends Bird {
    public void makeSound() {
        System.out.println("Penguin sound...");
    }
}public class LSPExample {
    public static void main(String[] args) {
        Bird sparrow = new Sparrow();
        Bird penguin = new Penguin();        sparrow.makeSound();
        penguin.makeSound();        FlyingBird flyingBird = new Sparrow();
        flyingBird.fly();  // Works without breaking substitution
    }
}

Політ винесено з базового класу в окремий інтерфейс FlyingBird. Bird тепер гарантує лише те, що справді вміє кожен птах, тобто видавати звук, а види, що літають, підключаються, реалізуючи FlyingBird. Penguin лишається повноцінним Bird, придатним скрізь, де очікується Bird, без падіння, що чатує під час виконання. Тепер дизайн відповідає реальності, а не бореться з нею.

I: розділення інтерфейсів

Клієнти не повинні залежати від інтерфейсів, яких вони не використовують. Принцип надає перевагу невеликим і конкретнішим інтерфейсам перед одним великим універсальним.

Приклад проблеми:

package InterfaceSegregationWithViolation;
// Violates ISP: Robot is forced to implement eat()
// even though it doesn't need it.
interface Worker {
    void work();
    void eat();
}class Human implements Worker {
    public void work() {
        System.out.println("Human working...");
    }    public void eat() {
        System.out.println("Human eating...");
    }
}class Robot implements Worker {
    public void work() {
        System.out.println("Robot working...");
    }    public void eat() {
        throw new UnsupportedOperationException("Robots do not eat!");
    }
}public class ISPViolation {
    public static void main(String[] args) {
        Human human = new Human();
        human.work();
        human.eat();        Robot robot = new Robot();
        robot.work();
        robot.eat(); // Causes error
    }
}

Інтерфейс Worker пакує дві непоʼязані здатності, працювати й їсти, в один контракт. Робот може працювати, але не може їсти, і все ж йому доводиться надавати eat(). Кинути виняток — єдиний вихід, і через це клас хибно повідомляє про свої можливості. Виклик eat() на чомусь із типом Worker може впасти, якщо обʼєкт виявиться Robot. Для деяких реалізацій цей інтерфейс вимагає забагато.

Розділена версія:

package InterfaceSegregationWithoutViolation;
interface Worker {
    void work();
}interface Eatable {
    void eat();
}class Human implements Worker, Eatable {
    public void work() {
        System.out.println("Developer is coding...");
    }    public void eat() {
        System.out.println("Developer is eating...");
    }
}class Robot implements Worker {
    public void work() {
        System.out.println("Robot is working...");
    }
}public class ISPExample {
    public static void main(String[] args) {
        Human human = new Human();
        human.work();
        human.eat();        Robot robot = new Robot();
        robot.work();
    }
}

Товстий інтерфейс перетворився на два сфокусовані, Worker і Eatable. Кожен клас реалізує лише те, що справді вміє: Human реалізує обидва, а Robot лише Worker. Не лишається ні непідтримуваних методів, ні винятків, ні оманливих контрактів. Кожен клас чесно відображає свої здатності.

D: інверсія залежностей

Модулі верхнього рівня не повинні залежати від модулів нижнього рівня. І ті, й інші мають залежати від абстракцій, а не від конкретних реалізацій.

Ось версія, яка це порушує:

package DependencyInversionWithViolation;
class WiredKeyboard {
    public void connect() {
        System.out.println("Wired keyboard connected");
    }
}// Violates DIP: Computer is tightly coupled to WiredKeyboard.
class Computer {
    private WiredKeyboard keyboard = new WiredKeyboard(); // Tight coupling    public void start() {
        keyboard.connect();
    }
}public class DIPViolation {
    public static void main(String[] args) {
        Computer pc = new Computer();
        pc.start();
    }
}

У цій схемі Computer перебуває на верхньому рівні, а WiredKeyboard — низькорівнева деталь. Оскільки Computer сам створює WiredKeyboard і тримає його в полі, він назавжди привʼязаний до цього типу клавіатури. Підтримка бездротової клавіатури вимагала б правки самого Computer. Модульне тестування теж майже неможливе: ні фейкову, ні mock-клавіатуру не підставити, коли залежність створюється всередині, а не передається ззовні.

А ось версія, що йому слідує:

package DependencyInversionWithoutViolation;
interface Keyboard {
    void connect();
}class WiredKeyboard implements Keyboard {
    public void connect() {
        System.out.println("Wired keyboard connected");
    }
}class WirelessKeyboard implements Keyboard {
    public void connect() {
        System.out.println("Wireless keyboard connected");
    }
}class Computer {
    private Keyboard keyboard;    public Computer(Keyboard keyboard) {
        this.keyboard = keyboard;
    }    public void start() {
        keyboard.connect();
    }
}public class DIPExample {
    public static void main(String[] args) {
        Keyboard wired = new WiredKeyboard();
        Computer pc1 = new Computer(wired);
        pc1.start();        Keyboard wireless = new WirelessKeyboard();
        Computer pc2 = new Computer(wireless);
        pc2.start();
    }
}

Тепер Computer залежить від інтерфейсу Keyboard, тобто від абстракції, а не від конкретного класу клавіатури. Справжня клавіатура приходить ззовні через конструктор — цей прийом називається впровадженням залежностей. Дротову, бездротову чи будь-яку майбутню клавіатуру можна підключити, жодного разу не змінюючи Computer. Тестувати теж стає легко, бо можна впровадити mock-клавіатуру.

Усі пʼять з одного погляду

  • SRP: дайте кожному класу одну роботу й одну причину для зміни.
  • OCP: додавайте поведінку розширенням, не чіпаючи робочий код.
  • LSP: дочірній клас має працювати скрізь, де працює батьківський.
  • ISP: краще кілька тонких інтерфейсів, ніж один роздутий.
  • DIP: спирайтеся на абстракції, а не на конкретні класи.

Крізь усі пʼять прикладів проходить спільна нитка: абстракція й розділення. Майже кожне порушення зводилося до класу, який робив забагато або був намертво привʼязаний до конкретного типу, а майже кожне виправлення розділяло обовʼязки й писало код проти інтерфейсу.

Уже впевнене володіння цими пʼятьма принципами робить код помітно чистішим і простішим у підтримці, а ще це добра підготовка до співбесід з LLD. Патерни проєктування — природна наступна тема.