Автор: Sergey Teplyakov

Сегодня я хочу рассказать об одной из новых возможностей 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-ка, которая показывает анализатор и фиксер в действии:

ExtractAsyncMethod

Помогла статья? Оцените её!
0 из 5. Общее количество голосов - 0
 

You have no rights to post comments

Дмитрий Крикунов

Публикую статьи, обучающие курсы и новости по программированию: алгоритмам, языкам (С++, Java), параллельному программированию, паттернам и библиотекам (Qt, boost).