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

    В библиотеке 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%\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 не добавляют элемент, если он уже присутствует в коллекции); однако все списки добавляют только один новый элемент. Решается данная проблема путем добавления явного постусловия конкретному классу коллекции (ListofT или, как в нашем случае, классу DoubleList), однако в этом случае теряется главная фишка контрактов интерфейсов: возможность специфицировать поведение семейства классов.

    Заключение


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

    Дополнительные ссылки

    1. ContractsAndInheritance project on GitHub. Содержит все примеры данной статьи с тестами и комментариями
    2. Бертран Мейер. Объектно-ориентированное конструирование программных систем
    3. Проектирование по контракту. Наследование
    Share post

    Comments 10

      +3
      Можно в двух словах смысл контрактов на примерах? С Мейером лично общался, Эйфель щупал, но в среде .NET на реальных задачах — профита не нашёл (но и глубоко не искал). Т.е. с простыми случаями типа коллекций — понятно. Но именно в мире ынтырпрайза, на примерах ынтырпрайза? Когда у меня есть метод начисления заработной платы для сотрудника, где сотрудник и его должность — это параметры метода начисления денег, которое происходит на счёт сотрудника, являющийся его свойством. Что здесь описать в контракте? Убедиться, что баланс счёта сотрудника вырос на размер зп, дёрнув при этом БД и пройдясь по ассоциациям? Если да, то чем это отличается от покрытия этого же кода — тестами?
        +1
        В моем энтерпрайзе мне постоянно приходится думать над следующим: я вижу метод, принимающий некоторый объект. Обычно, подсовывать null туда нельзя, но в редких случаях, и если очень хочется то можно. Т.е. чтобы понять, что мне нужно сделать, мне нужно читать код, а там можем быть полотно спагетти-кода на пару экранов.

        Тоже самое касается и постусловий. Если класс, у которого есть коллекция в виде какого-то свойства. В большинство людей используют пустую коллекцию, и никогда не возвращают null, но это далеко не всегда. Постусловие в этом плане четко скажет, чего ожидать от этого свойства (или возвращающего значения метода).

        Да, в большинстве случаев более или менее сложные предусловия и постусловия живут в библиотеках, но некоторые бизнес-правила тоже можно формализовать. В целом так: исключения типа ArgumentNullException, InvalidArgumentEception, ArgumentOutOfRangeExcpetion все заменяются на соответствующие предусловия (ведь все эти исключения, на самом деле выбрасываются при нарушении предусловий). С постусловиями и инвариантами сложнее, поскольку они внутренние по отношению к вызывающему коду.

        В целом, рассмотрение взаимоотношений классов через призму контрактов позволяет мне понять, сферы ответственности обоих классов, и кто кому что должен. Опять таки, если поднимутся вопросы с некоторыми принципами, типа Liskov Substitution Principle, то контракты здесь очень ок.

        Ну и очень ОК использование контрактов для интерфейсов, понять семантику которых только исходя из документации весьма сложно.
          0
          >> Обычно, подсовывать null туда нельзя,
          Э-хе-хех…
          99% предусловий, которые я видел — это проверки на notnull. Что мешало MS сделать ссылочные типы НЕ допускающие null в качестве значения?
            0
            Да, согласен. Можно юзать что-то типа F#-а, там все референс-типы non-nullable. А из ОО языков, кажется только Eiffel поддерживает non-nullability на уровне языка.
              +1
              угу. тока для нормального интерпрайз проэкта — Ф-шарп илиЭйфил — это совсем не вариант, ты ж понимаешь…
          +1
          Нет, в контракте описывается, что сотрудник и должность должны быть ненулевыми, и что после выполнения метода сотрудник все еще остается ненулевым.
            +1
            Насчёт тестов — да, контракты позволяют писать тесты в декларативной форме. Равно как и не из описания в комментариях, а из утверждений контракта класса понять что он делает, что он требует и что гарантирует. Более явно это преимущество будет после интеграции наработок по Spec# в одну из будущих версий C#.

            Также контракты позволяют формировать автоматические тесты, работать по CDD(Contract-Driven Development) (по аналогии с TDD). Под .Net есть интересная разработка «Pex» (идёт вместе с «Moles»), которая учитывает контракты при построении автоматических white-тестов.

            По поводу БД — можно использовать описание котрактов через готовые модели (библиотеки классов, исключительно используемую для проверки контрактов).

            Под .Net контракты только появились и выгода в их использовании пока ещё не столь очевидна из-за того, что различные инструменты, которые могли бы их использовать в полной мере всё ещё развиваются. Те же наработки для Eiffel-я по непрерывным фоновым автоматическим тестам, предложенями компилятора по исправлению ичастков кода, параллельному программированию, когда котракты выступают в роли механизма синхронизации потоков и пр.

            ИМХО при текущей поддержке контрактов в .Net среде их выгодя для Enterprise не столь очевидна, но их следует применять хотя бы для того чтобы писать более обдуманный код, делиться предположениями/ожиданиями реализации с коллегами, которые потом будут переиспользовать ваш класс, да и просто на будущее, т.к. у контрактов очень большие перспективы.
              0
              Предусловия: сотрудник и должность не нулевые
              Постусловие: счёт сотрудника увеличился (хотя бы не уменьшился)
              Инвариант: счёт неотрицательный
            • UFO just landed and posted this here
                0
                У меня последняя версия.

                public int Count
                {
                get
                {
                // We should add some hints to static checker to eliminate a warning
                // Contract.Ensures(Contract.Result() == _backingList.Count);
                return _backingList.Count;
                }
                }


                Закомментируйте строку с Contract.Ensures, тогда предупреждение появится, что статик чекер не может гарантировать удовлетворение одного из постусловий.

              Only users with full accounts can post comments. Log in, please.