На переднем крае

Code Contracts: наследование и принцип Лисков

Дино Эспозито

Дино Эспозито

Подобно контрактам в жизни программные контракты связывают вас дополнительными ограничениями и требуют затрат некоторого времени. Чтобы соблюсти контракт, вы наверняка захотите убедиться, что не нарушаете его условия. Когда дело доходит до программных контрактов, в том числе Code Contracts (контрактов кода) в Microsoft .NET Framework, почти все разработчики рано или поздно выказывают сомнения по поводу издержек размещения контрактов в классах. Годятся ли контракты для вашего программного обеспечения независимо от типа сборки (отладочной или рабочей)? Или контракты главным образом помогают на этапе отладки, после чего их следует убирать из рабочего кода?

В Eiffel (это первый язык, в котором были введены программные контракты) есть ключевые слова для определения предусловий, постусловий и инвариантов. Таким образом, в Eiffel контракты являются частью языка. Если контракты используются в исходном коде класса, они становятся неотъемлемой частью этого кода.

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

В этой статье я рассмотрю несколько сценариев, где Code Contracts особенно полезны для повышения качества проекта ПО в целом.

Для чего нужны контракты кода

У разработчиков ПО давно вошло в привычку писать методы, тщательно проверяющие любые входные параметры. Если входной параметр не соответствует ожиданиям метода, генерируется исключение. Этот шаблон известен под названием «if-then-throw». С контрактами предусловий тот же код выглядел бы изящнее и лаконичнее. Но больший интерес представляет то, что такой код было бы и легче читать, потому что предусловия позволяют четко заявлять, что именно требуется, а не выполнять проверки на все, что нежелательно. Поэтому на первый взгляд программные контракты кажутся просто более изящным подходом, предотвращающим исключения в методах класса. Но на самом деле за контрактами стоит нечто гораздо большее.

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

Однако Code Contracts не ограничиваются предусловиями, даже несмотря на то, что предусловия — самая легкая часть программных контрактов. Сочетание предусловий, постусловий и инвариантов (а также широкое их применение во всем коде) дает явное преимущество и действительно ведет к повышению качества кода.

Сравнение утверждений, контрактов кода и тестов

Code Contracts отличаются от утверждений и других средств отладки. Хотя контракты помогают локализовать ошибки, они не заменяют ни отладчик, ни тщательно продуманный набор модульных тестов. Code Contracts — подобно утверждениям (операторам контроля) (assertions) — указывают условие, которое должно быть выполнено в определенной точке выполнения программы.

Невыполненное утверждение — симптом того, что где-то что-то не так. Однако утверждение не может подсказать, почему это произошло и где корни этой проблемы. С другой стороны, в случае неудачи контракт кода сообщит куда больше. Вы сможете узнать многие подробности, например, из-за чего возникло исключение — из-за получения неприемлемого значения данным методом, неудачи в вычислении возвращаемого значения или из-за недопустимого состояния. Если утверждение сообщает только об обнаружении симптомов проблемы, то контракт кода может показать ценную информацию о том, как должен использоваться метод. Эта информация в конечном счете поможет понять, что нужно исправить, чтобы больше не нарушать данное утверждение.

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

Модульные тесты — отличный инструмент, помогающий в процессе глубокого рефакторинга. Но контракты, возможно, более информативны, чем тесты, при документировании ожидаемого поведения методов. Чтобы извлечь реальную пользу из тестирования на этапе разработки, вы должны использовать разработку, управляемую тестами (test-driven development, TDD). Для документирования и проектирования методов контракты — более простой способ, чем TDD.

Контракты добавляют дополнительную информацию в ваш код и оставляют за вами решение о том, переносить ли эту информацию в развертываемые двоичные файлы. При модульном тестировании используется внешний проект, позволяющий оценить, как работает ваш код. Будете вы компилировать информацию контрактов или нет, в любом случае ее наличие помогает на этапах документирования и проектирования.

Code Contracts и входные данные

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

Code Contracts в .NET относятся к библиотекам и могут быть хорошим дополнением к аннотациям данных (а в некоторых случаях и их заменой). Аннотации данных отлично работают применительно к UI, так как в Silverlight и ASP.NET есть компоненты, понимающие эти аннотации и соответственно подстраивающие код или HTML-вывод. Однако на уровне предметной области часто требуется нечто большее, чем просто атрибуты, и Code Contracts являются идеальной заменой. Я вовсе не утверждаю, что вы не получите те же возможности с помощью атрибутов. Но считаю, что в отношении читаемости и выразительности результаты гораздо лучше при использовании Code Contracts. (Кстати, именно поэтому группа разработки Code Contracts предпочитает чистый код без атрибутов.)

Наследуемые контракты

Программные контракты являются наследуемыми почти на любой платформе, которая их поддерживает, и .NET Framework — не исключение. Когда вы наследуете новый класс от существующего, производный класс подхватывает поведение, контекст и контракты своего предка. Вполне естественный ход вещей. Наследование контрактов не создает никаких затруднений в случае инвариантов и постусловий, но немного проблематично в отношении предусловий. Возьмем инварианты и рассмотрим код на рис. 1.

Рис. 1. Наследование инвариантов

public class Rectangle
{
  public virtual Int32 Width { get; set; }
  public virtual Int32 Height { get; set; }

  [ContractInvariantMethod]
  private void ObjectInvariant()
  {
    Contract.Invariant(Width > 0);
    Contract.Invariant(Height > 0);
  }
}

public class Square : Rectangle
{
  public Square()
  {
  }

  public Square(Int32 size)
  {
    Width = size;
    Height = size;
  }

