понедельник, 5 марта 2012 г.

Доступно о SOLID

Не так давно, около месяца назад, я сделал небольшой доклад, посвященный SOLID, для своих коллег. Объяснить, что это такое оказалось не совсем тривиальным делом. Поэтому, я думаю, многим моя публикация окажется полезной. В этом посте я попытался собрать самые простые и понятные объяснения по каждому принципу.
Как известно, SOLID - это аббревиатура от пяти принципов проектирования, названия которых так удачно зашифровал в этом слове Роберт Мартин.
Собственно вот эти принципы:
1. Принцип единственности ответственности
2. Принцип открытости/закрытости
3. Принцип подстановки Лисков
4. Принцип разделения интерфейса
5. Принцип инверсии зависимостей

Обо всем по порядку.

Принцип единственности ответственности
Как показывает практика, самое сложное в этом принципе - запомнить его определение:
"Не должно быть более одной причины для изменения класса"

Нарушение этого принципа хорошо демонстрирует следующий пример:

  1. class Email
  2. {
  3.   public String Theme { get; set; }
  4.   public String From { get; set; }
  5.   public String To { get; set; }
  6. }
  7.  
  8. class EmailSender
  9. {
  10.   public void Send(Email email)
  11.   {
  12.     // ... sending...
  13.     Console.WriteLine("Email from '" + email.From + "' to '" + email.To + "' was send");
  14.   }
  15. }
  16.  
  17. class Program
  18. {
  19.   static void Main(string[] args)
  20.   {
  21.     Email e1 = new Email() { From = "Me", To = "Vasya", Theme = "Who are you?" };
  22.     Email e2 = new Email() { From = "Vasya", To = "Me", Theme = "vacuum cleaners!" };
  23.     Email e3 = new Email() { From = "Kolya", To = "Vasya", Theme = "No! Thanks!" };
  24.     Email e4 = new Email() { From = "Vasya", To = "Me", Theme = "washing machines!" };
  25.     Email e5 = new Email() { From = "Me", To = "Vasya", Theme = "Yes" };
  26.     Email e6 = new Email() { From = "Vasya", To = "Petya", Theme = "+2" };
  27.  
  28.     EmailSender es = new EmailSender();
  29.     es.Send(e1);
  30.     es.Send(e2);
  31.     es.Send(e3);
  32.     es.Send(e4);
  33.     es.Send(e5);
  34.     es.Send(e6);
  35.  
  36.     Console.ReadKey();
  37.   }
  38. }
* This source code was highlighted with Source Code Highlighter.
Обратите внимание на класс EmailSender. Кроме того, что при помощи метода Send, он отправляет сообщения, он еще и решает как будет вестись лог. В данном примере лог ведется через консоль.
Если случится так, что нам придется менять способ логирования, то мы будем вынуждены внести правки в класс EmailSender. Хотя, казалось бы, эти правки не касаются отправки сообщений. Очевидно, EmailSender выполняет несколько обязанностей и, чтобы класс не был привязан только к одному способу вести лог, нужно вынести выбор лога из этого класса.

  1. class EmailSender
  2. {
  3.   public EmailSender(ILog log)
  4.   {
  5.     _log = log;
  6.   }
  7.   public void Send(Email email)
  8.   {
  9.     // ... sending...
  10.     _log.Write("Email from '" + email.From + "' to '" + email.To + "' was send");
  11.   }
  12.  
  13.   private ILog _log;
  14. }
  15.  
  16. class Program
  17. {
  18.   static void Main(string[] args)
  19.   {
  20.     Email e1 = new Email() { From = "Me", To = "Vasya", Theme = "Who are you?" };
  21.     Email e2 = new Email() { From = "Vasya", To = "Me", Theme = "vacuum cleaners!" };
  22.     Email e3 = new Email() { From = "Kolya", To = "Vasya", Theme = "No! Thanks!" };
  23.     Email e4 = new Email() { From = "Vasya", To = "Me", Theme = "washing machines!" };
  24.     Email e5 = new Email() { From = "Me", To = "Vasya", Theme = "Yes" };
  25.     Email e6 = new Email() { From = "Vasya", To = "Petya", Theme = "+2" };
  26.  
  27.     EmailSender es = new EmailSender(new ConsoleLog());
  28.     es.Send(e1);
  29.     es.Send(e2);
  30.     es.Send(e3);
  31.     es.Send(e4);
  32.     es.Send(e5);
  33.     es.Send(e6);
  34.  
  35.     Console.ReadKey();
  36.   }
  37. }
  38.  
  39. public interface ILog
  40. {
  41.   void Write(String str);
  42. }
  43.  
  44. public class ConsoleLog: ILog
  45. {
  46.   public void Write(string str)
  47.   {
  48.     Console.WriteLine(str);
  49.   }
  50. }
