Принцип замещения Лисков
Формулировка №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) { // ... } } |
Комментариев нет:
Отправить комментарий