Оператор yield return один из самых малоизвестных среди программистов C#. По крайней мере среди начинающих. И даже те, кто о нем кое-что знает, до конца не уверены, что правильно понимают принцип его работы. Этот досадный пробел обязательно нужно исправить. И, я надеюсь, эта статья вам поможет с этим.
Оператор yield return возвращает элемент коллекции в итераторе и перемещает текущую позицию на следующий элемент. Наличие оператора yield return превращает метод в итератор. Каждый раз, когда итератор встречает yield return он возвращает значение.
Этот оператор сигнализирует нам и компилятору, что данное выражение – итератор. Задача итератора перемещаться между элементами коллекции и возвращать значение текущего. Многие привыкли называть счетчик в цикле итератором, но это не так, ведь счетчик не возвращает значение.
Итератор преобразуется компилятором в «конечный автомат», который отслеживает текущую позицию и знает, как «переместиться» на следующую позицию. При этом значение элемента последовательности вычисляется в момент обращения к нему.
Вот простейший пример итератора:
1 2 3 4 5 6 |
public static IEnumerable<int> GetItems()
{ foreach (var i in List) { yield return i; } } |
Итераторы могут возвращать только тип IEnumerable<>.
Итераторы являются синтаксическими ярлыками для более сложного шаблона перечислителя. Когда компилятор C # встречает итератор, он расширяет его содержимое в CIL-код, который реализует шаблон перечислителя. Такая инкапсуляция существенно экономит время программиста.
Первый вопрос, который возникнет у неискушенного программиста: «Зачем мне использовать итератор? Я прекрасно могу выводить последовательность и без него».
Конечно можете. Различие в подходах. Итератор позволяет делать так называемое «ленивое вычисление». Это значит, что значение элемента вычисляется только когда он запрашивается.
Чтобы лучше понять, как работает yield return, мы сравним его с традиционными циклами. На примерах все станет понятно.
1) Обратите внимание, что с yield return нам не нужно создавать дополнительный список, чтобы заполнить его значениями. А значит мы получаем экономию памяти, ведь нам требуется лишь память для текущего элемента коллекции. При поэлементной обработке не выделяется память, достаточно кэша.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
static IEnumerable<int> GetSequence() { Random rand = new Random(); List<int> list = new List<int>(); for (int i = 0; i < 3; i++) list.Add(rand.Next()); return list; } static IEnumerable<int> GetSequence() { Random rand = new Random(); for (int i = 0; i < 3; i++) yield return rand.Next(); } |
2) Возможность не вычислять результат для всего перечисления. Это главное преимущество. Вы помните, что yield return возвращает значение в момент его обработки? В этом примере мы бесконечно генерируем числа:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
IEnumerable<int> GetInfinityWithIterator() { var i = 0; while (true) yield return ++i; } IEnumerable<int> GetInfinityWithLoop() { var i = 0; var list = new List<int>(); while (true) list.Add(++i); return list; } |
Вы уже видите разницу? Сейчас все поймете:
1 2 3 4 |
foreach(var item in GetInfinityWithIterator().Take(5)) { Console.WriteLine(item); } |
Мы используем LINQ оператор Take, чтобы ограничить количество выборки. В случае с yield return цикл остановится на пятом элементе.
1 2 3 4 |
foreach(var item in GetInfinityWithLoop().Take(5)) { Console.WriteLine(item); } |
А заполнение списка прервать нельзя. В результате получим ошибку Out of memory.
3) Возможность корректировать значения коллекции после выполнения итератора. Так как yield return возвращает элемент коллекции на момент реальной обработки (при отображении значения элемента в консоли, например), то мы можем изменять элементы коллекции даже после выполнения итератора. Ведь итератор на самом деле не возвращает реальные значения, когда вы его вызываете. Итератор знает где взять значения. И он их вернет только тогда, когда они реально потребуются. Это так называемая Lazy load.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
IEnumerable<int> MultipleYieldReturn(IEnumerable<int> mass) { foreach (var item in mass) yield return item * item; } IEnumerable<int> MultipleLoop(IEnumerable<int> mass) { var list = new List<int>(); foreach (var item in mass) list.Add(item * item); return list; } |
А теперь вызовем эти методы:
1 2 3 4 5 6 7 |
var mass = new List<int>() { 1, 2, 3 }; var MultipleYieldReturn = Helper.MultipleYieldReturn(mass); var MultipleLoop = Helper.MultipleLoop(mass); mass.Add(4); Console.WriteLine(string.Join(",",MultipleYieldReturn)); Console.WriteLine(string.Join(",", MultipleLoop)); |
Результат ожидаем:
После инициализации переменных MultipleYieldReturn и MultipleLoop добавим в коллекцию еще один элемент:
1 2 |
Console.WriteLine(string.Join(",",MultipleYieldReturn)); Console.WriteLine(string.Join(",", MultipleLoop)); |
Посмотрите на результат теперь:
На момент вывода результатов в консоль коллекция содержала значение 4. Так как yield return выдает значения в момент их запроса, итератор обработал все актуальные значения. Традиционный цикл выполнился при инициализации переменной MultipleLoop, а на тот момент коллекция содержала всего 3 значения.
4) Обработка исключений с yield return имеет нюансы. Оператор yield return нельзя использовать в секции try-catch, только try-finally.
Например, как бы мы стали писать, не зная об ограничении:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
public IEnumerable TransformData(List<string> data) { foreach (string item in data) { try { yield return PrepareDataRow(item); } catch (Exception ex) { Console.Error.WriteLine(ex.Message); } } } |
В таком варианте блок catch никогда не отловит ошибку. Все дело в отложенном выполнении yield return. Об ошибке мы узнаем только в момент реальной работы с данными от итератора. Например, когда выводим данные из итератора на консоль.До тех пор итератор не работает с реальными данными.
Если вам все же нужно «отловить» ошибку в этом итераторе, то можно поступить так:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
public IEnumerable TransformData(List<string> data) { string text; foreach (string item in data) { try { text = PrepareDataRow(item); } catch (Exception ex) { Console.Error.WriteLine(ex.Message); continue; } yield return text; } } |
Говоря о yield return нельзя не упомянуть о втором операторе с yield. Это yield break. По своему назначению он аналогичен оператору break, просто применяется только в итераторах. Вот небольшой пример:
1 2 3 4 5 6 7 8 9 10 |
IEnumerable<int> GetNumbers() { int i = 0; while (true) { if (i = 5) yield break; yield return i++; } } |
Из примера видно, что по достижении значения 5 итератор завершится, но до тех пор будет исправно выдавать значения.
Давайте подведем итоги. Когда же нужно использовать yield return?