Поделиться через


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

Инварианты и наследование в Code Contracts

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

Дино ЭспозитоВ предыдущих статьях я рассмотрел два наиболее распространенных типа программных контрактов — пред- и постусловия — и проанализировал их синтаксис и семантику с точки зрения Code Contracts API в Microsoft .NET Framework 4. На этот раз я впервые представлю третий из важнейших типов контрактов — инвариант (invariant), а затем мы продолжим изучать поведение классов на основе контрактов при применении наследования.

Инварианты

В общем смысле инвариант — это условие, которое всегда дает true в данном контексте. Применительно к объектно-ориентированному ПО инвариант указывает условие, которое всегда оценивается как true в каждом экземпляре некоего класса. Инвариант — важное средство, своевременно уведомляющее вас всякий раз, когда состояние любого экземпляра данного класса становится недопустимым. Другими словами, контракт инварианта формально определяет условия, при которых экземпляр класса считается находящимся в допустимом состоянии. Как бы пафосно это ни звучало, но это первая концепция, которую вы должны освоить с самого начала, а затем реализовать при моделировании предметной области через классы. Проектирование, управляемое предметной областью (domain-driven design, DDD), теперь является проверенной временем методологией для моделирования сложных бизнес-сценариев, где инвариантной логике отводится значимое место. DDD, по сути, настоятельно рекомендует никогда не иметь дела с экземплярами классов, находящихся в недопустимом состоянии. Аналогично DDD рекомендует писать фабрики классов, которые возвращают объекты в допустимом состоянии, и делать так, чтобы после каждой операции ваши объекты возвращались в допустимое состояние.

DDD — не более чем методология, и реализация контрактов возлагается на вас. В .NET Framework 4 контракты кода (Code Contracts) сильно помогают в создании успешной реализации с минимальными усилиями. Давайте подробнее рассмотрим инварианты в .NET Framework 4.

Где находится инвариантная логика?

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

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

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

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

Инварианты в Code Contracts

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

Контракт инварианта определяется с помощью одного или более специализированных методов. Это методы экземпляра, закрытые, возвращающие void и дополненные специальным атрибутом ContractInvariantMethod. Они также не могут содержать никакого кода, кроме вызовов, необходимых для определения инвариантных условий. Например, в эти методы нельзя добавлять никакую логику — даже для простой записи в журнал состояния класса. Вот как определяется контракт инварианта для класса:

public class News {
  public String Title {get; set;}
  public String Body {get; set;}

  [ContractInvariantMethod]
  private void ObjectInvariant()
  {
    Contract.Invariant(!String.IsNullOrEmpty(Title));
    Contract.Invariant(!String.IsNullOrEmpty(Body));
  }
}

В классе News в виде инвариантных условий заявляется, что Title и Body никогда не могут быть пустыми или равны null. Заметьте: чтобы этот код работал, вы должны включить в конфигурации проекта для различных видов сборки (тех, которые вам нужны) полную проверку в период выполнения (рис. 1).

Инварианты требуют установить параметр Perform Runtime Contract Checking в Full

Рис. 1. Инварианты требуют установить параметр Perform Runtime Contract Checking в Full

Теперь попробуйте выполнить следующий простой код:

var n = new News();

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

В DDD инварианты связаны с концепцией фабрики. Фабрика — это просто открытый метод, отвечающий за создание экземпляров класса. В DDD каждая фабрика отвечает за возврат экземпляров сущностей предметной области в допустимом состоянии. Главное заключается в том, что при использовании инвариантов вы должны гарантировать соблюдение условий в любое определенное время. Но в какое именно?

Как в DDD, так и в реализации Code Contracts инварианты должны проверяться при выходе из любого открытого метода, включая конструкторы и аксессоры set. На рис. 2 приведена новая версия класса News, в которую добавлен конструктор. Фабрика идентична конструктору с тем исключением, что ей — как статическому методу — можно присваивать собственное контекстно-зависимое имя; в результате код получается более читаемым.

Рис. 2. Инварианты и конструкторы с поддержкой инвариантов

public class News
{
  public News(String title, String body)
  {
    Contract.Requires<ArgumentException>(
      !String.IsNullOrEmpty(title));
    Contract.Requires<ArgumentException>(
      !String.IsNullOrEmpty(body));

    Title = title;
    Body = body;
  }

  public String Title { get; set; }
  public String Body { get; set; }

  [ContractInvariantMethod]
  private void ObjectInvariant()
  {
    Contract.Invariant(!String.IsNullOrEmpty(Title));
    Contract.Invariant(!String.IsNullOrEmpty(Body));
  }
}

