Реализация итераторов в C# (часть 1)

Автор оригинала: Raymond Chen
  • Перевод
От переводчика:
Не так давно мой менее опытный коллега спросил меня о том, для чего используется yield return в C#. Я не очень часто пишу свои итераторы, поэтому, отвечая ему, я сомневался в своих словах. Справившись в MSDN, я укрепился в сказанном, но у меня возник вопрос: “А во что же всё таки компилируется эта инструкция?” К тому моменту, я уже был знаком с переводимой статьёй, однако всё, что в ней сказано, давно “выветрилось”. Статья старая, но мне думается, что она может быть полезна для определённой группы разработчиков, привыкшей читать русскоязычные статьи и документы.

Ссылка на продолжение: реализация итераторов в C# (часть 2)



Как и анонимные методы, итераторы в C# являются сложным синтаксическим сахаром. Вы можете реализовать их полностью самостоятельно (в конце концов, в ранних версиях C# вам приходилось делать это), но использовать компилятор намного удобнее. Идея, стоящая за итераторами заключается в том, что они принимают функцию с yield return выражениями (и, возможно, yield break выражениями) и конвертируют её в конечный автомат. Когда вызывается yield return, состояние функции сохраняется, и при повторном обращении к итератору для получения очередного объекта это состояние восстанавливается. Главное в итераторах то, что все локальные переменные итератора (в том числе параметры итератора как предварительно инициализировнные локальные переменные, включая скрытый параметр this) становятся переменными-членами (далее полями) вспомогательного класса. Помимо этого вспомогательный класс содержит поле state, которое следит, где произошло прерывание исполнения, и поле current, хранящее самый последний из уже перечисленных объектов.

class MyClass {
    int limit = 0;
    public MyClass(int limit) { this.limit = limit; }
 
    public IEnumerable<int> CountFrom(int start)
    {
        for (int i = start; i <= limit; i++)
            yield return i;
    }
}

Метод CountFrom создаёт перечислитель целых чисел, который производит целые числа от start до limit включительно с шагом 1. Компилятор неявно конвертирует этот перечислитель во что-то вроде этого:
class MyClass_Enumerator : IEnumerable<int> {
     int state$0 = 0; // внутренний член
     int current$0; // внутренний член
     MyClass this$0; // неявный параметр CountFrom
     int start;     // явный параметр CountFrom
     int i;         // локальная переменная метода CountFrom
 
     public int Current {
         get { return current$0; }
     }
 
     public bool MoveNext()
     {
         switch (state$0) {
             case 0: goto resume$0;
             case 1: goto resume$1;
             case 2: return false;
         }
 
         resume$0:;
         for (i = start; i <= this$0.limit; i++) {
             current$0 = i;
             state$0 = 1;
             return true;
             resume$1:;
         }
         state$0 = 2;
         return false;
     }
 
     // ... прочее счетоводство, неважное здесь ...
}
 
public IEnumerable<int> CountFrom(int start)
{
     MyClass_Enumerator e = new MyClass_Enumerator();
     e.this$0 = this;
     e.start = start;
     return e;
}

Перечисляющий класс автоматически генерируется компилятором и, как было обещано, он содержит поля для состояния и текущего объекта, плюс по одному полю на каждую локальную переменную. Свойство Current просто возвращает текущий объект. Вся же настоящая работа происходит в методе MoveNext.

Для генерации метода MoveNext компилятор берёт написанный вами код и производит несколько трансформаций:
  • Все ссылки на переменные должны быть скорректированы, поскольку код перенёсся во вспомогательный класс.
  • this становится this$0, поскольку внутри сгенерированной функции this указывает на автоматически сгенерированный класс вместо оригинального.
  • m становится this$0.m, если m является членом исходного класса (нестатическим полем, свойством или методом). В действительности, это правило излишне в сочетании с предыдущим, т.к. запись имени члена класса без префикса m это просто сокращение для this.m.
  • v становится this.v, если v это параметр или локальная переменная. Это правило также излишне, т.к. запись v равносильна this.v, но я обращаюсь явно, чтобы вы обратили внимание, что хранилище переменной изменилось.

Помимо этого компилятору приходится иметь дело со всеми инструкциями yield return.
Каждое выраженик yield return x преобразуется в
current$0 = x;
state$0 = n;
return true;
resume$n:;
где n это возрастающее число, начинающееся с единицы 1.

Кроме этого есть выражения yield break.
Каждое выражение yield break преобразуется в
state$0 = n2;
return false;
где n2 — число, на единицу большее, чем наибольший номер из всех состояний, использующихся в выражениях yield return. Не забывайте, что в конце каждой функции подразумевается вызов yield break.

Наконец, компилятор вставляет большой диспетчер состояний в самом начале функции.
switch (state$0) {
    case 0: goto resume$0;
    case 1: goto resume$1;
    case 2: goto resume$2;
    // ...
    case n: goto resume$n;
    case n2: return false;
}
по одному case-выражению создаётся для каждого состояния, плюс на начальное и конечное состояние n2.

Комментарии 16

    –1

    public IEnumerable CountFrom(int start, int limit)
    {
    for (int i = start; i <= limit; i++)
    yield return i;
    }


    Недавно задумался, не лучше ли будет написать:

    public IEnumerable CountFrom(int start, int limit)
    {
    List res = new List();
    for (int i = start; i <= limit; i++)
    res.Add( i );
    return res;
    }


    Из плюсов второго подхода мне видится более понятный синтаксис - ясно, во что это превратится после компиляции. Из минусов - полный просчет коллекции может и не понадобится в будущем, с итератором (первый вариант) получается отложенная инициализация. Неясен правда вопрос производительности.

    А какие есть еще отличия по использованию этих двух кусков кода?

      +2
      Собственно минус, который вы упомянули, убивает возможность использования такой конструкции на практике.
      • НЛО прилетело и опубликовало эту надпись здесь
          +2
          «Из минусов — полный просчет коллекции может и не понадобится в будущем, с итератором (первый вариант) получается отложенная инициализация.»
          Память, память. В итераторе в каждый момент времени память выделена только под текущий объект и состояние. В списке — сразу подо все.

          Для инта это мелочи, а вот для массива документов…
          0
          Недавно обнаруженный понятный, но неявный момент:

          // вызываем метод с yield return
          var list = GetValueListEnumerator();
          
          foreach (var value in list)
          {
              // что-то делаем
          }
          
          // не тут-то было, енумератор закончился!
          foreach (var value in list)
          {
              // еще что-то делаем
          }
          
            0
            Сделайте между прогонами циклов list.Reset()
              0
              Вы ничего не путаете? По IEnumerator нельзя делать foreach.
                0
                IEnumerable, опечатался в названии метода.
                  0
                  В этом случае проблем все равно быть не должно, потому как в каждом из циклов используется свой перечислитель.
                    –1
                    Ну попробуйте.
                      +9
                      Сами-то пробовали?
                      image
                +3
                Вы путаете IEnumerator с IEnumerable. По первому делать foreach нельзя.
              • НЛО прилетело и опубликовало эту надпись здесь
                  +3
                  Признайтесь, вы внимательно читали статью?
                    0
                    Очень странная статья. Я думал, намного лучше будет. Сколько раз сюда ходил — было намного лучше.
                    +1
                    Стоит ещё отметить, что методы, включающие в себя yield-конструкции, могут возвращать не только IEnumerable, но и IEnumerator. При этом конечные автоматы, генерируемые компилятором, немного отличаются. Подробности можно почитать здесь — csharpindepth.com/articles/chapter6/iteratorblockimplementation.aspx

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                    Самое читаемое