Обратная связь

Для меня очень важно получать обратную связь от Bас. Пишите мне, если есть вопросы, интересные темы для обсуждения или по любым другим поводам. Мой  почтовый ящик и гугл-группа.

четверг, 29 октября 2009 г.

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

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

Формулировка №2: подтипы должны быть заменяемы базовыми типами.

Примеры

Проверка абстракции на тип

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

Надо заметить, что принципы проектирования взаимосвязаны. Нарушение одного из принципов скорее всего приведет к нарушению одного или нескольких других принципов.

Ошибочное наследование

Проблема

Мы хотим реализовать свой список с интерфейсом IList. Его особенностью будет то, что все записи в нем дублируются.

   1:  public class DoubleList<T> : IList<T>
   2:  {
   3:      private readonly IList<T> innerList = new List<T>();
   4:   
   5:      public void Add(T item)
   6:      {
   7:          innerList.Add(item);
   8:          innerList.Add(item);
   9:      }
  10:   
  11:      ...  

Данная реализация не представляет никакой опасности, если рассматривать ее изолированно. Взглянем на использование этого класса с точки зрения клиента. Клиент, абстрагируясь от реализаций, пытается работать со всеми объектами типа IList одинаково:

   1:  [Fact]
   2:  public void CheckBehaviourForRegularList()
   3:  {
   4:      IList<int> list = new List<int>();
   5:   
   6:      list.Add(1);
   7:   
   8:      Assert.Equal(1, list.Count);
   9:  }
  10:   
  11:  [Fact]
  12:  public void CheckBehaviourForDoubleList()
  13:  {
  14:      IList<int> list = new DoubleList<int>();
  15:   
  16:      list.Add(1);
  17:   
  18:      Assert.Equal(1, list.Count); // fail
  19:  }

Поведение списка DoubleList отличается от типичных реализаций IList. Получается, что наш DoubleList не может быть заменен базовым типом. Это и есть нарушение принципа замещения Лисков.

Проблема заключается в том, что теперь клиенту необходимо знать о конкретном типе объекта, реализующем интерфейс IList. В качестве такого объекта могут передать и DoubleList, а для него придется выполнять дополнительную логику и проверки.

Решение

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

Проектирование по контракту

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

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

Рассмотрим пред- и пост-условия для интерфейса IList. Для функции Add:

  • пред-условие: item != null
  • пост-условие: count = oldCount + 1

Для нашего DoubleList и его функции Add:

  • пред-условие: item != null
  • пост-условие: count = oldCount + 2

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

Другими словами, когда мы используем интерфейс IList, то как пользователи этого базового класса знаем только его пред- и пост-условия. Нарушая принцип проектирования по контракту мы меняем поведение унаследованного объекта.

Я не призываю начать пользоваться языками, которые используют встроенную поддержку проектирования по контракту, а только хочу показать, что есть способ формального определения нарушения принципа замещения Лисков.

Этот пост входит в серию Принципы проектирования классов (S.O.L.I.D.):


Ссылки

R. Martin: The Liskov Substitution Principle

LosTechies: The Liskov Substitution Principle

Wikipedia: Liskov substitution principle

CodingEfficiency: SOLID – L: Liskov Substitution Principle

DimeCasts: # 92 - Creating SOLID Code: Liskov Substitution Principle

22 коммент.:

  1. Я думаю, что под словом "программа" в 1-й формулировке принципа следует понимать не программу как некий законченый программный модуль, а функцию. Иначе это сбивает с толку и затрудняет понимание, не так ли?
    В целом хочу сказать спасибо за популяризацию этих принципов. Оказалось, многие из них я применял интуитивно, не зная об их существовании, но теперь как пелена с глаз :)
    ОтветитьУдалить
  2. Объясни пожалуйста, что значит ослабление или усиление условий в проектировании по контракту?
    ОтветитьУдалить
  3. @efix
    > Я думаю, что под словом "программа" в 1-й формулировке принципа следует понимать не программу как некий законченый программный модуль, а функцию

    Ага, так лучше.

    > Объясни пожалуйста, что значит ослабление или усиление условий в проектировании по контракту?

    Если для функции Add родительского класса справедливо:
    * пред-условие: item != null
    * пост-условие: count = oldCount + 1

    То для дочернего можно использовать:
    * пред-условие: -
    * пост-условие: count = oldCount + 1 && is_changed = true

    Так образом, пользователи дочернего могут обращаться с ним, как с родительским.
    ОтветитьУдалить
  4. Здравствуйте Александр!!!

    У меня возник один вопрос, возможно не к месту и, возможно, глупый, но все же,не могли бы вы объяснить связь между проектированием по контракту и модульным тестированием , или же это просто два совершенно разных подхода к проектированию классов!
    я недавно разбирался с Проектированием по контракту по книге Бертрана Мейера там ничего о тестировании не говориться!
    ОтветитьУдалить
  5. @pArTiZaN

    Привет.

    Это два подхода к разработке (если под "модульным тестированием" ты подразумеваешь TDD). Их можно применять вместе. Видимо только это их и связывает.
    ОтветитьУдалить
  6. >>то поведение P не измениться

    Исправь, пожалуйста

    Некоторое время назад я обнаружил принципиальное отличие в подходе к сериализации в .NET и Java. Заключается оно в том, что в Java для пометки класса сериализуемым необходимо отнаследовать его от маркерного (пустого) интерфейса (есть и другие подходы к сериализации, но сейчас остановимся именно на этом), поэтому дочерние классы автоматически становятся сериализуемыми. В .NET для этих целей есть атрибут Serializable, который не наследуется в дочерних классах.

    Получается, с одной стороны, Java ведет себя правильнее по Лискоу, с другой, - сериализуемость, доставшаяся по наследству, может принести массу проблем. Так кто прав: .NET или Java? Этот вопрос я попытался прояснить в этом топике на rsdn, но не очень успешно.
    ОтветитьУдалить
  7. @Idsa
    Я думаю, что аттрибут - это более правильный подход. Вообще не рекомендую использование пустых интерфейсов для "пометки" объекта.

    На счет того, должны ли наследники класса быть сериализуемыми или нет, вопрос спорный. Я в практике еще никогда не сталкивался с подобной проблемой. Точнее мне не приходилось запрещать сериализацию какого-либо класса, поэтому будет класс сериализуемым или нет, не так критично.
    ОтветитьУдалить
  8. >>Я думаю, что аттрибут - это более правильный подход.

    Здесь суть не в выборе атрибут/интерфейс. Ведь в .NET есть возможность сделать атрибут наследуемым, но разработчики .NET почему-то сделали "не как в Java", пойдя наперекор принципу Лискоу (ты считаешь, что принцип Лискоу здесь нарушается?). Если развивать эту логику, получается, что любые ненаследуемые метаданные нарушают принцип Лискоу.

    >>Вообще не рекомендую использование пустых интерфейсов для "пометки" объекта.

    Мне тоже не очень нравится этот подход, да и в .NET я его ни разу не видел. Хотя в том же топике я пытался заодно узнать, используют ли маркерные интерфейсы после появления в Java аннотаций, и там привели интересный юзкейс, в котором аннотации (как и наши родные атрибуты) бессильны: "можно делать методы someMethod(MarkerInterface o) — с аннотациями такое не прокатит". Это может пригодиться, если нам нужно вынести зависимость класса от объекта с некоторыми метаданными.
    ОтветитьУдалить
  9. @Idsa
    > ты считаешь, что принцип Лискоу здесь нарушается?
    Если я не могу использовать вместо наследуемого класса интерфейс ISerializable, то по определению нарушается :)

    > Если развивать эту логику, получается, что любые ненаследуемые метаданные нарушают принцип Лискоу

    Т.е. ты предполагаешь, что аттрибуты тоже должны передаваться наследникам?

    > можно делать методы someMethod(MarkerInterface o) — с аннотациями такое не прокатит

    Не понял этот пример, можешь подробнее?
    ОтветитьУдалить
  10. >>Если я не могу использовать вместо наследуемого класса интерфейс ISerializable, то по определению нарушается :)

    Слушай, точно :) Я в этом направлении не размышлял. Выходит, атрибут C# нарушает принцип замещения Лискоу всегда (хотя тут можно спорить...), а поведение сериализации в Java не определено: наличие маркерного интерфейса не гарантирует сериализацию.

    >>Т.е. ты предполагаешь, что аттрибуты тоже должны передаваться наследникам?

    Они и передаются, если в AttributeUsage задан Inherited = true. Я склоняюсь к мысли, что любой ненаследуемый атрибут нарушает принцип замещения Лискоу.

    >>Не понял этот пример, можешь подробнее?

    Допустим, у тебя зависимость "любой объект, поддающийся бинарной сериализации". В Java для этого достаточно получить объект, реализующий интерфейс ISerializable, а в C# - никак.
    ОтветитьУдалить
  11. @Idsa
    > Я склоняюсь к мысли, что любой ненаследуемый атрибут нарушает принцип замещения Лискоу
    Это не так, потому что ты не можешь использовать аттрибут вместо класса. Но ты можешь использовать интерфейс вместо класса. Т.е. аттрибут вообще никак не влияет на нарушение или не нарушение этого принципа.

    > В Java для этого достаточно получить объект, реализующий интерфейс ISerializable, а в C# - никак
    Можно пример кода?
    ОтветитьУдалить
  12. >>Т.е. аттрибут вообще никак не влияет на нарушение или не нарушение этого принципа.

    Я тоже так сначала думал, но ребята с rsdn меня переубедили. Пример:

    ClassA classInstance = new ClassA();
    ClassA derivedClassInstance = new ClassB();

    ClassA помечен атрибутом Serializable, а ClassB - нет.

    Пытаемся сериализовать derivedClassInstance и получаем исключение. Это ли не нарушение принципа замещения Лискоу?

    >>Можно пример кода?

    public class SerializableEntityManager
    {
    private ISerializable _serializableEntity;

    /*Зависимость должна быть бинарно-сериализуемой. В Java это выглядело бы так, а в С# - хз. Ну и в общем подобный подход - аргумент за маркерные интерфейсы как средство задания метаданных*/
    public Serializator(ISerializable serializableEntity)
    {
    _serializableEntity = serializableEntity;
    }
    }
    ОтветитьУдалить
  13. @Idsa
    Если ты реализуешь ISerializable у класса, то любой его наследник будет сериализуем. Т.е. по принципу: вместо наследников мы можем использовать их родителей (в данном случае абстракцию - интерфейс ISerializable).

    Речь не идет о метаданных. Метаданные не участвуют во взаимодействии объектов. Мы оперируем в коде не аттрибутами или названиями методов, а классами, абстрактными классами или интерфейсами.
    ОтветитьУдалить
  14. >>Если ты реализуешь ISerializable у класса, то любой его наследник будет сериализуем. Т.е. по принципу: вместо наследников мы можем использовать их родителей (в данном случае абстракцию - интерфейс ISerializable).

    Погоди, погоди. Это ты сейчас насчет чего? Я же вверху про C# и атрибуты говорил, а не про Java и ISerializable. И то, что ты говоришь, не совсем правда: стандартный механизм "сделать класс несериализуемым, если у него сериализуемый родитель" - выкидывать исключение в каком-то там методе (не помню уже). Так что один из потомков может оказаться несериализуемым (я выше писал про "вероятностную сериализацию" в Java :) ).

    >>Речь не идет о метаданных. Метаданные не участвуют во взаимодействии объектов. Мы оперируем в коде не аттрибутами или названиями методов, а классами, абстрактными классами или интерфейсами.

    Метаданные не участвуют во взаимодействии объектов? Хм... А то, что BinaryFormatter при взаимодействии с сериализуемым объектом проверяет наличие у него атрибута Serializable - это ли не взаимодействие?
    ОтветитьУдалить
  15. @Idsa
    Ты путаешь теплое с мягким. Мы говорим про принцип проектирования, который влияет на дизайн системы. Аттрибуты здесь никакую роль не играют.
    ОтветитьУдалить
  16. Возможно, действительно путаю. Однако никак не могу уловить существенной разницы между добавлением двух элементов вместо одного в твоем примере и наличием/отсутствия атрибута в моем.

    Более того, если мысленно объединить эти примеры, и представить логику "Count возвращает 0, если для класса не задан атрибут [ReturnNotZeroCount]", разница между примерами еще более размывается.
    ОтветитьУдалить
  17. Формулировка №2: подтипы должны быть заменяемы базовыми типами.
    По моему наоборот.
    ОтветитьУдалить
  18. И пример с листом мне кажется не корректным. Два класса реализуют свое поведение, наследуясь от интерфейса. Где здесь нарушение? Вот если б DoubleList наследовался от конкретного List, тогда да, а так по-моему все нормально.
    ОтветитьУдалить
  19. @Андрей

    > Формулировка №2: подтипы должны быть заменяемы базовыми типами
    > По моему наоборот

    Забавно.

    > ...по-моему все нормально

    Происходит нарушения контракта IList, этому не нормально.
    ОтветитьУдалить
  20. А где описан контракт IList и в частности его поведение "Count должен увеличиваться на 1 при однократном успешном вызове Add и уменьшаться на 1 при однократном успешном вызове Remove) ?
    Count - кол-во элементов в коллекции. Делать какие либо предположения о нём глупо(как и вообще делать наивные предположения). Нужно узнать кол-во - дёрните св-ва и узнаете. И для DoubleList там будет 2 после одного вызова.

    Возможно что хрупкие ментальные модели пользователей нарушатся от такого, но это неизбежно.
    ОтветитьУдалить
  21. @Григорий Перепечко
    В том, то и дело, что нигде не описано, разве что может быть в документации.

    А как бы вы отнеслись к .NET фреймворку, если бы в нем был объект SetList унаследованный от IList, который добавлял в себя элементы два раза?

    Думаю, что после первого знакомства с этой его "особенностью" вы бы поостереглись им пользоваться.

    Но самое главное здесь в другом. Посмотрите код:
    public void Method1(IList myList)
    {
    ///
    }

    Метод принимает на вход IList, и вы ждете от объекта myList его "нормального" поведения, другими словами выполнения контракта интерфейса IList. Если этот контракт будет нарушен одним из наследников этот интерфейса и этого наследника передадут в метод Method1, то вам придется внутри Method1 делать даункаст, чтобы проверить подтип.
    ОтветитьУдалить
  22. > Формулировка №2: подтипы должны быть заменяемы базовыми типами
    > По моему наоборот

    Андрея смутила русская википедия. Там действительно пурга написана:
    SOLID (объектно-ориентированное программирование)
    Принцип подстановки Лисков
    ОтветитьУдалить