* This source code was highlighted with Source Code Highlighter.
Теперь, чтобы сменить способ логирования, нужно отправить нужный экземпляр типа ILog в конструктор EmailSender.

Принцип открытости/закрытости
"Ваши классы должны быть отрытыми для расширения и закрыты для модификации"


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

  1. public class Report
  2. {
  3.   public uint Total;
  4.   public String Name;
  5.   public DateTime CreateDate;
  6.   public override string ToString()
  7.   {
  8.     return "Name: " + Name + "\t" +
  9.         "Created: " + CreateDate.ToLongDateString() + "\t" +
  10.         "Total: " + Total;
  11.   }
  12. }
  13.  
  14. class ReportProcessor
  15. {
  16.   List<Report> _reports;
  17.  
  18.   public ReportProcessor()
  19.   {
  20.     _reports = new List<Report>
  21.     {
  22.       new Report { Total = 100, Name = "Average", CreateDate = DateTime.Now },
  23.       new Report { Total = 120, Name = "Sum", CreateDate = DateTime.Now.AddDays(-1) },
  24.       new Report { Total = 130, Name = "Average", CreateDate = DateTime.Now.AddDays(-2) },
  25.       new Report { Total = 90, Name = "Average", CreateDate = DateTime.Now.AddDays(-3) },
  26.       new Report { Total = 100, Name = "Sum", CreateDate = DateTime.Now.AddDays(-4) },
  27.       new Report { Total = 110, Name = "Sum", CreateDate = DateTime.Now.AddDays(-2) },
  28.       new Report { Total = 170, Name = "Average", CreateDate = DateTime.Now.AddDays(-1) },
  29.       new Report { Total = 130, Name = "Sum", CreateDate = DateTime.Now.AddDays(-4) },
  30.       new Report { Total = 120, Name = "Average", CreateDate = DateTime.Now.AddDays(-3) },
  31.       new Report { Total = 180, Name = "Sum", CreateDate = DateTime.Now.AddDays(-7) }
  32.     };
  33.   }
  34.  
  35.   public List<Report> GetByName(String name)
  36.   {
  37.     List<Report> rs = new List<Report>();
  38.     foreach (Report r in _reports)
  39.       if (r.Name == name)
  40.         rs.Add(r);
  41.     return rs;
  42.   }
  43. }
  44.  
  45. class Program
  46. {
  47.   static void Main(string[] args)
  48.   {
  49.     ReportProcessor rp = new ReportProcessor();
  50.  
  51.     foreach (Report r in rp.GetByName("Sum"))
  52.       Console.WriteLine(r);
  53.  
  54.     Console.ReadKey();
  55.   }
  56. }
* This source code was highlighted with Source Code Highlighter.
Есть какой-то набор отчетов, которые класс ReportProcessor может предоставить. В данном случае программа выведет на экран отчеты с названием Sum. Как вы поступите, если вам нужно будет выбирать отчеты еще и по итоговому полю? Наверняка первая мысль, это добавить такой вот метод в класс ReportProcessor

  1. public List<Report> GetByTotal(uint total)
  2. {
  3.   List<Report> rs = new List<Report>();
  4.   foreach (Report r in _reports)
  5.     if (r.Total == total)
  6.       rs.Add(r);
  7.   return rs;
  8. }
