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

Альтернативная проверка предусловий в Code Contracts

При попытке использования библиотеки Code Contracts в реальном проекте может возникнуть небольшая сложность: хотя сам класс Contract с методами проверки предусловий и постусловий, располагается в mscorlib начиная с 4-й версии .NET Framework, но без установки самой библиотеки Code Contracts, они не попадают в результирующую сборку.

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

Однако Code Contracts поддерживает дополнительный «режим совместимости», который позволяет «жестко зашить» проверки предусловий в результирующий код, так что они будут видны всем, не зависимо от того, установлены контракты на машине разработчика или нет.

Постановка проблемы

Давайте вначале рассмотрим пример, который более четко покажет, в чем проблема.

class SimpleClass
{
   
public int Foo(string
s)
    {
       
Contract.Requires(s != null
);
       
return s.Length;
    }
}

С этим кодом совершенно все в порядке и при попытке вызова метода Foo с null, мы получим нарушение контракта, что при установленной библиотеке Code Contracts и включенной проверке предусловий приведет к генерации исключения 'System.Diagnostics.Contracts.__ContractsRuntime.ContractException' .

Да, именно этого мы и ждем, но особенность заключается в том, что код генерации исключения генерируется не компилятором, а отдельным процессом, который запускается сразу после компиляции. А это значит, что без библиотеки Code Contracts, выполнение этого кода приведет к генерации NullReferenceExcpetion, поскольку никакой дополнительной валидации аргументов не останется и в помине. Я неоднократно сталкивался с тем, что такое поведение вызывало примерно такую реакцию: «WTF? Куда делась моя проверка!»

Поскольку мы не хотим слышать подобные “WTF?!?” от наших коллег, у которых контракты не установлены, то хотелось бы иметь способ зашить проверку предусловий более основательным образом.

Ручная проверка предусловий

Библиотека Code Contracts позволяет использовать предусловия в старом формате. Это значит, что если существующий метод уже содержит проверку входных параметров (т.е. проверку предусловий), то для преобразования их в полноценные предусловия после них достаточно добавить вызов Contract.EndContractBlock:

public class SimpleClass
{
   
public int Foo(string
s)
    {
       
if (s == null
)
           
throw new ArgumentNullException("s"
);
       
Contract
.EndContractBlock();

       
return s.Length;
    }
}

Добавление вызова Contract.EndContractBlock превращает одну (или несколько) проверок входных параметров в полноценные предусловия. Теперь, для любого разработчика, у которого контракты не установлены, этот код будет выглядеть, как и раньше. В то время, как обладатели контрактов, смогут пользоваться всеми их преимуществами, такими как проверка валидности программы с помощью Static Checker-а, автоматическая генерация документации, возможность отлова всех нарушений контрактов (подробнее об этом будет ниже). Отличие этого способа проверки лишь в том, что их нельзя отключить и выпилить из кода полностью.

Данный подход можно совмещать с более продвинутыми техниками использования контрактов. Так, например, можно совмещать old-style проверку предусловий совместно с проверкой постусловий и инвариантов. Но поскольку постусловия и инварианты в большей мере касаются самого класса, а не его клиентов, то это никак не затронет всех тех разработчиков, у которых контракты не установлены.

ПРИМЕЧАНИЕ
Библиотека Code Contracts позволяет настраивать, какие проверки должны оставаться в результирующем коде. Если разработчики достаточно уверены в своем коде, то они могут убрать все проверки из кода и сэкономить несколько тактов процессора на каждой из них. Более подробно об уровнях мониторинга и возможностях по их управлению можно почитать в статье: «Мониторинг утверждений в период выполнения».

Использование существующих методов проверки

Еще одним стандартным способом валидации аргументов является использование специальных классов (guard-ов) с набором разных методов, типа NotNull, NotNullOrEmpty и т.п. Библиотека Code Contracts поддерживает возможность превращения подобных методов в полноценные контракты: для этого методы класса валидатора нужно пометить атрибутом ContractArgumentValidatorAttribute.

ПРИМЕЧАНИЕ
К сожалению атрибут ContractArgumentValidatorAttribute не входит в состав .NET Framework версии 4.0, он появится только в версии 4.5. Разруливается эта ситуация путем добавления в ваш проект файла ContractExtensions.cs, который появится в %ProgramFiles%\Microsoft\Contracts\Language\CSharp после установки библиотеки Code Contracts.

