Проектирование и рефакторинг / Принцип замещения Лисков и контракты

в 13:24, , рубрики: .net, code contracts, ооп, проектирование по контракту, метки: , , ,

Идея этой заметки навеяна статьей Александра Бындю “Дополнение к LSP” и может рассматриваться, как развернутый комментарий к статье Александра.

Итак, вопрос следующий, предположим, один из членов команды пытается реализовать интерфейс IListofT в классе DoubleListofT таким образом, чтобы при добавлении элемента с помощью метода Add, добавлялся бы не один, а два одинаковых элемента. Поскольку класс ListofT всегда добавляет только один элемент, то можно считать, что данное поведение нарушает принцип замещения Лисков (LSP – Liskov Substitution Principle).


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

Для начала нужно внести небольшую правку в условие задачи (да, я понимаю, что это не честно, но в данном случае это оправдано). В исходной задаче говорилось о реализации классом DoubleList интерфейса IListofT, однако на самом деле в .NET Framework метод Add объявлен не в интерфейсе ICollectionofT, а значит, следует рассматривать, нарушает ли класс DoubleListofT контракт коллекции, а не списка.

ПРИМЕЧАНИЕ
Для простоты чтения кода и запуска примеров, я буду использовать не обобщенную версию класса DoubleList, а специализированный контейнер для хранения строк, т.е. по сути, наш DoubleList будет реализовать интерфейс ICollectionofstring.

