1

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

Если у кого-то при прочтении возникнут уточнения/критика/предложения по улучшению, то прошу писать в коментарии или отдельным ответом. Ну, или же, напрямую править мой ответ.

Моя основная цель вопроса/ответа именно в том, что бы написать максимально доступном для людей любого уровня:

  • С максимально простыми и короткими примерами. (всегда всем влом разбираться в куче чужого кода)
  • С минимальным количеством терминологии. Максимально простым языком.

Ясно что статей по теме - море. Но простых для понимания и с короткими листингами кода МАЛО.

Лично я не нашел ни одной.

  • 1
    Вы бы ещё больше тему взяли для одного конкретного поста, чтобы побольше полотнище было. И, понимаете, so - не документация, пока у кого-то конкретного вопроса не возникнет - лучше и не писать книгу в ответ. У вас есть конкретный вопрос по какому-то конкретному принципу? Нету, уже разобрались - ну и не стоит тогда писать реферат как я провёл лето тьфу как я понял принципы solid. Есть - тогда описывайте конкретный вопрос, чем конкретнее - тем лучше. Полотна же... уф... Сейчас в вашем вопросе нет никакого вопроса - его надо закрыть. – A K Oct 31 '18 at 15:29
  • @AK причиной написания вопроса/ответа лежало отсутствие максимально короткого и понятного обьяснения принципов SOLID. Все что я нашел было сложным для понимания или же имело слишком много текста, а так же имело большие полотнища кода. Все что здесь написано - значительно короче тех источников, с которых я брал информацию и без излишней терминологии, которая не редко усложняет понимание новичкам. – Andrew Stop_RU_war_in_UA Oct 31 '18 at 15:31
  • Мне кажется, вы не очень хорошо представляете, что такое so и как его готовить. Причиной написания вопроса должен служить вопрос. Слишком широкие (too abroad) вопросы не приветствуются и закрываются. Вопроса у вас нет. Огромное полотнище текста читать очень сложно. Когда придёт человек с конкретным вопросом по какому-то одному принципу (а таких конкретных непоняток на каждый принцип могут быть десятки разных у разных людей) - нужно дать конкретный ответ, а не отправлять к yet another описанию. – A K Oct 31 '18 at 15:33
  • @AK будет просто прекрасно, если ты укажешь мне на конкретные ошибки понимания конкретного принципа из перечисленных. – Andrew Stop_RU_war_in_UA Oct 31 '18 at 15:35
  • Вы предлагаете перенести весь ответ в вопрос и приписать "правильно ли понимаю", чтобы получить десятки ответов? Ужасный подход: чрезмерно общий вопрос. Я предлагаю закрыть вопрос как чрезмерно общий. Заминусовать ответ и вопрос и подождать пока он не будет удалён системой. Я не сомневаюсь, что вы хотите сделать мир лучше, просто вот именно так -- не очень хороший способ. – A K Oct 31 '18 at 15:37
  • @AK этот ответ на мой же вопрос формулировался на основе десятка статей, которые я отобрал и сложил воедино. Не говоря уже о нескольких десятках, которые я просто отсеял как слишком сложные. Я не понимаю твоего желания заминусить сложную тему, которая написана доступным языком и с реально короткими и простыми для понимания примерами. Тем более, что на собеседованиях знание этой темы задается одним вопросом. – Andrew Stop_RU_war_in_UA Oct 31 '18 at 15:48
  • Вы не хотите написать статью на каком-нибудь профильном ресурсе, например, хабрахабр? Мне кажется, это будет более полезно. – A K Oct 31 '18 at 16:37
  • @AK Единственный "профильный" ресурс, на котором я сижу, это SO. И его же предпочитаю использовать как универсальный справочник, ибо в других местах, практически, всегда слишком много воды, слишком сложно, слишком много лишней информации, или же, написано настолько фанатично подробно, что обьяснение становится непонятным (как, например, оф.документация майкрсофта по шарпу, где понятный ответ с SO в 3 строки превращается в 4 листа а4 мелким текстом и кучей кода, который сложно разобрать что бы применить на практике). – Andrew Stop_RU_war_in_UA Oct 31 '18 at 16:46
  • Посмотрите, пожалуйста, на свой ответ: в нём не три строки, а четыре листа а4 мелким шрифтом и кучей кода, которую сложно разобрать. Давайте может оставим stackoverflow тем stackoverflow который мы любим: конкретные вопросы и краткие ответы по существу, м? Впрочем, я так понимаю, вы фанатик, поэтому вряд ли готовы прислушаться к другому мнению. – A K Oct 31 '18 at 16:49
  • @AK Вопрос, хоть, и общий, но задается на собеседованиях одним вопросом. В моем ответе хоть и не 3 строки, но каждый из пунктов описан здесь более коротко, чем в любой другой статье, правда? А еще - для максимально широкой аудитории. И с более коротким листингами в примерах. Я понимаю и принимаю твою точку зрения в даной ситуации. И, даже, ее считаю правильной. Просто бывают ситуации когда люди с противоположными точками зрения могут оказаться оба правы ( https://s00.yaplakal.com/pics/pics_original/0/4/6/11424640.jpg ). Думаю, что это одна из таких ситуаций. – Andrew Stop_RU_war_in_UA Oct 31 '18 at 17:14
  • Неверно, прочитал с удовольствием, отличный материал, спасибо @Andrew – NewView Oct 31 '18 at 17:45
  • 1
    как по мне нужно задать отдельный вопрос(если он отсутствует иначе ответить в существующем) по каждому принципу – Bald Nov 01 '18 at 06:04

1 Answers1

4

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

Следствие применения принципов:

  • Больше кода
  • Но зато этот код будет легче дорабатывать и тестировать(если правильно применить)

Есть еще принцип YAGNI - You Aint Gonna Need It (Тебе это не нужно). Идея в том, что если у тебя на данную секунду нету обьективных причин применять какой-то принцип или паттерн здесь, то его применять не нужно вообще. На примере SOLID: если тебе не нужно дорабатывать и развивать код в будущем(проэкт на 1 вечер, например) -- пиши код проще и без применения SOLID. Попытка создать гибкое решение на ранних стадиях разработки очень часто является созданием переусложненного решения.

Вернемся к нашим овцам:

SOLID - это аббревиатура:

  • SRP : Single responsibility principle - "У класса должна быть только одна причина для изменения"
  • OCP : Open/Closed principle - Программные сущности(классы, модули, функции и т.п.) должны быть открытыми для расширения, но закрытыми для модификации.
  • LSP : Liskov substitution principle - возможность вместо базового типа подставить любой его подтип.
  • ISP : Interface segregation principle - Принцип разделения интерфейсов
  • DIP : Dependency inversion principle - Инверсия зависимостей (Зависеть нужно от абстракций)

Постараемся выяснить и понять их на примерах языка c#. Я буду идти НЕ ПО ПОРЯДКУ.




Single responsibility. - "У класса должна быть только одна причина для изменения". Звучит очень расплывчасто, да?

PS: "Принцип единой обязанности/ответственности" совсем звучит неинтуитивно, поэтому советую так не запоминать.

Причины для изменения логики работы класса:

  • Изменение отношений между классами
  • Введение новых требований или отмена старых.

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

Возьмем за пример Bird. Есть птицы, которые умеют летать, а есть те, которые НЕ летают. Будет странным, если мы создадим экземпляр Страуса или Киви, которые смогут летать. То есть вместо класса

public class Bird
{
    public void Fly() { }
}

У нас должны быть 3 класса:

public class Bird
{
     public void Jump() { }
}

public class BirdFlying: Bird
{
     public void Fly() { }
}

public class BirdNonFlying: Bird
{
    public void Run() {}
}

Нюансы применения:

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



Interface segregation - это предоставление каждому клиенту минимального (в идеале - отдельного!) интерфейса.

Рассмотрим код:

interface IMachine
{
    public bool print(List<Item> item);
    public bool staple(List<Item> item);
    public bool fax(List<Item> item);
    public bool scan(List<Item> item);
    public bool photoCopy(List<Item> item);
}  

class Machine : IMachine
{
   public bool print(List<Item> item){} //Печатает все документы

   public bool staple(List<Item> item){} //Скрепляет степлером

   public bool fax(List<Item> item) {} //Отправляет факсом документы

   public bool scan(List<Item> item) {} //Сканирует документы

   public bool photoCopy(List<Item> item) //Делает копии документов
} 

Проблемы кода:

  1. Перекомпилирование всего кода, даже, при маленьком изменении.
  2. Клиент получит доступ ко многим "левым" методам, даже, если он заинтересован только в печати(например)

По принципу ISP нам нужно разделить один интерфейс на несколько: IPrinter , IStaple , IFax , IScan , IPhotoCopy

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

interface IMachine : IPrinter, IFax, IScan, IPhotoCopy,IStaple
{
    public bool print(List<Item> item);
    public bool staple(List<Item> item);
    public bool fax(List<Item> item);
    public bool scan(List<Item> item);
    public bool photoCopy(List<Item> item);
}

А так же добавить конструктор класса в Machine

public  Machine(IPrinter printer, IFax fax, IScan scan, IPhotoCopy photoCopy, IStaple staple)
{
    this.Printer = printer;
    this.fax = fax;    
    this.scan = scan;
    this.photoCopy = photocopy;
    this.staple = staple;
}

Тогда создание такой универсальной машины будет таким:

var allOneClient  = new Machine(
                        new Printer(), 
                        new Fax(), 
                        new Scanner(), 
                        new PhotoCopy(), 
                        new Staple() );

Реализуя так, мы получаем следующие преимущества:

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



Open/Closed - Программные сущности должны быть открытыми для расширения, но закрытыми для модификации.

Что это значит?

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

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

Допустим, у нас есть класс для сортировки массива

public class BubbleSorter
{
    public void Sort(int[] data)
    {
        int n = data.Length;
        for (int i = 0; i < n - 1; i++)
            for (int j = 0; j < n - i - 1; j++)
                if (data[j] > data[j + 1])
                {
                    int temp = data[j];
                    data[j] = data[j + 1];
                    data[j + 1] = temp;
                }
    }
}

Этот метод умеет сортировать только целые числа.

Если наша программа уже сейчас нуждается в таком же методе но не только для целых чисел(или будет нуждатся в будущем наверняка) -- это и есть наша точка расширения.

Мы используем существующие в дотнете интерфейсы и перепишем немного код:

public class BubbleSorter<T>
{
    IComparer<T> _comparer;

    public BubbleSorter(IComparer<T> comparer)
    {
        _comparer = comparer;
    }

    public void Sort(T[] data)
    {
        int n = data.Length;
        for (int i = 0; i < n - 1; i++)
            for (int j = 0; j < n - i - 1; j++)
                if (_comparer.Compare(data[j], data[j + 1]) > 0)
                {
                    var temp = data[j];
                    data[j] = data[j + 1];
                    data[j + 1] = temp;
                }
    }
}

Этими изменениями мы добились многого, а именно:

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

Нюансы применения:

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

Второй пример применения принципа: Через наследование.

Есть у нас класс, который обрабатывает CSV (Comma Separated Value). То есть пишет таблицу в текстовый файл где кома - это разделитель ячеек в строке, а переход на новую строку это новая строка таблицы [вообще там все сложнее, но это для простоты понимания]

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

public class Csv
{ 
    //Some methods
}

public class CsvWithHeader: Csv
{
    //header realization
}

Этот кусок написан на основе ответа Что такое принцип открытости и закрытости?

Если интересно, там есть еще третий пример применения даного принципа через применение Декоратора.




Liskov substitution - идея реализации "ПРАВИЛЬНОГО" полиморфизма. То есть нужно реализовать наследование подтипов так, что бы была возможность вместо базового типа подставить любой его подтип.

Иногда это сложно сделать. Рассмотрим сложность реализации подобного на примере:

public interface IDuck
{
   void Swim();
   // contract says that IsSwimming should be true if Swim has been called.
   bool IsSwimming { get; }
}
public class OrganicDuck : IDuck
{
   public void Swim() { }
   bool IsSwimming { get{}}
}
public class ElectricDuck : IDuck
{
   bool _isSwimming;

   public void Swim()
   {
      if (!IsTurnedOn)
        return;

      _isSwimming = true;
      //swim logic
   }

   bool IsSwimming { get { return _isSwimming; } }
}

Допустим у нас есть некий метод, который должен что-то делать с утками. Не важно что. public void DoSomethingWithDuck(IDuck: duck) {}.

Здесь сложность применения принципа лежит в том, что электрическая утка может поплыть только в том случае, если она включена. То есть, если мы в этот метод засунем ВЫКЛЮЧЕННУЮ электрическую утку и вызовем duck.Swim(); то она не сможет поплыть. То есть в даном случае LSP соблюдается только при первом взгляде, но на практике этот принцип не соблюдается.

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

if (duck is ElectricDuck)
{
    ((ElectricDuck)duck).TurnOn();
}
duck.Swim();

Но это будет именно костылем.

Правильное решение при котором будет слюблюдаться принцип - изменить электрическую утку таким образом, что бы она включалась автоматически при вызове метода .Swim();.

Второй способ - не делать электрическую утку "родственником" органической вовсе.




Dependency inversion. Всюду этот принцип обьясняется как "Модули верхнего уровня не должны зависеть от модулей нижнего уровня. И те и другие должны зависеть от абстракций". Как по мне, то эту фразу понять довольно сложно. Что же это означает на практике?

Обратимся к автору принципа (Боб Мартин), и коротко он говорит "Зависеть нужно от абстракций, а не от чего-то конкретного". Применяя этот принцип, одни модули можно легко заменять другими, всего лишь меняя модуль зависимости, и тогда никакие перемены в низкоуровневом модуле не повлияют на высокоуровневый.

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

На примере это может выглядеть следующим образом:

public class MySQLConnection
{
   public bool Connect() 
   {
         //Коннектимся к MYSQL бд
   }
}

public class PasswordReminder
{ 
    private MySQLConnection _dbConnection;

    public void PasswordReminder (MySQLConnection dbConnection) 
    {
        _dbConnection = dbConnection;
    }
}

Класс PasswordReminder зависит от MySQLConnection. Но более высокуровневый PasswordReminder не должен зависеть от более низкуровневого модуля MySQLConnection.

Если нам нужно изменить подключение с MySQLConnection на MongoDBConnection, то придётся менять прописанное в коде внедрение конструктора в класс PasswordReminder.

Класс PasswordReminder должен зависеть от абстракций, а не от чего-то конкретного. Но как это сделать?

public interface IDbConnection
{
    public Connect();
}

public class DbConnection: IDbConnection
{
    public bool Connect()
    {
        //Коннектимся к MYSQL бд
    }
}

public class PasswordReminder
{
    private IDbConnection _dbConnection;

    public void PasswordReminder (IDbConnection dbConnection) 
    {
        _dbConnection = dbConnection;
    }
}

Как следствие этих изменений PasswordReminder() зависит только от абстракции. Мы можем приконектится к MongoDB вместо MYSQL изменением всего одного метода -- Connect() Или же создать MongoDBConnection и унаследовать от IDbConnection;

  • 7
    Начинание, конечно, хорошее, но вы бежите галопом по европам - это будет понятно для опытного прогера (которому, по сути, такие статьи не нужны), а для начинающего пользы от статьи 0, так как ничего не раскрыто (Interface segregation в 4 строчки? DI вообще начался за здравие, закончился за упокой). Имхо, подобных статей полно, но если вы хотите действительно нанести пользу читателю, то каждый принцип стоит рассмотреть отдельным вопросом и со всех сторон (и почитайте про DI, то что вы написали про него, вообще не в тему) – tym32167 Oct 31 '18 at 15:22
  • 3
    про DI советую отличную книжку от Mark Seemann. – tym32167 Oct 31 '18 at 15:23
  • 1
    @tym32167 если бы было бы полно, я сумел бы найти ее. На практике я выдирал куски из разных статей, и описывал их более простым языком. Увы, но толковой и простой для понимания информации в одном месте мною найдено не было. :) DI подправлю в ближайшее время, если найду более толковые примеры. Спасибо за критику :) – Andrew Stop_RU_war_in_UA Oct 31 '18 at 15:24
  • ну, например, раз, два, про DI вот есть – tym32167 Oct 31 '18 at 15:30
  • 1
    И я бы это, не советовал вам слепо верить книжкам Роберта Мартина. Он иногда пишет дичь (хз, может торопится или это от переводчиков услуга такая), думайте в первую очередь своей головой – tym32167 Oct 31 '18 at 15:37
  • 1
    "Interface segregation. Грубо говоря, это тот же принцип, что и Single responsibility." - совсем нет. Даже не близко. –  Oct 31 '18 at 16:24
  • 1
    @tym32167 а по поводу "начинающим пользы от статьи 0, а бывалым уже безполезно" я скажу так: всегда находятся люди, которые уже не начинающие, но и все еще не бывалые. И им тоже нужно разбираться как-то. А разбираться нужно начиная с чего-то более простого. А то ты поделил людей на 2 категории, как буд-то тех кому нужно знать, но еще не знают вообще не существует. :) – Andrew Stop_RU_war_in_UA Oct 31 '18 at 17:27
  • @PashaPash спасибо – Andrew Stop_RU_war_in_UA Oct 31 '18 at 17:28
  • я поделил на тех, по сути, кто понимает принципы и не понимает. – tym32167 Oct 31 '18 at 17:40
  • Тим, хорошо описанный и подобранный материал - всегда интересен, а понимающих и непонимающих в одном лице может быть два :) Джекил и Хайд например :) – NewView Oct 31 '18 at 17:49
  • И судя по реакции, материал затронул много читателей, что собственно и характеризует его как полезный. Личные мнения и восприятия не всегда совпадают с истинной картиной мира :) – NewView Oct 31 '18 at 17:52
  • 1
    @NewView я пока не вижу тут хорошо описанного и подробного материала, а местами автор просто не прав. Потому пожелаем атору прислушаться к советам, накатать действительно полезный материал и получить кучу плюсов :) – tym32167 Oct 31 '18 at 18:50
  • @PashaPash Допустим если взять статьи https://www.dotnetcurry.com/software-gardening/1257/interface-segregation-principle-isp-solid-principle и https://www.codeproject.com/Tips/766045/Interface-Segregation-Principle-ISP-of-SOLID-in-Cs , то они показывают ровно то, что я изначально написал про ISP. Как я вижу, ISP и SRP -- разбиение на логически обобщенные одиницы меньшего размера с одной большой и неудобной "универсальной вундервафли". Ты же говоришь, что они и близко не похожи. Обьясни различия между SRP и ISP т.к. я их не вижу и не понимаю, в таком случае. – Andrew Stop_RU_war_in_UA Oct 31 '18 at 19:22
  • 1
    ISP - это не "разбиение на меньшие вундервафли". Это предоставление каждому клиенту минимального (в идеале - отдельного!) интерфейса. Проблема даже не в том, что в ответе ISP был неправильно описан. Проблема в том, что в SRP описано какое-то смешение SRP и ISP. Пример - пишется проект, в котором есть летающие смертные птицы (и никаких других). Класс птиц - один, с методами Fly и Die. Но если при этом есть потребитель этого класса, цель которого только убивать птиц - он должен получить интерфейс IKillable { Die }. Просто для того, чтобы случайно не заставить птиц взлететь. –  Oct 31 '18 at 19:35
  • А не потому что "умирание - это отдельная ответственность" :) Суть ISP - клиент не должен зависеть от тех интерфейсов (от той части публичного интерфейса класса), которыми он не пользуется. "убиватель" не должен зависеть от IBird { Die, Fly }. –  Oct 31 '18 at 19:36
  • А по поводу сути srp - этот принцип не про разбиение по аспектам поведения, и не о дроблении на мелкие части. Он о причинах внесения изменений. стоит почитать https://blog.cleancoder.com/uncle-bob/2014/05/08/SingleReponsibilityPrinciple.html : However, as you think about this principle, remember that the reasons for change are people. It is people who request changes. And you don’t want to confuse those people, or yourself, by mixing together the code that many different people care about for different reasons –  Oct 31 '18 at 20:08
  • Пожалуйста, не пишите никогда "ньюанс", нет такого слова ("нюанс" не происходит от англ. "new"). Коробит каждый раз, если честно. – Андрей NOP Nov 01 '18 at 04:39
  • Как по мне ответ полезен, в плане освежить память. Всегда поддерживаю такие ответы, даже если их можно нагуглить при желании. – Gardes Nov 01 '18 at 20:20