воскресенье, 13 июня 2010 г.

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

  • В прошлый раз мы начали говорить об итераторах в языке C# и рассмотрели пример создания итератора своими руками. Решение оказалось не очень сложным, но достаточно “многословным”, поэтому в этот раз будет рассмотрено решение той же задачи (итерирование собственной простейшей коллекции) с помощью блока итераторов.

    Блоки итераторов (Iterator Blocks)

    Начиная с версии 2.0 в языке C# появилась возможность реализации итераторов с помощью «блока итераторов» (iterator block), в результате, наш предыдущий пример может быть переписан следующим образом:

    public class CustomContainer
    {
        // Остальной код аналогичен
        public IEnumerator<int> GetEnumerator()
        {
            for (int i = 0; i < list.Count; i++)
            {
                yield return list[i];
            }
        }
    }

    Впечатляет! Вместо 40 строк кода, мы получили всего 4, причем две из них это фигурные скобки. Но прежде чем делать какие-то выводы, стоит взглянуть на то, во что преобразует этот код компилятор:

    class CustomContainer
    {
        // Остальной код аналогичен
        public IEnumerator<int> GetEnumerator()
        {
            __GetEnumeratorIterator iterator =
               new __GetEnumeratorIterator(0); /*state = “before”*/
            iterator.__this = this;
            return iterator;
        }

        [CompilerGenerated]
        private sealed class __GetEnumeratorIterator : IEnumerator<int>, IEnumerator, IDisposable
        {
            // Fields
            private int __state;
            private int __current;
            public CustomContainer __this;
            public int __i;

            // Methods
            [DebuggerHidden]
            public __GetEnumeratorIterator(int __state)
            {
                this.__state = __state;
            }

            bool IEnumerator.MoveNext()
            {
                switch (this.__state)
                {
                    case 0: /*before*/
                        this.__state = -1; /*running*/
                        this.__i = 0;
                        while (__i < this.__this.list.Count)
                        {
                            this.__current = this.__this.list[__i];
                            __state = 1; /*suspended*/
                            return true;
                        Label_0056:
                            __state = -1; /*running*/
                            __i++;
                        }
                        break;

                    case 1:
                        goto Label_0056;
                }
                return false;
            }

            [DebuggerHidden]
            void IEnumerator.Reset()
            {
                throw new NotSupportedException();
            }

            void IDisposable.Dispose() { }

            // Properties
            int IEnumerator<int>.Current
            {
                [DebuggerHidden]
                get { return this.__current; }
            }

            object IEnumerator.Current
            {
                [DebuggerHidden]
                get { return this.__current; }
            }

        }
    }

    Блок итератора преобразовывается в закрытый вложенный класс,  реализующий интерфейсы IEnumerator, IEnumerator<T> и IDisposable, примем, если ваш метод будет возвращать интерфейс IEnumerator (т.е. необобщенный интерфейс), то в любом случае будет реализованы все три интерфейса, при этом обобщенным  интерфейсом будет IEnumerator<object>. В случае возврата интерфейса IEnumerable (или IEnumerable<T>), к этим трем  интерфейсам добавятся еще два: IEnumerable и IEnumerable<T>.

    Автоматически сгенерированный класс содержит несколько обязательных и несколько необязательных дополнительных полей. Каждый  сгенерированный класс содержит поле __state (состояние конечного автомата), ссылку на внешний класс (__this),  а также поле __current, тип которого соответствует типу возвращаемого значения блока итератора. Необязательными полями  являются поля, соответствующие локальным переменным метода GetEnumerator (в данном случае __i), а также все параметры  этого метода (поскольку в данном примере метод GetEnumerator не содержит параметров, то соответствующих полей нет).

    ПРИМЕЧАНИЕ
    Конечно же имена вложенного класса и всех его переменных и методов не являются такими "благозвучными". Для  устранения конфликта имен компилятор генерирует имена, которые являются некорректными с точки зрения языка C#, например, реальное имя сгенерированного класса может быть таким: <GetEnumerator>d__0.

    Большинство сгенерированных методов достаточно просты. Метод GetEnumerator каждый раз просто создает экземпляр итератора и в параметре конструктора передает целочисленное значение, которое является начальным значением состояния (важность  этого решения будет понятна при рассмотрении классов, реализующих IEnumerator), а также устанавливает свойство __this, давая возможность итератору получить доступ к самому контейнеру и всему его содержимому; свойство Current возвращает текущее  значение итератора (переменную __current), метод Reset не реализован (причем это не особенность реализации, об этом  явно сказано в спецификации языка C#), метод Dispose является пустым (позднее я приведу пример, когда это будет не  так), а вся основная работа делается методом MoveNext.

    Именно метод MoveNext содержит основной код, который до этого находился в методе GetEnumerator, а также именно в нем  находится реализация конечного автомата, отвечающего за изменение текущего значения, возвращаемого итератором.  Конечный автомат содержит некоторое количество "предустановленных" состояний (которые описаны в спецификации языка  C#), а также ряд дополнительных состояний, количество которых зависимости от кода (точнее от количества операторов  yield return).
     

    Figure3
    Рисунок 3 – Конечный автомат состояний итератора

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

    static IEnumerator<int> GetNumbers()
    {
        string padding = "\t\t";
        Console.WriteLine(padding + "Первая строка метода GetNumbers()"); // 1
        Console.WriteLine(padding + "Сразу перед yield return 7"); // 2
        yield return 7;  // 3
        Console.WriteLine(padding + "Сразу после yield return 7"); // 4
        Console.WriteLine(padding + "Сразу перед yield return 42"); // 5
        yield return 42;  // 6
        Console.WriteLine(padding + "Сразу после yield return 42");  //7
    }

    public static void Main()
    {
        Console.WriteLine("Вызываем GetNumbers()");
        IEnumerator<int> iterator = GetNumbers();
        Console.WriteLine("Вызываем MoveNext()...");
        // Прежде чем обратиться к первому элементу коллекции
        // нужно вызвать метод MoveNext
        bool more = iterator.MoveNext();
        Console.WriteLine("Result={0}; Current={1}", more, iterator.Current);

        Console.WriteLine("Снова вызываем MoveNext()...");
        more = iterator.MoveNext();
        Console.WriteLine("Result={0}; Current={1}", more, iterator.Current);
        Console.WriteLine("Снова вызываем MoveNext()...");
        more = iterator.MoveNext();
        Console.WriteLine("Result={0} (stopping)", more);
    }

    Результат выполнения этого кода:

    Вызываем GetNumbers()
    Вызываем MoveNext()...
                    Первая строка метода GetNumbers()
                    Сразу перед yield return 7
    Result=True; Current=7
    Снова вызываем MoveNext()...
                    Сразу после yield return 7
                    Сразу перед yield return 42
    Result=True; Current=42
    Снова вызываем MoveNext()...
                    Сразу после yield return 42
    Result=False (stopping)

    Метод MoveNext сгенерированного класса:

    private bool MoveNext()
    {
        switch (this.__state)
        {
            case 0: /*before*/
                this.__state = -1; /*running*/
                Console.WriteLine(Test.padding + "Первая строка метода GetNumbers()"); // 1
                Console.WriteLine(Test.padding + "Сразу перед yield return 7"); // 2
                this.__current = 7; // 3
                this.__state = 1; /*state: “suspended”; substat: “after first yield return”*/
                return true;

            case 1: /*state: “suspended”; substat: “after first yield return”*/
                this.__state = -1; /*running*/
                Console.WriteLine(Test.padding + "Сразу после yield return 7"); // 4
                Console.WriteLine(Test.padding + "Сразу перед yield return 42"); // 5
                this.__current = 42; // 6
                this.__state = 2; /*state: “suspended”; substate: “after second yield return”*/
                return true;

            case 2: /*state: “suspended”; substate: “after second yield return”*/
                this.__state = -1; /*after*/
                Console.WriteLine(Test.padding + "Сразу после yield return 42"); //7
                break;
        }
        return false;
    }

    Поскольку весь код метода GetEnumerator расположен в методе MoveNext сгенерированного класса, то этот код не вызовется сразу после создания объекта итератора, а лишь после вызова метода MoveNext. При этом даже при вызове метода MoveNext этот код не будет вызван целиком, как мы привыкли думать о коде обычного метода, вместо этого он будет вызываться по частям.

    При первом вызове метода MoveNext, будет выполнена часть кода, с начала метода до первого оператора yield return (будут выполнены строки 1 и 2). После чего в текущее значение итератора будет сохранено значение 7, текущее состояние итератора будет сохранено путем установки значения __state в 1 (состояние: suspended; подсостояние: after first yield return), а метод MoveNext вернет true (что скажет вызывающему коду о том, что получен следующий элемент коллекции).

    При следующем вызове метода MoveNext  выполнение будет продолжено сразу же после предыдущего оператора yield return (выполнятся строки 4 и 5), текущее значение итератора станет равным 42, а текущее состояние итератора станет равным 2 (состояние: suspended; подсостояние: after second yield return) и, опять же, метод MoveNext вернет true.

    Следующий вызов метода MoveNext «продолжит» выполнение со строки 7, после чего состояние итератора станет равным -1 (state: after), а метод MoveNext вернет false, что скажет вызывающему коду о том, что коллекция завершена.

    При генерации конечного автомата в сгенерированном коде нет различий между состояниями before, running и after (каждому из них соответствует состояние, равное -1), поскольку поведение кода в эти моменты времени является одинаковым (согласно спецификации, попытка обращения к свойству Current приводит к неопределенному поведению). Состояние конечного автомата suspended содержит множество «подсостояний», которое определяется количеством ключевых слов yield return блока итераторов; это было бы явно видно при использовании дополнительной переменной состояния, однако в данной реализации это делается за счет того, что состоянию suspended соответствует не одно значение, а множество положительных значений переменной __state: в нашем случае 1 (after first yield return) и 2 (after second yield return).

    Интерфейсы IEnumerable и IEnumerable<T>

    При возвращение интерфейса IEnumerable или IEnumerable<T> компилятор генерирует код, очень похожий на рассмотренный  ранее, но с некоторыми модификациями. Главной особенностью в этом случае является то, что сгенерированный класс  помимо реализации интерфейсов IEnumerable и IEnumerable<T>, все еще реализует IEnumerator, IEnumerator<T> и  IDisposable. В результате мы получаем сущность, которая одновременно является и итератором и коллекцией, что не  совсем логично с точки зрения дизайна, но что сделано то сделано:) Но поскольку возможность независимых проходов по коллекции все равно необходима, разработчики пошли на следующий шаг: при первом вызове метода GetEnumerator возвращается this, а при последующих вызовах (эта проверка является потокобезопасной) – возвращается новый объект, содержащий первоначальные состояние параметров. В связи с этим, появляется новое состояние (-2), которое можно назвать "before GetEnumerator called", а также  появляются поля, содержащие первоначальные значения параметров (поскольку эти параметры  могут изменяться после создания enumerable-объекта).

    Давайте изменим предыдущий пример таким образом, чтобы функция GetNumbers возвращала IEnumerable<int> и  посмотрим на код, генерируемый компилятором:

    static IEnumerable<int> GetNumbers()
    {
        yield return 7;
        yield return 42;
    }

    Код, сгенерированный компилятором:

    private static IEnumerable<int> GetNumbers()
    {
        return new GetNumbersIterator(-2); /*state: before GetEnumerator called*/
    }

    private sealed class GetNumbersIterator : IEnumerable<int>,
         IEnumerable, IEnumerator<int>, IEnumerator, IDisposable
    {
        // Fields
        private int __state;
        private int __current;
        private int l__initialThreadId;

        // Methods
        public GetNumbersIterator(int __state)
        {
            this.__state = __state;
            this.l__initialThreadId = Thread.CurrentThread.ManagedThreadId;
        }

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

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

                case 2: /*suspended*/
                    this.__state = -1; /*after*/
                    break;
            }
            return false;
        }

        IEnumerator<int> IEnumerable<int>.GetEnumerator()
        {
            if ((Thread.CurrentThread.ManagedThreadId == this.l__initialThreadId)
                && (this.__state == -2)) /*__state == "before GetEnumerator called"*/
            {
                this.__state = 0; /*before*/
                return this;
            }
            return new GetNumbersIterator(0); /*state == "before"*/
        }
        // Остальной код опущен
    }

    Теперь становится понятным причина, по которой первоначальное состояние итератора передается в конструкторе,  в этом случае у нас может быть два первоначальных состояния итератора: -2 (before GetEnumerator called) и  0 (before). Это объясняется тем, что наш итератор играет две роли: роль итератора и роль итерируемой коллекции, а раз  так, то нам нужно как-то отличать, что мы создаем. Поскольку внутри метода GetNumbers мы создаем коллекцию, мы  передаем начального состояния, равного -2, а при создании  другого экземпляра итератора, мы указываем начальное состояние, равное 0. Затем в методе GetEnumerator отслеживается, является ли это обращение к нему первым (в этом случае возвращается this), либо нет (в таком случае возвращается новый итератор).

    В следующий раз: блок finally или как не отстрелить себе ногу с помощью блока итераторов

    3 комментария:

    1. Было интересно узнать как итераторы преобразовываются компиляторами.

      Решил повозиться с твоими примерами. И появились несколько вопросов:

      1)Если смотреть скомпилированный под .NET 2.0 файл программы декомпилятором (к примеру, .NET Reflector'ом ), то почему то метод GetEnumerator() остается таким какой он есть. Если же выбрать в Reflector'е .NET 1.0, то метод GetEnumerator() действительно преобразуется в тот код, что ты описал. Чем это можно объяснить? Тем, что рефлектор не хочет показывать истину?
      P.S.Хотя если смотеть код IL, то там видно что метод преобразовывается в то, что ты описал.

      2)Если же посмотреть скомпилированный код dotPeek'ом, то он показывает все правильно, за исключением метода MoveNext. Что интересно в коде данного метода нет label'ов. Опять же почему он так показывает?

      Знаю что эти вопросы больше связаны с декомпиляторами. Но очень хотелось бы понять причину такого поведения?
      P.S. Как я сам понимаю, декомпиляторы по разному преобразуют IL, в результате и получается такая ситуация.

      ОтветитьУдалить
    2. @Danil: да, все дело в том, что в языке C# 1.0 не существовало языковой конструкции "блока итераторов", но они появились в C# 2.0. Это значит, что при декомпиляции в C# 2.0 и выше будут использовать высокоуровневые концепции типа yield return и yield break, вместо рукопашного кода.

      Тоже самое справедливо и для других конструкций. Так, например, некоторые декомпиляторы уже научились распознавать новые асинхронные возможности из C# 5.0, что делает затруднительным анализ внутренностей этих возможностей.

      Суть такая: декомпилятор старается показать максимально читабельный код для человека, а не реверс инжинирить код к самым примитивным языковым конструкциям.

      ОтветитьУдалить
    3. Этот комментарий был удален автором.

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