воскресенье, 14 февраля 2016 г.

Принцип замещения Лисков


Принцип замещения Лисков

Формулировка №1: eсли для каждого объекта o1 типа S существует объект o2 типа T, который для всех программ P определен в терминах T, то поведение P не изменится, если o2 заменить на o1 при условии, что S является подтипом T.

Формулировка №2: Функции, которые используют ссылки на базовые классы, должны иметь возможность использовать объекты производных классов, не зная об этом.

Короткая версия: Derived classes must be substitutable for their base classes



Примеры
Проверка абстракции на тип
В начале статьи есть код проверки абстракции на тип на примере нарушения принципа открытости/закрытости. Теперь мы видим, что класс Repository нарушает еще и принцип замещения Лисков. Дело в том, что внутри класса Repository мы оперируем не только абстрактной сущностью AbstractEntity, но и унаследованными типами. А это значит, что в данном случае подтипы AccountEntity и RoleEntity не могут быть заменены типом, от которого они унаследованы. По определению имеем нарушение.
Надо заметить, что принципы проектирования взаимосвязаны. Нарушение одного из принципов скорее всего приведет к нарушению одного или нескольких других принципов.

Ошибочное наследование
Проблема
Мы хотим реализовать свой список с интерфейсом IList. Его особенностью будет то, что все записи в нем дублируются.
?
1
2
3
4
5
6
7
8
9
10
11
public class DoubleList<T> : IList<T>
{
    private readonly IList<T> innerList = new List<T>();
  
    public void Add(T item)
    {
        innerList.Add(item);
        innerList.Add(item);
    }
  
