понедельник, 5 марта 2012 г.

Наследование интерфейсов и контракты

В библиотеке 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 они не содержат никакой информации о предполагаемом поведении. Но если посмотреть на это с другой стороны, то важность контрактов именно для таких методов значительно выше. В случае с конкретным методом, мы можем посмотреть его реализацию и определить явные или неявные предусловия и постусловия. Но для определения контракта интерфейса мы можем отталкиваться лишь от скудной и неформальной документации или проанализировать все реализации этого интерфейса, чтобы определить «общий» знаменатель того поведения, которое не должно нарушаться согласно принципу подстановки.

Дополнительные ссылки
  1. ContractsAndInheritance project on GitHub. Содержит все примеры данной статьи с тестами и комментариями

8 комментариев:

  1. Сергей, я правильно понимаю, что для использования контрактов нужно использовать один интерфейс с атрибутом ContractClass и собственно класс контракта с атрибутом ContractClassFor? В этом случае пустой интерфейс с атрибутом ContractClass выглядит странно. На мой взгляд класса контракта вполне достаточно. Может ты знаешь, зачем потребовался отдельное описание интерфейса?

    ОтветитьУдалить
  2. Не совсем тебя понял, что ты имеешь ввиду под "пустой интерфейс с атрибутом Contractclass выглядит странно"?

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

    ОтветитьУдалить
  3. Ок. Попробую снова :). Посмотри, если я хочу сделать контракт например для интерфейса IList. Зачем делать для этого еще один IMyList : IList. Не нужно делать еще один интерфейс только для того, чтобы его рассматривать для контракта. Т.е класса ListContract : IList вполне бы хватило. Т.е. класс контракта вполне явно указывает КАКОЙ интерфейс будет реализован. Соответственно не вижу надобности в интерфейсе с атрибутом ContractClass.
    [ContractClassFor(typeof(ICollection))]
    internal abstract class CollectionContract : ICollection. Вот здесь по идее у нас вся необходимая инфа есть. Ага, понял, почему ты не понял меня в первом посте. У меня вопрос как быть с интерфейсами, которые уже объявлены и которые могут не иметь атрибута ContractClass.

    ОтветитьУдалить
  4. Чтобы контракт для интерфейса работал нужно выполнение двух условий:
    (1) Атрибут ContractClass на интерфейсе с указанием типа контракта и
    (2) Класс контракта, реализующего этот интерфейс с атрибутом ContractClassFor

    Добавить контракт к существующему интерфейсу просто путем создания класса с атрибутом ContractClassFor (т.е. выполнение только второго условия) недостаточно. Поскольку в этом случае каждый мог бы добавить контракт к чему угодно.

    Интерфейсам, которые уже объявлены нельзя добавить контракт без его изменения.

    ОтветитьУдалить
  5. Добавить контракт к существующему интерфейсу просто путем создания класса с атрибутом ContractClassFor (т.е. выполнение только второго условия) недостаточно. Поскольку в этом случае каждый мог бы добавить контракт к чему угодно


    Каждый мог бы добавить контракт к чему угодно. А разве это не цель? Если я реализую класс контракта, который реализует ТОТ интерфейс, который бы соответствовал контракту, то я четко указываю, какой интерфейс меня интересует. Предположим, если ты делаешь контракт на какой-нибудь список, вероятность того что часть списков будет выполнять контракт, а часть нет - как-то попахивает это... Если же надо реализовать какой-то специфический контракт - то можно сделать и отдельный интерфейс. Но в общем случае - это выглядит странно..

    ОтветитьУдалить
  6. Стоп. Ты не реализуешь класс контракта, ты просто реализуешь интерфейс, у которого есть контракт, причем только один.

    У интерфейса должен быть только один контракт и он должен разрабатываться автором самого интерфейса, поскольку именно он (автор) знает семантику этого интерфейса. И именно поэтому связка должна быть двусторонней (тип интерфейса должен сам указывать контрактный класс), поскольку в противном случае у интерфейса было бы 22 противоречивых контракта.

    ОтветитьУдалить
  7. Разработчики Code Contracts посчитали возможность ослабление постусловий бессмысленным, поэтому такой возможности у нас с нет.

    Походу ошибка: не постусловий, а предусловий.

    ОтветитьУдалить
  8. @Alexander: спасибо большое! Обязательно поправлю опечатку.

    ОтветитьУдалить