В прошлый раз мы начали говорить об итераторах в языке 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).
Рисунок 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 или как не отстрелить себе ногу с помощью блока итераторов
Было интересно узнать как итераторы преобразовываются компиляторами.
ОтветитьУдалитьРешил повозиться с твоими примерами. И появились несколько вопросов:
1)Если смотреть скомпилированный под .NET 2.0 файл программы декомпилятором (к примеру, .NET Reflector'ом ), то почему то метод GetEnumerator() остается таким какой он есть. Если же выбрать в Reflector'е .NET 1.0, то метод GetEnumerator() действительно преобразуется в тот код, что ты описал. Чем это можно объяснить? Тем, что рефлектор не хочет показывать истину?
P.S.Хотя если смотеть код IL, то там видно что метод преобразовывается в то, что ты описал.
2)Если же посмотреть скомпилированный код dotPeek'ом, то он показывает все правильно, за исключением метода MoveNext. Что интересно в коде данного метода нет label'ов. Опять же почему он так показывает?
Знаю что эти вопросы больше связаны с декомпиляторами. Но очень хотелось бы понять причину такого поведения?
P.S. Как я сам понимаю, декомпиляторы по разному преобразуют IL, в результате и получается такая ситуация.
@Danil: да, все дело в том, что в языке C# 1.0 не существовало языковой конструкции "блока итераторов", но они появились в C# 2.0. Это значит, что при декомпиляции в C# 2.0 и выше будут использовать высокоуровневые концепции типа yield return и yield break, вместо рукопашного кода.
ОтветитьУдалитьТоже самое справедливо и для других конструкций. Так, например, некоторые декомпиляторы уже научились распознавать новые асинхронные возможности из C# 5.0, что делает затруднительным анализ внутренностей этих возможностей.
Суть такая: декомпилятор старается показать максимально читабельный код для человека, а не реверс инжинирить код к самым примитивным языковым конструкциям.
Этот комментарий был удален автором.
ОтветитьУдалить