Как я добавлял поддержку Code Contracts для VS2015

В последние несколько недель я активно занимался доработкой Code Contracts, исправлением некоторых неприятных ошибок и добавлением поддержки VS2015. А поскольку VS2015 только что увидела свет, то подобная поддержка будет весьма кстати. Теперь обо всем об этом по порядку, да еще и с рядом технических подробностей.

Итак, первое, что нужно знать о Code Contracts, что эта штука жива. Код лежит в открытом доступе на гитхабе (https://github.com/Microsoft/CodeContracts) и есть ряд людей, которые активно занимаются наведением там порядка. Я являюсь owner-ом репозитория, но занимаюсь этим в свое свободное время. Помимо меня есть еще несколько человек, которые наводят порядок в Code Contracts Editor Extensions (@sharwell) и в некоторых других областях.

Code Contracts можно разделить на несколько составляющих:

  • ccrewrite — тул, который занимается «переписыванием» IL-а, выдиранием утверждений (Contract.Requires/Ensures/Assert/Assume/if-throw) и заменой их на нужные вызовы методов контрактов, в зависимости от конфигурации.
  • cccheck — тул, который занимается статическим анализом и формальным доказательством во время компиляции, что программа является корректной.
  • Code Contracts Editor Extensions — расширение к VS, которое позволяет «видеть» контракты прямо в IDE.


Есть еще ряд тулов, например, для генерации документации, а также плагин к ReSharper, который упрощает добавление предусловий/постусловий и показывает ошибки ccrewrite прямо в IDE.

Я занимаюсь двумя составляющими — ccrewrite и плагином, но сейчас хочу остановиться именно на ccrewrite и на сложностях, с которыми я столкнуться при добавлении поддержки VS2015.

Breaking changes в VS2015


Команда компиляторов C#/VB сделала потрясающую работу при разработке с нуля новых компиляторов. Они добавили кучу точек расширения и теперь не нужна степень PhD, чтобы написать для студии довольно функциональный анализатор. Но не обошлось и без ломающих изменений.

Для нормальной работы, ccrewrite должен четко знать, как работает компилятор языков C#/VB, и во что трансформируется тот или иной код. Особенно доставляют блоки итераторов, асинхронные методы и замыкания, ради которых компиляторы C#/VB делают всякие разные хитрости. Особенно печально становится, когда поведение компиляторов начинает меняться и генерируемый код становится несколько иным.

Разработчики компилятора C# 6.0 (a.k.a. Roslyn) внес ряд оптимизаций в генерируемый IL код, что привело к поломкам декомпиляторов и ccrewrite.

Кэширование лямбда-выражений

Возможно, вы замечали в декомпилированном коде странные статические поля, которые начинаются с CS$<>9__. Это души кэши лямбда-выражений, которые не захватывают внешнего контекста (лямбда-выраженя, который захватывают внешний контекст приводят к генерации замыканий, и для них генерируются классы вида<>c__DisplayClass1).

static void Foo()
{
    Action action = () => Console.WriteLine("Hello, lambda!");
    action();
}

В этом случае, «старый» компилятор сгенерирует поле CS$<>9__CachedAnonymousMethodDelegatef и проинициализирует его ленивым образом:

private static void b__e()
{
      Console.WriteLine("Hello, lambda!");      
}
 
private static Action CS$<>9__CachedAnonymousMethodDelegatef;

static void Foo()
{
    if (CS$<>9__CachedAnonymousMethodDelegatef == null)
    {
        CS$<>9__CachedAnonymousMethodDelegatef = new Action(b__e);
    }
 
    Action CS$<>9__CachedAnonymousMethodDelegatef = 
        CS$<>9__CachedAnonymousMethodDelegatef;
    CS$<>9__CachedAnonymousMethodDelegatef();
}

Компилятор C# 6.0 использует другой подход. Разработчики экспериментальной ОС — Midori выяснили, что вызов экземплярного метода через делегат является более эффективным, чем вызов статического метода. Поэтому Roslyn-компилятор для того же самого лямбда-выражения генерирует другой код:

private sealed class StaticClosure
{
    public static readonly StaticClosure Instance = new StaticClosure();
    public static Action CachedDelegate;
 
    // Анонимный метод стал экземплярным методом
    internal void FooAnonymousMethodBody()
    {
        Console.WriteLine("Hello, lambda!");
    }
}
 
static void Foo()
{
    Action actionTmp;
    if ((actionTmp = StaticClosure.CachedDelegate) == null)
    {
        StaticClosure.CachedDelegate = new Action(
            StaticClosure.Instance.FooAnonymousMethodBody)
        actionTmp = StaticClosure.CachedDelegate;
    }
    Action action = actionTmp;
    action();
}

Теперь создается «замыкание» — класс StaticClosure (настоящее имя <>c) со статическим полем для хранения делегата — CachedDelegate (<>9__8_0) и «синглтоном». Но теперь, тело анонимного метода находится в экземплярном методе FooAnonymousMethodBody (b__8_0).

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

Теперь давайте посмотрим, когда это изменение приводит к проблемам в ccrewrite.
Утверждения в Code Contracts задаются в виде вызовов методов класса Contract, что несколько осложняет задание контрактов для интерфейсов и абстрактных классов. Чтобы обойти это ограничение, необходимо создать специальный класс контрактов, помеченный атрибутом ContractClassFor. Но это вызывает ряд дополнительных сложностей.

[ContractClass(typeof (IFooContract))]
interface IFoo
{
    void Boo(int[] data);
}
 
[ExcludeFromCodeCoverage,ContractClassFor(typeof (IFoo))]
abstract class IFooContract : IFoo
{
    void IFoo.Boo(int[] data)
    {
        Contract.Requires(Contract.ForAll(data, n => n == 42));
    }
}
 
class Foo : IFoo
{
    public void Boo(int[] data)
    {
        Console.WriteLine("Foo.Boo was called!");
    }
}

В данном случае, метод Foo.Boo вообще не содержит предусловий и ccrewrite должен вначале найти класс контракта (IFooContracts), «выдрать» контракт из метода IFooContracts.Boo и перенести его в метод Foo.Boo. В случае простых предусловий, сделать это не сложно, а вот при наличии замыканий все становится интереснее.

Теперь, нужно найти внутренний класс IFooContracts.<>c, скопировать его в класс Foo, скопировать вызов Contract.Requires из метода IFooContracts.Foo и обновить IL, чтобы он работал с новой копией, а не с оригинальным замыканием. В некоторых случаях все бывает еще веселее: наличие вложенных замыканий (нескольких областей видимости, в каждой из которых есть захватывающий анонимный метод) потребует обновления вложенных классов в правильном порядке — от самого вложенного, до самого верхнего (именно поэтому здесь находится вот эта логика).

Асинхронный метод с двумя await-ами

Еще одно изменение в новом компиляторе связано с генерируемым кодом для асинхронных методов. Старый компилятор генерировал разный код для асинхронного метода с одним оператором await и с несколькими операторами await. У нового компилятора появилась новая оптимизация для асинхронных методов с двумя await-ами, что тоже доставило немало хлопот.

Давайте рассмотрим следующий простой пример:

public async Task FooAsync(string str)
{
    Contract.Ensures(str != null);
 
    await Task.Delay(42);
 
    return 42;
}

Компилятор языка C# (pre-Roslyn) преобразовывает этот код следующим образом:

  1. Создается структура, которая реализует IAsyncStateMachine и вся логика метода переезжает в метод MoveNext.
  2. В методе FooAsync оставалась «фасадная» логика: создание экземпляра AsyncTaskMethodBuilder и инициализация экземпляра конечного автомата.


Вот как выглядит генерируемый код:

private struct FooAsync_StatemMachine : IAsyncStateMachine
{
    // Аргумент метода FooAsync(string str)
    public string str;
    // Состояние конечного автомата
    public int l__state;
    // Библиотечный класс для создания асинхронных операций.
    // Очень напоминает TaskCompletionSource.
    public AsyncTaskMethodBuilder t__builder;
    // "ожидатель" результатов запущенной задачи
    private TaskAwaiter u__taskAwaiter;
 
    public void MoveNext()
    {
        int num = this.l__state;
        int result;
        try
        {
            TaskAwaiter taskAwaiter = default(TaskAwaiter);
            if (num != 0)
            {
                // Начало метода
                // Именно сюда перекочевала проверка предусловий
                Contract.Requires(this.str != null);
                taskAwaiter = Task.Delay(42).GetAwaiter();
 
                // Стандартный паттерн: возвращаем управление и используем
                // этот же метод в качестве "продолжения": нас позовут,
                // когда запущенная задача будет завершена
 
                if (!taskAwaiter.IsCompleted)
                {
                    // l__state равный 0 означает, что текущая операция
                    // запущена и мы ждем результатов.
                    this.l__state = 0;
 
                    // Передаем this AsyncTaskBuilder-у, чтобы он вызвал 
                    // этот же метод, когда текущая запущенная задача завершится
                    // t__bulder.AwaitUnsafeOnCompleted(..., this);
                    return;
                }
            }
 
            // Сюда мы попадем только когда текущая задача, сохраненная
            // на предыдущем этапе, будет завершена.
 
            // Вызов GetResult приведет к генерацию исключения, если 
            // задача завершилась с ошибкой
            taskAwaiter.GetResult();
 
            // Устанавливаем результат исполнения
            result = 42;
 
        }
        catch (Exception exception)
        {
            // Метод завершился с ошибкой
            this.l__state = -2;
            this.t__builder.SetException(exception);
            return;
        }
 
        // Метод завершился успешно
        this.l__state = -2;
        this.t__builder.SetResult(result);
    }
}
 
public Task FooAsync(string str)
{
    var stateMachine = new FooAsync_StatemMachine
    {
        l__state = -1,
        t__builder = AsyncTaskMethodBuilder.Create(),
        str = str,
    };
 
    stateMachine.t__builder.Start(ref stateMachine);
    return stateMachine.t__builder.Task;
}

Тут довольно много букв, но основная идея такая:

  1. Предусловие асинхронного метода находится внутри конечного автомата. Именно поэтому ccrewrite должен вытянуть его и перенести в метод FooAsync. В противном случае нарушение предусловия будет приводить к faulted таске, а не к «синхронному исключению».
  2. Существует определенный паттерн, как ccrewrite определяет, где находится предусловие. В случае асинхронного метода с одним оператором await, оригинальное начало метода, а значит и предусловия находятся сразу же внутри условия if (num!= 0). Это важно!
  3. Генерируемый код зависит от числа операторов await внутри асинхронного метода. При наличии двух и более операторов await старый компилятор генерирует конечный автомат на основе switch-а, и ccrewrite обрабатывал этот паттерн корректным образом.


Компилятор C# 6.0 генерирует аналогичный код для асинхронного метода с одним await-ом, но совершенно иной код, при наличии двух await-ов.

ПРИМЕЧАНИЕ
Еще одно изменение компилятора C# 6.0: в Debug-режиме для конечного автомата генерируется класс, а не структура. Сделано это для поддержки Edit and Continue.

Если метод FooAsync изменить следующим образом:

public async Task FooAsyncOrig(string str)
{
    Contract.Ensures(str != null);
 
    await Task.Delay(42);
    await Task.Delay(43);
 
    return 42;
}

То компилятор C# 6.0, вместо генерации switch-а, понятного любому декомпилятору и ccrewrite, сгенерирует код, очень похожий на код с одним оператором await, но с небольшими модификациями:

// Начало метода MoveNext
if (num != 0)
{
    // ccrewrite считал, что здесь находится предусловие!
    if (num == 1)
    {
          taskAwaiter = this.u__taskAwaiter;
          this.u__taskAwaiter = default(TaskAwaiter);
          this.l__state = -1;
          goto OperationCompleted;
    }
    
    // А оно находится здесь!
    Contract.Requires(this.str != null);
    taskAwaiter = Task.Delay(42).GetAwaiter();

Поскольку это новый паттерн, то ccrewrite наивно искал контракты сразу же внутри условия if (num!= 0) и рассматривал вложенный if в качестве предусловий/постусловий. Пришлось его научить новым трюкам, чтобы обрабатывать этот вариант корректным образом.

В качестве заключения

Работа на IL-уровне — это ходьба по тонкому льду. Поиск паттернов довольно сложный, модификация IL-кода не интуитивна и даже простая задача, как проверка постусловий в асинхронных методах, может потребовать больших усилий. К тому же, многие вещи являются деталями реализации компилятора и могут меняться от версии к версии. Здесь мы рассмотрели только несколько примеров, но это далеко не все изменения со стороны компилятора C# 6.0. Как минимум еще немного изменился IL код, генерируемый при использовании деревьев выражений, который тоже сломал несколько тест-кейсов.

Все еще остались пара неприятных багов, над которыми идет работа. Есть проблема с Error List в VS2015, а постусловия в асинхронных методах, видимо, никогда нормально не работали. Но, самое главное, что проект жив и, скорее всего, будет развиваться. Так что если у вас есть пожелания, особенно в области ccrewrite, пишите об этом или заводите баги на github-е!

Ссылки


  1. Code Contracts на GitHub
  2. Code Contracts Editor Extensions на гитхаб
  3. Последний релиз на GitHub
  4. Code Contracts на Visual Studio Gallery
  5. Code Contracts Editor Extensions на Visual Studio Gallery
  6. Цикл статей о контрактном программировании в .NET

© Habrahabr.ru