  [ContractInvariantMethod]
  private void ObjectInvariant()
  {
    Contract.Invariant(Width == Height);
  }
  ...
}

У базового класса Rectangle два инварианта: ширина (Width) и высота (Height) больше нуля. Производный класс Square добавляет еще одно инвариантное условие: ширина и высота должны совпадать. Это имеет смысл хотя бы с точки зрения логики. Квадрат подобен прямоугольнику с дополнительным ограничением: ширина и высота всегда должны быть идентичны.

В случае постусловий все работает примерно так же. Производный класс, который переопределяет какой-либо метод и добавляет больше постусловий, просто расширяет возможности базового класса и действует как особый случай родительского класса.

А как насчет предусловий? Здесь становится ясно, почему суммирование контрактов по иерархии классов является операцией деликатной. Логически метод класса идентичен математической функции. Они получают некие входные значения и возвращают какие-то выходные данные. В математике диапазон значений, возвращаемых функцией, известен как область значений (codomain). Добавляя инварианты и постусловия в метод производного класса, вы просто увеличиваете область значений метода. Но при добавлении предусловий вы сужаете эту область. Стоит ли об этом беспокоиться? Читайте дальше.

Принцип Лисков

SOLID — популярный акроним, составленный из первых пяти букв названий пяти ключевых принципов проектирования ПО, в том числе принцип единственной обязанности (Single responsibility principle), открытости/закрытости (Open/closed principle), разделения интерфейсов (Interface segregation principle) и инверсии зависимости (Dependency inversion principle). Буква «L» в SOLID относится к принципу подстановки Лисков (Liskov substitution principle). Подробнее о принципе Лисков см. по ссылке bit.ly/lKXCxF.

Если в двух словах, то, согласно принципу Лисков, использование подкласса в любом месте, где ожидается его класс-предок, должно быть безопасным. Как бы эмоционально это ни звучало, этого мы как раз и не получаем в готовом виде при чистом подходе к объектно-ориентированному программированию. Ни один компилятор объектно-ориентированного языка не способен всегда гарантировать соблюдение этого принципа.

Безопасность использования любого производного класса в тех местах, где ожидается класс-предок, возлагается на разработчика. Заметьте, что я сказал «безопасность». При чистой ориентации на объекты возможно применение любого производного класса там, где ожидается родительский класс. Но «возможно» не значит «безопасно». Для соблюдения принципа Лисков вы должны придерживаться простого правила: область применения метода не должна сужаться в подклассе.

Code Contracts и принцип Лисков

Помимо формального и абстрактного определения, принцип Лисков весьма сильно влияет на программные контракты, и его можно легко перефразировать в терминах специфической технологии вроде .NET Code Contracts. Ключевой момент заключается в том, что производный класс не может просто так добавлять предусловия. В ином случае он сузит диапазон возможных значений, принимаемых методом, а это чревато ошибками в период выполнения.

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

Рис. 2. Иллюстрация принципа Лисков

public class Rectangle
{
  public Int32 Width { get; private set; }
  public Int32 Height { get; private set; }

  public virtual void SetSize(Int32 width, Int32 height)
  {
    Width = width;
    Height = height;
  }
}
public class Square : Rectangle
{
  public override void SetSize(Int32 width, Int32 height)
  {
    Contract.Requires<ArgumentException>(width == height);
    base.SetSize(width, width);
  }
}

Класс Square наследует от Rectangle и просто добавляет одно предусловие. В этот момент следующий код выдаст ошибку:

private static void Transform(Rectangle rect)
{
  // Высота становится в два раза больше ширины
  rect.SetSize(rect.Width, 2*rect.Width);
}

Изначально метод Transform был рассчитан на работу с экземплярами класса Rectangle, и он вполне нормально справлялся со своими обязанностями. Допустим, что в один прекрасный день вы расширили систему и стали передавать экземпляры Square в тот же (не модифицированный) код, как показано ниже:

var square = new Square();
square.SetSize(20, 20);
Transform(square);

В зависимости от связей между Square и Rectangle метод Transform может начать сбоить без явных на то причин.

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

private static void Transform(Rectangle rect)
{
  // Высота становится в два раза больше ширины
  if (rect is Square)
  {
    // ...
    return;
  }
  rect.SetSize(rect.Width, 2*rect.Width);
}

Но независимо от ваших усилий снежный ком уже покатился и с каждой минутой становится все больше. Приятная новость насчет .NET и компилятора C# состоит в том, что, если вы используете Code Contracts для выражения предусловий, то получаете предупреждение от компилятора при попытке нарушения принципа Лисков (рис. 3).

Предупреждение от компилятора при попытке нарушения принципа Лисков

Рис. 3. Предупреждение от компилятора при попытке нарушения принципа Лисков

Наименее понятный и реже всего применяемый SOLID-принцип

Преподавая проектирование .NET-классов в течение уже нескольких лет, я могу с уверенностью сказать, что многие разработчики далеки от понимания и применения SOLID-принципов и, в частности, принципа Лисков. Весьма часто причины странного поведения программной системы связаны с нарушением именно принципа Лисков. Тем более приятно отметить, что Code Contracts значительно помогают в этой области, если, конечно, вы обращаете внимание на предупреждения компилятора.


Дино Эспозито (Dino Esposito) — автор книги «Programming Microsoft ASP.NET 4» (Microsoft Press, 2011) исоавтор «Microsoft .NET: Architecting Applications for the Enterprise» (Microsoft Press, 2008). Проживает в Италии и часто выступает на отраслевых мероприятиях по всему миру. Читайте его заметки на twitter.com/despos.

Выражаю благодарность за рецензирование статьи экспертуМануэлю Фандрику (ManuelFahndrich).