Приципы проектирования классов (методология SOLID)

advertisement
Принципы
проектирования классов
Качество архитектуры

Что такое «хороший» дизайн?
Дизайн, который (как минимум) не обладает
признаками «плохого».

Что такое «плохой» дизайн?
Признаки плохого дизайна:







Жесткость
Хрупкость
Монолитность
Вязкость
Неоправданная сложность
Дублирование
Непонятность
Жесткость
Дизайн является жестким, если одно
изменение влечѐт за собой каскад
последующих изменений в зависимых
модулях.
Чем больше модулей подвергается
изменениям, тем более жестким является
проект.
Хрупкость
Проект является хрупким, если при
внесении одного изменения программа
«разрушается» во многих местах.
Новые проблемы очень часто возникают в
местах, которые не связаны с изменѐнным
компонентом.
В процессе исправления ошибок возникают
новые ошибки.
Монолитность
Проект является монолитным, если он
содержит компоненты, которые могут быть
повторно использованы в других системах,
однако усилия, связанные с изоляцией
компонента, слишком велики.
В результате возрастает дублирование
исходного кода.
Вязкость
Проект является вязким, когда реализовать
какую-либо функциональность намного проще
«неправильными» методами (фактически
применением антипаттернов).
При реализации функциональности
«неправильными» методами высока
вероятность допустить ошибку, а реализация
«правильными» методами слишком сложна.
В результате затраты времени на реализацию
функциональности неоправданно возрастают.
Неоправданная сложность
Проект имеет неоправданную сложность,
если содержит элементы, которые не
используются в настоящий момент времени
(и, возможно, не будут использоваться
никогда).
Возникает, когда разработчики пытаются
предвидеть возможные изменения в коде и
проводят мероприятия, связанные с этими
потенциальными изменениями.
Архитектурные принципы
Роберт Мартин составил список из пяти принципов,
способствующих улучшению дизайна:

принцип единственности ответственности
(SRP, The Single Responsibility Principle),

принцип открытости/закрытости
(OCP, The Open Closed Principle),

принцип подстановки Лисков
(LSP, The Liskov Substitution Principle),

принцип разделения интерфейса
(ISP, The Interface Segregation Principle),

принцип инверсии зависимости
(DIP, The Dependency Inversion Principle).
Связанность
Связанность (coupling) — это мера, определяющая,
насколько жестко один элемент связан с другими
элементами, либо каким количеством данных о других
элементах он обладает.
При проектировании необходимо обеспечивать
наименьшую связанность.
Элемент с низкой степенью связанности зависит от не
очень большого числа других элементов и имеет
следующие свойства:
 Малое число зависимостей между классами
(подсистемами).
 Слабая зависимость одного класса (подсистемы) от
изменений в другом классе (подсистеме).
 Высокая степень повторного использования подсистем.
Сцепление
Сцепление (cohesion, связность, функциональное
сцепление) — это мера сфокусированности обязанностей
класса.
Объект обладает высокой степенью сцепления, если его
обязанности тесно связаны между собой.
Объект с низкой степенью сцепления выполняет много
разнородных функций или несвязанных между собой
обязанностей.
Слабое сцепление приводит к следующим проблемам:
 Трудность понимания.
 Сложность повторного использования.
 Сложность поддержки.
 Ненадежность, постоянная подверженность
изменениям.
Принцип единственности ответственности
Формулировка: класс или модуль должен иметь
одну и только одну причину для изменений.
Последствия нарушения:
 Монолитность.
 Потенциальное дублирование.
 Большие классы.
Следствия применения:
 Компактные классы.
 Уменьшение связанности.
 Увеличение сцепления.
Принцип единственности ответственности
Пусть есть класс, представляющий собой некоторый
продукт со встроенной валидацией данных:
public class Product {
public int Price { get; set; }
public Product(int price) {
Price = price;
}
public bool IsValid() {
return Price > 0;
}
}
Product product = new Product(100);
bool isValid = product.IsValid();
Принцип единственности ответственности
Предположим, что объект продукта стал
использоваться некоторым объектом CustomerService,
для которого используется другой алгоритм проверки
корректности:
public class Product
{
public bool IsValid(bool isCustomerService) {
if (isCustomerService)
return Price > 100000;
return Price > 0;
}
}
// CustomerService usage
Product product = new Product(100);
bool isValid = product.IsValid(true);
Принцип единственности ответственности
Очень вероятно, что вскоре будут добавлены новые
алгоритмы валидации. Становится понятно, что за
валидацию данных должен отвечать отдельный объект:
public interface IProductValidator {
bool IsValid(Product product);
}
public class DefaultProductValidator : IProductValidator {
public bool IsValid(Product product) {
return product.Price > 0;
}
}
public class CustomerServiceProductValidator : IProductValidator {
public bool IsValid(Product product) {
return product.Price > 100000;
}
}
Принцип единственности ответственности
Реализация класса продукта изменится следующим
образом:
public class Product {
private readonly IProductValidator validator;
public Product(int price) : this(price, new DefaultProductValidator())
{ }
public Product(int price, IProductValidator validator) {
Price = price;
this.validator = validator;
}
public int Price { get; set; }
public bool IsValid() {
return validator.IsValid(this);
}
}
Принцип единственности ответственности
Теперь объект будет использоваться так:
// Default usage
Product product = new Product(100);
// CustomerService usage
Product product = new Product(100,
new CustomerServiceProductValidator());
Принцип открытости/закрытости
Формулировка: программные сущности
должны быть открыты для расширения, но
закрыты для изменения
Последствия нарушения:
 Хрупкость.
