Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Возможности C# из года в год становятся всё шире. Разные фичи делают жизнь программиста приятнее, но предназначение и особенности некоторых из них могут быть очевидны не всем. Например, старый-добрый yield. Для некоторых разработчиков, особенно начинающих, это самая настоящая магия – непонятная, но интересная. В данной статье будет показано, как же всё-таки работает yield, и что на самом деле скрыто за этим волшебным словом. Приятного чтения!
Зачем нужен yield
Ключевое слово yield используется для создания генераторов последовательностей элементов. Эти генераторы не создают коллекции - вместо этого хранится лишь текущее состояние, а по команде производится переход к следующему. Таким образом, объём требуемой памяти оказывается минимальным и напрямую не зависит от количества элементов. Нетрудно догадаться, что генерируемые последовательности могут быть бесконечными.
Таким образом, генератор в самом простом случае хранит лишь некоторый текущий элемент и содержит набор команд, которые необходимо выполнить для получения нового. Во многих случаях это гораздо удобнее, чем создавать коллекцию полностью и хранить все её элементы.
Конечно, ничто не мешает просто написать для реализации поведения генератора собственный класс. Однако с yield делать такие генераторы намного проще. Никаких новых классов создавать не придётся – всё будет работать, так сказать, само.
Отмечу, что yield не является фичей, доступной исключительно в C#. Однако несмотря на общую концепцию, реализация и особенности работы с yield в различных языках могут отличаться. Поэтому ещё раз напомню, что данная статья рассматривает yield исключительно в контексте C#.
Как пользоваться yield
Общий случай
Для начала необходимо создать метод, который будет генерировать интересующую нас последовательность. Единственное ограничение здесь состоит в том, что метод должен в качестве возвращаемого типа иметь один из представленных:
IEnumerable
IEnumerable<T>
IEnumerator
IEnumerator<T>
На самом деле yield может использоваться не только в методах, но и в свойствах и операторах. Однако для простоты в данной статье я буду рассматривать именно методы.
Пояснить, как работает yield, проще всего на примере. Допустим, есть у нас простенький yield-метод и некоторый код, использующий его:
static IEnumerator GetInts()
{
Console.WriteLine("first");
yield return 1;
Console.WriteLine("second");
yield return 2;
}
static void Main()
{
IEnumerator intsEnumerator = GetInts(); // print nothing
Console.WriteLine("..."); // print "..."
intsEnumerator.MoveNext(); // print "first"
Console.WriteLine(intsEnumerator.Current); // print 1
}
Очевидно, вызов функции GetInts вернёт объект, реализующий IEnumerator. При этом код метода выполняться НЕ будет. Можно сказать, выполнение метода будет приостановлено в самом начале.
При первом вызове MoveNext выполнение будет запущено. Инструкции будут выполняться вплоть до первого yield return. После этого выполнение опять приостановится, а в свойство Current будет записано значение, указанное возле yield return.
Таким образом, в результате выполнения этого кода будет выведено "...", затем "first", а в конце 1 - значение, записанное в свойство Current.
Нетрудно догадаться, что если вызвать MoveNext ещё раз, то выполнение метода вновь продолжится с того момента, где ранее было приостановлено. Соответственно, будет выведено сообщение "second", а в свойство Current будет записана 2.
Как уже было отмечено ранее, вызовы MoveNext запускают выполнение метода с момента, где оно было ранее приостановлено. Если во время выполнения будет достигнут конец метода, то текущий вызов MoveNext вернёт false. Дальнейшие вызовы не будут производить никаких действий и также вернут false.
Если вызвать метод GetInts ещё раз, то будет возвращён новый объект, который позволит вновь выполнить генерацию элементов.
Локальные переменные, поля и свойства
Локальные переменные, объявленные внутри yield-методов, сохраняют свои значения между вызовами MoveNext. Например:
IEnumerator GetNumbers()
{
string stringToPrint = "moveNext";
Console.WriteLine(stringToPrint); // print "moveNext"
yield return 0;
Console.WriteLine(stringToPrint); // print "moveNext"
stringToPrint = "anotherStr";
yield return 1;
Console.WriteLine(stringToPrint); // print "anotherStr"
}
Если у возвращённого методом GetNumbers генератора вызывать MoveNext, то сначала дважды будет выводиться "moveNext", а затем - "anoherStr". Такое поведение в целом ожидаемо и логично.
А вот с полями и свойствами может возникнуть неожиданность. Например:
string message = "message1";
IEnumerator GetNumbers()
{
Console.WriteLine(message);
yield return 0;
Console.WriteLine(message);
yield return 1;
Console.WriteLine(message);
}
void Method()
{
var generator = GetNumbers();
generator.MoveNext(); // print "message1"
generator.MoveNext(); // print "message1"
message = "message2";
generator.MoveNext(); // print "message2"
}
Так как метод GetNumbers обращается к полю, то изменение его значения влияет и на логику генерации последовательности. Причём в данном примере можно заметить, что значение поля было изменено буквально во время генерации последовательности.
Со свойствами картина аналогичная: если возвращаемое свойством значение изменится, то это может повлиять и на генерируемую последовательность.
yield break
Помимо yield return существует также и конструкция yield break, позволяющая прервать генерацию последовательности, то есть остановить генератор насовсем. Вызов MoveNext, при котором будет выполнен yield break, вернёт false. Очевидно, что никакого рода изменения полей или свойств не заставят генератор снова работать. Совсем другое дело, если метод, использующий yield, будет вызван заново – ведь при этом будет создан новый объект-генератор, который ещё не успел 'наткнуться' на yield break.
Давайте рассмотрим небольшой пример генератора, использующего yield break:
IEnumerator GenerateMultiplicationTable(int maxValue)
{
for (int i = 2; i <= 10; i++)
{
for (int j = 2; j <= 10; j++)
{
int result = i * j;
if (result > maxValue)
yield break;
yield return result;
}
}
}
Метод возвращает последовательность результатов умножений чисел от 2 до 10 друг на друга. При этом если произведение превышает определённый лимит (параметр maxValue), то генерация последовательности прекращается. Данный генератор ведёт себя так именно благодаря использованию конструкции yield break.
Возвращение IEnumerable
Как было сказано в самом начале, метод, использующий yield, может возвращать IEnumerable, то есть как бы саму последовательность, а не её итератор. Довольно часто более удобным вариантом может оказаться работа именно с IEnumerable, так как для этого интерфейса есть множество методов расширения, а также присутствует возможность обхода в цикле foreach.
*Примечание. *На самом деле, если возвращаемым типом метода будет IEnumerable, то фактический объект будет реализовывать и IEnumerable, и IEnumerator. Однако приводить его к IEnumerator не стоит :). Почему? Расскажу, когда полезем под капот всей этой системы.
А пока рассмотрим пример:
void PrintFibonacci()
{
Console.WriteLine("Fibonacci numbers:");
foreach (int number in GetFibonacci(5))
{
Console.WriteLine(number);
}
}
IEnumerable GetFibonacci(int maxValue)
{
int previous = 0;
int current = 1;
while (current <= maxValue)
{
yield return current;
int newCurrent = previous + current;
previous = current;
current = newCurrent;
}
}
Метод GetFibonacci возвращает последовательность Фибоначчи, первые два элемента в которой равны 1. Тот факт, что возвращаемым типом является IEnumerable, даёт возможность обхода элементов последовательности в цикле foreach. Этой возможностью и пользуется метод PrintFibonacci.
Важно помнить, что каждый раз при обходе этого IEnumerable функция будет выполняться заново. Дело в том, что foreach использует GetEnumerator для обхода элементов последовательности. Каждый новый вызов GetEnumerator вернет объект, производящий генерацию последовательности с самого её начала. Например:
int _rangeStart;
int _rangeEnd;
void TestIEnumerableYield()
{
IEnumerable polymorphRange = GetRange();
_rangeStart = 0;
_rangeEnd = 3;
Console.WriteLine(string.Join(' ', polymorphRange)); // 0 1 2 3
_rangeStart = 5;
_rangeEnd = 7;
Console.WriteLine(string.Join(' ', polymorphRange)); // 5 6 7
}
IEnumerable GetRange()
{
for (int i = _rangeStart; i <= _rangeEnd; i++)
{
yield return i;
}
}
При первом вызове string.Join будет произведён первый обход IEnumerable, в результате которого будет произведено выполнение кода из метода GetRange. Похожего результата можно было бы добиться, к примеру, используя цикл foreach. Перед вторым вызовом значения полей _rangeStart и _rangeEnd переопределяются и о чудо – обход того же самого IEnumerable даёт уже другой результат!
Если вы знакомы с LINQ, то подобное поведение, возможно, не будет казаться чем-то необычным, ведь работа с результатами LINQ-запросов строится аналогичным образом. Однако менее опытных разработчиков такие чудеса могут поставить в тупик. Так или иначе, стоит учитывать эту особенность.
Помимо того, что повторные обходы могут выдавать неожиданные результаты, есть и другая проблема. Дело в том, что все операции, выполнявшиеся для формирования элементов, будут выполняться повторно. Это может негативно сказаться на производительности приложения.
Когда пользоваться yield
В зависимости от ситуации и конкретного проекта, yield может использоваться повсеместно или не использоваться вообще. Помимо очевидных вариантов, эта конструкция может быть полезна, когда необходимо реализовать условно параллельное выполнение нескольких методов. Достаточно активно эту концепцию практикуют в игровом движке Unity.
Как правило, нет смысла использовать yield, например, для простой фильтрации или преобразования элементов существующей коллекции – с этим в большинстве случаев прекрасно справится LINQ. Однако yield позволяет генерировать последовательности элементов, которые, на самом деле, ни к какой коллекции не принадлежат. Например, при работе с деревом может быть удобной функция, которая перебирает предков конкретного узла:
public IEnumerable EnumerateAncestors(SyntaxNode node)
{
while (node != null)
{
node = node.Parent;
yield return node;
}
}
Этот метод позволяет перебирать предков, начиная от самого близкого. При этом не производится создание каких-либо коллекций, а генерация элементов может быть прекращена досрочно – например, если производится поиск конкретного предка. Если у вас есть идеи, как можно реализовать такое поведение без использования yield (и хотя бы в некоторой степени лаконично), то всегда жду вас в комментариях :).
Ограничения
При всей широте возможностей yield имеет ряд ограничений, связанных, в первую очередь, с внутренней реализацией. Некоторые из этих ограничений будут поясняться в следующем разделе, где мы наконец-то посмотрим, за счёт чего вся эта магия работает. Сейчас же давайте просто рассмотрим список тех самых ограничений:
несмотря на то, что интерфейс IEnumerator содержит метод Reset, объект, возвращаемый yield-методом, не имеет его корректной реализации. При попытке вызова Reset у этого объекта будет выброшено исключение типа NotSupportedException. Будьте осторожны с этим: не передавайте объект-генератор в методы, которые могут вызвать у него Reset;
yield нельзя использовать в анонимных методах или лямбда-выражениях;
yield нельзя использовать в методах, содержащих unsafe-код;
конструкцию yield return нельзя использовать внутри блока try-catch. Однако это ограничение не касается секций try блоков try-finally. yield break можно использовать в секциях try как try-catch, так и try-finally блоков.
Как же оно всё-таки работает?
Увидеть, во что превращаются yield-методы, поможет утилита dotPeek. Снова рассмотрим функцию GetFibonacci, возвращающую последовательность Фибоначчи с ограничением в maxValue:
IEnumerable GetFibonacci(int maxValue)
{
int previous = 0;
int current = 1;
while (current <= maxValue)
{
yield return current;
int newCurrent = previous + current;
previous = current;
current = newCurrent;
}
}
Активировав настройку 'Show compiler-generated code', произведём декомпиляцию приложения с помощью dotPeek. Как же выглядит метод GetFibonacci на самом деле?
Ну, как-то так:
[IteratorStateMachine(typeof(Program.d__1))]
private IEnumerable GetFibonacci(int maxValue)
{
d__1 getFibonacciD1 = new d__1(-2);
getFibonacciD1.<>4__this = this;
getFibonacciD1.<>3__maxValue = maxValue;
return (IEnumerable)getFibonacciD1;
}
Практически ничего общего с исходным методом, не так ли? Не говоря уж о том, что написано всё это несколько странным образом. Что ж, давайте разбираться.
Сперва переведём всё это дело на понятный язык (нет, не на IL):
[IteratorStateMachine(typeof(GetFibonacci_generator))]
private IEnumerable GetFibonacci(int maxValue)
{
GetFibonacci_generator generator = new GetFibonacci_generator(-2);
generator.forThis = this;
generator.param_maxValue = maxValue;
return generator;
}
По сути, это тот же самый код. Отличие заключаются в более приятных глазу названиях и отсутствии избыточных в данном случае конструкций. Кроме того, этот код, в отличие от показанного ранее, нормально воспринимается C#-компилятором. Далее в статье будет использоваться именно такая форма. Если у вас есть желание увидеть, как же оно выглядит безо всяких прикрас, то хватайте dotPeek (или ещё лучше – ildasm) и вперёд :).
Здесь создаётся специальный объект, в который сохраняется ссылка на текущий экземпляр, а также значение параметра maxValue. В конструктор передаётся '-2' – это, как мы увидим далее, начальное состояние генератора.
Класс генератора был создан компилятором автоматически, и вся логика, которую мы заложили в функцию, реализована там. Давайте же поглядим, из чего состоит этот класс.
Начнём с объявления:
class GetFibonacci_generator : IEnumerable,
IEnumerable,
IEnumerator,
IEnumerator,
IDisposable
В принципе, ничего неожиданного... Кроме неожиданно появившегося IDisposable! Кроме того, может показаться странным, что класс реализует IEnumerator, хотя метод GetFibonacci возвращает IEnumerable. Ну что же, давайте разбираться.
Рассмотрим конструктор:
public GetFibonacci_generator(int startState)
{
state = startState;
initialThreadId = Environment.CurrentManagedThreadId;
}
Код состояния, переданный при создании объекта, то есть '-2', сохраняется в поле. Кроме того, сохраняется идентификатор потока, в котором объект был создан. Назначение этих полей станет понятным далее, а сейчас взглянем на реализацию GetEnumerator:
IEnumerator IEnumerable.GetEnumerator()
{
GetFibonacci_generator generator;
if (state == -2 && initialThreadId == Environment.CurrentManagedThreadId)
{
state = 0;
generator = this;
}
else
{
generator = new GetFibonacci_generator(0);
generator.forThis = forThis;
}
generator.local_maxValue = param_maxValue;
return generator;
}
Обратите внимание, что при выполнении определённых условий метод вернёт не новый объект, а тот же самый. Эта особенность может показаться достаточно неожиданной. Подтвердить её поможет следующий фрагмент кода:
IEnumerable enumerable = prog.GetFibonacci(5);
IEnumerator enumerator = enumerable.GetEnumerator();
Console.WriteLine(enumerable == enumerator);
Удивительно, но при выполнении этого кода будет выведено 'True'. Кто бы мог подумать? :)
Важно отметить, что при вызове GetEnumerator в поле state возвращаемого объекта будет записан '0'. Как мы увидим далее, это достаточно важный момент.
После блока условия также производится важное присваивание:
generator.local_maxValue = param_maxValue;
Если вернуться к методу *GetFibonacci *(вернее, к тому, во что его превратил компилятор), то можно заметить, что в param_maxValue записано значение соответствующего параметра. Оно же записывается и в поле local_maxValue.
Может показаться странным, что для одного и того же параметра maxValue в генераторе выделено целых 2 поля – param_maxValue и local_maxValue. Пока что нам не хватает информации для того, чтобы прояснить этот момент, однако довольно скоро мы вернёмся к нему. Сейчас же давайте рассмотрим метод MoveNext:
bool IEnumerator.MoveNext()
{
switch (state)
{
case 0:
state = -1;
local_previous = 0;
local_current = 1;
break;
case 1:
state = -1;
local_newCurrent = local_previous + local_current;
local_previous = local_current;
local_current = local_newCurrent;
break;
default:
return false;
}
if (local_current > local_maxValue)
return false;
_current = local_current;
state = 1;
return true;
}
Именно тут реализована вся логика, которую мы заложили при написании метода GetFibonacci. Перед завершением работы MoveNext записывает текущий результат в поле _current. Именно это значение мы получим при обращении к свойству Current генератора последовательности.
Если генерация последовательности должна быть закончена (в данном случае при local_current > local_maxValue), то состояние генератора остаётся равным '-1'. Генератор с таким значением поля state перестаёт работать – MoveNext не будет производить каких-либо действий и просто вернёт false.
Стоит также обратить внимание, что в случаях, когда MoveNext возвращает false, значение поля _current (а, следовательно, и свойства Current) остаётся неизменным.
Фокусы с приведением типов
Вернёмся немного назад. При создании генератора в поле *state *записывается значение '-2'. Но по коду видно, что если state = -2, то MoveNext не будет выполнять каких-либо действий и попросту вернёт false. По сути, генератор не будет работать. К счастью, состояние '-2' заменяется на '0' при вызове метода GetEnumerator. А можно ли вызвать MoveNext, не вызывая при этом GetEnumerator?
Возвращаемый тип метода GetFibonacci – IEnumerable, следовательно, доступ к методу *MoveNext *отсутствует. Тем не менее, зная, что фактически полученный объект будет реализовывать не только IEnumerable, но и IEnumerator, можно воспользоваться приведением типов. В этом случае у разработчика будет возможность вызывать у генератора MoveNext, не прибегая к GetEnumerator, вот только... Все вызовы вернут false. Таким образом, 'обмануть' систему вроде бы и можно, да только ничего это вам не даст.
Вывод. yield-метод, возвращающий IEnumerable, фактически вернёт объект, который реализует и IEnumerable, и IEnumerator. Приведение такого объекта к IEnumerator даст генератор, который будет бесполезен вплоть до момента, пока не вызовется GetEnumerator. В то же время, после такого вызова генератор, казавшийся 'мёртвым', ни с того ни с сего начнёт работать. Данное поведение демонстрирует следующий код:
IEnumerable enumerable = GetFibonacci(5);
IEnumerator deadEnumerator = (IEnumerator)enumerable;
for (int i = 0; i < 5; ++i)
{
if (deadEnumerator.MoveNext())
{
Console.WriteLine(deadEnumerator.Current);
}
else
{
Console.WriteLine("Sorry, your enumerator is dead :(");
}
}
IEnumerator enumerator = enumerable.GetEnumerator();
Console.WriteLine(deadEnumerator == enumerator);
for (int i = 0; i < 5; ++i)
{
if (deadEnumerator.MoveNext())
{
Console.WriteLine(deadEnumerator.Current);
}
else
{
Console.WriteLine("Sorry, your enumerator is dead :(");
}
}
Как считаете, что будет выведено в окно консоли при выполнении всех указанных команд? Подсказка: в данной реализации первые 5 элементов последовательности Фибоначчи это 1, 1, 2, 3, 5.
Мы рассмотрели с вами случай приведения к IEnumerator. А можно ли поиграться с приведением к IEnumerable?
Объект, возвращённый при первом вызов GetEnumerator, очевидно, будет без проблем приводиться к IEnumerable и работать как подобает. Это становится совсем очевидным, если взглянуть на код:
IEnumerable enumerable = GetInts(0);
IEnumerator firstEnumerator = enumerable.GetEnumerator();
IEnumerable firstConverted = (IEnumerable)firstEnumerator;
Console.WriteLine(enumerable == firstEnumerator);
Console.WriteLine(firstConverted == firstEnumerator);
Console.WriteLine(firstConverted == enumerable);
В результате выполнения будет произведён вывод трёх 'True' в окно консоли, так как все три ссылки фактически указывают на один и тот же объект. Следовательно, приведение не принесёт сюрпризов, а попросту даст ссылку на уже существующий (а значит - корректно работающий) объект.
Но что же случится, если преобразовать к IEnumerable результат второго вызова GetEnumerator (ну или вызова, производимого в другом потоке)? С этим вопросом, давайте рассмотрим другой yield-метод:
IEnumerable RepeatLowerString(string someString)
{
someString.ToLower();
while (true)
{
yield return someString;
}
}
Очевидно, метод приводит полученную строку к нижнему регистру и затем бесконечно её возвращает. Вроде всё просто.
Хм, а вы заметили странность в коде выше? Метод RepeatLowerString, судя по названию, должен генерировать последовательность, состоящую из ссылок на переданную строку нижнем регистре. А что в итоге?
Верно, вызов ToLower ни на что тут не повлияет, так как он вообще-то не меняет исходную строку, а создаёт новую. Конечно, в нашем случае это не так уж важно, но в реальной практике ошибки подобного плана приводят к печальным последствиям, и с ними стоит бороться. Некорректный вызов ToLower, может, и не кажется особенно страшным, но куда большие проблемы могут возникнуть из-за скрытой в большой куче кода другой ошибки, связанной с "лишним" вызовом какой-нибудь другой функции.
В таких случаях на более-менее больших проектах часто используется статический анализатор. Это такое приложение, которое позволяет найти большое количество ошибок в коде за достаточно короткий промежуток времени. К примеру, статический анализатор легко бы смог найти ту ошибку в коде метода RepeatLowerString, о которой мы говорили ранее. Хотя также стоит отметить, что спектр обнаруживаемых анализатором ошибок не ограничивается одними лишь "бессмысленными вызовами" – он намного, намного шире.
В общем, рекомендую и вам использовать статический анализатор на своих проектах. При выборе конкретного приложения неплохим вариантом является PVS-Studio. Он находит достаточно много проблем, скрытых в исходниках, а также позволяет проверять код не только на C#, но и на C, C++ и Java. Если заинтересовались, то можете перейти на официальный сайт PVS-Studio по ссылке и совершенно бесплатно попробовать использовать анализатор в течение пробного периода.
Ну а я, тем временем, подправил метод RepeatLowerString:
IEnumerable RepeatLowerString(string someString)
{
string lower = someString.ToLower();
while (true)
{
yield return lower;
}
}
Что ж, давайте теперь проведём эксперимент с приведением к IEnumerable:
IEnumerable enumerable = RepeatLowerString("MyString");
IEnumerator firstEnumerator = enumerable.GetEnumerator();
IEnumerator secondEnumerator = enumerable.GetEnumerator();
var secondConverted = (IEnumerable)secondEnumerator;
var magicEnumerator = secondConverted.GetEnumerator();
for (int i = 0; i < 5; i++)
{
magicEnumerator.MoveNext();
Console.WriteLine(magicEnumerator.Current);
}
Что будет выведено в окно консоли при выполнении данного фрагмента?
Ничего! До вывода строки на экран дело не дойдёт, так как вся эта конструкция завалится с NullReferenceException. Неожиданно?
Ну, может и нет. На самом деле мы уже располагаем достаточной информацией для того, чтобы объяснить такое поведение. Тем не менее, давайте всё же этот момент подробно разберём.
Исключение было выброшено, когда magicEnumerator.MoveNext() привел к вызову метода ToLower. Он вызывается у параметра someString, который внутри генератора условно представлен полями param_someString и local_someString:
public string param_someString;
private string local_someString;
При этом, как вы, вероятно, помните, метод MoveNext, внутри которого и было выброшено исключение, работает именно с полем local_someString:
bool IEnumerator.MoveNext()
{
switch (this.state)
{
case 0:
this.state = -1;
this.local_lower = this.local_someString.ToLower();
break;
case 1:
this.state = -1;
break;
default:
return false;
}
this._current = this.local_lower;
this.state = 1;
return true;
}
Следовательно, null был записан в него. Но откуда он там взялся?
При вызове GetEnumerator в поле local_someString возвращаемого объекта всегда записывается значение из param_someString:
IEnumerator IEnumerable.GetEnumerator()
{
RepeatLowerString_generator generator;
if (state == -2 && initialThreadId == Environment.CurrentManagedThreadId)
{
state = 0;
generator = this;
}
else
{
generator = new RepeatLowerString_generator(0);
generator.forThis = forThis;
}
generator.local_someString = param_someString;
return generator;
}
Стало быть, null пришёл оттуда? Так и есть. Но почему же в этом поле оказался null? Давайте-ка взглянем на фрагмент кода ещё разок:
IEnumerable enumerable = RepeatLowerString("MyString");
IEnumerator firstEnumerator = enumerable.GetEnumerator();
IEnumerator secondEnumerator = enumerable.GetEnumerator();
var secondConverted = (IEnumerable)secondEnumerator;
var magicEnumerator = secondConverted.GetEnumerator();
for (int i = 0; i < 5; i++)
{
magicEnumerator.MoveNext(); // NRE
Console.WriteLine(magicEnumerator.Current);
}
При втором вызове GetEnumerator мы получим новый объект, в котором значение поля *local_SomeString *будет задано корректно. А будет ли задано значение param_someString? Увы, но нет – метод GetEnumerator этого не делает. Получается, в этом поле будет записано значение по умолчанию – то есть, тот самый null.
А ведь именно поле param_someString будет использовано для задания значения local_someString у объекта magicEnumerator! А исключение было выброшено как раз при попытке вызова local_someString.ToLower().
Вывод. Если GetEnumerator возвращает не this, то полученный объект не сможет полноценно выполнять роль IEnumerable. Проблема состоит в том, что у такого объекта не будут заданы необходимые для корректной работы значения полей param_*. В то же время это не актуально для yield-методов, которые не принимают каких-либо параметров. Например:
IEnumerable GetPositive()
{
int i = 0;
while (true)
yield return ++i;
}
Метод возвращает возрастающую последовательность положительных чисел, начиная с 1. А теперь взгляните на пример его использования:
IEnumerable enumerable = GetPositive();
IEnumerator firstEnumerator = enumerable.GetEnumerator();
IEnumerator secondEnumerator = enumerable.GetEnumerator();
var secondConverted = (IEnumerable)secondEnumerator;
IEnumerator magicEnumerator = secondConverted.GetEnumerator();
for (int i = 0; i < 5; i++)
{
magicEnumerator.MoveNext();
Console.WriteLine(magicEnumerator.Current);
}
Данный код отработает без проблем и выведет на экран числа от 1 до 5. Но лучше всё равно так не делать, хехех :).
2 поля для одного параметра
При рассмотрении сгенерированного класса неизбежно возникает вопрос – почему для хранения значения параметра выделяется два поля, а не одно. Возможно, к этому моменту вы уже догадались, в чём здесь дело, но на всякий случай давайте разберём этот момент подробнее.
Для наглядности напишем другой yield-метод:
IEnumerable GetInts(int i)
{
while (true)
{
yield return i++;
}
}
Весьма простенький метод, позволяющий получить возрастающую последовательность целых чисел, начиная с передаваемого i. Метод MoveNext созданного генератора выглядит примерно так:
bool IEnumerator.MoveNext()
{
switch (this.state)
{
case 0:
this.state = -1;
break;
case 1:
this.state = -1;
break;
default:
return false;
}
this._current = this.local_i++;
this.state = 1;
return true;
}
В данном коде важно заметить, что значение, записанное в поле local_i, меняется каждый раз при вызове MoveNext. Теперь вспомним, что исходное значение этого поля устанавливается при вызове GetEnumerator – оно берётся из второго поля – в данном случае, param_i:
IEnumerator IEnumerable.GetEnumerator()
{
GetInts_generator generator;
if ( state == -2
&& initialThreadId == Environment.CurrentManagedThreadId)
{
state = 0;
generator = this;
}
else
{
generator = new GetInts_generator(0);
generator.forThis = forThis;
}
generator.local_i = param_i;
return generator;
}
Значение param_i, в свою очередь, задаётся при вызове исходного yield-метода GetInts:
[IteratorStateMachine(typeof(GetInts_generator))]
private IEnumerable GetInts(int i)
{
GetInts_generator generator = new GetInts_generator(-2);
generator.forThis = this;
generator.param_i = i;
return generator;
}
После этого оно никогда не меняется. И всё-таки зачем здесь нужно поле param_i? Почему бы, к примеру, не присваивать значение сразу в local_i?
Возвращаемый тип объявленного нами ранее yield-метода GetInts – IEnumerable. У объекта такого типа можно несколько раз вызвать GetEnumerator. Как мы знаем, при первом вызове генератор вернёт себя же. С этой мыслью, давайте взглянем на следующий код:
IEnumerable enumerable = GetInts(0);
// enumerable.param_i = 0
IEnumerator firstEnumerator = enumerable.GetEnumerator();
// firstEnumerator.local_i = enumerable.param_i
Console.WriteLine(enumerable == firstEnumerator); // True
firstEnumerator.MoveNext();
// firstEnumerator.local_i++
firstEnumerator.MoveNext();
// firstEnumerator.local_i++
IEnumerator secondEnumerator = enumerable.GetEnumerator();
// secondEnumerator.local_i = ?
В первой строке производится вызов GetInts, возвращающий экземпляр класса-генератора. При этом в его поле param_i записывается переданный нами аргумент – '0'. Далее мы получаем firstEnumerator. В соответствии со сказанным ранее, фактически это будет тот же самый объект, что и enumerable. Отметим также, что при вызове GetEnumerator полю local_i возвращаемого объекта присваивается значение поля param_i объекта enumerable.
Ниже производится пара вызовов MoveNext. Это приводит к изменению значения поля local_i, причём как у firstEnumerator, так и у* enumerable*, ведь эти ссылки указывают на один и тот же объект.
В последней части представленного фрагмента производится получение второго IEnumerator. Как вы считаете, каким значением должно быть проинициализировано его поле local_i? Очевидно, тем самым, что было передано в yield-метод изначально.
Именно его и хранит поле param_i. Вне зависимости от того, как значение local_i будет меняться при вызовах MoveNext, поле param_i остаётся неизменным. Как мы видели ранее, значение этого поля записывается в поле local_i объекта, возвращаемого при вызове GetEnumerator.
Вывод. Объекты, возвращаемые при вызове GetEnumerator, в определённой степени независимы друг от друга. Они начинают генерировать последовательности, используя значения параметров, которые были переданы при вызове yield-метода. Достигается это благодаря хранению исходного значения параметра в дополнительном поле.
Возвращение IEnumerator
Выше мы рассмотрели несколько особенностей генераторов, классы которых построены на основе yield-методов, возвращающих IEnumerable. Все они так или иначе связаны с тем, что класс генератора реализует и IEnumerator, и IEnumerable. Куда проще всё обстоит с классами, генерирующимися на основе методов, которые возвращают IEnumerator.
Дело в том, что в этом случае генерируемый класс не будет реализовывать IEnumerable. Соответственно, рассмотренных ранее фокусов с приведением типов здесь уже не выйдет. Ниже перечислены основные отличия классов, генерируемых для yield-метода, возвращающего IEnumerator и yield-метода, возвращающего IEnumerable:
отсутствие метода GetEnumerator;
отсутствие поля initialThreadId;
использование одного поля для хранения значения параметра вместо двух.
Кроме того, небольшое отличие есть и в процессе создания генераторов. Возможно, вы помните, что при создании экземпляра генератора для yield-метода, возвращающего IEnumerable, в поле state записывалось значение '-2' и менялось оно лишь при вызове GetEnumerator. При таком значении state вызов MoveNext просто возвращает false без выполнения каких-либо действий.
Если генератор создавался для метода, возвращающего IEnumerator, то никакого GetEnumerator у него нет. Поэтому '0' записывается в поле state сразу при создании экземпляра.
Зачем генератор реализует IDisposable
В общем случае метод Dispose сформированного класса пуст. В таких случаях наследование *IDisposable *выглядит странным и ненужным, и у меня нет каких-либо идей по поводу того, зачем это наследование вообще сделано. Однако есть ситуации, когда Dispose всё же содержит код. Связаны эти ситуации с использованием оператора using.
Взгляните на следующие конструкции:
using (var disposableVar = CreateDisposableObject())
{
....
}
using var disposableVar = CreateDisposableObject();
....
Они обеспечивают гарантированный вызов метода Dispose у объекта disposableVar либо при выходе из соответствующего блока (первый пример), либо при выходе из метода (второй пример). Подробнее о using можно прочесть в официальной документации.
Наличие using в yield-методе влияет на формируемый класс генератора соответствующим образом. В частности, у объектов, фигурирующих в конструкции using, в нужные моменты будет вызываться Dispose. При этом, в соответствии с поведением, ожидаемым от оператора, Dispose будет вызван даже в случае, если во время выполнения было выброшено исключение.
Нетрудно догадаться, что метод Dispose самого генератора производит вызовы Dispose для всех соответствующих полей. В частности, для тех, что представляют локальные переменные, использующиеся с using в исходном yield-методе.
Рассмотрим простой пример:
static IEnumerable GetLines(string path)
{
using (var reader = new StreamReader(path))
{
while (!reader.EndOfStream)
yield return reader.ReadLine();
}
}
Данный метод возвращает объект, позволяющий построчно считывать информацию из файла. Наличие конструкции *using *не влияет на содержимое метода GetEnumerator, однако приводит к появлению нового метода:
private void Finally1()
{
this.state = -1;
if (this.local_reader == null)
return;
this.local_reader.Dispose();
}
Отметим, что после вызова Dispose полю state присваивается значение, при котором дальнейшие вызовы MoveNext (его рассмотрим чуть дальше) не будут производить каких-либо действий и просто вернут false.
На самом деле такой finally-метод не обязательно будет один – использование нескольких конструкций using приведёт к добавлению похожих методов и усложнению структуры MoveNext и Dispose. В данном простом случае метод Dispose выглядит вполне тривиально:
void IDisposable.Dispose()
{
switch (this.state)
{
case -3:
case 1:
try
{
}
finally
{
this.Finally1();
}
break;
}
}
Данная конструкция выглядит избыточной, однако усложнение структуры исходного метода и использование в нём нескольких using сразу наполнят её смыслом (и, скорее всего, сделают сложнее). Если заинтересовались, то предлагаю вам поэкспериментировать с этим самостоятельно :).
Вызов Dispose у генератора может иметь смысл в случае, когда необходимо прервать генерацию последовательности и освободить используемые ресурсы. Возможно, есть и другие ситуации, когда такой вызов и само наследование IDisposable будет полезным. Если у вас есть идеи по этому поводу, то напишите их, пожалуйста, в комментариях.
Наконец, давайте мельком взглянем на MoveNext:
bool IEnumerator.MoveNext()
{
try
{
switch (this.state)
{
case 0:
this.state = -1;
this.local_reader = new StreamReader(this.local_path);
this.state = -3;
break;
case 1:
this.state = -3;
break;
default:
return false;
}
if (!this.local_reader.EndOfStream)
{
this._current = this.local_reader.ReadLine();
this.state = 1;
return true;
}
this.Finally1();
this.local_reader = null;
return false;
}
fault
{
Dispose();
}
}
В данном коде реализуется поведение, ожидаемое при использовании оператора using в написанном yield-методе. Обратите внимание на конструкцию fault. На самом деле C# на момент написания статьи такую конструкцию не поддерживает, однако она используется в IL-коде. В самом простом случае это работает так: если в блоке try будет выброшено исключение, то выполнятся инструкции, указанные в fault. Хотя тут, надо полагать, всё не так просто! А как вы считаете? Приглашаю вас поделиться своими мыслями по поводу особенностей fault в комментариях :).
Таким образом, можно быть уверенным в том, что Dispose будет вызван у всех переменных, объявляемых через using, причём именно тогда, когда это будет нужно. Наличие различных ошибок также не повлияет на данное поведение.
Не вызывайте Reset!
Напоследок убедимся в том, что метод Reset в классе генератора действительно выбрасывает исключение:
[DebuggerHidden]
void IEnumerator.Reset()
{
throw new NotSupportedException();
}
Ну что же, коротко и ясно – перед нами NotSupportedException. Соответственно, нужно запомнить, что передавать генератор стоит только в те методы, в которых точно не будет произведён вызов Reset. Ну или хотя бы туда, где соответствующее исключение будет корректно обработано.
Заключение
В данной статье я постарался максимально полно разобрать информацию, касающуюся использования yield в C#. Мы рассмотрели самые различные кейсы – от простейших болванок до методов с циклами и ветвлениями, разобрали случаи, когда yield удобен, а когда он не очень-то нужен и даже поглядели 'под капот', углубив своё понимание происходящего и разобравшись в некоторой магии.
В разделе 'Ограничения' было упомянуто, что yield return нельзя использовать внутри блоков try-catch. Теперь, когда вы знаете, что же на самом деле представляют из себя yield-методы, вы можете поразмышлять над причиной этого и других ограничений. Ну а если хочется, чтобы это сделал кто-то другой, то можно перейти по ссылкам сюда и сюда.
Методы, в которых используется yield, действительно позволяют иногда сильно упростить себе жизнь. За этим удобством скрыт целый класс, генерируемый компилятором, поэтому применять эту фичу стоит лишь в тех случаях, когда это будет действительно приятнее, чем использовать, например, тот же LINQ. Кроме того, важно уметь разделять случаи, когда действительно полезно 'ленивое выполнение', и случаи, когда лучше просто закинуть нужные элементы в старый-добрый List и не париться :).
Если вам понравилась данная статья, то предлагаю вам подписаться на мой Twitter – иногда я выкладываю там посты с различными интересными моментами, которые нахожу в коде, а также анонсы статей на различные темы.
Что ж, у меня на этом всё. Большое спасибо за внимание и всего доброго!
Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Nikita Lipilin. What Is yield and How Does It Work in C#?.