вторник, 31 марта 2015 г.

Предусловия в конструкторах наследников

Существует одна типичная проблема с проверкой аргументов в конструкторах классов-наследников, которые должны передавать «куски» своих аргументов базовому классу. Простой пример этой проблемы выглядит так:

abstract class Base
{
   
private readonly int _length
;

   
protected Base(int length
)
    {
       
if (length <= 0) throw new ArgumentOutOfRangeException("length"
);

       
_length = length
;
    }
}

internal class Derived : Base
{
   
public Derived(string s
)
        :
base(s.Length
)
    {
       
// Проверка бесполезна!
        if (string.IsNullOrEmpty(s
)) throw new ArgumentNullException("s");
    }
}

Проблема в том, что конструктор базового класса вызывается до тела метода конструктора Derived (*). В результате, если конструктор наследника принимает составной объект и «выкусывает» часть, которая нужна конструктору базовому классу, то нормально проверить валидность составного объекта не получиться.

(*) Строго говоря, это не так, и мы посмотрим, как Code Contracts обходят эту проблему.

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

internal class Derived : Base
{
   
public Derived(string s
)
        :
base(ValidateAndExtractLength(s
))
    { }

   
private static int ValidateAndExtractLength(string s
)
    {
       
if (string.IsNullOrEmpty(s
)) throw new ArgumentNullException("s");

       
return s.Length;
    }
}

Данный подход работает, но теперь «предусловия» конструктора (которые являются ключевыми для клиентов класса) размазаны по вспомогательным статическим методам. Альтернатива – воспользоваться контрактами (a.k.a. Code Contracts).

abstract class Base
{
   
// дополнительные поля помогут понять, как действует
    // компилятор языка C# и Code Contract Rewriter
    private readonly int _baseFoo = 42
;
   
private readonly int _length
;

   
protected Base(int length
)
    {
       
Contract.Requires(length >= 0
);

       
_length = length
;
    }
}

internal class Derived : Base
{
   
private readonly string _s
;
   
private readonly int _derivedFoo = 36
;
   
public Derived(string s
)
        :
base(s.Length
)
    {
       
Contract.Requires(!string.IsNullOrEmpty(s
));
       
_s = s;
    }
}

Казалось бы, а в чем разница? Но она есть, и еще какая! Как и в случае с асинхронными методами и блоками итераторов, в случае предусловий в конструкторе, компилятор Code Contracts выполняет более сложное преобразование IL-кода, а не просто заменяет Contract.Requires на генерацию исключения.

ПРИМЕЧАНИЕ
Подробнее о предусловиях асинхронных методов см. Когда предусловия не являются предусловиями.

Приведенный выше код в откомпилированном виде будет выглядеть так:

internal abstract class Base_Decompiled
{
   
private readonly int _baseFoo
;
   
private readonly int _length
;
   
protected Base_Decompiled(int length
)
{
       
// Предусловия базового класса
__ContractsRuntime.Requires(length >= 0, null, "length >= 0"
);
       
// Инициализатор полей базового класса
this._baseFoo = 42
;
       
// Вызов конструктора базового класса (System.Object-а)
base..ctor
();
       
// Тело конструктора базового класса
this._length = length
;
}
}

internal class Derived_Decompiled : Base_Decompiled
{
private readonly string _s
;
private readonly int _derivedFoo
;
public Derived_Decompiled(string s
)
{
       
// Предусловия класса-наследника
__ContractsRuntime.Requires
(
           
!string.IsNullOrEmpty(s), null, "!string.IsNullOrEmpty(s)"
);
// Инициализатор полей класса-наследника
        this._derivedFoo = 36
;
       
// Вызов конструктора класса-наследника
base..ctor(s.Length
);
       
// Тело конструктора класса-наследника
this._s = s;
}
}

Это значит, проверка предусловия класса наследника будет выполнена до вызова конструктор базового класса!

При наличии контрактов процесс конструирования экземпляра наследника будет таким:

  • Проверка предусловий наследника: Contract.Requires(!string.IsNullOrEmpty(s));
  • Инициализатор полей наследника: _derivedFoo = 36;
  • Вызов конструктора базового класса
    • Проверка предусловия базового класса:
      Contract.Requires(length >= 0);
    • Инициализатор полей базового класса: _baseFoo = 42;
    • Тело конструктора базового класса: _length = length;
  • Тело конструктора класса-наследника: _s = s;

Существует два важных момента

Во-первых, магия с контрактами работает даже для так называемых Legacy Contract Requires: это значит, что для получения преимуществ контрактов достаточно добавить Contract.EndContractBlock после проверки аргументов.

public Derived(string s)
    :
base(s.Length
)
{
   
if (string.IsNullOrEmpty(s
))
       
throw new ArgumentNullException("s"
);
   
Contract.EndContractBlock();
}

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

internal class Derived : Base
{
   
// Поле должно быть открытым, чтобы удовлетворять требованию
    // доступности предусловий!
    public readonly int N = 42
;
   
private readonly string _s
;

   
public Derived(string s
)
        :
base(s.Length
)
    {
       
Contract.Requires(!string.IsNullOrEmpty(s
));
       
// error CC1011: This/Me cannot be used in Requires of a constructor
        Contract.Requires(N == 42
);
       
_s = s;
    }
}

З.Ы. Хотя контракты кажутся полудохлыми, они достаточно интенсивно используются в некоторых проектах внутри Майкрософт. Так, в новой билд-системе они используются на полную. Долгое время даже гоняли статический анализатор, хотя недавно от него все-таки отказались.

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

  1. Здорово, а в доках я это как-то пропустил что ли? Описано это где-нить?

    ОтветитьУдалить
    Ответы
    1. Поскольку у нас тут контракты используются по полной, я просто заметил их в декомпилированном коде, который декомпильнул R# при Go to Definition. Я потом специально поискал упоминание этой возможности в официальной доке, но ничего такого не нашел.
      Так что, если мы и пропустили это в доках, то оба:))

      Удалить
    2. Жаль-то как! Функционал-то очень важный, страшно полезный.

      Удалить
    3. Да, мне тоже очень и очень не понятно, почему к контрактам такое отношение.
      К сожалению, тут и работы слишком много, чтобы сделать что-то подобное в свободное время. Да и в open source в этом случае я не верю: тут нужно хорошее взаимодействие между ресерчерами и разработчиками.

      Удалить
  2. Вот уж действительно "кажутся полудохлыми" - такой важный механизм и так смахивает на заброшенный дом.

    ОтветитьУдалить
    Ответы
    1. Контракты-то теперь open source, но я, чего-то, не верю в то, что такую городушку можно эффективно развивать лишь с помощью комьюнити.
      И да, жаль, что этой туле так мало внимания уделяется...

      Удалить
  3. Сергей, по поводу "Да, мне тоже очень и очень не понятно, почему к контрактам такое отношение."
    Мне кажется, ответ вы написали в статье: "магия с контрактами".
    "магия" - это значит неоднозначное понимание кода и более высокий уровень входа для новых программистов.
    А чтобы избавиться от этой "магии" нужно контракты ввести на уровне синтаксиса языка, как это сделано в Eiffel.

    ОтветитьУдалить
  4. VS 2015 RC совсем не дружит с контрактами

    пичалька, надеюсь допилят

    ОтветитьУдалить
    Ответы
    1. Да, есть такое дело. Скоро должна эта ситуация исправиться слегка.

      Удалить