Следствия выполнения:
 Увеличение гибкости системы.
 Упрощение тестирования.
Принцип открытости/закрытости
Рассмотрим иерархию классов, используемую для
представления объектов базы данных (т.н.
метаобъектов). Корневой сущностью такой иерархии
будет являться следующий класс:
public class MetaObject
{
public string Name { get; set; }
public MetaObject(string name)
{
Name = name;
}
}
Принцип открытости/закрытости
Обозначим классы таблицы и представления:
public class Table : MetaObject
{
private readonly List<Field> fields = new List<Field>();
public Table(string name) : base(name) { }
public IEnumerable<Field> Fields { get { return fields; } }
}
public class View : MetaObject
{
private readonly List<Field> fields = new List<Field>();
public string Body { get; set; }
public IEnumerable<Field> Fields { get { return fields; } }
public View(string name) : base(name) { }
}
Принцип открытости/закрытости
Класс Field может иметь следующий вид:
public class Field : MetaObject
{
public string DataTypeName { get; set; }
public Field(string name)
: base(name)
{
DataTypeName = "INT";
}
}
Принцип открытости/закрытости
Рассмотрим теперь класс SqlDumpGenerator, который позволяет
получить SQL-скрипт, создающий указанные объекты БД:
public class SqlDumpGenerator {
public string Generate(IEnumerable<MetaObject> metaObjects) {
StringBuilder result = new StringBuilder();
foreach (MetaObject metaObject in metaObjects) {
StringBuilder createScript = new StringBuilder();
if (metaObject is Table) {
createScript.Append("CREATE TABLE ")
.AppendLine(metaObject.Name);
// ...
}
else if (metaObject is View) {
createScript.Append("CREATE VIEW ")
.AppendLine(metaObject.Name);
// ...
}
result.AppendLine(createScript.ToString());
}
return result.ToString();
}
}
Принцип открытости/закрытости
Данный класс нарушает принцип
открытости/закрытости, т.к. для добавления новой
функциональности (например, нового наследника
класса MetaObject) необходимо модифицировать код
функции Generate.
Совершенно очевидно, что для соблюдения принципа
следует создать метод GetCreateScript в классе
MetaObject:
public class MetaObject
{
public virtual string GetCreateScript()
{
// ...
}
}
Принцип открытости/закрытости
Теперь при добавления новых объектов, нет
необходимости в модификации метода Generate,
достаточно только добавить класс наследник
MetaObject.
Заметим, однако, что если функция Generate должна
добавлять скрипты создания в разном порядке (например,
сначала добавить все таблицы, а потом все
представления), то возникает необходимость в еѐ
модификации.
Вообще говоря, всегда найдѐтся такое изменение,
которое потребует модификации (а не добавления)
исходного кода. Разработчик должен сам определять
самые вероятные изменения и создавать абстракции,
которые позволяют защититься от этих изменений.
Принцип подстановки Лисков
Формулировка:
Пусть q(x) является свойством, верным относительно
объектов x некоторого типа T. Тогда q(y) также должно быть
верным для объектов y типа S, где S является подтипом типа T.
или
Функции, которые используют ссылки на базовые классы,
должны иметь возможность использовать объекты
производных классов, не зная об этом.
Последствия нарушения:
 Нарушение абстракций.
 Затруднение тестирования.
Следствия выполнения:
 Чѐткое определение абстракций.
 Упрощение тестирования.
 Упрощение понимания кода.