public static class Guard
{
    [
ContractArgumentValidatorAttribute
]
   
public static void IsNotNull<T>(T t) where T : class
    {
       
if (t == null
)
           
throw new ArgumentNullException("t"
);
       
Contract.EndContractBlock();
    }
}

Теперь мы можем использовать старый добрый метод IsNotNull для проверки предусловий:

public int Foo(string s)
{
   
Guard
.IsNotNull(s);

   
return s.Length;
}

Отступление от темы. Contract.ContractFailed

Возможно, вы обращали внимание на существование двух версии метода Contract.Requires, одна из которых является обобщенной (generic) и может использоваться для генерации нужного типа исключения, нарушение же необобщенной версии приводит к генерации внутреннего (internal) исключение типа ContractException.

Причина, по которой по умолчанию генерируется внутреннее исключение, заключается в том, что нарушение контракта не может быть восстановлено программным путем. Это баг в коде и для его устранения необходимо изменение этого кода. Однако при использовании любого подхода к проверке предусловий (Contract.Requires + 2 рассмотренных сегодня подхода), пользователь может «захавать» исключение, перехватив базовый тип исключения.

В классе Contract есть событие ContractFailed, которое будет вызываться при нарушении предусловий/постусловий/инвариантов. Например, перед запуском интеграционного или юнит-теста можно подписаться на это событие, и если падает предусловие, но тест остается зеленым, то можно закатывать рукава и идти искать того нерадивого программиста, который ловит исключения, не предназначенные для обработки.

Заключение

Использование одного из описанных здесь подходов для перехода к контрактному программированию позволяет плавно мигрировать код на контракты, не ломая жизнь остальной части команды. При этом вы можете использовать старые средства валидации со всеми достоинствами контрактов (включая возможность узнать о нарушении предусловий, описанную в предыдущем разделе), не заставляя всех и каждого устанавливать дополнительные утилиты на свои машины.

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

  1. Kak vam eto ?
    http://social.msdn.microsoft.com/Forums/en-US/codecontracts/thread/e2b9b734-2d6e-432b-9735-b3a2b6316c8c

    ОтветитьУдалить
  2. Я так полагаю, контракты пытаются запомнить предыдущее состояние объекта для вычисления постусловия. Вот и вынесли "код запоминания" за скобки.

    Тоска... Надеюсь, что это исправят.

    ОтветитьУдалить
  3. Да и ограничение по видимости не радует. Класс, как правило, имеет закрытые данные и проверки в публичных методах, а тут получается надо делать ещё закрытые "проверочные" методы, чтобы у них был доступ к данным класса.

    Или я что-то упустил?..

    ОтветитьУдалить
  4. @Alexander: не совсем понял о каких закрытых "проверочных" методах идет речь.

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

    ОтветитьУдалить
  5. Именно об этом. Если в своих if-then-throw (в public методах) я мог городить любые условия, то здесь получается в предусловиях можно использовать только public данные.

    ОтветитьУдалить
  6. Да, но это не баг, это фича.
    Нарушение предусловия является багом в вызывающем коде, а это значит, что вызывающий код должен быть способным проверить, находится ли целевой объект в удовлетворительном состоянии или правильные ли аргументы.

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

    ОтветитьУдалить
  7. В принципе да, обдумал, согласен. Откуда вызывающему знать что его запрос неверен если доступа к данным, определяющим это, у него нет (хотя от этого не легче).

    Ну а практическое применение того, что вызывающий код может получить доступ к предусловиям есть? Не считая внутренних нужд самой этой системы.

    ОтветитьУдалить
  8. Практическое следствие заключается только в понятности: мы точно знаем, что происходит с нашим классом и когда он находится в валидном или не валидном состоянии.

    Сама идея проектирования по контракту более полезна с точки зрения дизайна как такового, а не с точки зрения поддержки инструментов. Подобное же ограничение просто уменьшит количество ситуаций, когда объект будет находится в непонятном состоянии (ибо он черный ящик) и будет не понятно, что с ним не так.

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