Блоки итераторов, часть пятая: Активный или Пассивный

Некоторое время назад я опубликовал комментарий по поводу Летних Игр по Скриптингу, где я отметил наличие изоморфизма между «пассивными» коллекциями, , и «активными» событиями. Обычно вы думаете про события, как про что-то, что «вызывает» вас, отдавая вам аргументы событий. А о коллекциях вы думаете как о чём-то таком, из чего вы «вытаскиваете» данные, запрашивая следующее значение до тех пор, пока не закончите. Но вы можете трактовать поток срабатываний события как последовательность объектов – аргументов событий. И, аналогично, вы можете реализовать итератор так, чтобы он вызывал ваш метод для каждого объекта в коллекции. Чёрт возьми, да вы могли бы реализовать весь LINQ в этой модели, если вам бы этого хотелось.

Реализация блоков итераторов явно входит в «пассивную» парадигму. Но она не обязана быть такой. Мы могли бы обойтись подходом вроде «инверсии управления». «Пассивные» итераторы вынуждены изображать сопрограммы при помощи маленькой машины состояний, которая знает, как вернуться в то состояние, на котором исполнение кода прервалось в прошлый раз. Но у нас уже есть механизм для возврата кода «в то состояние, на котором он прервался» - это то, что вы делаете всякий раз при вызове метода! Вы запоминаете, чем занимались, вызываете метод, а затем продолжаете с того места, на котором остановились. Мы могли бы сделать то же самое с итераторами. Мы могли сказать, что вот это:

void Integers(int length, IObserver<int> observer)
{
  for (int i = 0; i < length; ++i) yield return i;
}

является синтаксическим сахаром для

void Integers(int length, IObserver<int> observer)
{
  for (int i = 0; i < length; ++i) observer.Yield(i);
  observer.Break();
}

То есть, мы «возбуждаем событие» , вызывая observer всякий раз, как у нас есть значение, и возбуждаем другое событие, когда значения закончились.

Это было бы значительно более тривиальным преобразованием, чем наш нынешний подход с машиной состояний, но, поскольку большинство людей хочет иметь перебор последовательностей в «пассивной» модели, мы провели более трудную работу, чтобы этого добиться.

Я также говорил, что это имеет какое-то отношение к обработке исключений. Какая связь?

Вы заметили нечто странное в том, как мы обрабатываем блоки finally? Смотрите, что происходит с «активной моделью»:

TextReader reader = File.Open(file);
try
{
трам-пам-пам обработка строк
}
finally
{
reader.Close();
}

Если «трам-пам-пам» реализовано как вызов observer.Yield(line), то что произойдет, если код, потребляющий результаты, выбросит исключение? Легко. Это просто вызов метода, такой же как и все. Стек вызовов будет раскручен, мы найдем finally, файл закроется, всё отлично.

Теперь предположим, что это реализовано как MoveNext «пассивного» итератора. Что произойдёт, если код, потребляющий результаты, выбросит исключение? Если мы мы потребляем результат, то мы уже вернулись из вызова MoveNext! Нет никакого «try», никакого «finally». Но обычно finally всё же каким-то образом исполняется! Если код, потребляющий результаты, бросает исключение, то велики шансы, что это произошло в foreach; когда внутри foreach вылетает исключение, он вызывает Dispose() енумератора, а когда вызывается Dispose() енумератора, мы выясняем, какие «finally» остались невыполненными, когда мы в последний раз вышли из MoveNext, и выполняем специальный метод, который делает всё, что там оставалось в этих блоках.

Это весьма криво. Вот что тут происходит: для блоков finally, «пассивные» итераторы имеют ту же семантику, что и «активные» итераторы. Когда вы делаете yield из блока try, снабжённого finally, всё выглядит так, как будто finally всё еще «на стеке» и будет выполнено, если потребитель бросает исключение.

Но что, если блок try снабжён catch?

Первые дизайнеры С# 2.0 – и помните, это было задолго до моего прихода в команду – устроили огромный спор об этом. Спор, который был повторён в миниатюре, когда я послал всем им письмо с просьбой обосновать это решение. Есть три основных позиции:

1) Вообще не разрешать yield return в блоках try. (Или в блоках, которые являются сахаром для try, типа блоков using.) Использовать какой-то другой механизм вместо «finally» для выражения идеи «вот этот код надо выполнить, когда вызывающий прекращает перебор».

2) Разрешить yield return во всех блоках try

3) Разрешить yield return в блоках try, у которых есть блоки finally, но запретить, если у них есть блоки catch.

Недостатки (1) – в том, что придётся придумать некий синтаксис для представления логики «finally», но не «finally», и в том, что становится труднее итерироваться по чему-то такому, что требует очистки в конце, типа итерирования по содержимому файла. Это делает итераторы непонятными и маломощными; мы должны избегать этого варианта, если это возможно.

Недостаток (2) – в том, что поведение обработки исключений для блоков finally и блоков catch становится глубоко и странно несогласованным. Если у вас есть yield в блоке try с finally, и потребитель итерирования бросает исключение, то выполняется finally, как будто вы в «активной» модели. Но если у вас yield находится в блоке try с catch, и потребитель итерирования бросает исключение, то catch не выполняется, потому что его нет в стеке вызовов. Пользователи обоснованно ожидают, что finally и catch при возникновении исключения работают более-менее одинаково; то есть, что управление одинаково передаётся в соответствующий блок catch или finally.

Недостаток (3) – в том, что это правило выглядит произвольным и кривым – до тех пор, пока вы не прочитаете пять избыточно подробных постов блога с объяснениями, о чём там себе думала команда дизайнеров.

Очевидно, они выбрали (3), и теперь вы знаете, почему.

В следующий раз мы завершим эту серию взглядом на небезопасный код. Теперь, когда вы знаете всё это, понять, почему в блоках итераторов запрещен небезопасный код, будет сравнительно просто.

оригинал статьи