Принцип подстановки Лисков
Рассмотрим классический пример нарушения данного принципа.
Определим класс прямоугольника:
public class Rectangle {
private double width;
private double height;
public double Width {
get { return width; }
set { width = value; }
}
public double Height {
get { return height; }
set { height = value; }
}
public double GetArea() {
return Width * Height;
}
}
Принцип подстановки Лисков
Пусть теперь нашей системе
потребовался класс квадрата. Как
известно, квадрат — это частный
случай прямоугольника, иными
словами, квадрат является
прямоугольником (выполняется
отношение is a).
Принцип подстановки Лисков
Однако все стороны квадрата равны между собой, поэтому мы
вынуждены объявить методы установки ширины и высоты
виртуальными в классе прямоугольника и перекрыть их в классе
квадрата соответствующим образом:
public class Square : Rectangle {
public override double Width {
get { return base.Width; }
set {
base.Width = value;
base.Height = value;
}
}
public override double Height {
get { return base.Height; }
set {
base.Width = value;
base.Height = value;
}
}
}
Принцип подстановки Лисков
Очевидной проблемой видится явная избыточность данных в
классе квадрата (достаточно хранить только одну сторону). Но
предположим, что нас не волнуют проблемы экономии памяти.
Рассмотрим следующий простой тест:
[TestFixture]
public class Tests {
private void InternalTestRectangleArea(Rectangle rectangle) {
rectangle.Width = 3;
rectangle.Height = 2;
Assert.That(rectangle.GetArea(), Is.EqualTo(6));
}
[Test]
public void TestArea() {
InternalTestRectangleArea(new Rectangle());
InternalTestRectangleArea(new Square());
}
}
Принцип подстановки Лисков
Заметим, что сами по себе классы квадрата и
прямоугольника корректны.
Однако, указанные выше тесты предполагали,
что ширина и высота прямоугольника
независимы, то есть класс Square нарушил
инвариант класса Rectangle.
Это означает, что отношение наследования
относится также и к поведению объектов. С
этой точки зрения «квадрат» не является
«прямоугольником».
Принцип разделения интерфейса
Формулировка: Клиенты не должны зависеть
от интерфейсов, в которых не нуждаются.
Последствия нарушения:
 Усложнение кода.
 Возникновение ненужных зависимостей
между модулями.
Следствия выполнения:
 Упрощение понимания кода.
 Уменьшение связанности.
Принцип разделения интерфейса
В качестве наглядной демонстрации нарушения
данного принципа рассмотрим следующий пример.
Рассмотрим систему классов, представляющую два
типа работников с методами Work и Eat:
public interface IWorker
{
void Work();
void Eat();
}
Принцип разделения интерфейса
class Worker : IWorker
{
public void Work()
{
// .... working
}
public void Eat()
{
// ...... eating in launch break
}
}
class SuperWorker : IWorker
{
public void Work()
{
//.... working much more
}
public void Eat()
{
//.... eating in launch break
}
}
Принцип разделения интерфейса
Заметим, что если к указанной системе классов мы
захотим добавить класс робота, окажется, что
реализация метода Eat в данном классе будет пустой:
class Robot : IWorker
{
public void Work()
{
// .... working
}
public void Eat()
{ }
}
Принцип разделения интерфейса
Другой недостаток указанного интерфейса
демонстрирует класс менеджера, который управляет
работником следующим образом:
class Manager
{
public IWorker Worker { get; set; }
public void Manage()
{
Worker.Work();
}
}
Данный класс использует лишь часть интерфейса
IWorker.
Принцип разделения интерфейса
Указанные выше примеры демонстрируют нарушение
рассматриваемого принципа.
Решением является разбиение интерфейса IWorker:
public interface IWorkable {
void Work();
}
public interface IFeedable {
void Eat();
}
class Worker : IWorkable, IFeedable {
public void Work() {
// .... working
}
public void Eat() {
// ...... eating in launch break
}
}
Принцип разделения интерфейса
Теперь в классе Robot нет необходимости создавать
фиктивные реализации методов:
public class Robot : IWorkable
{
public void Work()
{
// .... working
}
}
Класс менеджера зависит только от той части
интерфейса, которая ему требуется:
class Manager {
public IWorkable Worker { get; set; }
public void Manage() {
Worker.Work();
}
}
Принцип инверсии зависимости
Формулировка:
Модули верхнего уровня не должны зависеть от модулей
нижнего уровня. Оба должны зависеть от абстракций.
Абстракции не должны зависеть от деталей. Детали должны
зависеть от абстракций.
Абстракции — это абстрактные классы (интерфейсы),
описывающие основные сущности системы.
Следствия нарушения:
 Монолитность.
 Хрупкость.
Следствия выполнения:
 Увеличение гибкости системы.
 Возможность повторного использования кода.
 Упрощение тестирования.