Код, использующий класс News, может выглядеть так:

var n = new News("Title", "This is the news");

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

var n = new News("Title", "This is the news");
n.Title = "";

Присвоив пустую строку свойству Title, вы переведете объект в недопустимое состояние. Поскольку инварианты проверяются при выходе из открытых методов (а аксессоры set свойств являются открытыми методами), вы снова получите исключение. Интересно, что если вы используете вместо открытых свойств открытые поля, то инварианты не проверяются и код выполняется как ни в чем ни бывало. Тем не менее, ваш объект находится в недопустимом состоянии.

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

Хотя инварианты должны проверяться при выходе из открытого метода, в теле любого открытого метода состояние может быть временно недопустимым. Важно, чтобы инварианты были равными true до и после выполнения открытых методов.

Как предотвратить переход объекта в недопустимое состояние? Утилита статического анализа вроде Microsoft Static Code Checker могла бы помочь обнаружить, что некое присваивание нарушит инвариант. Инварианты не только берегут вас от неправильного поведения объекта, но и способны выявлять непродуманно указанные входные данные. Благодаря этому упрощается поиск ошибок в коде, использующем данный класс.

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

На рис. 3 показан другой класс, в котором определен инвариантный метод. Этот класс может выступать в роли корневого в модели предметной области.

Рис. 3. Корневой класс на основе инварианта для модели предметной области

public abstract class DomainObject
{
  public abstract Boolean IsValid();

  [Pure]
  private Boolean IsValidState()
  {
    return IsValid();
  }

  [ContractInvariantMethod]
  private void ObjectInvariant()
  {
    Contract.Invariant(IsValidState());
  }
}

В классе DomainObject инвариантное условие выражается через закрытый метод, объявленный как чистый (т. е. не изменяющий состояние). На внутреннем уровне этот закрытый метод вызывает абстрактный метод, который производные классы будут использовать для определения своих инвариантов. На рис. 4 представлен возможный класс, производный от DomainObject и переопределяющий метод IsValid.

Рис. 4. Переопределение метода, используемого инвариантами

public class Customer : DomainObject
{
  private Int32 _id;
  private String _companyName, _contact;

  public Customer(Int32 id, String company)
  {
    Contract.Requires(id > 0);
    Contract.Requires(company.Length > 5);
    Contract.Requires(!String.IsNullOrWhiteSpace(company));

    Id = id;
    CompanyName = company;
  }
  ...
  public override bool IsValid()
  {
    return (Id > 0 && !String.IsNullOrWhiteSpace(CompanyName));
  }
}

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

var c = new Customer(1, "DinoEs");

Если не смотреть на конструктор Customer, все выглядит нормально. Однако, поскольку Customer наследует от DomainObject, вызывается конструктор DomainObject и проверяется инвариант. Так как IsValid в DomainObject виртуальный (на самом деле абстрактный), вызов переадресуется IsValid, определенному в Customer. К сожалению, проверка происходит в еще не полностью инициализированном экземпляре. Вы получаете исключение, но это не ваша вина. (В новейшем выпуске Code Contracts эта проблема устранена, и проверка инвариантов в конструкторах откладывается до вызова самого внешнего конструктора.)

Эта ситуация связана с известной проблемой: не вызывайте виртуальные члены из конструктора. В данном случае это не вы так кодировали — просто таков побочный эффект наследования контрактов. У вас есть два выхода: убрать абстрактный метод IsValid из базового класса или прибегнуть к коду, показанному на рис. 5.

Рис. 5. Переопределение метода, используемого инвариантами

public abstract class DomainObject
{
  protected Boolean Initialized;
  public abstract Boolean IsValid();

  [Pure]
  private Boolean IsInValidState()
  {
    return !Initialized || IsValid();
  }

  [ContractInvariantMethod]
  private void ObjectInvariant()
  {
    Contract.Invariant(IsInValidState());
  }
}

public class Customer : DomainObject
{
  public Customer(Int32 id, String company)
  {
     ...
    Id = id;
    CompanyName = company;
    Initialized = true;
  }
     ...
}

Защищенный член Initialized действует как сторож, не позволяющий вызывать переопределенный IsValid до тех пор, пока не будет завершена инициализация объекта. ЧленInitialized может быть полем или свойством. Однако, если это поле, вы получите второй проход инвариантов, в чем нет никакой необходимости — все уже было проверено. В этом отношении использование поля ускоряет работу кода.

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

Будьте осторожны

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

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


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

Выражаю благодарность за рецензирование статьи экспертам Мануэлю Фандрику (Manuel Fahndrich) и Брайену Грюнкмейеру (Brian Grunkemeyer).