В последние несколько недель я активно занимался доработкой 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 <Foo>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(<Foo>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 (<Foo>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<int> 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<int> 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<int> FooAsync(string str)
{
    var stateMachine = new FooAsync_StatemMachine
    {
        l__state = -1,
        t__builder = AsyncTaskMethodBuilder<int>.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<int> 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

Комментарии (15)


  1. sidristij
    21.07.2015 21:59
    +5

    На нашем проекте, не так чтобы сильно крупном, после года разработки было решено их выпилить к чертям, т.к. билды и автоматизация тестирования (что требует постоянных билдов на тачке) жутко замедлялись. Крайне не советуем -) Прямо на текущем спринте к нам придет счастье =)


    1. SergeyT Автор
      21.07.2015 22:17
      +1

      У меня в текущем проекте (200К строк где-то) контракты используются сверх активно. Да, билд тормозится сильно. Но теперь есть планы и возможности по устранению этих проблем.

      Так что я все еще советую;)


      1. imashaman
        21.07.2015 22:51
        +2

        Из нашего опыта замедление там не линейное, а квадратичное — после миллиона строк все просто умирает.
        Причина пожалуй не в количестве строк а гранулярности интерфейсов и контрактов.


        1. SergeyT Автор
          22.07.2015 05:26

          Да, rewriting имеет сложность O(N) от количества зависимостей переписываемой сборки (нужно получить полное транзитивное замыкание всех зависимостей прежде чем можно будет вычислить контракты класса). Это одна из причин существенного замедления ccrewrite при увеличении числа проектов (поскольку обычно при том растет и число зависимостей каждого из них).


  1. lostmsu
    22.07.2015 00:13

    Отличный набор утилит! Очень не хватало его под VS2015. Не знал, что исходники теперь открыты.
    Правда использовать перестал, т.к. не дружит с многопоточностью. Для моего случая нужно было как минимум указать точки, в которых инварианты класса должны выполняться, и потребовать lock на определённом объекте. Жаль лишнего времени нет, посмотрел бы, можно ли это вкрутить.


    1. SergeyT Автор
      22.07.2015 00:20
      +2

      Да, исходники открыты с начала года. Но там довольно высокий порог вхождения, ИМХО. В статическом анализаторе вообще без бутылки и PhD не раобраться. В rewriter-е разобраться можно, но тоже довольно трудоемко.

      По теме: контракты вообще по своей природе не очень хорошо дружат с многопоточностью. В Eiffel-е есть SCOOP, но даже там дружба контрактов и многопоточности далека от идеала. Я бы посоветовал вот что (чем пользуюсь активно): постараться избегать мутабельного состояния, переходя к чистым функциям и таскам. В этом случае число инвариантов сведется к минимуму (формально, у неизменяемого объекта они не нужны: предусловий конструктора достаточно), что уменьшит проблемы от использования в многопоточном окружении.

      З.Ы. У rewriter-а есть очень нехорошее поведение. Когда есть анонимный метод, проверяющий постусловие и захватывающий только Contract.Result, то в этом случае ccrewrite генерирует некорректный с точки зрения многопоточности код, протаскивая результат через статическое свойство.


  1. ApeCoder
    22.07.2015 10:29

    Можно ли заставить rewriter поддерживать гигантские assemblies состоящие из множества .netmodule и насколько просто сделать это?


    1. SergeyT Автор
      22.07.2015 19:54

      Там нужно вначале выяснить, что именно тормозит. Есть все шансы, что проблемы не в самом rewriter-е, а в CCI — туле, который используется для анализа и генерации IL-а. Если это так, то решать глобальную проблему с производительностью нужно будет глобальным образом — переходом на CCI2 (есть и такое) или на Розлиновские потроха для чтения/генерации IL-а. Но такие радикальные шаги будет довольно сложно реализовать.


      1. ApeCoder
        22.07.2015 21:33

        Спасибо.
        Насколько я знаю, проблема вообще в неподдержке многофайловых assembly в принципе, а не в тормозах. Но сам в это не погружался.


        1. SergeyT Автор
          22.07.2015 21:36

          Я не совсем понял мысль. О какой «неподдержке» идет речь? Тул этот асболютно standalone, тут нет разницы между MS и non-MS assemblies.


          1. ApeCoder
            23.07.2015 08:47

            Я слышал, что тул не поддерживает многомодульные assembly вне зависимости от того, MS или не MS.


            1. SergeyT Автор
              23.07.2015 08:51

              Понял. Да, вполне возможно, что так и есть.


  1. EngineerSpock
    22.07.2015 15:49

    А чтобы юзать CodeContracts обязательно нужно соглашаться на перепись бинарки? Просто, ну, не нравится мне идея, что нечто (кто-то) будет переписывать то, что команда написала, ну, вот прям совсем не нравится.


    1. SergeyT Автор
      22.07.2015 19:57

      Сейчас это — by design. Ну и с альтернативами ведь довольно плохо все. Ведь основная фишка контрактов (ладно, одна из основных) — это возможность включать/отключать некоторые утверждения по требованию. Вот одна сборка, там нужно все — включая инварианты, а вот эта — тут оставляем только предусловия. А вот здесь мы вообще хотим положить контракты рядом, чтобы они никак не влияли на эффективность нашего кода, но использовались нашими клиантами, если они захотят. В общем, вменяемая альтернатива — это впиливать это на уровне компилятора. И недавно даже proposal появился, хотя ХЗ, что из него выйдет и выйдет ли вообще что-то.


  1. PsyHaSTe
    23.07.2015 15:38

    Спасибо за статью! Особенно порадовало упоминание Midori. Я-то думал, что проект давно загнулся, и мы обречены сидеть под виндами до скончания веков…