В библиотеке Code Contracts, которая любезно предоставляет возможности контрактного программирования на платформе .NET, для задания предусловий и постусловий используются вызовы статических методов класса Contract. С одной стороны – это хорошо, поскольку альтернативная реализация на основе атрибутов была бы слишком ограниченной. С другой стороны – это добавляет определенные сложности, когда дело касается контрактов интерфейсов или абстрактных методов, которые, по своей природе, не содержат никакого кода, а значит и вызывать методы просто не откуда.
Решается эта с помощью двух атрибутов: ContractClassAttribute, который вешается на интерфейс или абстрактный класс, и ContractClassForAttribute – который вешается на сам контракт.
/// <summary> /// Custom collection interface /// </summary> [ContractClass(typeof(CollectionContract))] public interface ICollection { void Add(string s); int Count { get; } bool Contains(string s); } /// <summary> /// Contract class for <see cref="ICollection"/>. /// </summary> [ContractClassFor(typeof(ICollection))] internal abstract class CollectionContract : ICollection { public void Add(string s) { Contract.Ensures(Count >= Contract.OldValue(Count)); Contract.Ensures(Contains(s)); } public int Count { get { Contract.Ensures(Contract.Result<int>() >= 0); return default(int); } } [Pure] public bool Contains(string s) { return default(bool); } }
Польза от данного интерфейса ICollection кажется сомнительной, но зато с их помощью мы сможем увидеть все необходимые возможности и ограничения контрактов, применительно к наследованию интерфейсов. Основное внимание в этом примере стоит уделить двум членам класса CollectionContract: методу Add и свойству Count, которые задают предусловия/постусловия соответствующих методов.
Теперь, если некоторый класс реализует наш интерфейс ICollection и нарушит постусловие, то мы увидим это во время выполнения в виде исключения (при определенном символе CONTRACT_FULL), а также, возможно, во время статического анализа кода Static Checker-ом:
internal class CustomCollection : ICollection { private readonly List<string> _backingList = new List<string>(); public void Add(string s) { // Ok, we're crazy enough to violate precondition // of ICollection interface if (Contains(s)) _backingList.Remove(s); else _backingList.Add(s); } public int Count { get { // We should add some hints to static checker to eliminate a warning Contract.Ensures(Contract.Result<int>() == _backingList.Count); return _backingList.Count; } } public bool Contains(string s) { return _backingList.Contains(s); } }
В данном случае именно это и происходит: Static Checker определяет, что в некоторых случаях постусловие метода Add не выполняется (при добавлении существующего элемента, мы его удаляемJ). Но если мы ему не поверим, то можем увидеть нарушение контракта во время выполнения.
[Test] public void TestAddTwiceAddsTwoElements() { var collection = new CustomCollection(); int oldCount = collection.Count; collection.Add(""); collection.Add(""); Assert.That(collection.Count, Is.EqualTo(oldCount + 2)); }
Этот тест упадет не при вызове Assert.That, а раньше, при попытке повторного вызова метода Add, с исключением вида: System.Diagnostics.Contracts.__ContractsRuntime+ContractException: Postcondition failed: Count >= Contract.OldValue(Count)
ПРИМЕЧАНИЕ
Статический анализ является одной из самых интересных возможностей «контрактного программирования», но сейчас можно смело говорить, что эта штука в библиотеке Code Contracts еще не готова для реальных проектов. Во-первых, время компиляции может увеличиться на порядок (!), во-вторых, вокруг него придется не по-детски попрыгать с бубном, чтобы он понял, что происходит, но даже в этом случае он мало чем сможем помочь в сложных случаях. Даже в таком простом примере, как с классом CustomCollection пришлось добавлять постусловие в свойство Count вручную, поскольку без него статический анализатор вообще не понимал, что происходит и выдавал кучу предупреждений. Все остальные преимущества контрактов, типа декларативности, документации, формализации отношений и т.п. остаются, но работать они будут во время выполнения (например, в связке с юнит-тестами), а не во время компиляции.
Ослабление предусловия и усиление постусловий
Контракты позволяют формализовать отношения не только между классами и их клиентами, но также между классами и их наследниками. Предусловия виртуального метода скажут клиенту о том, что нужно выполнить для вызова этого метода, а постусловие – что данный метод сделает взамен; причем клиентский код может рассчитывать на выполнение этого контракта, не зависимо от того, какой будет динамический тип объекта, с которым он работает. Именно об этом говорит принцип замещения Лисков, который мы обсуждали в прошлый раз и к которому еще вернемся.
Однако принцип замещения не запрещает наследникам вносить изменения в семантику метода, если это не «поломает» предположения клиентов. Так, предусловие метода, переопределенного в наследнике, может быть менее строгим к вызывающему коду (может содержать более слабое предусловие), а постусловие может быть более строгим – метод наследника может давать более «точный» результат. Чтобы перевести с русского на русский, давайте рассмотрим простой пример:
class Base { public virtual object Foo(string s) { Contract.Requires(!string.IsNullOrEmpty(s)); Contract.Ensures(Contract.Result<object>() != null); return new object(); } } class Derived : Base { public override object Foo(string s) { // Now we're requiring empty string Contract.Requires(s != null); // And returning only strings Contract.Ensures(Contract.Result<object>() is string); return s; } }
В данном примере метод наследника требует меньше: пустая строка теперь является корректным значением; и дает более «точный» результат: возвращается не просто object, а тип string (хотя это гарантируется не компилятором, а Static Checker-ом).
ПРИМЕЧАНИЕ
Характерным примером усиления постусловия является возможность производными классами возвращать более конкретный тип. Эта возможность называется ковариантностью по типу возвращаемого значения и доступна в таких языках, как С++ или Java. Если бы язык C# поддерживал эту возможность, то можно было бы изменить сигнатуру метода Derived.Foo и возвращать string, а не object. Другим примером ослабление предусловий и усиления постусловий является ковариантность и контравариантость делегатов и интерфейсов, доступные с 4-й версии языка C#. Подробнее о «строгости» условий можно почитать в статье Проектирование по контракту. О корректности ПО, а о контрактах и наследовании – в статье Проектирование по контракту. Наследование.
Разработчики CodeContracts посчитали возможность ослабление постусловий бессмысленным, поэтому такой возможности у нас с нет. Приведенный выше код класса Derived компилируется, но предусловие метода Derived.Foo не ослабляется, а значит, при передаче пустой строки предусловие будет нарушено. Однако, в отличие от предусловий, с постусловиями у нас почти все в порядке. Постусловия (кстати, как и инварианты класса) «суммируются», что действительно позволяет гарантировать больше. (Если изменить тело метода Derived.Foo таким образом, чтобы в некоторых случаях возвращался int, а не string, то это нарушение будет обнаружено Static Checker-ом, а также будет проверено во время выполнения.)
Постусловия и интерфейсы
Теперь давайте перейдем от базовых классов, к интерфейсам. В первом разделе мы рассмотрели интерфейс ICollection, постусловием которого являлось «не уменьшение» количества элементов коллекции. Данное постусловие получено на основе контракта интерфейса ICollectionofT из состава BCL. После установки Code Contracts мы получаем в свое распоряжение не только возможность создания контрактов для собственных классов, но и возможность использования контрактов стандартных классов из состава BCL.
Но прежде чем переходить к анализу контрактов стандартных интерфейсов, давайте попробуем создать собственную иерархию интерфейсов, и поиграться с постусловиями метода Add.
[ContractClass(typeof(ListContract))] public interface IList : ICollection { } [ContractClassFor(typeof(IList))] internal abstract class ListContract : IList { public void Add(string s) { // Lets create stronger postcondition than ICollection.Add Contract.Ensures(Count == Contract.OldValue(Count) + 1); } // Постусловия свойства Count и метода Contains не поменялись }
Настоящий интерфейс IList добавляет не только постусловия, но и кучу всяких других интересностей, но в данном случае это не важно. Теперь добавляем класс, реализующий интерфейс IList, нарушающий постусловие интерфейса:
public class CustomList : IList { private readonly List<string> _backingList = new List<string>(); public void Add(string s) { // IList postcondition is Count = OldCount + 1, // we're violating it _backingList.Add(s); _backingList.Add(s); } public int Count { get { return _backingList.Count; } } public bool Contains(string s) { return _backingList.Contains(s); } }
Мы явно нарушаем постусловие метода Add интерфейса IList, поскольку увеличивает количество элементов не на один, а сразу же на 2. Однако печалька состоит в том, что ни статический анализатор, ни даже рерайтер никак не реагирует на усиление постусловий в интерфейсах. Сейчас, по сути, такая возможность библиотекой Code Contract не поддерживается (причем разработчики считают это фичей, а не багой, подробности здесь). Так что на данный момент мы можем усиливать постусловия виртуальных методов, можем усилить постусловие в классе, реализующем некоторый интерфейс, но мы не можем усиливать постусловия в интерфейсах наследниках!
Неприятность этого момента состоит в следующем: во-первых, единственный способ узнать о существовании более строго постусловия интерфейса заключается в ручном поиске кода контрактов (напомню, что ни Static Checker, ни рерайтер не добавляет информации о постусловии наследника в результирующий код); во-вторых, данный пример не искусственный, с этой проблемой можно столкнуться при использовании стандартных интерфейсов коллекций BCL.
КонтрактыICollection of T иIList of T
Если порыться хорошенько в сборке mscorlib.Contracts.dll, которая появляется после установки Code Contracts, то можно найти много чего интересного о контрактах стандартных классов .NET Framework и контрактах коллекций, в частности. Вот контракты основных методов интерфейсов ICollectionofT и IListofT:
// From mscorlib.Contracts.dll [ContractClassFor(typeof(ICollection<>))] internal abstract class ICollectionContract<T> : ICollection<T>, IEnumerable<T>, IEnumerable { public void Add([MarshalAs(UnmanagedType.Error)] T item) { Contract.Ensures(this.Count >= Contract.OldValue<int>(this.Count), "this.Count >= Contract.OldValue(this.Count)"); } public void Clear() { Contract.Ensures(this.Count == 0, "this.Count == 0"); } public int Count { get { int num = 0; Contract.Ensures(Contract.Result<int>() >= 0, "Contract.Result<int>() >= 0"); return num; } } } // From mscorlib.Contracts.dll [ContractClassFor(typeof (IList<>))] internal abstract class IListContract<T> : IList<T>, ICollection<T>, IEnumerable<T>, IEnumerable { void ICollection<T>.Add([MarshalAs(UnmanagedType.Error)] T item) { Contract.Ensures(this.Count == (Contract.OldValue<int>(this.Count) + 1), "Count == Contract.OldValue(Count) + 1"); } public int Count { get { int num = 0; return num; } } }
ПРИМЕЧАНИЕ
Контракты для разных версий .NET Framework располагаются в разных местах, контракты для 4-й версии фреймворка, например, расположены по следующему пути: “%PROGRAMS%MicrosoftContractsContracts.NETFrameworkv.4.0”. Сборки с контрактами называются следующим образом: OriginalAssemblyName.Contracts.dll: mscorlib.Contracts.dll, System.Contracts.dll, System.Xml.Contracts.dll.
Как мы видим, постусловие списка действительно сильнее и оно требует, чтобы при вызове метода Add в списке появился новый элемент, причем только один. Разница в постусловиях двух интерфейсов связана с тем, что не все коллекции BCL добавляют новый элемент при вызове метода Add (HashSet и SortedSet не добавляют элемент, если он уже присутствует в коллекции); однако все списки добавляют только один новый элемент. Решается данная проблема путем добавления явного постусловия конкретному классу коллекции (ListofT или, как в нашем случае, классу DoubleList), однако в этом случае теряется главная фишка контрактов интерфейсов: возможность специфицировать поведение семейства классов.
Заключение
Не всем разработчикам комфортно думать о контрактах интерфейсов или абстрактных методов, поскольку в .NET они не содержат никакой информации о предполагаемом поведении. Но если посмотреть на это с другой стороны, то важность контрактов именно для таких методов значительно выше. В случае с конкретным методом, мы можем посмотреть его реализацию и определить явные или неявные предусловия и постусловия. Но для определения контракта интерфейса мы можем отталкиваться лишь от скудной и неформальной документации или проанализировать все реализации этого интерфейса, чтобы определить «общий» знаменатель того поведения, которое не должно нарушаться согласно принципу подстановки.
Дополнительные ссылки
- ContractsAndInheritance project on GitHub. Содержит все примеры данной статьи с тестами и комментариями
- Бертран Мейер. Объектно-ориентированное конструирование программных систем
- Проектирование по контракту. Наследование
Автор: SergeyT