Автор: Sergey Teplyakov
В последние несколько недель я активно занимался доработкой 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, но с небольшими модификациями:// Начало метода MoveNextif (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