На переднем крае
Включение в классы программного контракта
Дино Эспозито (Dino Esposito)
Согласно старой, но доброй практике разработки ПО, рекомендуется размещать в начале каждого метода — до выполнения любой значимой логики — барьер в виде условных выражений. Каждое условное выражение проверяет свое условие, которому должны отвечать входные значения. Если условие не выполняется, код генерирует исключение. Этот шаблон часто называют If-Then-Throw.
Но достаточен ли If-Then-Throw для написания эффективного и корректного кода во всех случаях?
Понимание того, что такого шаблона недостаточно на все случаи жизни, не ново. Методология «Design by Contract» (DbC), введенная несколько лет назад Бертраном Мейером (Bertrand Meyer), основывается на том, что у каждой части ПО есть контракт, где формально описывается, что ожидается и что предоставляется. Шаблон If-Then-Throw почти охватывает первую часть контракта, но в нем полностью отсутствует вторая часть. Изначально DbC не поддерживается ни в каких мейнстримовых языках программирования. Однако существует ряд инфраструктур, позволяющих задействовать разновидности DbC в наиболее распространенных языках вроде Java, Perl, Ruby, JavaScript и, конечно, в языках для Microsoft .NET Framework. В .NET вы реализуете DbC через библиотеку Code Contracts, добавленную в .NET Framework 4; она находится в сборке mscorlib. Заметьте, что эта библиотека доступна приложениям Silverlight 4, но неприменима в приложениях Windows Phone.
Уверен: почти все разработчики в принципе согласятся с тем, что «contract-first» («сначала контракт») — отличный подход. Но не думаю, что так уж многие активно используют Code Contracts в приложениях .NET 4 после того, как Microsoft сделала доступными программные контракты и интегрировала их в Visual Studio. Эта статья посвящена в основном преимуществам подхода «contract-first», относящимся к удобству в сопровождении кода и упрощению разработки. Вы вполне сможете использовать аргументы из этой статьи для того, чтобы убедить своего босса применить Code Contracts в следующем проекте. В будущих выпусках этой рубрики я более глубоко рассмотрю такие аспекты, как конфигурация, средства исполняющей среды и программные механизмы вроде наследования.
Рассуждения о простом классе Calculator
Code Contracts (контракты кода) — это даже не инструмент, а состояние ума; не стоит откладывать их освоение до тех пор, пока вас не пригласят участвовать в проектировании крупного приложения, где требуется супер-архитектура и применение множества передовых технологий. Помните, что при плохом управлении даже самые эффективные технологии могут вызывать проблемы. Code Contracts полезны практически в любых типах приложений. Поэтому начнем с простого класса — традиционного Calculator наподобие:
public class Calculator
{
public Int32 Sum(Int32 x, Int32 y)
{
return x + y;
}
public Int32 Divide(Int32 x, Int32 y)
{
return x / y;
}
}
Вероятно, вы согласитесь, что этот код не реалистичен, так как в нем не хватает минимум одной важной части: проверки попытки деления на 0. Поскольку мы пишем более качественную версию этого класса, допустим заодно, что нам нужно проверять дополнительное условие: этот калькулятор не поддерживает отрицательные значения. Обновленная версия кода, в которую добавлено несколько выражений If-Then-Throw, показана на рис. 1.
Рис. 1. Класс Calculator, реализующий шаблон If-Then-Throw
public class Calculator
{
public Int32 Sum(Int32 x, Int32 y)
{
// Check input values
if (x <0 || y <0)
throw new ArgumentException();
// Perform the operation
return x + y;
}
public Int32 Divide(Int32 x, Int32 y)
{
// Check input values
if (x < 0 || y < 0)
throw new ArgumentException();
if (y == 0)
throw new ArgumentException();
// Perform the operation
return x / y;
}
}
Пока мы можем утверждать, что наш класс либо начинает обработку полученных им данных, либо (при недопустимых входных данных) просто генерирует исключение. Как насчет результатов, выдаваемых классом? Что именно мы знаем о них? Глядя на спецификации, мы должны ожидать, что оба метода возвращают значения от 0 и выше. Как установить это правило и сообщать о неудаче, если данное правило нарушается? Нужна третья версия кода, показанная на рис. 2.
Рис. 2. Класс Calculator с проверкой предусловий и постусловий
public class Calculator
{
public Int32 Sum(Int32 x, Int32 y)
{
// Check input values
if (x <0 || y <0)
throw new ArgumentException();
// Perform the operation
Int32 result = x + y;
// Check output
if (result <0)
throw new ArgumentException();
return result;
}
public Int32 Divide(Int32 x, Int32 y)
{
// Check input values
if (x < 0 || y < 0)
throw new ArgumentException();
if (y == 0)
throw new ArgumentException();
// Perform the operation
Int32 result = x / y;
// Check output
if (result < 0)
throw new ArgumentException();
return result;
}
}
Теперь оба метода работают в три этапа: проверяют входные значения, выполняют операцию и проверяют вывод. Проверки ввода и вывода служат разным целям. В первом случае можно выявить ошибки в вызвавшем коде, а во втором — ошибки в вашем коде. Так ли нужны проверки вывода? Я соглашусь, что контрольные условия можно проверять с помощью некоторых модульных тестов. В данном случае острой необходимости в таких проверках нет. Однако наличие проверок в коде четко проясняет, что может делать класс и чего не может; это во многом аналогично условиям в контракте сервиса.
Если сравнить исходный код на рис. 2 с изначальной версией класса, то заметно, что исходный вариант дополнен немалым количеством строк, а это всего-навсего простой класс с несколькими требованиями. Давайте смотреть дальше.
На рис. 2 мы выявили последовательное выполнение трех стадий (проверка ввода, операция и проверка вывода). А если работа операции достаточно сложна и требует наличия дополнительных точек выхода? Как быть, если некоторые из этих точек выхода относятся к ситуациям ошибок, где ожидаются другие результаты? Все может здорово усложниться. Но, чтобы проиллюстрировать сказанное, достаточно добавить досрочный выход в один из методов, как показано на рис. 3.
Рис. 3. Досрочный выход приводит к дублированию кода для постусловий
public Int32 Sum(Int32 x, Int32 y)
{
// Check input values
if (x <0 || y <0)
throw new ArgumentException();
// Shortcut exit
if (x == y)
{
// Perform the operation
var temp = x <<1; // Optimization for 2*x
// Check output
if (temp <0)
throw new ArgumentException();
return temp;
}
// Perform the operation
var result = x + y;
// Check output
if (result <0)
throw new ArgumentException();
return result;
}
В примере кода (и это лишь пример) метод Sum пытается выполнить досрочный выход, если два значения равны, и просто перемножает их вместо суммирования. Однако код, применяемый для проверки выходных значений, нужно повторять для каждого пути досрочного выхода.
Вывод таков: элегантно реализовать подход «contract-first» без серьезного инструментария или, по крайней мере, специфической вспомогательной инфраструктуры не удастся. Проверка предварительных условий сравнительно проста и дает минимальные издержки, но ручная работа с постусловиями делает всю кодовую базу запутанной и подверженной ошибкам. И это не считая некоторые другие аспекты контрактов, способные превратить исходный код классов в настоящую мешанину, например проверку условий, когда входные параметры являются наборами, и обеспечение того, чтобы класс всегда находился в известном допустимом состоянии при каждом вызове его метода или свойства.
Вводим контракты кода
Code Contracts в .NET Framework 4 — это инфраструктура, которая предоставляет гораздо более удобный синтаксис для выражения контракта класса. В частности, Code Contracts поддерживает три типа контрактов: предусловия, постусловия и инварианты. Предусловия указывают предварительные условия, которые должны быть соблюдены для безопасного выполнения метода. Постусловия выражают условия, которые необходимо проверить после успешного выполнения метода или генерации им исключения. Наконец, инвариант описывает условие, которое всегда true в течение жизненного цикла любого экземпляра данного класса. Точнее, инвариант указывает условие, которое должно выполняться после любого возможного взаимодействия между классом и клиентом, т. е. после выполнения открытых членов, включая конструкторы. Условия, выраженные в виде инвариантов, после вызова закрытого члена не проверяются и впоследствии могут временно нарушаться.
Code Contracts API состоит из списка статических методов, определенных в классе Contract. Для выражения предусловий используется метод Requires, а для выражения постусловий — метод Ensures. На рис. 4 показано, как переписать класс Calculator с применением Code Contracts.
Рис. 4. Класс Calculator, написанный с применением Code Contracts
using System.Diagnostics.Contracts;
public class Calculator
{
public Int32 Sum(Int32 x, Int32 y)
{
Contract.Requires<ArgumentOutOfRangeException>(x >= 0 && y >= 0);
Contract.Ensures(Contract.Result<Int32>() >= 0);
if (x == y)
return 2 * x;
return x + y;
}
public Int32 Divide(Int32 x, Int32 y)
{
Contract.Requires<ArgumentOutOfRangeException>(x >= 0 && y >= 0);
Contract.Requires<ArgumentOutOfRangeException>(y > 0);
Contract.Ensures(Contract.Result<Int32>() >= 0);
return x / y;
}
}
Быстрое сравнение рис. 3 и 4 откроет вам мощь эффективного API для реализации DbC. Код метода вновь очень четко читаем, и можно выделить лишь два уровня: информацию контракта, включающую как предусловия, так и постусловия, и реальное поведение. Вам больше не нужно смешивать условия с поведением, как на рис. 3. В итоге читаемость кода резко улучшается, и его сопровождение при работе в группе значительно облегчается. Например, вы можете быстро и безопасно добавить новое предусловие или отредактировать любое постусловие — ваше вмешательство ограничится лишь одним местом, и эти изменения можно четко отследить.
Информация контракта выражается обычным кодом на C# или Visual Basic. Инструкции контракта не похожи на традиционные декларативные атрибуты, но дух декларативности в них сохранен. Использование обычного кода вместо атрибутов расширяет возможности программирования для разработчиков, так как выражение условий становится более естественным. В то же время, применяя Code Contracts, вы получаете более прочную методологическую основу при рефакторинге кода. По сути, Code Contracts указывает поведение, ожидаемое вами от конкретного метода. Эта инфраструктура способствует соблюдению дисциплины кодирования, когда вы пишете методы, и помогает сохранять читаемость кода даже при весьма большом количестве пред- и постусловий. Хотя вы можете выражать контракты с помощью высокоуровневого синтаксиса, как на рис. 4, результаты компиляции такого кода на самом деле несильно отличаются от результатов компиляции кода, представленного на рис. 3. В чем же тогда весь фокус?
Дополнительный инструмент, интегрированный в процесс сборки в Visual Studio, — Code Contracts Rewriter — берет на себя реорганизацию кода, распознавая задуманные цели выраженных пред- и постусловий и раскрывая их в соответствующие блоки кода, которые помещаются туда, где они логически и должны быть. Как разработчик вы просто перестаете задумываться о том, где нужно разместить постусловие и где его придется продублировать, если в какой-то момент вам придется изменить код и добавить другую точку выхода.
Выражение условий
Точный синтаксис пред- и постусловий вы можете выяснить из документации Code Contracts; ее актуальную версию в виде PDF можно скачать с сайта DevLabs по ссылке bit.ly/f4LxHi. Я лишь кратко обрисую этот синтаксис. Следующий метод используется для того, чтобы указать необходимое условие и при его нарушении генерировать заданное исключение:
Contract.Requires<TException> (Boolean condition)
У этого метода есть несколько перегруженных версий, которые могут вас заинтересовать. Метод Ensures выражает постусловие:
Contract.Ensures(Boolean condition)
Когда дело доходит до написания предусловия, соответствующее выражение обычно будет содержать только входные параметры и, возможно, какой-то другой метод или свойство того же класса. Если это так, вам потребуется дополнить этот метод атрибутом Pure, отметив тем самым, что выполнение данного метода не изменит состояние объекта. Заметьте: в Code Contracts предполагается, что таковыми являются аксессоры get любых свойств.
Создавая постусловие, вам, вероятно, понадобится доступ к другой информации, скажем, к возвращаемому значению или начальному значению какой-то локальной переменной. Для этого используются специальные методы, например Contract.Result<T>, чтобы получить значение (типа T), возвращаемого из метода, и Contract.OldValue<T>, чтобы при начале выполнения метода получить значение, хранящееся в указанной локальной переменной. Наконец, вы также получаете возможность проверить какое-либо условие, когда при выполнении метода генерируется исключение. Тогда вы вызываете метод Contract.EnsuresOnThrow<TException>.
Средства сокращения
Синтаксис контракта более компактен, чем в обычном коде, но и он может «разрастаться». Если это случится, читаемость кода вновь окажется под угрозой. Естественное противоядие от этого — группирование нескольких инструкций контракта в подпрограмму, как показано на рис. 5.
Рис. 5. Использование средств сокращения — атрибутов ContractAbbreviator
public class Calculator
{
public Int32 Sum(Int32 x, Int32 y)
{
// Check input values
ValidateOperands(x, y);
ValidateResult();
// Perform the operation
if (x == y)
return x<<1;
return x + y;
}
public Int32 Divide(Int32 x, Int32 y)
{
// Check input values
ValidateOperandsForDivision(x, y);
ValidateResult();
// Perform the operation
return x / y;
}
[ContractAbbreviator]
private void ValidateOperands(Int32 x, Int32 y)
{
Contract.Requires<ArgumentOutOfRangeException>(x >= 0 && y >= 0);
}
[ContractAbbreviator]
private void ValidateOperandsForDivision(Int32 x, Int32 y)
{
Contract.Requires<ArgumentOutOfRangeException>(x >= 0 && y >= 0);
Contract.Requires<ArgumentOutOfRangeException>(y > 0);
}
[ContractAbbreviator]
private void ValidateResult()
{
Contract.Ensures(Contract.Result<Int32>() >= 0);
}
}
Атрибут ContractAbbreviator инструктирует реорганизатор (Code Contracts Rewriter) о том, как правильно интерпретировать дополненные методы. Без этого атрибута, который расценивается как своего рода раскрываемый макрос, метод ValidateResult (и другие методы ValidateXxx на рис. 5) содержал бы довольно запутанный код. Например, на что бы ссылался Contract.Result<T> при его использовании в void-методе? В настоящее время атрибут ContractAbbreviator должен явным образом определяться разработчиком в проекте, так как он не включен в сборку mscorlib. Его класс сравнительно прост:
namespace System.Diagnostics.Contracts
{
[AttributeUsage(AttributeTargets.Method,
AllowMultiple = false)]
[Conditional("CONTRACTS_FULL")]
internal sealed class
ContractAbbreviatorAttribute :
System.Attribute
{
}
}
Заключение
Итак, Code Contracts API — фактически класс Contract — является «родной» частью .NET Framework 4, так как содержится в сборке mscorlib. В свойствах проекта Visual Studio 2010 есть страница, специфичная для конфигурации Code Contracts. В каждом проекте вы должны заходить на эту страницу и явно разрешать проверки контрактов в период выполнения. Кроме того, вам потребуется скачать необходимые средства с сайта DevLabs. На этом сайте выберите установщик, подходящий для вашей версии Visual Studio. Эти средства (Runtime Tools) включают Code Contracts Rewriter и генератор интерфейсов, а также средство статической проверки (static checker).
Code Contracts помогает писать четкий код, заставляя указывать ожидаемые поведение и результаты для каждого метода. Как минимум, это дает методологическую основу при рефакторинге и совершенствовании кода. Нам нужно еще многое рассмотреть в Code Contracts. В частности, в этой статье я лишь мельком упомянул об инвариантах и не сказал вообще ни слова о наследовании контрактов. Об этом и многом другом я планирую рассказать в будущих статьях. Оставайтесь с нами!
Дино Эспозито (Dino Esposito) — автор книги «Programming ASP.NET MVC»(Microsoft Press, 2010) и соавтор «Microsoft .NET: Architecting Applications for the Enterprise» (Microsoft Press, 2008). Проживает в Италии и часто выступает на отраслевых мероприятиях по всему миру. Читайте его заметки на twitter.com/despos.
Выражаю благодарность за рецензирование статьи эксперту Брайену Грюнкмейеру (Brian Grunkemeyer)