вторник, 21 июля 2015 г.

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 <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. Сергей, спасибо за хорошие новости и отличный блог!
    Стало интересно: с рослином можно получить поведение, аналогичное ccrewrite? Например, менять код на нужный на шарпе и компилировать его, или как-то встроиться в сам процесс компиляции и делать необходимые действия там? И вообще не трогать IL. Если это реально, нет ли в планах изменить Code Contracts таким образом? Мне кажется, это было бы гораздо проще.

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

      Есть даже планы (и один мой коллега даже работает над их воплощением) - форкнуть Розлин и реализовать контракты именно таким образом. Но, это довольно трудоемкий процесс.

      И еще, команда C# думает о добавлении контрактов на уровне языка. Так что если они это добавят, то смысл в Code Contracts пропадет полностью.

      Удалить
  2. Отличные новости, спасибо!

    Несколько вопросов: правильно ли я понял. что контракты "живы" во многом благодаря твоей заинтересованности в их поддержке?

    И всвязи с твоим ответом выше - если поддержку контрактов запилят в Розлине, то они и анализы и статический чекер сами сделают без существующих нароботок? С новым набором опций и, возможно, с новым поведением?

    ОтветитьУдалить
    Ответы
    1. > правильно ли я понял. что контракты "живы" во многом благодаря твоей заинтересованности в их поддержке?

      В целом, да. Но не только моей. В моей команде они сверх активно используются. Так что я пару недель официально в рабочее время ими занимался;).

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

      > И всвязи с твоим ответом выше - если поддержку контрактов запилят в Розлине, то они и анализы и статический чекер сами сделают без существующих нароботок? С новым набором опций и, возможно, с новым поведением?

      Пока что никаких особых планов нет. Там есть пропоузал от Тауба (https://github.com/dotnet/roslyn/issues/119), и упоминание в design meetings, что команда разработчиков об этом думает. Никаких других сведений пока нет. Но если они возьмутся за это, то там будет примерно такая идея: Розлин начнен выставлять наружу контракты методов, которые можно будет включать в свой собственный анализ. Будут ли там встроенные анализаторы при этом - ХЗ, может быть, что их даже не будет.

      Поэтому да, поведение однозначно будет новым. Это совершенно точно!

      Удалить
  3. Очень нравятся Code Contracts, как и вообще идея контрактного программирования со статическим анализом. Но к программированию под Unity (который геймдев) никаким боком их присобачить не получается, дико жаль и бесит.

    ОтветитьУдалить
    Ответы
    1. В этом случае нужно разделять Design by Contract, как парадигму и Code Contracts, как инструмент. Отсутствие инструмента и правда раздражает. Но, как я понимаю, сделать здесь ничего нельзя((

      Удалить
  4. А остальные граждане, которые пилят Code Contracts в гитхабе - они тоже занимаются этим проектом в свободное от работы время?

    И второй вопрос - что вообще говорят про Code Contracts внутри самой MS?
    Потому что если открыть в referencesource какой-нибудь List, то там используются CC.
    А вот если посмотреть свежий EF7, то вместо них обнаружим решарперовские аннотации.

    Хотя есть ощущение, что команда R# тоже забросила эти свои аннотации. RSRP-376091 висит уже два года, третий пошёл.
    Мода на Design by contract прошла или что вообще происходит? Удобная ведь штука.

    ОтветитьУдалить
    Ответы
    1. Этот комментарий был удален автором.

      Удалить
    2. > А остальные граждане, которые пилят Code Contracts в гитхабе - они тоже занимаются этим проектом в свободное от работы время?

      Ну, там из штатных сотрудников МС-а только я. Так что судить сложно, но, скорее всего, тоже пилят в свободное время.

      > И второй вопрос - что вообще говорят про Code Contracts внутри самой MS?

      Тут все сложно. Сам факт того, что официально в эту тулу не вливаются деньги говорит о слабой применимости ее другими командами. Да, куски контрактов можно найти во многих местах в BCL, но они так и не нашли всеобщего применения.

      Мое ИМХО, причина в том, что инструмент был написан ресерчерами и не доведен до ума инженерами. Он тормозной и далеко не идеально работающий. Это отпугнуло очень многих разработчиков, как внутри МС, так и за ее пределами.

      Наша команда (build team) - один из самых больших клиентов контрактов. Там достаточно большая кодовая база и вся напичкана контрактами. Билд замедляется, но пока все это терпят. Правда одна из причин этого в том, что текущий middle management - это бывшие ресерчеры, которые работали над контрактами сами или принимали в их разработке косвенное участие.

      > Мода на Design by contract прошла или что вообще происходит? Удобная ведь штука.

      Мне не ясно, ведь вещи и правда очень хорошая. Вот теперь своими силами стараюсь возродить:) Надеюсь, что определенного пинка будет достаточно, чтобы это дело закрутилось снова.

      Удалить
    3. Вот это да! Стоило пожаловаться Сергею в блог, что RSRP-376091 не фиксится уже третий год - и фичу немедленно реализовали. Прям на следующее утро!
      Надо было на зарплату жаловаться!!!
      Сергей, сколько у меня желаний осталось?))

      Удалить
    4. > Надо было на зарплату жаловаться!!!
      Так, зарплату срочно поднять! (дай это прочитать ответственному за нее человеку!)

      > Сергей, сколько у меня желаний осталось?))
      Ну, еще как минимум 2:))

      Удалить
  5. Я пока не вникал как работает .NET Native, но не значит ли это, что IL не создаётся, и, соответственно, Code Contracts не будет работать?
    Кстати, по поводу .NET Native - было бы интересно узнать планы MS по поводу этого направления. Значит ли это что IL будет отмирать постепенно? Извиняюсь за вопрос не по теме.

    ОтветитьУдалить
    Ответы
    1. Да, в .NET Native контракты работать не будут.

      > Кстати, по поводу .NET Native - было бы интересно узнать планы MS по поводу этого направления. Значит ли это что IL будет отмирать постепенно? Извиняюсь за вопрос не по теме.

      Нет, не значит. У IL-а есть ряд преимуществ и недостатков. Наличие overhead-а, который он привносит - совершенно не критично для подавляющего большинства ынтырпрайз приложений, но для небольшой части приложений - сверх критичных к производительности и/или к потреблению памяти, устранение хотя бы его части будет очень полезным.

      .NET Native можно рассматривать как NGen на стероидах. Решает часть проблем, привносит другие проблемы. Так что IL не умирает:))

      Удалить
  6. По поводу добавления контрактов в язык.

    Вы не слыхали ли о каких-то альтернативных контрактам подходах?

    Спрашиваю вот почему: в голове давно крутится идея расширить систему типов языка чем-то вроде "ограничений", что должно одновременно заменить контракты и добавить много других плюшек (и, конечно, сложности) используя при этом систему типов, и не вводя концепцию пред\пост условий и инвариантов.

    Примерно так:

    public constraint PositiveInt32 : Int32
    {
    public static bool operator is(int x) => x > 0;
    }

    или

    namespace System.NonNullableDoubles
    {
    public constraint String : System.String
    {
    public static bool operator is(System.String s) => s != null;
    }
    }

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

    Идея базируется на системе типов F# которая пропогандирует против того чтоб объекты имели состояния, а напротив за то чтоб создавать отдельные типы для разных состояний объектов.

    Можно представить кучу интересных применений, от SortedList до библиотеки которая создаст "NotNullable" варианты BCL типов и решит таким образом проблему нулевых ссылок.

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

    ОтветитьУдалить
    Ответы
    1. Возможно что-то типа зависимых типов, поможет получить желаемое. Но я не уверен.

      Удалить