Pull to refresh

Принципы SOLID, только понятно

Level of difficultyEasy
Reading time6 min
Views52K

Когда я только знакомился с принципами SOLID, я искал понятные статьи на Хабр. При этом пришлось прочитать не одну статью, и полное понимание пришло сильно позже. Хотелось бы, чтобы новички на более простых примерах смогли почувствовать, о чем эти принципы:

Что такое SOLID и зачем оно надо?

При написании кода программисту следует руководствоваться определенными правилами. Часто эти правила написаны если не кровью, то слезами разработчиков, которые потом стараются исправить ваш код. Если это вообще возможно :)

Принципы S.O.L.I.D. — это 5 принципов, которые желательно принять во внимание программисту. В этой серии постов мы рассмотрим их один за другим. Принципы справедливы почти для любого современного ЯП.

Single Responsibility Principle — принцип единственной ответственности
Open Closed Principle — принцип открытости-закрытости
Liskov Substitution Principle — принцип подстановки Барбары Лисков
Interface Segregation Principle — принцип разделения интерфейса
Dependency Inversion Principle — принцип инверсии зависимостей

Максимально кратко про каждый принцип
Максимально кратко про каждый принцип

Дисклеймер

🔴 Disclaimer — полное понимание, как написать код в той или иной ситуации, приходит только с опытом. Примеры упрощенные и призваны познакомить с концепциями принципов.

Single Responsibility Principle — принцип единственной ответственности

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

Пример нарушения принципа:

struct Robot {
    void move() { /*Метод для передвижения*/ }
    void speak() { /*Метод: сказать фразу*/ }
};

Что плохо?:

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

  • Если будем добавлять методы (например, метод для полета робота), то изменение повлияет на весь класс. Пример с роботом — простой. В более сложных структурах изменение будет сделать крайне тяжело. А мы могли изменить только ту часть, которая отвечает за отдельную зону ответственности робота (в данном случае, полет — еще один вариант движения).

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

Исправленный код:

struct Movement { 
    void move() { /*Сложная логика движения*/ }
};
struct Speaker {
    void speak() { /*Сложная логика произнесения фразы*/ }
};

class Robot {
public:
    void move() { /*Простое использование movement*/ }
    void speak() { /*Простое использование speaker*/ }
private:
    Movement movement; // Логика передвижения
    Speaker speaker; // Логика произнесения фразы
};

Open Closed Principle — принцип открытости-закрытости

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

Пример нарушения принципа:

Допустим, есть игровой персонаж — рыцарь

struct Character {
    void displayInfo() { std::cout << "Я Рыцарь"; }
};

Мы добавляем возможность играть еще и за волшебника

struct Character {
    void displayInfo(const std::string& type) {
        if (type == "Knight") std::cout << "Я Рыцарь";
        if (type == "Wizard") std::cout << "Я Маг";
    }
};

Что плохо?:

  • При добавлении персонажа приходится добавлять все больше условий. При наличии 1000 персонажей это будет работать медленно.

  • Класс начинает обладать функционалом, который, может, и не нужен. У мага может быть набор заклинаний, у рыцаря его нет.

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

  • Существующий код уже написан, оттестирован, одобрен. Его изменение может вызвать много лишних проблем. Лучше не трогать то, что уже работает.

Исправленный код:

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

struct Character { virtual void displayInfo() = 0; };

struct Knight : public Character {
    void displayInfo() override { 
        std::cout << "Я Рыцарь"; 
    }
};
struct Wizard : public Character {
    void displayInfo() override { 
        std::cout << "Я Маг";
    }
};
// Любые другие персонажи

int main() {
    Character* character = new Knight();
    character->displayInfo(); // Я Рыцарь
    delete character;

    character = new Wizard();
    character->displayInfo(); // Я Маг
    delete character;

    return 0;
}

Liskov Substitution Principle — принцип подстановки Барбары Лисков

[Base] -> [Derived]

Если в коде программы Базовый класс заменить на его Наследника, то программа должна работать, так как в Наследнике есть все операции, которые были в Базовом. В Базовый класс нужно выносить только общую логику, которую наследники будут реализовывать. Наследников создаем только тогда, когда они правильно собираются реализовать логику Базового класса без проблем.

Пусть есть человек, который умеет только есть и спать

struct Person {
    virtual void eat() { std::cout << "есть\n"; }
    virtual void sleep() { std::cout << "спать\n"; }
  };