* This source code was highlighted with Source Code Highlighter.
И так при каждом добавлении новых требований? Если требований по фильтрации будет очень много, то нам придется добавить очень много новых методов в этот класс.
Кажется, что все-таки ничего плохого в этом все равно нет. Но добавление новых методов в класс это всегда потенциальная опасность. Ведь если класс уже реализует какую-то функциональность, то его исправление может нанести вред уже достигнутому.
Конкретно для нашего примера, чтобы избежать этих потенциальных ошибок, очень удобно использовать шаблон Спецификация.

  1. class ReportProcessor
  2. {
  3.   /// ...
  4.  
  5.   public List<Report> GetReports(ISpecification spec)
  6.   {
  7.     List<Report> rs = new List<Report>();
  8.     foreach (Report r in _reports)
  9.       if (spec.IsSatisfied(r))
  10.         rs.Add(r);
  11.     return rs;
  12.   }
  13. }
  14.  
  15. class Program
  16. {
  17.   static void Main(string[] args)
  18.   {
  19.     ReportProcessor rp = new ReportProcessor();
  20.  
  21.     foreach (Report r in rp.GetReports(new NameSpecification("Sum")))
  22.       Console.WriteLine(r);
  23.  
  24.     Console.ReadKey();
  25.   }
  26. }
* This source code was highlighted with Source Code Highlighter.
Теперь при помощи комбинирования спецификаций фильтра, можно получить разный набор  отчетов. Если нужно будет фильтровать каким-то иным образом, то нужно будет создать тип спецификации и использовать его в вызывающем коде.
Код самих спецификаций конкретно для нашего примера выглядит следующим образом

  1. public interface ISpecification
  2. {
  3.   bool IsSatisfied(Report r);
  4. }
  5.  
  6. public abstract class CompositeSpecification: ISpecification
  7. {
  8.   public ISpecification And(ISpecification spec)
  9.   {
  10.     return new AndSpecification(this, spec);
  11.   }
  12.   public ISpecification Or(ISpecification spec)
  13.   {
  14.     return new OrSpecification(this, spec);
  15.   }
  16.   public ISpecification Not(ISpecification spec)
  17.   {
  18.     return new NotSpecification(this);
  19.   }
  20.  
  21.   public abstract bool IsSatisfied(Report r);
  22. }
  23.  
  24. public class AndSpecification: ISpecification
  25. {
  26.   private ISpecification _spec1;
  27.   private ISpecification _spec2;
  28.  
  29.   public AndSpecification(ISpecification spec1, ISpecification spec2)
  30.   {
  31.     _spec1 = spec1;
  32.     _spec2 = spec2;
  33.   }
  34.  
  35.   public bool IsSatisfied(Report r)
  36.   {
  37.     return _spec1.IsSatisfied(r) && _spec2.IsSatisfied(r);
  38.   }
  39. }
  40.  
  41. public class OrSpecification: ISpecification
  42. {
  43.   private ISpecification _spec1;
  44.   private ISpecification _spec2;
  45.  
  46.   public OrSpecification(ISpecification spec1, ISpecification spec2)
  47.   {
  48.     _spec1 = spec1;
  49.     _spec2 = spec2;
  50.   }
  51.  
  52.   public bool IsSatisfied(Report r)
  53.   {
  54.     return _spec1.IsSatisfied(r) || _spec2.IsSatisfied(r);
  55.   }
  56. }
  57.  
  58. public class NotSpecification: ISpecification
  59. {
  60.   private ISpecification _spec;
  61.  
  62.   public NotSpecification(ISpecification spec)
  63.   {
  64.     _spec = spec;
  65.   }
  66.  
  67.   public bool IsSatisfied(Report r)
  68.   {
  69.     return !_spec.IsSatisfied(r);
  70.   }
  71. }
  72.  
  73. public class NameSpecification: CompositeSpecification
  74. {
  75.   private String _name;
  76.  
  77.   public NameSpecification(String name)
  78.   {
  79.     _name = name;
  80.   }
  81.  
  82.   public override bool IsSatisfied(Report r)
  83.   {
  84.     return r.Name == _name;
  85.   }
  86. }
  87.  
  88. public class TotalGreaterSpecification : CompositeSpecification
  89. {
  90.   private double _total;
  91.  
  92.   public TotalGreaterSpecification(double total)
  93.   {
  94.     _total = total;
  95.   }
  96.  
  97.   public override bool IsSatisfied(Report r)
  98.   {
  99.     return r.Total > _total;
  100.   }
  101. }