Принцип инверсии зависимости
Рассмотрим простое приложение, которое выводит на
консоль SQL-дамп содержимого некоторой таблицы.
Центральным объектом нашего приложения будет
объект DataScripter, а клиентский код, который его
использует, будет следующим:
public static class Program {
public static void Main(string[] args) {
IDbConnection connection = CreateConnection();
DataScripter scripter = new DataScripter();
using (IDbCommand command = connection.CreateCommand())
{
command.CommandText = string.Format("SELECT * FROM {0}", args[0]);
using (IDataReader reader = command.ExecuteReader())
{
Console.WriteLine(scripter.GetSqlDump(reader));
}
}
}
}
Принцип инверсии зависимости
Класс DataScripter выглядит следующим образом:
public class DataScripter
{
public string GetSqlDump(IDataReader dataReader)
{
StringBuilder result = new StringBuilder();
InsertDataDumper dataDumper = new InsertDataDumper();
while (dataReader.Read())
{
string rowSqlDump = dataDumper.GetRowDump(dataReader);
result.AppendLine(rowSqlDump);
}
return result.ToString();
}
}
Принцип инверсии зависимости
Класс InsertDataDumper может иметь следующий вид:
public class InsertDataDumper
{
public string GetRowDump(IDataRecord record)
{
StringBuilder result = new StringBuilder();
result.Append("INSERT INTO ");
// ...
return result.ToString();
}
}
Принцип инверсии зависимости
Функция GetSqlDump может оказаться
несколько перегруженной знаниями и
обязанностями:
 знает, что именно InsertDataDumper
генерирует SQL-дамп строки;
 умеет создавать объект InsertDataDumper.
Принцип инверсии зависимости
Рассмотрим теперь применение принципа инверсии
зависимостей.
Для этого выделим интерфейс IDataDumper:
public interface IDataDumper
{
string GetRowDump(IDataRecord record);
}
public class InsertDataDumper : IDataDumper
{
public string GetRowDump(IDataRecord record)
{
StringBuilder result = new StringBuilder();
result.Append("INSERT INTO ");
// ...
return result.ToString();
}
}
Принцип инверсии зависимости
Теперь вместо того, чтобы создавать объект InsertDataDumper
внутри метода GetSqlDump, передадим объекту DataScripter
соответствующий параметр:
public class DataScripter {
private readonly IDataDumper dataDumper;
public DataScripter(IDataDumper dataDumper) {
this.dataDumper = dataDumper;
}
public string GetSqlDump(IDataReader dataReader) {
StringBuilder result = new StringBuilder();
while (dataReader.Read()) {
string rowSqlDump = dataDumper.GetRowDump(dataReader);
result.AppendLine(rowSqlDump);
}
return result.ToString();
}
}
Принцип инверсии зависимости
Теперь код использования объекта DataScripter будет
выглядеть следующим образом:
IDbConnection connection = CreateConnection();
DataScripter scripter = new DataScripter(new InsertDataDumper());
using (IDbCommand command = connection.CreateCommand())
{
command.CommandText = string.Format("SELECT * FROM {0}", args[0]);
using (IDataReader reader = command.ExecuteReader())
{
Console.WriteLine(scripter.GetSqlDump(reader));
}
}
Принцип инверсии зависимости
Применение принципа инверсии зависимости
уменьшает связанность, что позволяет нам легко
изменить метод генерации дампа строки с INSERT на
UPSERT:
public class UpsertDataDumper : IDataDumper
{
public string GetRowDump(IDataRecord record)
{
StringBuilder result = new StringBuilder();
result.Append("REPLACE INTO ");
// ...
return result.ToString();
}
}
DataScripter scripter = new DataScripter(new UpsertDataDumper());
Принцип инверсии зависимости
Обратим внимание, на то, что дамп данных всегда полностью
сохраняется в строке. Такой подход может привести к перерасходу
памяти. Изменим механизм записи данных и применим принцип
инверсии зависимости. Для этого воспользуемся имеющейся в
библиотеке .NET абстракцией TextWriter:
public class DataScripter {
private readonly IDataDumper dataDumper;
private readonly TextWriter writer;
public DataScripter(IDataDumper dataDumper, TextWriter writer) {
this.dataDumper = dataDumper;
this.writer = writer;
}
public void GetSqlDump(IDataReader dataReader) {
while (dataReader.Read()) {
string rowSqlDump = dataDumper.GetRowDump(dataReader);
writer.WriteLine(rowSqlDump);
}
}
}
Принцип инверсии зависимости
Теперь код, который выводит дамп данных в консоль,
будет выглядеть следующим образом:
IDbConnection connection = CreateConnection();
DataScripter scripter =
new DataScripter(new InsertDataDumper(), Console.Out);
using (IDbCommand command = connection.CreateCommand())
{
command.CommandText = string.Format("SELECT * FROM {0}", args[0]);
using (IDataReader reader = command.ExecuteReader())
{
scripter.GetSqlDump(reader);
}
}
Принцип инверсии зависимости
Для вывода данных в файл можно использовать
следующий код:
// ...
DataScripter scripter = new DataScripter(
new InsertDataDumper(),
new StreamWriter("output.sql"));
// ...
Download