Не так давно, около месяца назад, я сделал небольшой доклад, посвященный SOLID, для своих коллег. Объяснить, что это такое оказалось не совсем тривиальным делом. Поэтому, я думаю, многим моя публикация окажется полезной. В этом посте я попытался собрать самые простые и понятные объяснения по каждому принципу.
Как известно, SOLID - это аббревиатура от пяти принципов проектирования, названия которых так удачно зашифровал в этом слове Роберт Мартин.
Собственно вот эти принципы:
1. Принцип единственности ответственности
2. Принцип открытости/закрытости
3. Принцип подстановки Лисков
4. Принцип разделения интерфейса
5. Принцип инверсии зависимостей
Обо всем по порядку.
Принцип единственности ответственности
Как показывает практика, самое сложное в этом принципе - запомнить его определение:
Нарушение этого принципа хорошо демонстрирует следующий пример:
Если случится так, что нам придется менять способ логирования, то мы будем вынуждены внести правки в класс EmailSender. Хотя, казалось бы, эти правки не касаются отправки сообщений. Очевидно, EmailSender выполняет несколько обязанностей и, чтобы класс не был привязан только к одному способу вести лог, нужно вынести выбор лога из этого класса.
Принцип открытости/закрытости
Выгода от использования такой практики очевидна. Не нужно пересматривать уже существующий код, не нужно менять уже готовые для него тесты.
Если нужно ввести какую-то дополнительную функциональность, то это не должно коснуться уже существующих классов или как-либо иначе повредить уже существующую функциональность.
Рассмотрим на примере:
Кажется, что все-таки ничего плохого в этом все равно нет. Но добавление новых методов в класс это всегда потенциальная опасность. Ведь если класс уже реализует какую-то функциональность, то его исправление может нанести вред уже достигнутому.
Конкретно для нашего примера, чтобы избежать этих потенциальных ошибок, очень удобно использовать шаблон Спецификация.
Код самих спецификаций конкретно для нашего примера выглядит следующим образом
Принцип замещения Лисков
Следовать этому принципу очень важно при проектировании новых типов с использованием наследования.
Этот принцип предупреждает разработчика о том, что изменение унаследованного производным типом поведения очень рискованно.
Нарушение этого принципа очень наглядно демонстрирует следующий пример
Очевидно, что не стоило наследовать класс прямоугольника от класса квадрата. Выход из такой ситуации только один - отказаться от наследования. Варианта исправить ситуацию в целом два:
1. Rectange и Square будут совсем разными типами
2. Square агрегирует объект типа Rectangle.
Какой вариант выбрать, это на совести разработчика. Но ясно одно - с переопределением поведения базового типа нужно быть очень аккуратным. Иначе использование проектируемого типа в качестве базового принесет очень много бед.
Принцип разделения интерфейсов
Использование этого принципа рекомендуется очень многими заслуженными авторитетами по разработке ПО уже давно. Тот же Мейер в своей книге "50 советов по улучщению кода и архитектуры ваших систем" ревностно призывает пользоваться указанной практикой.
При проектировании типов нужно придерживаться минимализма в интерфейсах. Слишком толстые интерфейсы нужно разделять. Если не все методы необходимы для использования типа, то это первый звонок того, что данный принцип не выполняется.
Рассмотрим на примере
Однако, при использовании объектов этого типа вы можете столкнуться с проблемой. Любой объект, который получит такой список может "редактировать" его. В определенных сценариях это может оказаться нежелательным. Для того, чтобы предостеречь подобное использование списка, следует разделить интерфейс ICustomList на два: полный и только для чтения.
Принцип инверсии зависимости
Этот принцип я описал в своем блоге ранее. Нет смысла повторяться. Чтобы ознакомиться с ним перейдите сюда.
Благодарности
Придумать что-то новое - не было целью моего доклада. В первую очередь я ориентировался на доступность и понятность материала. Поэтому при составлении доклада, я использовал много источников. Какие-то мысли были взяты из других блогов частично или полностью.
Я хочу выразить благодарность Александру Бындю за его серию постов о SOLID, а также автору вот этого блога за очень хорошие примеры кода.
PS По поводу Принципа подстановки Лисков недавно была очень интересная дискуссия. Ознакомиться с разными мнениями и позициями можно по следующим ссылкам
Блог Александра Бындю - Принцип замещения Лисков
Блог Александра Бындю - Дополнение к LSP
Блог Сергея Теплякова - Принцип замещения Лисков и контракты
Как известно, SOLID - это аббревиатура от пяти принципов проектирования, названия которых так удачно зашифровал в этом слове Роберт Мартин.
Собственно вот эти принципы:
1. Принцип единственности ответственности
2. Принцип открытости/закрытости
3. Принцип подстановки Лисков
4. Принцип разделения интерфейса
5. Принцип инверсии зависимостей
Обо всем по порядку.
Принцип единственности ответственности
Как показывает практика, самое сложное в этом принципе - запомнить его определение:
"Не должно быть более одной причины для изменения класса"
Нарушение этого принципа хорошо демонстрирует следующий пример:
Обратите внимание на класс EmailSender. Кроме того, что при помощи метода Send, он отправляет сообщения, он еще и решает как будет вестись лог. В данном примере лог ведется через консоль.
- class Email
- {
- public String Theme { get; set; }
- public String From { get; set; }
- public String To { get; set; }
- }
- class EmailSender
- {
- public void Send(Email email)
- {
- // ... sending...
- Console.WriteLine("Email from '" + email.From + "' to '" + email.To + "' was send");
- }
- }
- class Program
- {
- static void Main(string[] args)
- {
- Email e1 = new Email() { From = "Me", To = "Vasya", Theme = "Who are you?" };
- Email e2 = new Email() { From = "Vasya", To = "Me", Theme = "vacuum cleaners!" };
- Email e3 = new Email() { From = "Kolya", To = "Vasya", Theme = "No! Thanks!" };
- Email e4 = new Email() { From = "Vasya", To = "Me", Theme = "washing machines!" };
- Email e5 = new Email() { From = "Me", To = "Vasya", Theme = "Yes" };
- Email e6 = new Email() { From = "Vasya", To = "Petya", Theme = "+2" };
- EmailSender es = new EmailSender();
- es.Send(e1);
- es.Send(e2);
- es.Send(e3);
- es.Send(e4);
- es.Send(e5);
- es.Send(e6);
- Console.ReadKey();
- }
- }
* This source code was highlighted with Source Code Highlighter.
Если случится так, что нам придется менять способ логирования, то мы будем вынуждены внести правки в класс EmailSender. Хотя, казалось бы, эти правки не касаются отправки сообщений. Очевидно, EmailSender выполняет несколько обязанностей и, чтобы класс не был привязан только к одному способу вести лог, нужно вынести выбор лога из этого класса.
Теперь, чтобы сменить способ логирования, нужно отправить нужный экземпляр типа ILog в конструктор EmailSender.
- class EmailSender
- {
- public EmailSender(ILog log)
- {
- _log = log;
- }
- public void Send(Email email)
- {
- // ... sending...
- _log.Write("Email from '" + email.From + "' to '" + email.To + "' was send");
- }
- private ILog _log;
- }
- class Program
- {
- static void Main(string[] args)
- {
- Email e1 = new Email() { From = "Me", To = "Vasya", Theme = "Who are you?" };
- Email e2 = new Email() { From = "Vasya", To = "Me", Theme = "vacuum cleaners!" };
- Email e3 = new Email() { From = "Kolya", To = "Vasya", Theme = "No! Thanks!" };
- Email e4 = new Email() { From = "Vasya", To = "Me", Theme = "washing machines!" };
- Email e5 = new Email() { From = "Me", To = "Vasya", Theme = "Yes" };
- Email e6 = new Email() { From = "Vasya", To = "Petya", Theme = "+2" };
- EmailSender es = new EmailSender(new ConsoleLog());
- es.Send(e1);
- es.Send(e2);
- es.Send(e3);
- es.Send(e4);
- es.Send(e5);
- es.Send(e6);
- Console.ReadKey();
- }
- }
- public interface ILog
- {
- void Write(String str);
- }
- public class ConsoleLog: ILog
- {
- public void Write(string str)
- {
- Console.WriteLine(str);
- }
- }
* This source code was highlighted with Source Code Highlighter.
Принцип открытости/закрытости
"Ваши классы должны быть отрытыми для расширения и закрыты для модификации"
Выгода от использования такой практики очевидна. Не нужно пересматривать уже существующий код, не нужно менять уже готовые для него тесты.
Если нужно ввести какую-то дополнительную функциональность, то это не должно коснуться уже существующих классов или как-либо иначе повредить уже существующую функциональность.
Рассмотрим на примере:
Есть какой-то набор отчетов, которые класс ReportProcessor может предоставить. В данном случае программа выведет на экран отчеты с названием Sum. Как вы поступите, если вам нужно будет выбирать отчеты еще и по итоговому полю? Наверняка первая мысль, это добавить такой вот метод в класс ReportProcessor
- public class Report
- {
- public uint Total;
- public String Name;
- public DateTime CreateDate;
- public override string ToString()
- {
- return "Name: " + Name + "\t" +
- "Created: " + CreateDate.ToLongDateString() + "\t" +
- "Total: " + Total;
- }
- }
- class ReportProcessor
- {
- List<Report> _reports;
- public ReportProcessor()
- {
- _reports = new List<Report>
- {
- new Report { Total = 100, Name = "Average", CreateDate = DateTime.Now },
- new Report { Total = 120, Name = "Sum", CreateDate = DateTime.Now.AddDays(-1) },
- new Report { Total = 130, Name = "Average", CreateDate = DateTime.Now.AddDays(-2) },
- new Report { Total = 90, Name = "Average", CreateDate = DateTime.Now.AddDays(-3) },
- new Report { Total = 100, Name = "Sum", CreateDate = DateTime.Now.AddDays(-4) },
- new Report { Total = 110, Name = "Sum", CreateDate = DateTime.Now.AddDays(-2) },
- new Report { Total = 170, Name = "Average", CreateDate = DateTime.Now.AddDays(-1) },
- new Report { Total = 130, Name = "Sum", CreateDate = DateTime.Now.AddDays(-4) },
- new Report { Total = 120, Name = "Average", CreateDate = DateTime.Now.AddDays(-3) },
- new Report { Total = 180, Name = "Sum", CreateDate = DateTime.Now.AddDays(-7) }
- };
- }
- public List<Report> GetByName(String name)
- {
- List<Report> rs = new List<Report>();
- foreach (Report r in _reports)
- if (r.Name == name)
- rs.Add(r);
- return rs;
- }
- }
- class Program
- {
- static void Main(string[] args)
- {
- ReportProcessor rp = new ReportProcessor();
- foreach (Report r in rp.GetByName("Sum"))
- Console.WriteLine(r);
- Console.ReadKey();
- }
- }
* This source code was highlighted with Source Code Highlighter.
И так при каждом добавлении новых требований? Если требований по фильтрации будет очень много, то нам придется добавить очень много новых методов в этот класс.
- public List<Report> GetByTotal(uint total)
- {
- List<Report> rs = new List<Report>();
- foreach (Report r in _reports)
- if (r.Total == total)
- rs.Add(r);
- return rs;
- }
* This source code was highlighted with Source Code Highlighter.
Кажется, что все-таки ничего плохого в этом все равно нет. Но добавление новых методов в класс это всегда потенциальная опасность. Ведь если класс уже реализует какую-то функциональность, то его исправление может нанести вред уже достигнутому.
Конкретно для нашего примера, чтобы избежать этих потенциальных ошибок, очень удобно использовать шаблон Спецификация.
Теперь при помощи комбинирования спецификаций фильтра, можно получить разный набор отчетов. Если нужно будет фильтровать каким-то иным образом, то нужно будет создать тип спецификации и использовать его в вызывающем коде.
- class ReportProcessor
- {
- /// ...
- public List<Report> GetReports(ISpecification spec)
- {
- List<Report> rs = new List<Report>();
- foreach (Report r in _reports)
- if (spec.IsSatisfied(r))
- rs.Add(r);
- return rs;
- }
- }
- class Program
- {
- static void Main(string[] args)
- {
- ReportProcessor rp = new ReportProcessor();
- foreach (Report r in rp.GetReports(new NameSpecification("Sum")))
- Console.WriteLine(r);
- Console.ReadKey();
- }
- }
* This source code was highlighted with Source Code Highlighter.
Код самих спецификаций конкретно для нашего примера выглядит следующим образом
Выберем все отчеты Sum, в которых значение поля итого более 100.
- public interface ISpecification
- {
- bool IsSatisfied(Report r);
- }
- public abstract class CompositeSpecification: ISpecification
- {
- public ISpecification And(ISpecification spec)
- {
- return new AndSpecification(this, spec);
- }
- public ISpecification Or(ISpecification spec)
- {
- return new OrSpecification(this, spec);
- }
- public ISpecification Not(ISpecification spec)
- {
- return new NotSpecification(this);
- }
- public abstract bool IsSatisfied(Report r);
- }
- public class AndSpecification: ISpecification
- {
- private ISpecification _spec1;
- private ISpecification _spec2;
- public AndSpecification(ISpecification spec1, ISpecification spec2)
- {
- _spec1 = spec1;
- _spec2 = spec2;
- }
- public bool IsSatisfied(Report r)
- {
- return _spec1.IsSatisfied(r) && _spec2.IsSatisfied(r);
- }
- }
- public class OrSpecification: ISpecification
- {
- private ISpecification _spec1;
- private ISpecification _spec2;
- public OrSpecification(ISpecification spec1, ISpecification spec2)
- {
- _spec1 = spec1;
- _spec2 = spec2;
- }
- public bool IsSatisfied(Report r)
- {
- return _spec1.IsSatisfied(r) || _spec2.IsSatisfied(r);
- }
- }
- public class NotSpecification: ISpecification
- {
- private ISpecification _spec;
- public NotSpecification(ISpecification spec)
- {
- _spec = spec;
- }
- public bool IsSatisfied(Report r)
- {
- return !_spec.IsSatisfied(r);
- }
- }
- public class NameSpecification: CompositeSpecification
- {
- private String _name;
- public NameSpecification(String name)
- {
- _name = name;
- }
- public override bool IsSatisfied(Report r)
- {
- return r.Name == _name;
- }
- }
- public class TotalGreaterSpecification : CompositeSpecification
- {
- private double _total;
- public TotalGreaterSpecification(double total)
- {
- _total = total;
- }
- public override bool IsSatisfied(Report r)
- {
- return r.Total > _total;
- }
- }
* This source code was highlighted with Source Code Highlighter.
Таким образом, новые требования фильтрации не приводят к правкам в классе ReportProcessor.
- class Program
- {
- static void Main(string[] args)
- {
- ReportProcessor rp = new ReportProcessor();
- foreach (var r in rp.GetReports(new NameSpecification("Sum")
- .And(new TotalGreaterSpecification(100))))
- Console.WriteLine(r);
- Console.ReadKey();
- }
- }
* This source code was highlighted with Source Code Highlighter.
Принцип замещения Лисков
Следовать этому принципу очень важно при проектировании новых типов с использованием наследования.
"Функции, которые используют ссылки на базовые классы, должны иметь возможность использовать объекты производных классов, не зная об этом"
Этот принцип предупреждает разработчика о том, что изменение унаследованного производным типом поведения очень рискованно.
Нарушение этого принципа очень наглядно демонстрирует следующий пример
Есть класс прямоугольника и класс квадрата. Как подсказывает здравый смысл, класс квадрата просто наследуется от класса прямоугольника с той лишь разницей, что поддерживает ширину и высоту эквивалентными. Кажется все в порядке.
- class Rectangle
- {
- public virtual int Width { get; set; }
- public virtual int Height { get; set; }
- public int GetRectangleArea()
- {
- return Width * Height;
- }
- }
- class Square: Rectangle
- {
- public override int Width
- {
- get { return base.Width; }
- set
- {
- base.Height = value;
- base.Width = value;
- }
- }
- public override int Height
- {
- get { return base.Height; }
- set
- {
- base.Height = value;
- base.Width = value;
- }
- }
- }
* This source code was highlighted with Source Code Highlighter.
Такой код как ни странно выведет в консоль 100 вместо 30. Ладно, здесь мы видим, что создан был все таки квадрат, а затем использован в качестве прямоугольника. А если использование объекта класса Rectangle будет совсем не в том месте, где объект был создан? Скажем, пропущенный через несколько вызовов нескольких классов?
- class Program
- {
- static void Main(string[] args)
- {
- Rectangle rect = new Square();
- rect.Width = 3;
- rect.Height = 10;
- Console.WriteLine(rect.GetRectangleArea());
- Console.ReadKey();
- }
- }
* This source code was highlighted with Source Code Highlighter.
Очевидно, что не стоило наследовать класс прямоугольника от класса квадрата. Выход из такой ситуации только один - отказаться от наследования. Варианта исправить ситуацию в целом два:
1. Rectange и Square будут совсем разными типами
2. Square агрегирует объект типа Rectangle.
Какой вариант выбрать, это на совести разработчика. Но ясно одно - с переопределением поведения базового типа нужно быть очень аккуратным. Иначе использование проектируемого типа в качестве базового принесет очень много бед.
Принцип разделения интерфейсов
Использование этого принципа рекомендуется очень многими заслуженными авторитетами по разработке ПО уже давно. Тот же Мейер в своей книге "50 советов по улучщению кода и архитектуры ваших систем" ревностно призывает пользоваться указанной практикой.
Рассмотрим на примере
Вы хотите сделать какой-то класс реализующий данный интерфейс. Класс будет представлять из себя список с возможностью добавления, удаления элементов и т.д.
- public interface ICustomList<T> : IEnumerable<T>
- {
- void Insert(int index, T item);
- void RemoveAt(int index);
- T this[int index] { set; }
- void Add(T item);
- void Clear();
- bool Remove(T item);
- int IndexOf(T item);
- T this[int index] { get; }
- bool Contains(T item);
- int Count { get; }
- IEnumerator<T> GetEnumerator();
- }
* This source code was highlighted with Source Code Highlighter.
Однако, при использовании объектов этого типа вы можете столкнуться с проблемой. Любой объект, который получит такой список может "редактировать" его. В определенных сценариях это может оказаться нежелательным. Для того, чтобы предостеречь подобное использование списка, следует разделить интерфейс ICustomList на два: полный и только для чтения.
Такой прием позволит ограничить использование списков. Если какому-либо классу нежелательно "редактировать" список, то вместо ICustomList следует передавать ICustomEnumerable.
- public interface ICustomEnumerable<T>: IEnumerable<T>
- {
- int IndexOf(T item);
- T this[int index] { get; }
- bool Contains(T item);
- int Count { get; }
- IEnumerator<T> GetEnumerator();
- }
- public interface ICustomList<T> : ICustomEnumerable<T>
- {
- void Insert(int index, T item);
- void RemoveAt(int index);
- T this[int index] { set; }
- void Add(T item);
- void Clear();
- bool Remove(T item);
- }
* This source code was highlighted with Source Code Highlighter.
Принцип инверсии зависимости
Этот принцип я описал в своем блоге ранее. Нет смысла повторяться. Чтобы ознакомиться с ним перейдите сюда.
Благодарности
Придумать что-то новое - не было целью моего доклада. В первую очередь я ориентировался на доступность и понятность материала. Поэтому при составлении доклада, я использовал много источников. Какие-то мысли были взяты из других блогов частично или полностью.
Я хочу выразить благодарность Александру Бындю за его серию постов о SOLID, а также автору вот этого блога за очень хорошие примеры кода.
PS По поводу Принципа подстановки Лисков недавно была очень интересная дискуссия. Ознакомиться с разными мнениями и позициями можно по следующим ссылкам
Блог Александра Бындю - Принцип замещения Лисков
Блог Александра Бындю - Дополнение к LSP
Блог Сергея Теплякова - Принцип замещения Лисков и контракты
Класс. Доступно и понятно. И картинка шикарная по поводу открытости/закрытости :)
ОтветитьУдалитьСпасибо. Коллегам тоже эта картинка очень понравилась.
УдалитьДобрый день. Интересно написали, но ИМХО, пример с разделением интерфейсов неудачен. Обычно, когда говорят, про тонкие интерфейсы, то подразумевается, что один "толстый" разбивается на несколько тонких с меньшим coupling. В вашем примере - ICustomList наследует ICustomEnumerable. Т.е. пользователь интерфейса ICustomList автоматически имеет доступ к ICustomEnumerable. Разделение интерфейсов подразумевает разделение на одном уровне, т.е. увеличивая cohesion и уменьшая coupling.
ОтветитьУдалитьДобрый день!
УдалитьДа, разделение интерфейсов применяют для уменьшения связанности. И readonly интерфейсы я включаю туда же. Потому что их использование уменьшает связанность между типами.
Для того, чтобы было более понятно использование этого принципа неподготовленному специалисту, я использовал пример с readonly интерфейсом как самый наглядный.
Этот комментарий был удален автором.
УдалитьИ еще Мурад - я не знаю, много ли вам пишут комментов - но такая капча, как у вас - надолго отбивает охоту писать в комменты хоть что-то...
ОтветитьУдалитьeugene, у меня стоит стандартная форма ввода комментария. Ок, я посмотрю, что можно с этим поделать.
УдалитьКлассно, полезно, но жаль нет примеров на языке PHP, мало вынес из этой статьи, хотя мог бы и больше
ОтветитьУдалитьХорошо написано. Спасибо.
ОтветитьУдалитьThank you!
ОтветитьУдалить
ОтветитьУдалитьТакой код как ни странно выведет в консоль 100 вместо 30.(конец цитаты)
Так и надо, чтобы вывело 100. Пример: программа может рисовать прямоугольники и квадраты. Нажал на кнопку на панели кнопок "Прямоугольник", появился прямоугольник, мышкой можно задать высоту и ширину. Нажал на кнопку "Квадрат", появился квадрат. Задал ширину 30. Высота автоматически стала 30. Задал высоту 100. Ширина автоматически стала 100.
Так что нужны обоснования, что прямоугольник - базовый класс для квадрата - это плохо.
ИМХО. Написав нечто вроде "Rectangle rect = new Square();", и передав (для большей наглядности) переменную rect в метод, принимающий аргумент типа Rectangle, далее в этом методе мы работаем с прямоугольником. Мы не знаем, какая это фигура на самом деле, да нам и не надо, главное то, что ее можно трактовать как прямоугольник. А изменяя у прямоугольника ширину, мы совсем не ожидаем, что у него вдруг изменится еще и высота, или наоборот. В этом и проявляется странность.
УдалитьЭтот комментарий был удален автором.
ОтветитьУдалить"Принцип замещения Лисков", а автору про полиморфизм известно? И на что он надеялся, когда создал экземпляр квадрата со сторонами 30 и 100? о_О
ОтветитьУдалить