public class DoubleList : ICollection<string> {     private readonly List<string> _backingList = new List<string>();       public void Add(string item)     {         // Вместо добавления элемента один раз, добавляем его 2 раза          _backingList.Add(item);         _backingList.Add(item);     }       // Остальные методы не важны  } 

Теперь, предположим, что где-то в коде мы используем интерфейс ICollectionofstring (т.е. имеем полиморфное использование) и рассчитываем на то, что количество элементов увеличится строго на один. Подобное поведение можно выразить либо с помощью формального постусловия (например, с использованием Code Contract), либо можно выразить в виде юнит-теста. Поскольку к контрактам мы перейдем позже, то давайте рассмотрим вначале юнит-тест:

[Test] public void TestAddMethodAddsOnlyOneElement() {     ICollection<string> collection = new DoubleList();     int oldCount = collection.Count;       collection.Add("foo");     Assert.That(collection.Count, Is.EqualTo(oldCount + 1)); } 

Да, действительно, этот тест будет нормально работать при использовании в качестве объекта collection Listofstring, и будет падать в случае использования DoubleList.

Но вот только один вопрос: а вправе ли мы ожидать от метода Add интерфейса ICollectionofT именно такого поведения? Когда речь заходит о корректности (проще говоря, о том, баг это или нет, причем не важно, где: в коде или дизайне), то она определяется лишь тем, соответствует ли поведение кода спецификации или нет. Спецификация может быть формальной или не формальной, но программа некорректна лишь тогда, когда она перестает делать то, для чего она предназначена, а если никто не знает, для чего она предназначена, то уже никто не может сказать, что она работает не правильно.

Некоторое выражение, такое как x = y / 2, само по себе не является ни корректным, ни не корректным. Все зависит от того, каким должно быть отношение между x и y (подробнее об этом см. “Проектирование по контракту. Корректность ПО”). Ситуация с методом Add аналогична: без формального или хотя бы неформального описания того, что должен делать этот метод мы не может утверждать правильно ли он реализован наследником. Если же мы откроем официальную документацию для ICollection (Of T).Add Method, то все что мы увидим, это одну строчку, в которой будет сказано, что этот метод добавляет элемент в коллекцию. При этом все остальное (например, а точно ли он будет добавлен или в каком количестве), это лишь наши с вами допущения и единственное, что можно сделать, это постараться выяснить, как ведут себя другие реализации интерфейса ICollectionofT.

Давайте добавим следующий тест, но на этот раз, будем проверять, что добавление двух одинаковых элементов, будет увеличивать количество элементов на 2. Понятное дело, что это более частный случай, по сравнению с добавлением одного элемента, но ведь, опять-таки, если вернуться к документации метода Add, то там ни слова не говорится о том, что мы не можем добавлять одинаковые элементы; единственным ограничением является то, чтобы мы не добавляли элементы в коллекцию только для чтения. Других предусловий (в официальной документации они обычно выражаются в виде исключений) документация не содержит, а значит и никаких других проверок перед вызовом метода Add делать не нужно:

[TestCaseSource("GetAllCollections")] public void TestAddToCollectionTwiceAddsTwoElement(ICollection<string> collection) {     int oldCount = collection.Count;     if (collection.IsReadOnly)     {         Console.WriteLine("Current collection type ({0}) is readonly. Skipping it...",         collection.GetType());         return;     }       collection.Add("foo");     collection.Add("foo");     Assert.That(collection.Count, Is.EqualTo(oldCount + 2)); } 

Еще раз обращаю внимание на проверку предусловия метода Add, контракты – это не игра в одни ворота, поэтому мы должны выполнять свою часть договора, чтобы вызываемый код выполнял свою. Сам тест, является параметризованным (из состава NUnit), входные значения которого будут браться из метода GetAllCollections. Я не будут заморачиваться с поиском всех существующих классов, реализующих интерфейс ICollectionofT, а просто добавлю некоторые популярные типы коллекций вручную:

private static ICollection<string>[] GetAllCollections() {     return new ICollection<string>[]                  {                      new List<string>(),                      new HashSet<string>(),                      new Collection<string>(),                      new BindingList<string>(),                      new LinkedList<string>(),                      new ObservableCollection<string>(),                      new ReadOnlyCollection<string>(new List<string>()),                      new SortedSet<string>(),                      new string[]{},                  }; } 

Теперь запускаем приведенный выше тест и видим, что как минимум два типа коллекций добавляют лишь один элемент, хотя мы просили добавить два. Это, конечно же, типы коллекций, которые не позволяют хранить дубликаты, такие как HashSetofT и SortedSetofT.

А теперь давайте вернемся к исходному вопросу: нарушает ли реализация метода Add классом DoubleList принцип замещения Лисков? Ответ: Нет, не нарушает!

Метод Add интерфейса ICollectionofT не налагает никаких ограничений на то, какое количество элементов будет в коллекции после добавления, а значит, мы не можем требовать от всех классов, реализующих этот интерфейс, следовать определенному правилу. На самом деле, более правдоподобным (исходя из поведения коллекций, реализующих ICollectionofT) является следующее предусловие: после вызова метода Add, следующий за ним вызов метода Contains должен вернуть true и количество элементов коллекции не должно уменьшиться (т.е. newCount >= oldCount).

В таком случае, если в нашем тесте заменить существующее утверждение на:

Assert.IsTrue(collection.Contains("foo"));

то все тесты будут проходить успешно.

Заключение

Можно смело говорить о том, что DoubleList не нарушает принцип замещения Лисков, либо нарушает вместе с некоторыми стандартными классами коллекций из BCL. С другой стороны, я согласен, что дизайн класса DoubleList «кривоват», но не из-за нарушения некоторого принципа, понять формулировку которого в здравом уме практически невозможно (*), я бы скорее апеллировал к тому, что подобное поведение является интуитивно не понятным и наверняка приведет к проблемам в сопровождении.

Не зря Мейер в своей толстенной книге (см. доп. ссылки) столь важную роль отводит формальной спецификации программ с помощью утверждений (предусловий, постусловий и инвариантов). Именно отсутствие формального описания того, что должен делать метод делает таким сложным написания наследника, который бы работал «правильно», поскольку что такое «правильно», никто сказать не может. Сигнатура метода (и возможный комментарий) являются слишком неформальными, чтобы понять «изменится ли поведение при использовании наследника вместо базового класса».
В следующий раз мы посмотрим, как CodeContracts (поддержка которых частично включена в состав mscorlib) могут помочь в решении нашей задачи, и о том, какое же формальное постусловие есть у метода Add.

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

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

В замечательной книге Джеймса Коплиена «Программирование на языке С++» дается отличное, и совершенно непонятное определение принципа замещения Лисков:

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

В целом, все хорошо, за исключением того, что совершенно не понятно, что означает «в контексте Т» и совсем не понятно, что означает, «поведение Pне изменится», когда это самое поведение нигде не описано.

Другое популярное, но не более понятное определение, можно найти в книге Боба Мартина:

Должна быть возможность вместо базового типа подставить любое его подтип.

Это определение проще, но едва ли яснее. В любом объектно-ориентированном языке программирования, существует неявное преобразование от наследника к базовому классу (при учете использования открытого наследования), таким образом, это требование стоит отнести скорее к компилятору или языку программированию, а не к классам определяемым пользователям. Сам Мартин в своей книге (а также в статье The Liskov Substituion Principle) говорит о важности контрактов, и в частности, о важности предусловий и постусловий для спецификации поведения виртуальных методов.

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

Дополнительные ссылки
  1. Бертран Мейер. Объектно-ориентированное конструирование программных систем
  2. Роберт К. Мартин. Принципы, паттерны и методики гибкой разработки на языке C#
  3. Robert C. Martin The Liskov Substitution Principle
  4. Robert C. Martin Design Principles and Patterns
  5. Проектирование по контракту. О корректности ПО
  6. Проектирование по контракту. Наследование

Автор: SergeyT


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js