Andrey on .NET | Использование ключевого слова yield

Использование ключевого слова yield

Не часто приходится использовать ключевое слово yield. Обычно про него вспоминают при создании класса, реализующего интерфейс IEnumerable. Давайте разберемся, зачем оно нужно и как работает. А так же выясним, всегда ли надо разрабатывать класс для указанного выше интерфейса.

MSDN сухо поясняет: "ключевое слово yield сообщает компилятору, что метод, в котором оно содержится, является блоком итератора". Там же указано, что используется оно в сочетании с ключевыми словами return и break. Что это обозначает на практике? Давайте рассмотрим кратко и подробно.

Коротко о главном

Метод, использующий yield return и yield break, должен возвращать интерфейс IEnumerable. Проще говоря, результатом будет перечисление. Это позволяет использовать его, например, в конструкции foreach. При этом:

  • метод вызывается перед каждым проходом цикла для получения нового значения перечисления;
  • локальные переменные метода сохраняют текущие значения в течении всех итераций цикла;
  • отсутствие возвращаемого значения или вызов yield break завершают перечисление и сам цикл.

Данной информации достаточно для того, чтобы начать использовать yield. В качестве примера получим значения степеней от 1 до exponent для заданного числа number:

namespace YieldKeywordDemo
{
    using System;
    using System.Collections;

    public class PowerList
    {
        public IEnumerable Power(int number, int exponent)
        {
            int valueCounter = 0;
            int currentResult = 1;
            while (valueCounter++ < exponent) {
                currentResult = currentResult * number;
                yield return currentResult;
            }
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            PowerList demoObj = new PowerList();
 
            // Display powers of 2 up to the exponent 10
            foreach (int i in demoObj.Power(2, 10)) {
                Console.Write("{0} ", i);
            }

            Console.ReadKey(true);
        }
    }
}

После запуска на консоль будет выделен ряд чисел: 2 4 8 16 32 64 128 256 512 1024. На этом краткое описание можно закончить и начать писать свои методы с использованием yield.

Детали реализации

Если вам интересно как работает yield и почему сохраняются переменные, то перейдем к деталям его реализации. Посмотрим что получилось в результате работы компилятора.

Небольшая подсказка: обратите внимание, что внутренние переменные метода сохраняют значения между его вызовами. Это должно натолкнуть на мысль, что они не могут быть в стеке. А значит должен появиться новый объект для, как минимум, хранения их в куче. Еще не догадались?

Вложенный класс в .NET ReflectorЗагрузим полученный EXE файл проекта в .NET Reflector. Все оказывается достаточно просто. Компилятор создал вложенный private sealed класс, который реализует IEnumerable. Параметры и внутренние переменные нашего метода стали его public полями. Посмотрим результаты декомпиляции:

[CompilerGenerated]
private sealed class <Power>d__0 : IEnumerable<object>, IEnumerable, 
                                   IEnumerator<object>, IEnumerator, 
                                   IDisposable
{
    // Fields
    private int <>1__state;
    private object <>2__current;
    public int <>3__exponent;
    public int <>3__number;
    public PowerList <>4__this;
    private int <>l__initialThreadId;
    public int <currentResult>5__2;
    public int <valueCounter>5__1;
    public int exponent;
    public int number;

    // Methods
    [DebuggerHidden]
    public <Power>d__0(int <>1__state);
    private bool MoveNext();
    [DebuggerHidden]
    IEnumerator<object> IEnumerable<object>.GetEnumerator();
    [DebuggerHidden]
    IEnumerator IEnumerable.GetEnumerator();
    [DebuggerHidden]
    void IEnumerator.Reset();
    void IDisposable.Dispose();

    // Properties
    object IEnumerator<object>.Current { [DebuggerHidden] get; }
    object IEnumerator.Current { [DebuggerHidden] get; }
}

Код получения значений степени перемещен в метод MoveNext(). При этом, само значение сохраняется в свойстве Current. А в наш метод внесены небольшие изменения для его работы в рамках созданного класса.

private bool MoveNext()
{
    switch (this.<>1__state)
    {
        case 0:
            this.<>1__state = -1;
            this.<valueCounter>5__1 = 0;
            this.<currentResult>5__2 = 1;
            while (this.<valueCounter>5__1++ < this.exponent)
            {
                this.<currentResult>5__2 *= this.number;
                this.<>2__current = this.<currentResult>5__2;
                this.<>1__state = 1;
                return true;
            Label_0065:
                this.<>1__state = -1;
            }
            break;

        case 1:
            goto Label_0065;
    }
    return false;
}

Исходный метод Power() теперь используется для создания экземпляра внутреннего класса.

public IEnumerable Power(int number, int exponent)
{
    <Power>d__0 d__ = new <Power>d__0(-2);
    d__.<>4__this = this;
    d__.<>3__number = number;
    d__.<>3__exponent = exponent;
    return d__;
}

Как видим, в итоге компилятор практически создал за нас реализацию IEnumerable.

Разумеется, использование foreach не обязательно. Значения можно перебрать самостоятельно, используя свойство Current интерфейса IEnumerator. Например:

PowerList demoObj2 = new PowerList();
IEnumerable power = demoObj2.Power(2, 10);
IEnumerator e = power.GetEnumerator();
e.MoveNext();
int value = (int)e.Current;
Console.WriteLine("\n{0}", value);

Но стоит запомнить, что перебрать значения можно только один раз. Причина в сгенерированном коде метода Reset(), который отвечает за установку перечислителя на первый элемент:

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

Как видим, вызывать его бесполезно. Поэтому, если нам нужна возможность начинать перечисление сначала, то придется создавать класс и реализовывать IEnumerble самостоятельно.

