В последние несколько недель я активно занимался доработкой 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-е!
Сергей, спасибо за хорошие новости и отличный блог!
ОтветитьУдалитьСтало интересно: с рослином можно получить поведение, аналогичное ccrewrite? Например, менять код на нужный на шарпе и компилировать его, или как-то встроиться в сам процесс компиляции и делать необходимые действия там? И вообще не трогать IL. Если это реально, нет ли в планах изменить Code Contracts таким образом? Мне кажется, это было бы гораздо проще.
Где-то месяц назад у нас была встреча с командой Розлина, чтобы обсудить планы контрактов. Там же был поднят вопрос о реализации контрактов другим способом - на семантическом уровне, а не на уровне IL-а. Для этого нужно, чтобы Розлин добавил некоторые точки расширения на последних этапах своего пайплайна, прямо перед генерацией IL-кода. Они этого делать пока не хотят.
УдалитьЕсть даже планы (и один мой коллега даже работает над их воплощением) - форкнуть Розлин и реализовать контракты именно таким образом. Но, это довольно трудоемкий процесс.
И еще, команда C# думает о добавлении контрактов на уровне языка. Так что если они это добавят, то смысл в Code Contracts пропадет полностью.
Отличные новости, спасибо!
ОтветитьУдалитьНесколько вопросов: правильно ли я понял. что контракты "живы" во многом благодаря твоей заинтересованности в их поддержке?
И всвязи с твоим ответом выше - если поддержку контрактов запилят в Розлине, то они и анализы и статический чекер сами сделают без существующих нароботок? С новым набором опций и, возможно, с новым поведением?
> правильно ли я понял. что контракты "живы" во многом благодаря твоей заинтересованности в их поддержке?
УдалитьВ целом, да. Но не только моей. В моей команде они сверх активно используются. Так что я пару недель официально в рабочее время ими занимался;).
Есть надежды, что подключится еще сколько-то заинтересованных лиц (в виде контрибьюторов или же в виде "пинателей"), что заставит проект двигаться дальше своим ходом.
> И всвязи с твоим ответом выше - если поддержку контрактов запилят в Розлине, то они и анализы и статический чекер сами сделают без существующих нароботок? С новым набором опций и, возможно, с новым поведением?
Пока что никаких особых планов нет. Там есть пропоузал от Тауба (https://github.com/dotnet/roslyn/issues/119), и упоминание в design meetings, что команда разработчиков об этом думает. Никаких других сведений пока нет. Но если они возьмутся за это, то там будет примерно такая идея: Розлин начнен выставлять наружу контракты методов, которые можно будет включать в свой собственный анализ. Будут ли там встроенные анализаторы при этом - ХЗ, может быть, что их даже не будет.
Поэтому да, поведение однозначно будет новым. Это совершенно точно!
Очень нравятся Code Contracts, как и вообще идея контрактного программирования со статическим анализом. Но к программированию под Unity (который геймдев) никаким боком их присобачить не получается, дико жаль и бесит.
ОтветитьУдалитьВ этом случае нужно разделять Design by Contract, как парадигму и Code Contracts, как инструмент. Отсутствие инструмента и правда раздражает. Но, как я понимаю, сделать здесь ничего нельзя((
УдалитьА остальные граждане, которые пилят Code Contracts в гитхабе - они тоже занимаются этим проектом в свободное от работы время?
ОтветитьУдалитьИ второй вопрос - что вообще говорят про Code Contracts внутри самой MS?
Потому что если открыть в referencesource какой-нибудь List, то там используются CC.
А вот если посмотреть свежий EF7, то вместо них обнаружим решарперовские аннотации.
Хотя есть ощущение, что команда R# тоже забросила эти свои аннотации. RSRP-376091 висит уже два года, третий пошёл.
Мода на Design by contract прошла или что вообще происходит? Удобная ведь штука.
Этот комментарий был удален автором.
Удалить> А остальные граждане, которые пилят Code Contracts в гитхабе - они тоже занимаются этим проектом в свободное от работы время?
УдалитьНу, там из штатных сотрудников МС-а только я. Так что судить сложно, но, скорее всего, тоже пилят в свободное время.
> И второй вопрос - что вообще говорят про Code Contracts внутри самой MS?
Тут все сложно. Сам факт того, что официально в эту тулу не вливаются деньги говорит о слабой применимости ее другими командами. Да, куски контрактов можно найти во многих местах в BCL, но они так и не нашли всеобщего применения.
Мое ИМХО, причина в том, что инструмент был написан ресерчерами и не доведен до ума инженерами. Он тормозной и далеко не идеально работающий. Это отпугнуло очень многих разработчиков, как внутри МС, так и за ее пределами.
Наша команда (build team) - один из самых больших клиентов контрактов. Там достаточно большая кодовая база и вся напичкана контрактами. Билд замедляется, но пока все это терпят. Правда одна из причин этого в том, что текущий middle management - это бывшие ресерчеры, которые работали над контрактами сами или принимали в их разработке косвенное участие.
> Мода на Design by contract прошла или что вообще происходит? Удобная ведь штука.
Мне не ясно, ведь вещи и правда очень хорошая. Вот теперь своими силами стараюсь возродить:) Надеюсь, что определенного пинка будет достаточно, чтобы это дело закрутилось снова.
Вот это да! Стоило пожаловаться Сергею в блог, что RSRP-376091 не фиксится уже третий год - и фичу немедленно реализовали. Прям на следующее утро!
УдалитьНадо было на зарплату жаловаться!!!
Сергей, сколько у меня желаний осталось?))
> Надо было на зарплату жаловаться!!!
УдалитьТак, зарплату срочно поднять! (дай это прочитать ответственному за нее человеку!)
> Сергей, сколько у меня желаний осталось?))
Ну, еще как минимум 2:))
Да, в .NET Native контракты работать не будут.
ОтветитьУдалить> Кстати, по поводу .NET Native - было бы интересно узнать планы MS по поводу этого направления. Значит ли это что IL будет отмирать постепенно? Извиняюсь за вопрос не по теме.
Нет, не значит. У IL-а есть ряд преимуществ и недостатков. Наличие overhead-а, который он привносит - совершенно не критично для подавляющего большинства ынтырпрайз приложений, но для небольшой части приложений - сверх критичных к производительности и/или к потреблению памяти, устранение хотя бы его части будет очень полезным.
.NET Native можно рассматривать как NGen на стероидах. Решает часть проблем, привносит другие проблемы. Так что IL не умирает:))
По поводу добавления контрактов в язык.
ОтветитьУдалитьВы не слыхали ли о каких-то альтернативных контрактам подходах?
Спрашиваю вот почему: в голове давно крутится идея расширить систему типов языка чем-то вроде "ограничений", что должно одновременно заменить контракты и добавить много других плюшек (и, конечно, сложности) используя при этом систему типов, и не вводя концепцию пред\пост условий и инвариантов.
Примерно так:
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 типов и решит таким образом проблему нулевых ссылок.
Так вот еще раз повторю свой вопрос. Наверняка я не выдумал ничего нового в этой области. Может вы можете посоветовать ссылки на что-то подобное в существующих языках, или указать на явные логические ляпы?
Возможно что-то типа зависимых типов, поможет получить желаемое. Но я не уверен.
Удалить