Сегодня я хочу рассказать об одной из новых возможностей ErrorProne.NET, а в следующий раз показать, как она реализована.
В языке C# есть довольно много возможностей, которые выворачиваются в довольно сложный IL-код и приводят к поведению, не всегда очевидному для пользователей/читателей кода. Хорошим примером подобного рода является ограничение new() в обобщениях, использование которой приводит к использованию отражения (reflection) и созданию новых экземпляров объектов с помощью Activator.CreateInstance, что меняет «профиль» исключений и негативно сказывается на производительности.
Помимо этого, есть еще пара возможностей с очень схожей реализацией и любопытными последствиями в плане обработки исключений.
Предусловия в блоке итераторов
Блок итераторов (Iterator Block) выворачивается компилятором языка C# в конечный автомат для получения поведения, широко известного в узких кругах под названием continuation passing style. В самих таких конструкциях нет ничего страшного, но проблемы могут легко возникнуть при невинном, но не вполне корректном использовании.
Давайте посмотрим на такой, довольно примитивный пример:
public IEnumerable<string> ReadFile(string fileName)
{
if (fileName == null) throw new ArgumentNullException(nameof(fileName));
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Method 'ReadFile' has non-synchronous precondition
using (var textReader = File.OpenText(fileName))
{
string line;
while ((line = textReader.ReadLine()) != null)
{
yield return line;
}
}
}
Да, пример не идеальный, но вполне реальный. Вначале метода идет валидация аргументов, после чего открываем файл и построчно читается его содержимое. Главный же вопрос здесь заключается во времени генерации исключения. Насколько очевидно с первого взгляда, когда оно произойдет?
// Здесь
var content = ReadFile(null);
// Или здесь?
var projection = content.Select(s => s.Length);
// А может тут?
var result = projection.Count();
Поскольку блок итератора является не обычным методом, то произойдет исключение лишь при «материализации» итератора, а значит вылетит оно в строке 3. Поскольку блок итератора выполняется ленивым образом, то проверка предусловия произойдет лишь при первом вызове метода MoveNext на полученном итераторе. И это еще хорошо, когда между получением итератора и его потреблением находится лишь одна строка кода, то понять первопричину будет не сильно сложно, но ведь в реальности IEnumerable<T> может быть сохранен или передан в другую подсистему, в результате чего понять, что и когда пошло будет довольно сложно.
Решается эта проблема довольно типичным образом: метод нужно разбить на два, при этом в первом оставить проверку предусловия, а основную работу «передвинуть» в выделенный метод:
public IEnumerable<string> ReadFile(string fileName)
{
if (fileName == null) throw new ArgumentNullException(nameof(fileName));
return DoReadFile(fileName);
}
private IEnumerable<string> DoReadFile(string fileName)
{
using (var textReader = File.OpenText(fileName))
{
string line;
while ((line = textReader.ReadLine()) != null)
{
yield return line;
}
}
}
Не сложно догадаться, что ErrorProne.NET содержит специальное правило для отлавливания подобных случаев и фиксер, который поможет разбить метод на два.
Предусловия в асинхронных методов
Предусловия в блоках итераторов могут показать довольно экзотической штуковиной, но подобная же проблема присуща и другой языковой конструкции – «асинхронным» методам, т.е. методам, помеченным ключевым словом async.
Любой метод содержит формальный или неформальный контракт. Если вызывающая сторона выполняет некоторое условие, называемое предусловием, то метод сделает все возможное, чтобы выполнить свои обязательства (т.е. постарается гарантировать свои постусловия). С точки зрения времени исполнения, нарушение предусловий обычно моделируются с помощью генерации исключений ArgumentException (или производных от него классов), другие же типы исключений моделируют проблемы реализации и могут трактоваться как нарушение постусловий.
Тип исключения и время их выполнения позволяет лучше понять обязанности разных компонентов, а также разобраться в том, кто не прав и что чинить. Однако, когда речь заходит за асинхронные методы, все становится несколько сложнее.
С появлением TAP (Task-based async pattern) у метода появилось два способа сообщить о возникшей проблеме. Метод может бросить исключение в момент своего вызова, или же может вернуть задачу в «поломанном» состоянии. Исключения о невыполнении предусловия происходят синхронно и говорят клиенту метода о невыполнении своей части обязательств. Синхронность исключения даст ему понять, что он не прав и что операция даже не была начата. Поломанная же задача говорит о проблемах в выполнении обязательств со стороны реализации.
Теперь давайте вернемся к асинхронному методу:
public async Task<int> GetLengthAsync(string s)
{
if (s == null) throw new ArgumentNullException(nameof(s));
await Task.Delay(42);
return s.Length;
}
В какой момент будет выброшено исключение ANE?
// Во время вызова GetLengthAsync?
Task<int> task = GetLengthAsync(null);
// Или в момент получения результата «выполненной задачи»?
int lenth = task.GetAwaiter().GetResult();
Асинхронные методы реализованы таким образом, что часть метода до первого слова await будет выполняться синхронно, однако если в этом момент произойдет исключение, то оно не будет выброшено клиенту напрямую. Вместо этого, метод завершится «успешно» и исключение будет проброшено в момент получения результата из задачи.
Проблема с таким поведением аналогична проблеме с блоком итераторов. Мы можем получить таску, сохранить ее в поле, передать ее другому методу и «пронаблюдать» ArgumentNullException результат в противоположной части программы. Однако, по факту, задача даже не была «запущена», поскольку предусловия для ее выполнения не были выполнены.
И в этом случае используется тот же самый трюк с выделением метода: метод нужно разбить на два, из первого убрать ключевое слово async, но оставить проверку предусловия, а всю основную работу выделить во вспомогательный метод:
public Task<int> GetLengthAsync(string s)
{
if (s == null) throw new ArgumentNullException(nameof(s));
return DoGetLengthAsync(s);
}
private async Task<int> DoGetLengthAsync(string s)
{
await Task.Delay(42);
return s.Length;
}
Правила и еще раз правила
Каждый раз при переходе в новую команду я брался за чистку «свода правил», а.к.а. coding guidelines, поскольку в подавляющем числе случаев в них ничего не говорилось о хорошей практике обработки исключений. Теперь же, при наличии всяких умных анализаторов, большинство правил можно довольно легко автоматизировать и просто ломать компиляцию, когда они не выполняются.
(Да, я знаю, о существовании кастомных правил для FxCop-а, но как-то я нигде не видел кастомных правил для правильной обработки исключений. К тому же, FxCop толком никто не сопровождает, так что не факт, что он будет работать с последней версией компилятора, ведь он использует IL уровень анализа, на котором анализировать асинхронные методы ой как не просто).
Предусловия и контракты
Все мои рассуждения о предусловиях/постусловиях подтверждаются тем, что тулы для контрактного программирования, а именно, Code Contracts, обрабатывают предусловия блока итераторов и асинхронных методов особым образом. Они делают ровно те преобразования (на логическом уровне) которые продемонстрированы здесь: а именно, делают так, чтобы нарушения предусловий «срабатывали» синхронным образом (eagerly) и бросались клиенту в момент вызова метода, а не в момент обработки его результата!
Дополнительные ссылки
- ErrorProne.NET. Часть 1
- ErrorProne.NET. Часть 2
- Вики с описанием этой же проблемы на английском
- О внутренней кухне блока итераторов. В частях 1, 2, 3
Ну а вот gif-ка, которая показывает анализатор и фиксер в действии:
Спасибо! Это как раз то, чего очень не хватает в решарпере!
ОтветитьУдалитьА как пользоваться NuGet пакетом? Установил, потом скомпилировал солюшн, ожидал увидеть новые сообщения в Errors или Warnings, но что-то не вижу.
ОтветитьУдалитьСейчас NuGet пакет довольно старый. Я обновлю его на днях (или прикручу автоматический паблишинг пакетов из мастера, тогда пакеты всегда будут актуальными).
УдалитьСереж, вариант с async Do выглядит интересно. Но вот с итератором, есть скользкий момент. Получается, что в файле у тебя есть 5 публичных функций и 5 приватных с Do. Теперь ты добавляешь 6 и что видишь, что в общем-то можно вызвать существующую с Do(но чуть-чуть ее подшаманить). И ты получаешь, 6 публичных функций и 5 приватных (причем одна из них КРИВАЯ на две публичные). По моему опыту когда есть много возможностей сделать криво - сделают криво. Как там ты любишь говорить, надо сделать так, чтобы сделать правильно было легко, а неправильно - сложно. Вот здесь, IMO, легко сделать неправильно.
ОтветитьУдалитьЖень, я не совсем понял в чем кривизна, и какое есть альтернативное решение, кроме использования контрактов или подобных штук по переписыванию синтаксиса. Ну и не понятно, почему ты различает итераторы и асинк методы, ведь вызвать приватный метод напрямую можно в обоих случаях...
УдалитьИ да, изнутри класса ты всегда будешь иметь возможность вызвать функцию без проверки предусловия. Но в некоторых случаех это даже может быть полезным, поскольку ты можешь уже знать, что предусловие точно проверено.
Ок. Давай попробую. Ты пишешь метод DoFoo1() этот метод записывает в файл числа с определенным табулятором. Далее тебе надо сделать метод, который записывает уже не числа, а строки без разделителей. Есть два варианта - вынести общую часть в метод (DoFoo1) а уникальные в отдельные методы WriteNumberWithTabulator and WriteStrings. Но можно ведь не напрягаться и заколхозить это в одном DoFoo1!!! И так и будет сделано! C другой стороны колхозить методы без Do(без сплиттинга) сложнее. Поэтому осталось бы две функции без желания слить в одну.
УдалитьПочему отличаю async -
>>Асинхронные методы реализованы таким образом, что часть метода до первого >>слова await будет выполняться синхронно, однако если в этом момент >>произойдет исключение, то оно не будет выброшено клиенту напрямую. Вместо >>этого, метод завершится «успешно» и исключение будет проброшено в момент >>получения результата из задачи.
Врать не буду - не знал. :)
Когда я вызываю DoFoo() я не знаю КАКОЕ предусловие проверено. Еще пример. Разсплитил ты функцию на проверку предусловий и функционал. Ок. Разсплитил другую функцию. Далее, в функционал второй функции вставляешь Do первой функции. НО, у тебя проверялись только предусловия от второй функции. И все, проблема на ровном месте
Жень, мне все равно не понятно, в чем проблема, поскольку данный тул лишь подсказывает, стандартный подход по решению проблемы с исключениями в асинхронных методах.
УдалитьК тому же, я обычно разделяю публичные предусловия от непубличных, и именно для публичных это разделение имеет смысл больше всего. Если же ты хочешь DoFoo изнутри твоего кода, то ни один инструмент не сможет тебе подсказать, корректно это или нет, нужно ли тебе проверять предусловие или нет.
Да, вру, такой инструмент есть и это cccheck из CodeContracts. Если бы он работал нормально. Но ведь это уже другая история..
Сереж, я просто про то, что при таком подходе очень легко использовать DoFoo неправильно, не проверив предусловия. Но ок. Если тебе так ок, то пусть будет. У меня только один вопрос остался :). Ты в свой код кого-нибудь еще пускаешь на работе?
УдалитьЖеня, я понимаю, что использовать неправильно это дело можно. Но я не пойму, как эту проблему решить фундаментальным образом:(
УдалитьПо поводу последнего вопроса: подключайся к ErorrPrope.NET и узнаешь на собственном опыте;)
Здравствуйте Сергей,
ОтветитьУдалитьС большим интересом читаю посты посвященные статическому анализу. У меня несколько вопросов по ErrorProne.NET:
1. Строится ли CFG?
2. Проверка правил производится на основе символьного выполнения программы или другим способом?
3. Поддерживается ли межпроцедурный анализ?
4. Как боритесь с Розлином? Используются ли аннотации, используется ли ISymbol.Equals и как решается проблема того что сравнение двух символов возвращает false, когда оно должно возвращать true?
1. Нет.
Удалить2. На основе анализа синтаксического дерева (с анализом символом иногда).
3. Он не поддерживается Розлином, а поверх него ничего не прикручено. Во всяком случае пока.
4. Пока не сильно приходиться бодаться. По поводу проблемы с ISymbol.Equals я пока не понимаю, в чем проблема. Я это дело использую с завидным постоянством, но с проблемой такой не сталкивался.
День добрый, Сергей.
ОтветитьУдалитьНаверное,можно было бы внести такой рул. Особенно актуален для тех, кто параллельно работает на плюсах(unmanaged). Я налетел на эти грабли. Всегда использовал для swap такое(пример):
var x = 7;
var y = 17;
x ^= y ^= x ^= y;
На плюсах, как и ожидалось x=17 y=7
На шарпе x = 0 y = 7 (на жаве также)
Вот так на шарпе будет верно:
ОтветитьУдалитьx ^= y ^= x;
y ^= x;
Федор, спасибо.
УдалитьДа, рул довольно интересный. Любопытно поискать, насколько он часто встречается в .NET-ом коде...