* This source code was highlighted with Source Code Highlighter.
Выберем все отчеты Sum, в которых значение поля итого более 100.

  1. class Program
  2. {
  3.   static void Main(string[] args)
  4.   {
  5.     ReportProcessor rp = new ReportProcessor();
  6.  
  7.     foreach (var r in rp.GetReports(new NameSpecification("Sum")
  8.       .And(new TotalGreaterSpecification(100))))
  9.       Console.WriteLine(r);
  10.  
  11.     Console.ReadKey();
  12.   }
  13. }
* This source code was highlighted with Source Code Highlighter.
Таким образом, новые требования фильтрации не приводят к правкам в классе ReportProcessor.

Принцип замещения Лисков
Следовать этому принципу очень важно при проектировании новых типов с использованием наследования.
"Функции, которые используют ссылки на базовые классы, должны иметь возможность использовать объекты производных классов, не зная об этом"


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

  1. class Rectangle
  2. {
  3.   public virtual int Width { get; set; }
  4.   public virtual int Height { get; set; }
  5.   public int GetRectangleArea()
  6.   {
  7.     return Width * Height;
  8.   }
  9. }
  10.  
  11. class Square: Rectangle
  12. {
  13.   public override int Width
  14.   {
  15.     get { return base.Width; }
  16.     set
  17.     {
  18.       base.Height = value;
  19.       base.Width = value;
  20.     }
  21.   }
  22.   public override int Height
  23.   {
  24.     get { return base.Height; }
  25.     set
  26.     {
  27.       base.Height = value;
  28.       base.Width = value;
  29.     }
  30.   }
  31. }
* This source code was highlighted with Source Code Highlighter.
Есть класс прямоугольника и класс квадрата. Как подсказывает здравый смысл, класс квадрата просто наследуется от класса прямоугольника с той лишь разницей, что поддерживает ширину и высоту эквивалентными. Кажется все в порядке.

  1. class Program
  2. {
  3.   static void Main(string[] args)
  4.   {
  5.     Rectangle rect = new Square();
  6.     rect.Width = 3;
  7.     rect.Height = 10;
  8.  
  9.     Console.WriteLine(rect.GetRectangleArea());
  10.     Console.ReadKey();
  11.   }
  12. }
* This source code was highlighted with Source Code Highlighter.
Такой код как ни странно выведет в консоль 100 вместо 30. Ладно, здесь мы видим, что создан был все таки квадрат, а затем использован в качестве прямоугольника. А если использование объекта класса Rectangle будет совсем не в том месте, где объект был создан? Скажем, пропущенный через несколько вызовов нескольких классов?
Очевидно, что не стоило наследовать класс прямоугольника от класса квадрата. Выход из такой ситуации только один - отказаться от наследования. Варианта исправить ситуацию в целом два:
1. Rectange и Square будут совсем разными типами
2. Square агрегирует объект типа Rectangle.
Какой вариант выбрать, это на совести разработчика. Но ясно одно - с переопределением поведения базового типа нужно быть очень аккуратным. Иначе использование проектируемого типа в качестве базового принесет очень много бед.

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

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

  1. public interface ICustomList<T> : IEnumerable<T>
  2. {
  3.   void Insert(int index, T item);
  4.  
  5.   void RemoveAt(int index);
  6.  
  7.   T this[int index] { set; }
  8.  
  9.   void Add(T item);
  10.  
  11.   void Clear();
  12.  
  13.   bool Remove(T item);
  14.  
  15.   int IndexOf(T item);
  16.   
  17.   T this[int index] { get; }
  18.   
  19.   bool Contains(T item);
  20.   
  21.   int Count { get; }
  22.   
  23.   IEnumerator<T> GetEnumerator();
  24. }
* This source code was highlighted with Source Code Highlighter.
Вы хотите сделать какой-то класс реализующий данный интерфейс. Класс будет представлять из себя список с возможностью добавления, удаления элементов и т.д.
Однако, при использовании объектов этого типа вы можете столкнуться с проблемой. Любой объект, который получит такой список может "редактировать" его. В определенных сценариях это может оказаться нежелательным. Для того, чтобы предостеречь подобное использование списка, следует разделить интерфейс ICustomList на два: полный и только для чтения.

  1. public interface ICustomEnumerable<T>: IEnumerable<T>
  2. {
  3.   int IndexOf(T item);
  4.  
  5.   T this[int index] { get; }
  6.  
  7.   bool Contains(T item);
  8.  
  9.   int Count { get; }
  10.  
  11.   IEnumerator<T> GetEnumerator();
  12. }
  13.  
  14. public interface ICustomList<T> : ICustomEnumerable<T>
  15. {
  16.   void Insert(int index, T item);
  17.  
  18.   void RemoveAt(int index);
  19.  
  20.   T this[int index] { set; }
  21.  
  22.   void Add(T item);
  23.  
  24.   void Clear();
  25.  
  26.   bool Remove(T item);
  27. }
