Как я добавлял поддержку 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 (
Простой тест показал, что вызов делегата через экземплярный метод действительно процентов на 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) преобразовывает этот код следующим образом:
- Создается структура, которая реализует IAsyncStateMachine и вся логика метода переезжает в метод MoveNext.
- В методе 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;
}
Тут довольно много букв, но основная идея такая:
- Предусловие асинхронного метода находится внутри конечного автомата. Именно поэтому ccrewrite должен вытянуть его и перенести в метод FooAsync. В противном случае нарушение предусловия будет приводить к faulted таске, а не к «синхронному исключению».
- Существует определенный паттерн, как ccrewrite определяет, где находится предусловие. В случае асинхронного метода с одним оператором await, оригинальное начало метода, а значит и предусловия находятся сразу же внутри условия if (num!= 0). Это важно!
- Генерируемый код зависит от числа операторов 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-е!
Ссылки
- Code Contracts на GitHub
- Code Contracts Editor Extensions на гитхаб
- Последний релиз на GitHub
- Code Contracts на Visual Studio Gallery
- Code Contracts Editor Extensions на Visual Studio Gallery
- Цикл статей о контрактном программировании в .NET