В библиотеке 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#. Подробнее о «строгости» условий можно почитать в статье Проектирование по контракту. О корректности ПО, а о контрактах и наследовании – в статье Проектирование по контракту. Наследование.
Разработчики Code Contracts посчитали возможность ослабление предусловий бессмысленным, поэтому такой возможности у нас с нет. Приведенный выше код класса Derived компилируется, но предусловие метода Derived.Foo не ослабляется, а значит, при передаче пустой строки предусловие будет нарушено. Однако, в отличие от предусловий, с постусловиями у нас почти все в порядке. Постусловия (кстати, как и инварианты класса) «суммируются», что действительно позволяет гарантировать больше. (Если изменить тело метода Derived.Foo таким образом, чтобы в некоторых случаях возвращался int, а не string, то это нарушение будет обнаружено Static Checker-ом, а также будет проверено во время выполнения.)
Постусловия и интерфейсы
Теперь давайте перейдем от базовых классов, к интерфейсам. В первом разделе мы рассмотрели интерфейс ICollection, постусловием которого являлось «не уменьшение» количества элементов коллекции. Данное постусловие получено на основе контракта интерфейса ICollection of T из состава 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 и контрактах коллекций, в частности. Вот контракты основных методов интерфейсов ICollection of T и IList of T:
// 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%\Microsoft\Contracts\Contracts\.NETFramework\v.4.0\”. Сборки с контрактами называются следующим образом: OriginalAssemblyName.Contracts.dll: mscorlib.Contracts.dll, System.Contracts.dll, System.Xml.Contracts.dll.
Как мы видим, постусловие списка действительно сильнее и оно требует, чтобы при вызове метода Add в списке появился новый элемент, причем только один. Разница в постусловиях двух интерфейсов связана с тем, что не все коллекции BCL добавляют новый элемент при вызове метода Add (HashSet и SortedSet не добавляют элемент, если он уже присутствует в коллекции); однако все списки добавляют только один новый элемент. Решается данная проблема путем добавления явного постусловия конкретному классу коллекции (List of T или, как в нашем случае, классу DoubleList), однако в этом случае теряется главная фишка контрактов интерфейсов: возможность специфицировать поведение семейства классов.
Заключение
Не всем разработчикам комфортно думать о контрактах интерфейсов или абстрактных методов, поскольку в .NET они не содержат никакой информации о предполагаемом поведении. Но если посмотреть на это с другой стороны, то важность контрактов именно для таких методов значительно выше. В случае с конкретным методом, мы можем посмотреть его реализацию и определить явные или неявные предусловия и постусловия. Но для определения контракта интерфейса мы можем отталкиваться лишь от скудной и неформальной документации или проанализировать все реализации этого интерфейса, чтобы определить «общий» знаменатель того поведения, которое не должно нарушаться согласно принципу подстановки.
Дополнительные ссылки
-
ContractsAndInheritance project on GitHub. Содержит все примеры данной статьи с тестами и комментариями
-
Бертран Мейер. Объектно-ориентированное конструирование программных систем
Сергей, я правильно понимаю, что для использования контрактов нужно использовать один интерфейс с атрибутом ContractClass и собственно класс контракта с атрибутом ContractClassFor? В этом случае пустой интерфейс с атрибутом ContractClass выглядит странно. На мой взгляд класса контракта вполне достаточно. Может ты знаешь, зачем потребовался отдельное описание интерфейса?
ОтветитьУдалитьНе совсем тебя понял, что ты имеешь ввиду под "пустой интерфейс с атрибутом Contractclass выглядит странно"?
ОтветитьУдалитьЕсли ты за мой пустой интерфейс IList, то я просто не добавлял туда новых методов, чтобы его не захламлять. В целом же, там они вполне могут быть (и наверняка будут).
Ок. Попробую снова :). Посмотри, если я хочу сделать контракт например для интерфейса IList. Зачем делать для этого еще один IMyList : IList. Не нужно делать еще один интерфейс только для того, чтобы его рассматривать для контракта. Т.е класса ListContract : IList вполне бы хватило. Т.е. класс контракта вполне явно указывает КАКОЙ интерфейс будет реализован. Соответственно не вижу надобности в интерфейсе с атрибутом ContractClass.
ОтветитьУдалить[ContractClassFor(typeof(ICollection))]
internal abstract class CollectionContract : ICollection. Вот здесь по идее у нас вся необходимая инфа есть. Ага, понял, почему ты не понял меня в первом посте. У меня вопрос как быть с интерфейсами, которые уже объявлены и которые могут не иметь атрибута ContractClass.
Чтобы контракт для интерфейса работал нужно выполнение двух условий:
ОтветитьУдалить(1) Атрибут ContractClass на интерфейсе с указанием типа контракта и
(2) Класс контракта, реализующего этот интерфейс с атрибутом ContractClassFor
Добавить контракт к существующему интерфейсу просто путем создания класса с атрибутом ContractClassFor (т.е. выполнение только второго условия) недостаточно. Поскольку в этом случае каждый мог бы добавить контракт к чему угодно.
Интерфейсам, которые уже объявлены нельзя добавить контракт без его изменения.
Добавить контракт к существующему интерфейсу просто путем создания класса с атрибутом ContractClassFor (т.е. выполнение только второго условия) недостаточно. Поскольку в этом случае каждый мог бы добавить контракт к чему угодно
ОтветитьУдалитьКаждый мог бы добавить контракт к чему угодно. А разве это не цель? Если я реализую класс контракта, который реализует ТОТ интерфейс, который бы соответствовал контракту, то я четко указываю, какой интерфейс меня интересует. Предположим, если ты делаешь контракт на какой-нибудь список, вероятность того что часть списков будет выполнять контракт, а часть нет - как-то попахивает это... Если же надо реализовать какой-то специфический контракт - то можно сделать и отдельный интерфейс. Но в общем случае - это выглядит странно..
Стоп. Ты не реализуешь класс контракта, ты просто реализуешь интерфейс, у которого есть контракт, причем только один.
ОтветитьУдалитьУ интерфейса должен быть только один контракт и он должен разрабатываться автором самого интерфейса, поскольку именно он (автор) знает семантику этого интерфейса. И именно поэтому связка должна быть двусторонней (тип интерфейса должен сам указывать контрактный класс), поскольку в противном случае у интерфейса было бы 22 противоречивых контракта.
Разработчики Code Contracts посчитали возможность ослабление постусловий бессмысленным, поэтому такой возможности у нас с нет.
ОтветитьУдалитьПоходу ошибка: не постусловий, а предусловий.
@Alexander: спасибо большое! Обязательно поправлю опечатку.
ОтветитьУдалить