Твердые принципы

Принципы SOLID — это концепции объектно-ориентированного проектирования, применимые к разработке программного обеспечения. Он разработан Робертом К. Мартином. SOLID расшифровывается как принцип единой ответственности, принцип открытости-закрытости, принцип замещения Лискова, принцип разделения интерфейса и принцип инверсии зависимостей.

SOLID — это системная стратегия проектирования, которая гарантирует, что ваша программа будет модульной, понятной и поддающейся рефакторингу. Следование принципам SOLID также экономит время и усилия как при разработке, так и при обслуживании. SOLID не дает вашему коду стать негибким и хрупким, позволяя создавать долговечное программное обеспечение.

Слово ТВЕРДЫЙ акроним для:

  • Принцип единой ответственности (SRP)
  • Принцип открытия-закрытия (OCP)
  • Принцип замещения Лисков (LSP)
  • Принцип разделения интерфейсов (ISP)
  • Принцип инверсии зависимостей (DIP)

1. Принцип единой ответственности

Каждому классу должно быть дано одно задание. Чтобы быть более конкретным, должна быть только одна причина для смены класса. Ниже приведен пример класса Java, который нарушает принцип единой ответственности (SRP):

public class Vehicle {
    public void printVehicleDetails() {}
    public double calculatePrice() {}
    public void saveDetails() {}
    public Vehicle getVehicleDetails(int vehicleId)
}

Вышеупомянутый класс Vehicle отвечает за три отдельные задачи: отчетность, вычисления и управление базой данных.

2. Принцип «открыто-закрыто»

Программные объекты (такие как классы, модули и функции) должны быть расширяемыми, но не модифицируемыми. В соответствии с принципом открытия-закрытия модуль должен быть открыт для расширения, но закрыт для модификации при появлении новых требований. Мы можем использовать расширение для предоставления дополнительных функций модулю.

Рассмотрим приведенный ниже метод класса VehicleCalculations:

public class VehicleCalculations {
    public double calculateAmount(Vehicle vehicle) {
        if (vehicle instanceof Car) {
            return vehicle.getPrice() * 0.3;
        if (vehicle instanceof Bike) {
            return vehicle.getPrice() * 0.4;
    }
}

Допустим, мы хотим создать новый подкласс с именем Van. Нам пришлось бы добавить еще одно выражение if к вышеупомянутому классу, что противоречит принципу открытости-закрытости.
Лучшим решением было бы переопределить метод calculateAmount для подклассов Car и Van:

public interface Vehicle {
    public double calculateAmount();
}
public class Car implements Vehicle {
    public double calculateAmount() {
        return this.getPrice() * 0.3;
}
public class Bike implements Vehicle{
    public double calculateAmount() {
        return this.getPrice() * 0.4;
}
public class Truck implements Vehicle{
    public double calculateAmount() {
        return this.getPrice() * 0.4;
}

3. Принцип подстановки Лискова

Барбара Лисков предложила принцип замещения Лисков (LSP). Это относится к наследованию в том смысле, что производные классы должны быть на 100% взаимозаменяемы со своими исходными классами. Иными словами, если класс A является подтипом класса B, мы должны иметь возможность заменить B на A, не влияя на поведение программы. Рассмотрим следующий пример базового класса Rectangle и класса, производного от Square:

public class Rectangle {
    private double height;
    private double width;
    public void setHeight(double height) { this.height = height; }
    public void setWidht(double width) { this.width = width; }
}
public class Square extends Rectangle {
   @Override
   public void setHeight(double height) {
        super.setHeight(height);
        super.setWidth(height);
    }
    @Override
    public void setWidth(double width) {
        super.setHeight(width);
        super.setWidth(width);
    }
}

Поскольку вы не можете заменить базовый класс Rectangle производным от него классом Square, приведенные выше классы не следуют принципу подстановки Лискова. Класс Square включает дополнительные требования, например, высота и ширина должны быть равны. В результате замена Square на Rectangle может привести к неожиданному поведению.

4. Принцип разделения интерфейсов

Клиенты не должны быть вынуждены полагаться на элементы интерфейса, которые они не используют, в соответствии с принципом разделения интерфейса (ISP). Иными словами, не заставляйте клиентов реализовывать интерфейс, который им не нужен.

Предположим, что есть интерфейс транспортного средства и класс Bike:

public interface Vehicle {
    public void drive();
    public void stop();
    public void openDoors();
    public void closeDoors();
}
public class Bike implements Vehicle {

    // Can be implemented
    public void drive() {...}
    public void stop() {...}
       
    // Can not be implemented
    public void openDoors() {...}
    public void closeDoors() {...}
}

Как видите, методы openDoors() и closeDoors() не имеют смысла для класса Bike, потому что у велосипеда нет дверей! Чтобы решить эту проблему, ISP предлагает разбить интерфейсы на несколько крошечных взаимосвязанных интерфейсов, чтобы не требовался класс для реализации каких-либо интерфейсов (и, следовательно, методов), которые ему не требуются.

5. Принцип обращения зависимостей

Мы должны использовать абстракцию (абстрактные классы и интерфейсы), а не реальные реализации, в соответствии с принципом инверсии зависимостей. Модули высокого уровня не должны зависеть от модулей низкого уровня, а должны зависеть от абстракции. Потому что абстракция не зависит от деталей, а детали не зависят от абстракции. Это отделяет программное обеспечение.

Рассмотрим следующий сценарий. Поскольку класс Car зависит от конкретного класса Engine, он не следует принципу инверсии зависимостей.

public class Car {
    private Engine engine;
    public Car(Engine engine) {
        this.engine = engine;
    }
    public void start() {
        this.engine.start();
    }
}
public class Engine {
   public void start() {...}
}

На данный момент приведенный выше код будет работать, но что, если мы захотим добавить другой тип двигателя, например дизельный? Для этого потребуется реорганизовать класс Car. Это невозможно сделать по принципу Open/Close.
Однако, создав слой абстракции, мы можем решить эту проблему. Давайте предоставим интерфейс Car вместо того, чтобы полагаться только на Engine:

public interface IEngine {
    public void start();
}

Теперь мы можем связать любой тип Engine с классом Car, который реализует интерфейс Engine:

public class Car {
    private EngineInterface engine;
    public Car(IEngine engine) {
        this.engine = engine;
    }
    public void start() {
        this.engine.start();
    }
}
public class PetrolEngine implements IEngine {
   public void start() {...}
}
public class DieselEngine implements IEngine {
   public void start() {...}
}

Практики

1. Модульное тестирование

Модульное тестирование — это подход к тестированию, при котором отдельные модули проверяются разработчиком на наличие ошибок. Это касается функциональной исправности независимых модулей.

Основная цель состоит в том, чтобы изолировать каждый компонент системы, чтобы обнаружить, оценить и исправить любые недостатки.

Модульное тестирование — преимущества:

  • Сокращает количество ошибок при изменении текущей функциональности или уменьшении количества дефектов во вновь созданных функциях.
  • Дефекты обнаруживаются на ранних этапах процесса тестирования, что снижает стоимость тестирования.
  • Улучшает переработку кода и улучшает дизайн.
  • Когда модульные тесты используются в сочетании с процессом сборки, качество сборки улучшается.

2. Качество кода

Компания стандартизировала и определила приоритетность набора качеств и стандартов качества кода. Ниже приведены основные характеристики, которые могут быть использованы для его определения.

  • Надежность. Он должен работать надежно, без частых сбоев.
  • Ясность. Должны соответствовать языковым нормам кодирования и именования. Любой, кто не является создателем кода, найдет его простым для чтения и понимания. Гораздо проще поддерживать и расширять код, если он прост для понимания. Это необходимо, чтобы люди, как и компьютеры, понимали его.
  • Удобство сопровождения. Хороший код не должен быть слишком сложным. Если кто-либо, работающий с кодом, хочет внести какие-либо изменения, он должен сначала понять весь контекст кода.
  • Хорошо задокументирован. В идеальном случае код не требует пояснений, хотя всегда рекомендуется добавлять комментарии к коду, поясняющие его назначение и функции. Это значительно облегчает понимание и поддержку тем, кто не участвовал в создании кода.
  • Проверено. Чем выше качество кода, тем меньше в нем недостатков. Тщательное тестирование отсеивает существенные недостатки, гарантируя, что программа работает так, как ожидалось.
  • Расширяемость:код должен быть расширяемым, чтобы его не приходилось отбрасывать каждые несколько недель при возникновении новых потребностей.
  • Эффективность. Для выполнения требуемой операции высококачественный код не потребляет ненужных ресурсов.
  • Безопасность: должна предотвращать уязвимости в коде.
  • Меньший технический долг.Команды с небольшим техническим долгом могут быстро развиваться и создавать новые функции, не увязая в низкокачественном коде, который не требует сопровождения.

Как обеспечить качество кода?

  • Следует избегать повторения кода.
  • Код должен быть читабельным.
  • Не изобретайте велосипед.
  • Комментирование и поддержание.
  • Полностью избегайте жесткого кодирования.
  • Придерживайтесь книги стилей и устанавливайте правила проверки кода.