На этом все. Остается только приложить исходный код (C#, Visual Studio 2010):
YieldKeywordDemo.zip (6.64 kb)

Комментарии (15) -

Будем надеяться, что в C#5 добавят таки поддержку рекурсивного yield а также возможность делать yield из анонимных функций.

@ Дмитрий: Насчет анонимных функций - для небольших методов, имеющих только локальную значимость, было бы удобно.

А вот рекурсивный yield мне кажется сомнительное удовольствие. Это для обхода деревьев и подобных структур? Вот такая реализация сходу (возможно можно лучше придумать): надо при каждом yield return

* записывать значение в Current
* выходить из всех рекурсивно вызванных методов, чтобы завершился MoveNext(). Но при этом "замораживать" их состояние до следующего вызова. Т.е. создаем по объекту на каждый рекурсивный вызов.
* при следующем MoveNext() передать управление именно до остановленного объекта, при этом "пропустив" часть кода.
* возможен спор как должен отрабатывать yield break – выход из текущего метода или вообще завершение рекурсии

Не сильно ли получится ресурсоемкая система? Плюс лишние объекты - лишний мусор в куче. Мне кажется, что в общем случае, эффективнее перечисление через рекурсию делать вручную (тем более что то же дерево уже само по себе будет классом).

Ну а сейчас не лучше


IEnumerable<T> RecursiveMethod(x)
{
  yield return f(x);

  foreach (var item in x.Items)
    foreach (var result in RecursiveMethod(item))
      yield return result; // неопрятно
}

"Неопрятно" это два yield return result? А так этот вариант имеет тот же недостатки. В частности будет создана масса объектов (по числу вложенности рекурсии) в куче. Значения будут копироваться из объекта в объект (первое значение в списке продет по всем объектам, второе – на один объект меньше и т.д.). Поэтому мне больше нравится подход, в котором класс, который использует рекурсию, будет сам реализовывать IEnumerable.

Антон 20.09.2010 20:11:06

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

@ Антон: А упоминание что "Разумеется, использование foreach не обязательно." и дальше по тексту не то Smile ?

Алексей 09.12.2010 15:35:29

Очень познавательно. Спасибо.
Вот только сразу возникает вопрос.
Код который даёт рефлектор будет работать?
По моему нет. Не говоря уже о некорректных идентификаторах,
goto Label_0065; стоит вне зоны видимости Label_0065.
Как такой код мог быть вообще получен?

И как решить обратную задачу, т. е. свернуть этот класс обратно в yield или
хотябы сделать код рабочим?

Подскажу только один вариант - поправить вручную.

Причем так в .NET Reflector выглядит, как правило, именно автоматически сгенерированный код (который создается сразу в IL). Обычный код декомпилируется без подобных заковырок в исходник.

Честно говоря, не разу не использовал рефлектор для таких задач (получить рабочий исходник). А для понимания кода и изучения механизмов C# такого варианта, как выдает он, вполне достаточно. Причем yield встречается не так и часто.

Алексей 09.12.2010 17:20:17

Andrey :
Подскажу только один вариант - поправить вручную.

Поправить не получается потому что
goto Label_0065; стоит вне зоны видимости Label_0065.
в IL это работает, а в C# нет.

Пытаюсь восстановить исходный yield, но у меня пример посложнее,
Вашей экспоненты, поэтому получается пока плохо. Smile
  

Алексей :
в IL это работает, а в C# нет.

Ну так выше я и говорил об этом - для развернутого yield нет исходного С# кода. Создается сразу IL. Поэтому только разбираться что делает MoveNext() и переписывать самому.

Всегда смущало возвращаемое значение IEnumerable у метода GetEnumerator(), при реализации итератора, когда по факту из метода возвращалось что-то другое, например число. Сейчас правда немного прояснилось.

Т.е. если я правильно понял, компилятор на самом деле после каждого вызова метода Power() будет возвращать на самом деле объект, в нашем случае PowerList (реализующий уже IEnumerable), у которого свойство Current содержит значение переменной стоящей после yield return, т.е. currentResult  


        public IEnumerable Power(int number, int exponent)
        {
            int valueCounter = 0;
            int currentResult = 1;
            while (valueCounter++ < exponent) {
                currentResult = currentResult * number;
                yield return currentResult;
            }
        }


@ Pav: Не совсем, но вы на правильном пути.

1) PowerList сам не реализует IEnumerable, а лишь возвращает перечисление (в одном из методов).

2) Компилятор создает за нас тип, который реализует и IEnumerable и IEnumerator. Важно понять, что IEnumerable это абстракция самого списка, IEnumerator можно рассматривать как указатель, которые позволяет перемещаться по нему и читать значения

3) Метод Power() преобразуется в метод для создания экземпляра данного типа.

4) Исходный (наш) код Power() перемещается в метод IEnumerator.MoveNext(), который на его основе выдает значения. Чтобы локальные переменные метода не пропадали в стеке, они становятся членами созданного класса.

5) foreach получает список (IEnumerable), запрашивает у него IEnumerator для навигации по списку и вызывая MoveNext() начинает получать значения.

Интересная задачка в тему:
andreyakinshin.gitbooks.io/.../...fterYield-Q.html

Иосиф 05.12.2017 12:17:40

Создается такой класс: private sealed class <Power>d__0 : IEnumerable<object>... Значит каждый раз будет boxing ?

Иосиф В данном примере да, т.к. использовался
public IEnumerable Power(int number, int exponent)
От него можно избавиться если переписать как
public IEnumerable<int> Power(int number, int exponent)

Добавить комментарий