* This source code was highlighted with Source Code Highlighter.
Такой прием позволит ограничить использование списков. Если какому-либо классу нежелательно "редактировать" список, то вместо ICustomList следует передавать ICustomEnumerable.

Принцип инверсии зависимости
Этот принцип я описал в своем блоге ранее. Нет смысла повторяться. Чтобы ознакомиться с ним перейдите сюда.

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

PS По поводу Принципа подстановки Лисков недавно была очень интересная дискуссия. Ознакомиться с разными мнениями и позициями можно по следующим ссылкам
Блог Александра Бындю - Принцип замещения Лисков
Блог Александра Бындю - Дополнение к LSP
Блог Сергея Теплякова - Принцип замещения Лисков и контракты

14 комментариев:

  1. Класс. Доступно и понятно. И картинка шикарная по поводу открытости/закрытости :)

    ОтветитьУдалить
    Ответы
    1. Спасибо. Коллегам тоже эта картинка очень понравилась.

      Удалить
  2. Добрый день. Интересно написали, но ИМХО, пример с разделением интерфейсов неудачен. Обычно, когда говорят, про тонкие интерфейсы, то подразумевается, что один "толстый" разбивается на несколько тонких с меньшим coupling. В вашем примере - ICustomList наследует ICustomEnumerable. Т.е. пользователь интерфейса ICustomList автоматически имеет доступ к ICustomEnumerable. Разделение интерфейсов подразумевает разделение на одном уровне, т.е. увеличивая cohesion и уменьшая coupling.

    ОтветитьУдалить
    Ответы
    1. Добрый день!
      Да, разделение интерфейсов применяют для уменьшения связанности. И readonly интерфейсы я включаю туда же. Потому что их использование уменьшает связанность между типами.
      Для того, чтобы было более понятно использование этого принципа неподготовленному специалисту, я использовал пример с readonly интерфейсом как самый наглядный.

      Удалить
    2. Этот комментарий был удален автором.

      Удалить
  3. И еще Мурад - я не знаю, много ли вам пишут комментов - но такая капча, как у вас - надолго отбивает охоту писать в комменты хоть что-то...

    ОтветитьУдалить
    Ответы
    1. eugene, у меня стоит стандартная форма ввода комментария. Ок, я посмотрю, что можно с этим поделать.

      Удалить
  4. Классно, полезно, но жаль нет примеров на языке PHP, мало вынес из этой статьи, хотя мог бы и больше

    ОтветитьУдалить
  5. Хорошо написано. Спасибо.

    ОтветитьУдалить

  6. Такой код как ни странно выведет в консоль 100 вместо 30.(конец цитаты)
    Так и надо, чтобы вывело 100. Пример: программа может рисовать прямоугольники и квадраты. Нажал на кнопку на панели кнопок "Прямоугольник", появился прямоугольник, мышкой можно задать высоту и ширину. Нажал на кнопку "Квадрат", появился квадрат. Задал ширину 30. Высота автоматически стала 30. Задал высоту 100. Ширина автоматически стала 100.

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

    ОтветитьУдалить
    Ответы
    1. ИМХО. Написав нечто вроде "Rectangle rect = new Square();", и передав (для большей наглядности) переменную rect в метод, принимающий аргумент типа Rectangle, далее в этом методе мы работаем с прямоугольником. Мы не знаем, какая это фигура на самом деле, да нам и не надо, главное то, что ее можно трактовать как прямоугольник. А изменяя у прямоугольника ширину, мы совсем не ожидаем, что у него вдруг изменится еще и высота, или наоборот. В этом и проявляется странность.

      Удалить
  7. Этот комментарий был удален автором.

    ОтветитьУдалить
  8. "Принцип замещения Лисков", а автору про полиморфизм известно? И на что он надеялся, когда создал экземпляр квадрата со сторонами 30 и 100? о_О

    ОтветитьУдалить