И есть студент, который наследуется от Person. Он, помимо есть и спать, может еще учиться

struct Student : public Person {
    // То, что умеет человек, а также...
    void learn() { std::cout << "матмод... тяжело...\n"; }
};

И он умеет делать все то, что умеет человек. Потому что студент — это человек, кто бы что не говорил.

Пример нарушения всех мыслимых и немыслимых норм мог бы быть таким:

struct Student : public Person {
    // ...
    void sleep() override { std::cout << "я люблю слушать музыку в колонках.\n"; }
    void learn() { std::cout << "матмод... тяжело...\n"; }
};

Тут студента уже нельзя назвать человеком. Потому что он не делает все то же, что и человек, плюс что-то дополнительно. Ну и ведет себя как не будем говорить кто.

Наследник ведь расширяет интерфейс. Значит, только добавляет дополнительный функционал (говорили про это в Open-Closed). Значит, он не должен ломать то, что написано раньше.

💀Интересный момент: что в данном случае является Базовым классом, а что Наследником?

  • Квадрат

  • Прямоугольник

Hidden text

Ответ: кажется, что, так как из математики, квадрат — это прямоугольник, то Базовый класс — прямоугольник, а квадрат — его особенная версия. Но на деле все сложнее, тут однозначного ответа нет. Хотите подробнее — читайте Эффективное использование C++. Скотт Майерс. Правило 32. В комментах ссылка на файл.

Interface Segregation Principle — принцип разделения интерфейса

Клиенты не должны зависеть от интерфейсов, которые они не используют. Большие интерфейсы следует разбивать на интерфейсы поменьше. Так клиенты смогут использовать только те интерфейсы, которые им нужны. Это делает менее связанный код, уменьшает зависимости между элементами системы, упрощает изменения в коде.

Пример будет на Java, потому что в Java синтаксически есть интерфейс, в C++ это немного по-другому работает

Пример нарушения принципа:

interface Robot {
    void move();
    void speak();
    void fly();
}

Что плохо?:

В этом примере, если какой-нибудь DroneRobot использует метод fly(), но не использует методы move() и speak(), он все равно должен реализовывать интерфейс, который включает эти методы. Это приводит к тому, что класс DroneRobot зависит от интерфейсов, которые ему не нужны.

Исправленный код:

interface Movable { void move(); }
interface Speakable { void speak(); }
interface Flyable { void fly(); }

// Реализуем только необходимые интерфейсы
class Robot implements Movable, Speakable {
    @Override
    public void move() { /*Сложная логика движения*/ }
    @Override
    public void speak() { /*Сложная логика произнесения фразы*/ }
}

// Реализуем только необходимые интерфейсы
class Drone implements Flyable {
    @Override
    public void fly() { /*Сложная логика полета*/ }
}

Dependency Inversion Principle — принцип инверсии зависимостей

Модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций. Абстракции не должны зависеть от реализации. Реализация должна зависеть от абстракции.

Пример нарушения принципа:

struct Database {
    void saveData(User user) { /*Сохранение данных в БД*/ }
};

class UserService {
private:
    Database database;
public:
    void addUser(User user) {
        database.saveData(user);
        // Дополнительная логика
    }
};

Что плохо?:

UserService зависит от конкретного класса Database, что делает его менее гибким и более зависимым от изменений в Database. Если пользователя нужно будет еще записывать в файл, отправлять на другой сервис в определенном виде, то придется переписывать и изменять много кода. Модуль верхнего уровня тесно связан с модулем нижнего уровня, а нужно, чтобы оба они зависели от абстракции.

Исправленный код:

Используем абстракцию (интерфейс) для БД:

class IDatabase {
public:
    virtual void saveData(User user) = 0;
};

class Database : public IDatabase {
public:
    void saveData(User user) override { /*Сохранение данных в БД*/ }
};

class UserService {
private:
    IDatabase& database;
public:
    UserService(IDatabase& db): database(db) {}
    void addUser(User user) {
        database.saveData(user);
        // Дополнительная логика
    }
};

Здесь UserService слабо связан с базой данных, он зависит от абстракции в виде интерфейса. Не важно, какая база данных будет использоваться и как она внутри работает (обрабатывает пользователя), написанный код меняться не будет.

Заключение

Вот и все. Рад, если вам понравилось и было полезно. Приглашаю к дискуссии в комментариях, если есть какие-то вопросы/предложения/критика.

Tags:
Hubs:
+56
Comments94

Articles