    ... 
Данная реализация не представляет никакой опасности, если рассматривать ее изолированно. Взглянем на использование этого класса с точки зрения клиента. Клиент, абстрагируясь от реализаций, пытается работать со всеми объектами типа IList одинаково:
?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[Fact]
public void CheckBehaviourForRegularList()
{
    IList<int> list = new List<int>();
  
    list.Add(1);
  
    Assert.Equal(1, list.Count);
}
  
[Fact]
public void CheckBehaviourForDoubleList()
{
    IList<int> list = new DoubleList<int>();
  
    list.Add(1);
  
    Assert.Equal(1, list.Count); // fail
}
Поведение списка DoubleList отличается от типичных реализаций IList. Получается, что наш DoubleList не может быть заменен базовым типом. Это и есть нарушение принципа замещения Лисков.
Проблема заключается в том, что теперь клиенту необходимо знать о конкретном типе объекта, реализующем интерфейс IList. В качестве такого объекта могут передать иDoubleList, а для него придется выполнять дополнительную логику и проверки.

Решение
Правильным решением будет использовать свой собственный интерфейс, например,IDoubleList. Этот интерфейс будет объявлять для пользователей поведение, при котором добавляемые элементы удваиваются. Более подробно об этом можно прочитать в дополнении к LSP.

Проектирование по контракту
Есть формальный способ понять, что наследование является ошибочным. Это можно сделать с помощью проектирования по контракту. Бернард Мейер, его автор, сформулировал следующий принцип:

Наследуемый объект может заменить родительское пред-условие на такое же или более слабое и родительское пост-условие на такое же или более сильное. (перефразировано)

Рассмотрим пред- и пост-условия для интерфейса IList. Для функции Add:
  • пред-условие: item != null
  • пост-условие: count = oldCount + 1
Для нашего DoubleList и его функции Add:
  • пред-условие: item != null
  • пост-условие: count = oldCount + 2
Теперь стало видно, что по контракту пост-условие базового класса не выполняется.
Другими словами, когда мы используем интерфейс IList, то как пользователи этого базового класса знаем только его пред- и пост-условия. Нарушая принцип проектирования по контракту мы меняем поведение унаследованного объекта.
Я не призываю начать пользоваться языками, которые используют встроенную поддержку проектирования по контракту, а только хочу показать, что есть способ формального определения нарушения принципа замещения Лисков.

Принцип разделения интерфейса

Формулировка: клиенты не должны зависеть от методов, которые они не используют

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


Примеры
Лишняя абстракция в наследовании
Проблема
Речь идет о базовых классах, которые вынуждают своих наследников знать и делать слишком много. Печально известный пример – класс MembershipProvider. Для использования этого класса нужно реализовать 27 абстрактных методов и свойств.
Рассмотрим пример из жизни. Это, конечно, не скопированный код один в один, но очень приближено к тому, что было. Есть базовый класс для аудиторов EntityAuditor. Он унаследован от класса AuditorBase, который предоставляет ORM, и реализует методAuditEntityFieldSet этого базового класса. Также EntityAuditor добавляет свой абстрактный метод CreateLogRow, который используется в методе AuditEntityFieldSet и должен быть переопределен в конкретных реализациях:
?
1
2
3
4
5
6
7
8
9
10
11
12
13
public abstract class EntityAuditor : AuditorBase
{
    public override void AuditEntityFieldSet(IEntityCore entity, int fieldIndex, object originalValue)
    {
        // ...
         
        CreateLogRow(...
         
        // ...
    }
  
    protected abstract LogRowEntity CreateLogRow(decimal? fieldId, string oldValue, string newValue, IEntityCore entity);
}
После этого начинаем реализовывать наследников. Например, создадим аудитор для класса Product:
?
1
2
3
4
5
6
7
public class ProductAuditor : EntityAuditor
{
    protected override LogRowEntity CreateLogRow(decimal? fieldId, string oldValue, string newValue, IEntityCore entity)
    {
        // ...
    }
}
Сейчас добавлению наследников ничего не мешает. Теперь представим, что в методе AuditEntityFieldSet понадобилась дополнительная логика, при которой нужно вызвать метод UpdateDuplicates. Этот метод также является абстрактным и требует реализации в наследниках:
?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public abstract class EntityAuditor : AuditorBase
{
    public override void AuditEntityFieldSet(IEntityCore entity, int fieldIndex, object originalValue)
    {
        // ...
         
        CreateLogRow(...
  
        UpdateDuplicates(...
         
        // ...
    }
  
    protected abstract LogRowEntity CreateLogRow(decimal? fieldId, string oldValue, string newValue, IEntityCore entity);
  
    protected abstract void UpdateDuplicates(IEntityCore entity, decimal fieldId, object current);
}
  
  
public class ProductAuditor : EntityAuditor
{
    protected override LogRowEntity CreateLogRow(decimal? fieldId, string oldValue, string newValue, IEntityCore entity)
    {
        // ...
    }
  
    protected override void UpdateDuplicates(IEntityCore entity, decimal fieldId, object current)
    {
        // реализация
    }
}
  
public class AccountAuditor : EntityAuditor
{
    protected override LogRowEntity CreateLogRow(decimal? fieldId, string oldValue, string newValue, IEntityCore entity)
    {
        // ...
    }
  
    protected override void UpdateDuplicates(IEntityCore entity, decimal fieldId, object current)
    {
        // здесь ничего нет!
    }
}
EntityAuditor требует реализации метода UpdateDuplicates даже в тех наследниках, где он не нужен, как, например, в AccountAuditor. Проблема в том, что частный случай (UpdateDuplicates), который используется только в половине наследников, мы сделали общим, т.е. обязательным для всех наследников нашего EntityAuditor. Получается, что чем больше наследников будет у EntityAuditor, тем больше бесполезного кода мы будем писать, тем больше наследники будут знать лишнего о своем базовом классе. Это может сильно помешать нам в дальнейшем при рефакторинге или изменении логики в EntityAuditor.

Решение
В данном случае решение очень простое. Если наследникам класса EntityAuditor не нужна функция UpdateDuplicates, то и реализовывать ее они не должны. В С# это делается простой заменой ключевого слова abstract на virtual:
?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public abstract class EntityAuditor : AuditorBase
{
    public override void AuditEntityFieldSet(IEntityCore entity, int fieldIndex, object originalValue)
    {
        // ...
         
        CreateLogRow(...
  
        UpdateDuplicates(...
         
        // ...
    }
  
    protected abstract LogRowEntity CreateLogRow(decimal? fieldId, string oldValue, string newValue, IEntityCore entity);
  
    protected virtual void UpdateDuplicates(IEntityCore entity, decimal fieldId, object current)
    {
    }
}
Теперь наследники отчищены от ненужной связности:
?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class ProductAuditor : EntityAuditor
{
    protected override LogRowEntity CreateLogRow(decimal? fieldId, string oldValue, string newValue, IEntityCore entity)
    {
        // ...
    }
  
    protected override void UpdateDuplicates(IEntityCore entity, decimal fieldId, object current)
    {
        // реализация
    }
}
  
public class AccountAuditor : EntityAuditor
{
    protected override LogRowEntity CreateLogRow(decimal? fieldId, string oldValue, string newValue, IEntityCore entity)
    {
        // ...
    }
}
В других случаях могут быть другие решения этой проблемы. Если у вас есть примеры из практики, давайте разбирать их в комментариях.

«Жирный» интерфейс
Проблема
У нас есть интерфейс ISpecification. С помощью него мы можем узнать подходит ли продукт заявке – метод IsSuitable и является ли поле продукта измененным – методIsFieldChanged:
?
1
2
3
4
5
6
public interface ISpecification
{
    bool IsSuitable(Product realty, Offer offer);
     
    bool IsFieldChanged(Product oldValue, Product newValue);
}
Чем является наш модуль для сторонних клиентов? Он является набором интерфейсов, с помощью которых модуль может быть использован. В данном случае проблема заключается в том, что клиентом первой функции является консольное приложение, а второй – класс хранилища:
?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public interface ISpecification
{
    bool IsSuitable(Product realty, Offer offer);
  
    bool IsFieldChanged(Product oldValue, Product newValue);
}
  
/// <summary>
/// Хранилище для продуктов
/// </summary>
public class ProductRepository : IRepository<product>
{
    public void Save(Product product)
    {
        // ...
  
        specification.IsFieldChanged(...
  
        // ...
    }
}
  
/// <summary>
/// Программа расчета подходящих продуктов
/// </summary>
public class Program
{
    public static void Main(string[] args)
    {
        // ...
  
        specification.IsSuitable(...
    }
}
</product>
Допустим, что мы уже написали несколько конкретных спецификаций:
?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class PriceSpecification : ISpecification
{
    public bool IsSuitable(Product realty, Offer offer)
    {
        // ...
    }
  
    public bool IsFieldChanged(Product oldValue, Product newValue)
    {
        // ...
    }
}
  
...
Уже видно, что полученный результат нас не устраивает. При рефакторинге или изменении логики в консольном приложении и методе IsSuitable, нам придется затронуть все классы, которые реализовали интерфейс ISpecification. Например, представьте, что будет если в метод IsSuitable мы захотим добавить еще один параметр? А если конкретных спецификаций накопилось уже с десяток? Основная мысль в том, что теперь различные части системы зависят друг от друга, хоть и косвенно. Консольное приложение зависит от логики хранилища и наоборот.

Решение
Главное правило в данном случае звучит так: если клиенты интерфейса разделены, то и интерфейс должен быть разделен соответствующим образом.
После разделения получаем:
?
1
2
3
4
5
6
7
8
9
public interface ISpecification
{
    bool IsSuitable(Product realty, Offer offer);
}
  
public interface IChangeFieldDetector
{
    bool IsFieldChanged(Product oldValue, Product newValue);
}
Теперь консольное приложение работает интерфейсом ISpecification, а хранилище работает с интерфейсом IChangeFieldDetector. Проблема с зависимостью решена.
Кроме этого, мы решили еще и проблему с наследниками первой реализацииISpecification. Теперь класс может реализовывать только один интерфейс, за счет чего его на много проще поддерживать:
?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class PriceSpecification : ISpecification
{
    public bool IsSuitable(Product realty, Offer offer)
    {
        // ...
    }
}
  
class PriceChangeFieldDetector : IChangeFieldDetector
{
    public bool IsFieldChanged(Product oldValue, Product newValue)
    {
        // ...
    }
}
Вся информация взята с этого блога.

Комментариев нет:

Отправить комментарий