среда, 16 июня 2010 г.

Итераторы в языке C#. Часть 3

  • И напоследок... блок finally

    Последним моментом, о котором нужно обязательно сказать при рассмотрении итераторов в C# являются проблемы,  связанные с блоком finally внутри блока итераторов. Давайте в наш последний пример добавим блок try/finally и даже не  глядя на сгенерированный код, подумаем о его поведении и возможных последствиях:

    public static IEnumerable<int> GetNumbers()
    {
        try
        {
            yield return 7; // 1
            // 2: обработка первого элемента внешним кодом
            yield return 42; // 3
            // 4: обработка второго элемента внешним кодом
        }
        finally
        {
            Console.WriteLine("Внутри блока finally метода GetNumbers");
        }
    }

    Как уже было сказано ранее, блок итератора не выполняется последовательно, а разворачивается в конечный автомат, реализация которого  находится в методе MoveNext. Очевидно, блок finally должен выполняться не после каждого вызова метода MoveNext, а только  один раз на полную итерацию последовательности, поскольку в противном случае мы можем, например, освободить ресурсы, которые  потребуются при следующей итерации цикла. А раз так, то кто сможет гарантировать, что пользователь захочет пройти последовательность целиком? Почему, получив итератор из метода GetNumbers, пользователь обязательно должен вызвать MoveNext более одного раза?

    Но несмотря на то, что компилятор не может гарантировать вызов блока finally итератора, он делает все возможное, чтобы свести такую вероятность к минимуму. Давайте рассмотрим сгенерированный код:

    private sealed class GetNumbersIterator : IEnumerable<int>, IEnumerable,
            IEnumerator<int>, IEnumerator, IDisposable
    {

        // Остальные методы остаются такими же, поэтому пропущены
        private void m__Finally3()
        {
            this.__state = -1; /*after*/
            Console.WriteLine("Внутри блока finally метода GetNumbers");
        }

        private bool MoveNext()
        {
            try
            {
                switch (this.__state)
                {
                    case 0: /*before*/
                        this.__state = -1; /*running*/
                        this.__state = 1; /*running; can finilize*/
                        this.__current = 7;
                        this.__state = 2; /*suspended*/
                        return true;

                    case 2: /*suspended*/
                        this.__state = 1; /*running; can finilize*/
                        this.__current = 42;
                        this.__state = 3; /*suspended*/
                        return true;

                    case 3: /*suspended*/
                        this.__state = 1; /*running*/
                        // Нормальное завершение блока итератора
                        this.m__Finally3();
                        break;
                }
                return false;
            }
            fault
            {
                // Возникло исключение в блоке try
                this.System.IDisposable.Dispose();
            }
        }

        void IDisposable.Dispose()
        {
            switch (this.__state)
            {
                case 1:
                case 2:
                case 3:
                    try
                    {
                    }
                    finally
                    {
                        // Явный вызов метода Dispose
                        this.m__Finally3();
                    }
                    break;
            }
        }
    }

    Если блок итератора содержит блок finally, то весь код, расположенный в этом блоке помещается в отдельный метод (в нашем случае в метод m_Finally3()), который будет вызван в следующих случаях:

    1) после нормального завершения итерирования коллекции (либо после вызова yield break, либо после обыкновенного завершения блока итератора);

    2) в случае генерации исключения в блоке итератора (вы могли обратить внимание на ключевое слово fault вместо finally; это не ошибка, такого ключевого слова нет в языке C#, но такая конструкция существует языке IL, которая означает, что этот фрагмент кода будет выполнен только в случае генерации исключения, после чего исключение будет проброшено далее по стеку);

    3) в случае вызова метода Dispose.

    На последнем случае давайте остановимся подробнее и попытаемся понять для чего вообще итератор реализует интерфейс IDisposalbe. Итак, что произойдет, если исключение произойдет не в нашем коде, а в коде пользователя, при обработке первого элемента коллекции? Давайте снова вернемся к нашему последнему примеру:

    public static IEnumerable<int> GetNumbers()
    {
        try
        {
            yield return 7; // 1
            // 2: обработка первого элемента внешним кодом
            yield return 42; // 3
            // 4: обработка второго элемента внешним кодом
        }
        finally
        {
            Console.WriteLine("Внутри блока finally метода GetNumbers");
        }
    }

    Большинство разработчиков вправе предполагать, что если между строками 1 и 3 (т.е. при обработке первого элемента коллекции пользовательским кодом) произойдет исключение, то блок finally должен быть выполнен точно также, как и при возникновении исключения непосредственно в блоке итератора, например, в строке 1. Такое поведение становится более очевидным, если вместо возвращения двух магических чисел, блок итератора будет выполнять более осмысленную работу, например открывать файл и возвращать строки по одной:

    public static IEnumerable<string> ReadFile(string filename)
    {
        using (TextReader reader = File.OpenText(filename))
        {
            string line;
            while ((line = reader.ReadLine()) != null)
            {
                yield return line;
            }
        }
    }

    В этом фрагменте блок try/finally генерирует за нас компилятор, но это никак не влияет на поведение. Давайте зададим предыдущий вопрос еще раз: вправе ли разработчик рассчитывать на корректное освобождение ресурсов, если пользовательский код сгенерирует исключение?

    К сожалению ответ на этот вопрос будет утвердительным в том случае, если пользователь будет следовать общепринятым идиомам использования итераторов: воспользуется оператором foreach, либо реализует аналогичную функциональность самостоятельно:

    foreach (var s in ReadFile(filename))
    {
        // Обрабатываем очередную строку,
        // при этом при обработке может возникнуть исключение
        Console.WriteLine(s);
    }

    Компилятор преобразовывает оператор foreach следующим образом:

    IEnumerator<string> enumerator = ReadFile(filename).GetEnumerator();
    try
    {
        while (enumerator.MoveNext())
        {
            var s = enumerator.Current;
            // Обрабатываем очередную строку,
            // при этом при обработке может возникнуть исключение
            Console.WriteLine(s);
        }
    }
    finally
    {
        IDisposable disposable = enumerator as System.IDisposable;
        if (disposable != null) disposable.Dispose();
    }

    В таком случае, если при обработке элемента коллекции возникнет исключение, то автоматически будет вызван метод Dispose итератора, что приведет к вызову блока finally блока итератора.

    Заключение

    Подобные знания внутренностей языка программирования могут показаться излишним, и в большинстве случаев, скорее всего, так оно и есть. Но итераторы играют весьма важную роль в языке программирования C#, так, например, большая часть особенностей использования LINQ 2 Objects основана на итераторах и на их «ленивом» выполнении. И это касается не только разработки и применения некоторых библиотек, это также касается  реализации многих повседневных задач. В большинстве случаев прикладной программист может найти другие пути решения и не использовать блоки итераторов, но существует ряд задач, которые очень просто и элегантно решаются именно с их помощью (например, работа с деревьями), поэтому если вы все же столкнетесь с необходимостью применения этого инструмента, желательно обладать достаточным опытом и знаниями, чтобы случайно не прострелить себе ногу.

    Литература

    1. Гамма Э. и др. Приемы объектно-ориентированного проектирования. Паттерны проектирования. Питер, 2007
    2. Skeet J. C# In Depth: What you need to master C# 2 and 3. Manning Publications, 2008
    3. Hejlsberg A. et al. The C# Programming Language. 3rd Edition. A-W Professional, 2008
    4. Skeet J. Iterators, iterator blocks and data pipelines. http://csharpindepth.com/Articles/Chapter11/StreamingAndIterators.aspx
    5. Chen R. The implementation of iterators in C# and its consequences. Parts 1 - 4

    6 комментариев:

    1. Спасибо за переводы! :) Однозначно, классные статьи!

      ОтветитьУдалить
    2. хм... Спасибо за спасибо, но это, вроде бы не переводы:))

      ОтветитьУдалить
    3. Серега, кажется, ты забыл упомянуть об ограничениях, которые команда разработчиков\архитекторов вынуждена была принять в процессе реализации блока итераторов:
      - невозможно использовать yield return внутри блока try/catch;
      - нельзя использовать ref и out переменные внутри блока итераторов;
      - запрещено использование анонимных итераторов;
      - также не допускается использование unsafe кода внутри блока итераторов.

      ОтветитьУдалить
    4. Саня, привет!
      Да, серию постов Эрика Липперта про итераторы я тоже читал:) Но, лучше чем он об этом все равно не напишешь:)

      ОтветитьУдалить
    5. Отличный блок статей. Спасибо - подчерпнул для себя много нового.

      ОтветитьУдалить