Главная / Статьи / Принципы 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. Паттерны проектирования — естественная следующая тема.