Когда я только знакомился с принципами 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 слабо связан с базой данных, он зависит от абстракции в виде интерфейса. Не важно, какая база данных будет использоваться и как она внутри работает (обрабатывает пользователя), написанный код меняться не будет.

Заключение

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

Комментарии (71)


  1. artromone Автор
    29.04.2024 11:03

    Обещал ссылку на "Эффективное использование C++. Скотт Майерс. Правило 32". Вот она: https://t.me/art_rom/84?comment=157


  1. Kerman
    29.04.2024 11:03
    +40

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

    1. Single responsibility. В вашем примере нарушается инкапсуляция класса непонятно за какими целями. Вообще вполне нормально может быть, что робот может и move и speak. Вполне возможно, что у него эти методы завязаны на одних и тех же данных внутри класса. Если слепо следовать S, то у класса вылезают кишки наружу, логика переносится в другие классы, плодятся контроллеры, сервисы и прочие странные мутанты. Я уж не говорю про DDD, где класс соответствует термину предметной области - тут прямой конфликт с принципом единственной ответственности.

    2. Вы приводите в качестве примера простое использование наследования. Никакого отношения к OCP это не имеет. OCP - это когда часть класс спроектирован так, что можно без труда поменять логику в унаследованном классе, не трогая базовый класс.

    3. LSP. А что, если подставить экземпляр студента вместо персоны, он не будет работать? Пример нарушения - это как раз когда наследуют прямоугольник от квадрата и удивляются, почему при задании одного размера его не видно. Конечно, квадрат - это частный случай прямоугольника.

    4. ISP. Интерфейс вообще-то строится для клиентского кода. Соответственно, интерфейс может дробиться только до уровня требований этого кода. Если он требует объект, который должен и говорить и двигаться, то разделять эти интерфейсы не нужно. В вашем примере вы рассматриваете неправильную проектировку самого класса, а не интерфейсов. Ну на этапе создания Robot уже должно дойти, что он не умеет летать, верно?

    5. Интерфейс ради интерфейса? Вот зачем? Внедрять лишнюю сущность, когда нет ещё уверенности, что будет другая база данных - это раздувание кода ради культа карго.


    1. artromone Автор
      29.04.2024 11:03

      Я понимаю, из-за простоты примеров могло показаться, что я рекомендую везде и всюду использовать SOLID. Разумеется, это не так. А примеры простые, чтобы объяснить, про что принципы. Речь не о том, когда их уместно применять, об этом дисклеймер в начале. Вы очень подробно написали про каждый принцип, это требует осмысления. Но цель статьи, ещё раз - совсем новичков с SOLID познакомить.


      1. sshikov
        29.04.2024 11:03
        +12

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

         Но цель статьи, ещё раз - совсем новичков с SOLID познакомить.

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

        И что самое смешное - понимание этого самого "зачем" как раз дает ответ на вопрос, нужно ли принципы эти пихать везде и всюду. Еще раз - смысл "зачем" нормально укладывается в один абзац. Попробуйте найти ответ сами.


        1. Vcoderlab
          29.04.2024 11:03
          +1

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

          Хоть я и не автор, к которому вы обращались, но мне стало интересно. Поэтому хочу уточнить, это ли вы имели в виду (ссылка)?

          In software engineering, SOLID is a mnemonic acronym for five design principles intended to make object-oriented designs more understandable, flexible, and maintainable.

          К слову, в википедии на русском (ссылка) это тоже есть:

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


          1. sshikov
            29.04.2024 11:03

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

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

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


      1. yrub
        29.04.2024 11:03
        +1

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


        1. nronnie
          29.04.2024 11:03

          Ну а как тогда карму на хабре зарабатывать - про гипсовые камни писать? :)


    1. MonkAlex
      29.04.2024 11:03
      +10

      Интерфейс вообще-то строится для клиентского кода

      Почему? Можно делать интерфейс "что требуется моему апи", как контракт входной. И тогда интерфейс может просто быть, без реализаций. И таки могут быть интерфейсы как в статье - чисто "IMove", когда нужно выполнить логику движения условно. Это тоже нормально.

      А если надо, можете собрать из 2-3 интерфейсов свой предметный - IRobot : IMove, ISpeak например.

      Интерфейс ради интерфейса? Вот зачем? Внедрять лишнюю сущность, когда нет ещё уверенности, что будет другая база данных - это раздувание кода ради культа карго.

      Как минимум для тестирования удобнее. Да, раздувание, но удобно.


      1. Kerman
        29.04.2024 11:03
        +8

        Потому, что интерфейсом пользуются. Если им не пользуются, то "you ain't gonna need it". Тебе не нужен интерфейс без реализации, потому что им пользоваться не будут.

        Я ничего не имею против интерфейсов, я понимаю зачем нужны композитные интерфейсы и чем это лучше попыток наплодить базовых классов в сложной иерархии. Интерфейсы это не только модно-молодёжно, но и действительно классно.

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

        Как с любым инструментом, интерфейсы хороши там, где им место. Я не берусь утверждать, где именно, но что-то мне подсказывает, что там, где клиентскому коду приходится работать с разными классами. То есть, у интерфейса должно быть минимум две реализации.

        В случае с тестами оказывается, что мок класс - тоже класс. Внезапное такое откровение. И он станет второй реализацией.


    1. nronnie
      29.04.2024 11:03

      Я бы вообще не рекомендовал фанатично следовать этим принципам

      Лучше уж фанатично следовать, чем не следовать вообще :)


    1. KReal
      29.04.2024 11:03
      +3

      Интерфейс ради интерфейса? Вот зачем? Внедрять лишнюю сущность, когда нет ещё уверенности, что будет другая база данных - это раздувание кода ради культа карго.

      Тут как раз есть ответ - для тестов.


      1. nronnie
        29.04.2024 11:03
        +4

        Бывает (реже) что и не для тестов. Например, интерфейс может разрабатываться раньше реализации (contract first) чтобы его клиента и реализацию можно было разрабатывать параллельно (разными разработчиками, а то и командами). Еще, например, интерфейс и его реализацию можно разместить в разных проектах - тогда клиент API становится зависим только от проекта с интерфейсом и проект с реализацией можно обновлять независимо. Но, вот, что реально выбешивает, это когда начинают прицеплять интерфейсы к каким-нибудь DTO и прочим "anemic models" - но это уж, как говорится, "научили дурака богу молиться".


      1. passiboom1991
        29.04.2024 11:03

        а как без этого?


    1. JustMoose
      29.04.2024 11:03
      +1

      В вашем примере нарушается инкапсуляция класса

      А можно подробностей? Что-то я посмотрел на пример из статьи и не увидел там нарушения инкапсуляции :(


    1. petejones83
      29.04.2024 11:03
      +4

      Single responsibility он про reason to change, в данном случае не ходить и говорить, а про то, как он ходит и говорит. Условно, если в классе Robot мы формируем команды, то способ их передачи конкретным железкам должен быть вынесен в отдельный класс.

      Тут хороший пример SRP в секции про Dependency Inversion: UserService не знает, в какие конкретно колонки кладутся отдельные поля, может, там вообще документная база, или просто в текстовый файл пишется.


    1. VaVM
      29.04.2024 11:03
      +3

      Конечно, квадрат - это частный случай прямоугольника

      это классический пример антипаттерна Rectangle-Square. Его также часто называют антипаттерном Ellipse-Circle.

      Оба ответа автора неверные: ни класс Rectangle нельзя делать наследником Square, ни класс Square нельзя делать наследником Rectangle.

      Принцип подстановки Лисков (Liskov Substitution Principle) это уточнëнная формулировка принципа "is a" (является), обычно рассматриваемого вместе с принципом "has a" (содержит).

      Если объект типа A всегда является по поведению s(a) объектом типа F, с поведением s(f), то A можно наследовать от F. При этом имеется ввиду абстракция поведения.

      Например, яблоко типа Apple всегда является ПО ПОВЕДЕНИЮ фруктом Fruit.

      Но ни прямоугольник типа Rectangle не является по поведению (абстракции поведения) квадратом Square, ни наоборот. Ошибка в том, что путают а) классы и объекты; б) абстракцию поведения (относится к классу и наследуется) и состояние объекта (значение полей данных, не имеет отношения к наследованию). Прямоугольник может находиться в СОСТОЯНИИ квадрата - но это не имеет никакого отношения к наследованию классов.


      1. Ogra
        29.04.2024 11:03

        Square вполне себе является Rectangle, и если его сделать иммутабельным (ну хотя бы не давать менять ему размер), то можно и LSP соблюсти.


      1. Kerman
        29.04.2024 11:03
        +3

        Проблема всех этих синтетических примеров, когда собака наследуюется от животного в том, что не даётся полного контекста, как эти классы будут использоваться в будущем. Пока нет контекста, как используется квадрат и прямоугольник, всё, что мы предполагаем о правильности или неправильности - гадание на кофейной гуще.

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

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


      1. sophist
        29.04.2024 11:03

        Ошибка в том, что подменяют понятия. В [верном] утверждении "квадрат есть частный случай прямоугольника" речь идет об иммутабельных объектах, а в примерах нарушения LSP — уже об имеющих состояние.


      1. nronnie
        29.04.2024 11:03

        Тут, на самом деле, нет ни антипаттерна, ни парадокса, просто в утверждении "квадрат есть прямоугольник" слово "есть" в разных контекстах может иметь разное значение. С точки зрения математики - да, по определению "квадрат это (есть) прямоугольник все стороны которого одинаковы". С точки зрения какого-то конкретного приложения - может быть да, а может быть уже и нет, потому что контекст уже другой.


      1. Hitriy_Zhuk
        29.04.2024 11:03

        "Оба ответа автора неверные: ни класс Rectangle нельзя делать наследником Square, ни класс Square нельзя делать наследником Rectangle."

        А почему?
        Квадрат это прямоугольник с равными сторонами.
        И в принципе можно наследовать от такового.


        1. xxxDef
          29.04.2024 11:03

          если квадрат - это частный случай прямоугольника - то можно использовать класс прямоугольника напрямую и не надо ничего наследовать. А если это фигура у которой задается только один размер (свойство "длина стороны A") то никак это поведение из класса прямоугольника не сделать (где уже есть два свойства "длина А и длина Б")


          1. nronnie
            29.04.2024 11:03

            то никак это поведение из класса прямоугольника не сделать (где уже есть два свойства "длина А и длина Б")

            У квадрата есть тоже и "ширина", и "высота". Просто они всегда одинаковы (инвариант класса).


            1. Mausglov
              29.04.2024 11:03

              этот самый инвариант и мешает сделать квадрат производным классом прямоугольника, а прямоугольник - производным классом квадрата


          1. Mausglov
            29.04.2024 11:03

            del


        1. MrGrig192
          29.04.2024 11:03

          Потому что нет контекста. Если мы будем добавлять еще сущности типа ромб. Ромб можно наследовать от прямоугольника или от квадрата? Квадрат это частный случай ромба, или квадрат от ромба можно наследовать? Можете сказать нет, ведь ромб может совсем не содержать прямой угол. Но почему мы берем за основу именно прямой угол, а не равность сторон? Если мы хотим отображать фигуры в приложении. То должен быть базовый класс четырех угольник. От него уже можно наследовать квадраты, прямоугольники, ромбы, параллелограммы и тд и тп. Наследовать их друг от друга это делать их зависимыми от частных случаев


  1. TerraV
    29.04.2024 11:03
    +7

    С годами начал испытывать отвращение к красивым акронимам. В большинстве случаев "красота" акронима ущемляет смысловое наполнение. В частности какой нахрен "Liskov Substitution Principle", вы ваще о чем? Надо было что-то ткнуть на "L"?


    1. JustMoose
      29.04.2024 11:03

      Принцип подстановки Лисков (англ. Liskov Substitution Principle, LSP) — принцип организации подтипов в объектно-ориентированном программировании, предложенный Барбарой Лисков в 1987 году.
      SOLID (сокр. от англ. single responsibility, open–closed, Liskov substitution, interface segregation и dependency inversion) в программировании — мнемонический акроним, введённый Майклом Фэзерсом (Michael Feathers) для первых пяти принципов, названных Робертом Мартином[1][2] в начале 2000-х.

      (c) wiki

      Видимо всё-таки сначала придумали сокращение LSP, и только потом его воткнули в SOLID. Не наоборот.


      1. TerraV
        29.04.2024 11:03
        +5

        С чего вы взяли что я утверждаю будто LSP появился после SOLID? Я утверждаю, что если бы этот акроним шел бы к примеру из испанского "SOLIDO" то там был бы еще один супер-важный термин начинающийся на "O", а если бы на немецком "SOLIDE", то был бы супер-важный термин начинающийся на "E". А если бы в английском языке SOLID не было б "L" (предположим любая другая буква типа SOXID), то и LSP ваще бы никто не знал. Классический пример "хвост виляет собакой".


        1. Shatun
          29.04.2024 11:03
          +4

          Я утверждаю, что если бы этот акроним шел бы к примеру из испанского "SOLIDO" то там был бы еще один супер-важный термин начинающийся на "O", а если бы на немецком "SOLIDE", то был бы супер-важный термин начинающийся на "E". А если бы в английском языке SOLID не было б "L" (предположим любая другая буква типа SOXID), то и LSP ваще бы никто не знал. Классический пример "хвост виляет собакой".

          Конкретно c SOLID это неверно(хотя справедливо для многих других аббервиаутр). Сами принципы были сформулированы в 2000г Мартином, а позже, в 2004, Фэзерс увидел что эти буквы составляют аббревиатуру.


        1. JustMoose
          29.04.2024 11:03

          Просто я не умею читать мысли. А из вашего первоначального сообщения можно было предположить, что LSP назвали именно так только потому, что нужно было что-то придумать на букву L в акрониме SOLID.


  1. jimquery
    29.04.2024 11:03

    Но разве это верно в случае LSP?
    Код должен работать, а как он будет это делать - зависит от конкретной реализации в наследнике.
    Иначе, для чего тогда по вашему, нужен полиморфизм?


    1. nronnie
      29.04.2024 11:03
      +1

      Полиморфизм - это возможность использовать разные реализации в одном и том же клиентском коде который их использует без его изменения. Если, например, класс на входной параметр "null" выводит сообщение: "Ололо, вот он null", а его наследник роняет все приложение, то это плохой полиморфизм, потому что как раз нарушается LSP (а с т.з. "контрактного программирования", которое с LSP сильно связано, нарушается правило "не усиливать предусловия").


      1. jimquery
        29.04.2024 11:03

        "не усиливать предусловие" - Вы имеете ввиду - реализовать в наследниках логику, соблюдая контракт?
        Не могу сказать, что хорошо разбираюсь, но отталкиваюсь от здравого смысла (автор привёл семантическую ошибку как нарушение LSP) и посмотрел ещё пару статей про SOLID на Хабре и авторы указывают, что переопределение общих методов в наследниках возможно.


        1. nronnie
          29.04.2024 11:03
          +2

          "не усиливать предусловие" - Вы имеете ввиду - реализовать в наследниках логику, соблюдая контракт?

          Да, именно (в основном) об этом LSP и есть. "Контракт" это не только сигнатуры методов (которые и так в наследнике будут такие же) но и некоторые правила их поведения.


          1. jimquery
            29.04.2024 11:03

            Спасибо, но всё же пока не нашёл ответа на свой вопрос.
            В статье Принципы SOLID на примерах приводятся классы Account, SalaryAccount, DepositAccount в разделе про LSP и каждый повторно реализует общую логику с учётом своих особенностей.
            В статье Принципы SOLID в картинках автор явно пишет

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

            В статье Принципы SOLID, о которых должен знать каждый разработчик аналогично первым двум.
            Это первые три результата из тех, что выдал поиск.


            1. sshikov
              29.04.2024 11:03
              +3

              Спасибо, но всё же пока не нашёл ответа на свой вопрос.

              А вы сформулируйте вопрос четче. Пока не очень понятно, что вам непонятно.

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

              К сожалению, типы данных, включая сигнатуры методов, в существующих языках, недостаточно мощные, чтобы полностью описать контракт. Поэтому часть контракта описывается неформально, и не может быть проверена компилятором. Там где контракт сводится к сигнатуре функции, где функции чистые, то есть скажем в функциональных языках, LSP не имеет особого смысла - сигнатура совпадает - значит функцию можно использовать вот тут вместо другой. И это может проверить компилятор.

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


              1. jimquery
                29.04.2024 11:03

                Прошу прощения. Пример автора утверждает, что переопределение общего метода в классе-наследнике противоречит принципу LSP. И мой вопрос был, действительно ли это так.
                Спасибо за Ваш развёрнутый ответ.


                1. dyadyaSerezha
                  29.04.2024 11:03

                  Если виртуальный метод не объявлен как private или final, то он может быть переопределен (в смысле override), не нарушая никаких принципов. Это разве не очевидно?


              1. dyadyaSerezha
                29.04.2024 11:03

                1) тесты могут проверять и документ кровать собой пред- и пост-условия контракта

                2) вроде как в новом С++ будут добавлять это прямо в язык. Кажись)


      1. plFlok
        29.04.2024 11:03

        а его наследник роняет все приложение,

        О, как же я ненавижу NotImplementedException


        1. nronnie
          29.04.2024 11:03

          Это норм, но на время разработки: "Просто еще не реализовали". Если реализовывать и не собираются, то правильно кидать NotSupportedException - в общем-то, и это не совсем хорошо, и совсем хорошо перепроектировать интерфейсы чтобы такого метода в классе вообще не было, но это далеко не всегда рационально, а то и вообще возможно. Пришлось бы, например, для System.IO.Stream лепить отдельные интерфейсы типа "IReadableStream", "IWritableStream", "ISeekableStream", и еще выдумывать какой-то API чтобы с ними по отдельности работать и т.п. И, в принципе, если в базовом классе (Stream) прописано "при таких-то условиях метод сей мечет NotSupportedException", то нарушения контрактов, так-то, и нет. Или, вот, еще у GoF в разделе про паттерн "Composite" есть обсуждение на подобную тему: как быть с свойством Children для листового узла - и, в общем-то, приходят к тому, что идеального решения тут придумать не получается.


          1. Free_ze
            29.04.2024 11:03

            Чем плохо "лепить отдельные интерфейсы"? Ошибки компиляции всегда лучше исключений.


            1. DistortNeo
              29.04.2024 11:03
              +1

              Ну вот смотрите: у Stream могут быть интерфейсы: IReadableStream, IWritableStream, ISeekableStream, IBufferedStream, IAsyncReadableStream. IAsyncWritableStream, которые могут быть по-разному скомбинированы. А ешё это может быть какой-нибудь NetworkStream со своими интерфейсами.

              Ну да, можно обмазаться дженериками и писать что-то типа:

              void MyMethod<TStream>(TStream stream) where TStream : IReadableStream, IAsyncReadableStream, IBufferedStream, ...

              Но это реально уродство получается, ещё и ограничениями, потому что нельзя перегружать функции с одниаковыми сигнатурами, но разными трайтами. Куда проще просто один if внутри функции воткнуть.

              Короче, баланс разумного должен быть.


              1. Free_ze
                29.04.2024 11:03

                у Stream могут быть интерфейсы: ..., которые могут быть по-разному скомбинированы.

                Справедливости ради, там только три вариации, на которые бросается NotSupportedException: CanRead, CanWrite, CanSeek. То есть интерфейсов будет здесь всего три: IReadable, IWritable, ISeekable.

                реально уродство

                Вы не видите проблемы с огромным Stream с мешаниной функционала, который еше и опционален, так почему просто список интерфейсов вас огорчает?

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

                Технически это можно решить сделав общий интерфейс, объединяющий необходимое (interface IGodStream: IReadable, IWritable, ISeekable {}) и оно будет работать так, как ожидается - недостаток функциональности будет алармить на этапе компиляции.

                Другое дело - какой практический смысл иметь перегрузки для совершенно разного функционала? Разве такие методы могут иметь одинаковое имя?

                Куда проще просто один if внутри функции воткнуть.

                Не проще, т.к. поддержка механизации этой вариативности ложится на наследников, да и на ровном месте рождается абстрактный класс с этими if внутри. Далеко ходить не надо, можно оценить, как это устроено в дотнете.

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


          1. DirectoriX
            29.04.2024 11:03

            Вот, например, stdin вполне себе stream, но умеет только read. А stdout - только write. А TCP сокет может read+write, но всё равно не seek. То есть, если вам в аргумент функции write_hello(IStream writer) положили некий неизвестный stream - вы явно должны проверить его, потому что он может не уметь write.

            С другой стороны, в Rust сделано именно так, как вам не нравится: Stdin - только Read, TcpStream - Read+Write, File - Read+Write+Seek, и вроде даже это "API чтобы с ними по отдельности работать" проблем не вызывает, наоборот понятно кто что умеет, безо всяких if(stream.can_write())


            1. nronnie
              29.04.2024 11:03

              Ну а как туда вписать что-то что "только Write", или "только Read + Seek", только "Write + Seek"? И как еще и сделать все это действительно полиморфным, чтобы код которому, например, нужен только "Read" мог (без изменений) работать и с сокетом и с файлом?

              По сути, нам надо будет заводить семь отдельных интерфейсов (три базовых и четыре комбинированных) и в случае, к примеру, файлов еще и по отдельному методу создания потока на каждый интерфейс (e.g. OpenForRead(), OpenForWrite(), OpenForReadWriteAndSeek(), etc). Может оно как-то с совсем уж академической точки зрения и правильно, но с практической выглядит как-то так не так.


            1. plFlok
              29.04.2024 11:03
              +1

              положили некий неизвестный stream, [...] он может не уметь write

              Так должны класть как раз известный - "IWritableStream", грубо говоря.

              Интерфейсы - история про контракты. А тут подкладывают явно то, что контракт нарушает, и это почему-то геморой принимающей стороны?


        1. dyadyaSerezha
          29.04.2024 11:03

          А если просто сделать пустой метод, то можно забыть его написать.


  1. Yura_PST
    29.04.2024 11:03
    +4

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


  1. nronnie
    29.04.2024 11:03
    +1

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

    Говнокод плохой код:

    public abstract class Character
    {
        protected Character() {}
    
        public abstract void SayHello();
    }
    
    public class Wizard: Character
    {
        public override void SayHello() => Console.WriteLine("Hello, I'm the Wizard!");
    }
    
    public class Knight: Character
    {
        public override void SayHello() => Console.WriteLine("F*** you, I'm the Knight!");
    }
    

    Хороший код:

    public abstract class Character
    {
        protected Character() {}
    
        public abstract void SayHello(TextWriter output);
    }
    
    public sealed class Wizard: Character
    {
        public override void SayHello(TextWriter output) => output.WriteLine("Hello, I'm the Wizard!");
    }
    
    public sealed class Knight: Character
    {
        public override void SayHello(TextWriter output) => output.WriteLine("F*** you, I'm the Knight!");
    }
    


  1. kozlov_de
    29.04.2024 11:03
    +1

    Single Responsibility Principle

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

    Еще чтобы было проще прочитать и понять. Переключение контекста проблемы в мозгу очень затратная операция. А еще задачи можно разделит между разными программистами.

    Open Closed Principle 

    Опять же смотри полиморфизм

    Принцип подстановки Барбары Лисков

    почти что = определение подтипов

    Полиморфизм подтипов для этого и вводят. Как тут подсказывают, LSP = полиморфизм типов + правило что подтип не должен "усиливать предусловия"

    Но есть и другие виды полиморфизма, не менн полезные:

    Interface Segregation Principle

    это практически определение интерфейса: чтобы уменьшить зависимость между классами выдели зависимость в интерфейс. YAGNI в чистом виде

    Dependency Inversion Principle

    Тут все сложно. И то что определение не поместилось в одном предложении намекает на это. Для борьбы с зависимостями можно использовать абстракции. Но есть еще следующие методы:

    • DI (зависимости внедрены внешним кодом и класс их помнит всю свою жизнь при инжекции в конструктор или только в контексте вызова при инжекции в метод)

    • IoC (зависимости знает внешний код, а класс их вообще не знает)

    • Factory method (для управления зависимостями создается специальный класс, который больше ничего не умеет)

    • Контекстная зависимость (Contextual Dependency): Модуль может определить зависимость, которая будет разрешена в контексте выполнения. Например, модуль может определить интерфейс зависимости, а внешний код может предоставить конкретную реализацию этой зависимости в контексте выполнения. Модуль будет использовать эту зависимость в своей работе, но не будет знать о конкретной реализации.

    Но и это еще не все про DIP

    Пресловутую проблему "You wanted a banana but what you got was a gorilla holding the banana and the entire jungle" можно обойти тем что банану не нужно знать кто его владелец. Т.е. если код следует предметной области, то DIP не нужен.

    Вотъ(С)


    1. nronnie
      29.04.2024 11:03

      DI считается частным случаем IoC. Даже картинку где-то встречал на эту тему :)


  1. zubrbonasus
    29.04.2024 11:03
    +4

    Неверная трактовка:

    Single Responsibility Principle

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

    Верная трактовка:

    The single-responsibility principle (SRP) is a computer programming principle that states that "A module should be responsible to one, and only one, actor."


  1. af7
    29.04.2024 11:03

    По моему, автор молодец! Он добился своей цели. Написал короткую статью про очень важную и полезную вещь. Пусть его изложение и далеко от идеала, но помогает начинающим в этой теме. А хабр, и всё сообщество неравнодушных комментаторов, в данном случае, продолжает тему в виде дискуссий и покрывает многие пробелы, сглаживает шероховатости. Читатель имеет возможность посмотреть на ошибки и их обсуждения, как те что были описаны в самой статье, так и те, о которых говорится в комментариях. Особенно приятно видеть философские примечания о целесообразности некоторых принцыпов в разных ситуациях!


    1. RichardMerlock
      29.04.2024 11:03
      +6

      Так вот почему через раз я стал читать статьи с комментов.... Комменты становятся интереснее статьи! Это уровень комментариев подрос?


      1. IvanPetrof
        29.04.2024 11:03

        Пора хабру генерировать первый автокоммент по статье, при помощи gpt ))


      1. Ogra
        29.04.2024 11:03
        +7

        Комменты становятся интереснее статьи! 

        Always has been


    1. TsarS
      29.04.2024 11:03
      +9

      Я не припомню ни одной статьи на Хабре про SOLID, комментарии к которой не начинались бы с "Вы неверно понимаете принцип .... На самом деле это ....")


      1. zaiats_2k
        29.04.2024 11:03
        +6

        Ещё обязательно должен быть комментарий "если столько людей понимают неверно, то от него больше вреда, чем пользы".


  1. sepulkary
    29.04.2024 11:03
    +10

    Я когда-то пытался сформулировать эти принципы своими словами, может быть, кому-то это покажется полезным:

    SRP

    Single-responsibility principle, принцип единственной ответственности. Предполагает проектирование классов, имеющих только одну причину для изменения, позволяет вести проектирование в направлении, противоположном созданию «Божественных объектов». Класс должен отвечать за удовлетворение запросов только одной группы лиц.

    OCP

    Open–closed principle, принцип открытости/закрытости. Классы должны быть закрыты от изменения (чтобы код, опирающийся на эти классы, не нуждался в обновлении), но открыты для расширения (классу можно добавить новое поведение). Вкратце — хочешь изменить поведение класса — не трогай старый код (не считая рефакторинга, т. е. изменение программы без изменения внешнего поведения), добавь новый. Если расширение требований ведет к значительным изменениям в существующем коде, значит, были допущены архитектурные ошибки.

    LSP

    Liskov substitution principle, принцип подстановки Барбары Лисков: поведение наследующих классов должно быть ожидаемым для кода, использующего переменную базового класса. Или, другими словами, подкласс не должен требовать от вызывающего кода больше, чем базовый класс, и не должен предоставлять вызывающему коду меньше, чем базовый класс.

    ISP

    Interface segregation principle, принцип разделения интерфейса. Клиент интерфейса не должен зависеть от неиспользуемых методов. В соответствии с принципом ISP рекомендуется создавать минималистичные интерфейсы, содержащие минимальное количество специфичных методов. Если пользователь интерфейса не пользуется каким-либо методом интерфейса, то лучше создать новый интерфейс, без этого метода.

    DIP

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

    Сам Роберт Мартин считал SRP самым трудным для понимания и уточнял, что в первую очередь под "единой ответственностью" имеется в виду ответственность группы лиц, т. е. класс не должен закрывать потребности, например, бухгалтерии и транспортного отдела.


    1. megahertz
      29.04.2024 11:03
      +1

      У вас получилось самое точное изложение. Лаконично и понятно.


  1. dexie
    29.04.2024 11:03
    +5

    Я очень люблю задавать вопрос про SOLID на собеседованиях, не потому что жду четкого и верного ответа, а потому что мне интересно как человек понимает принципы. Даже по комментариям здесь видно что сколько людей - столько трактовок и мнений :) А из трактовки и понимания уже легко понять опыт человека, и проблемы, с которыми он сталкивался.


    1. DSRussell
      29.04.2024 11:03
      +1

      А я люблю отвечать что не знаю что это когда спрашивают :)


      1. dexie
        29.04.2024 11:03
        +2

        Мы вам перезвоним


  1. GoodGod
    29.04.2024 11:03
    +1

    Если вам кажется, что вы понимаете принципы SOLID, значит вы не понимаете принципы SOLID.

    Hidden text

    Если вам кажется, что вы понимаете квантовую теорию, значит вы не понимаете квантовую теорию.

    Ричард Фейман.


    1. MountainGoat
      29.04.2024 11:03

      Никто не знает столько, сколько не знаю я.


  1. vanya6194
    29.04.2024 11:03

    Блин, на самом деле в первом примере проще было бы тогда сделать класс с каким нибудь более абстрактным названием NCP (например) и в конструкторе прокинуть нужный указатель на Move и Speaker чем в каждом классе писать руками делегирующий вызов.


  1. freeg0r
    29.04.2024 11:03

    Принципы справедливы почти для любого современного ЯП.

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


    1. sshikov
      29.04.2024 11:03

      Строго говоря, они не только для ООП. Ну т.е. понятно, что если в языке нет наследования, и объектов, некоторые вещи в нем обязательно будут сформулированы иначе. Но общую идею вполне можно выделить. Ну вот давайте глянем на LSP. LSP вообще говоря про типы. А типы это совсем не обязательно ООП, это все что угодно, в функциональном языке они тоже есть.