В последние несколько недель я активно занимался доработкой 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) преобразовывает этот код следующим образом:
- Создается структура, которая реализует IAsyncStateMachine и вся логика метода переезжает в метод MoveNext.
- В методе 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; }
Тут довольно много букв, но основная идея такая:
- Предусловие асинхронного метода находится внутри конечного автомата. Именно поэтому 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<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-е!
Ссылки
- Code Contracts на GitHub
- Code Contracts Editor Extensions на гитхаб
- Последний релиз на GitHub
- Code Contracts на Visual Studio Gallery
- Code Contracts Editor Extensions на Visual Studio Gallery
- Цикл статей о контрактном программировании в .NET
ссылка на оригинал статьи http://habrahabr.ru/post/263235/
Добавить комментарий