Сообщество .Net разработчиков замерло в ожидании выхода C# 7.0 и новых фич которые он принесет. Каждая версия языка которому уже в следующем году исполнится 15 лет принесла с собой что-то новое и полезное. И хотя каждая фича достойна отдельного упоминания, сегодня я хочу поговорить о ключевом слове yield
. Я заметил, что начинающие разрабочики (и не только) избегают его использования. В этой статье я постараюсь донести преимущества и недостатки, а также выделить случаи, когда применение yield
целесообразно.
yield
создает итератор и позволяет нам не писать отдельный класс когда мы реализуем IEnumerable
. C# содержит два выражения использующих yield
: yield return <expression>
и yield break
. yield
может применятся в методах, операторах и свойствах. Я буду говорить о методах, так как yield
работает везде одинаково.
Применяя yield return
мы декларируем, что данный метод возвращает последовательность IEnumerable
, элементами которой являются результаты выражений каждого из yield return
. Причем с возвращением значения, yielf return
передает управление вызывающей стороне и продолжает исполнение метода после запроса следующего элемента. Значения переменных внутри метода с yield
сохраняются между запросами. yield break
в свою очередь играет роль хорошо известного break
используемого внутри циклов. Пример ниже вернет последовательность чисел от 0 до 10:
private static IEnumerable<int> GetNumbers() {
var number = 0;
while (true) {
if (number > 10)
yield break;
yield return number++;
}
}
Важно упомянуть, что у применения yield
есть несколько ограничений, о которых нужно знать. Вызов Reset
у итератора бросает NotSupportedException
. Мы не можем использовать его в анонимных методах и методах содержащих unsafe
код. Так же, yield return
не может располагаться в блоке try-catch
, хотя ничто не мешает разместить его в секции try
блока try-finally
. yield break
может располагаться в секции try
как try-catch
так и try-finally
. Причины таких ограничений я приводить не буду, так как они детально изложены Эриком Липертом здесь и здесь.
Давайте посомтрим во что превращается yield
после компиляции. Каждый метод с yield return
представляет собой машину состояний, которая переходит из одного состояния в другое в процессе работы итератора. Ниже приведено простое приложение, которое выводит в консоль бесконечную последовательность нечетных чисел:
internal class Program
{
private static void Main1() {
foreach (var number in GetOddNumbers(10))
Console.WriteLine(number);
}
private static IEnumerable<int> GetOddNumbers(int staringWith) {
var previous = staringWith;
while (true)
if (++previous%2 != 0)
yield return previous;
}
}
Компилятор сгенерирует следующий код:
internal class Program
{
private static void Main() {
IEnumerator<int> enumerator = null;
try {
enumerator = GetOddNumbers().GetEnumerator();
while (enumerator.MoveNext())
Console.WriteLine(enumerator.Current);
} finally {
if (enumerator != null)
enumerator.Dispose();
}
}
[IteratorStateMachine(typeof(CompilerGeneratedYield))]
private static IEnumerable<int> GetOddNumbers() {
return new CompilerGeneratedYield(-2);
}
[CompilerGenerated]
private sealed class CompilerGeneratedYield : IEnumerable<int>,
IEnumerable, IEnumerator<int>, IDisposable, IEnumerator
{
private readonly int _initialThreadId;
private int _current;
private int _previous;
private int _state;
[DebuggerHidden]
public CompilerGeneratedYield(int state) {
_state = state;
_initialThreadId = Environment.CurrentManagedThreadId;
}
[DebuggerHidden]
IEnumerator<int> IEnumerable<int>.GetEnumerator() {
CompilerGeneratedYield getOddNumbers;
if ((_state == -2) && (_initialThreadId == Environment.CurrentManagedThreadId)) {
_state = 0;
getOddNumbers = this;
} else {
getOddNumbers = new CompilerGeneratedYield(0);
}
return getOddNumbers;
}
[DebuggerHidden]
IEnumerator IEnumerable.GetEnumerator() {
return ((IEnumerable<int>)this).GetEnumerator();
}
int IEnumerator<int>.Current {
[DebuggerHidden] get { return _current; }
}
object IEnumerator.Current {
[DebuggerHidden] get { return _current; }
}
[DebuggerHidden]
void IDisposable.Dispose() { }
bool IEnumerator.MoveNext() {
switch (_state) {
case 0:
_state = -1;
_previous = 0;
break;
case 1:
_state = -1;
break;
default:
return false;
}
int num;
do {
num = _previous + 1;
_previous = num;
} while (num%2 == 0);
_current = _previous;
_state = 1;
return true;
}
[DebuggerHidden]
void IEnumerator.Reset() {
throw new NotSupportedException();
}
}
}
Из примера видно, что тело метода с yield
было заменено сгенерированным классом. Локальные переменные метода превратились в поля класса. Сам класс реализует как IEnumerable
так и IEnumerator
. Метод MoveNext
содержит логику замененного метода с тем лишь отличием, что она представлена в виде машины состояний. В зависимости от реализации изначального метода, сгенерированный класс может дополнительно содержать реализацию метода Dispose
.
Проведем два теста и замерим производительность и потребление памяти. Сразу отмечу — эти тесты синтетические и приводятся только чтоб продемонстрировать работу yield
в сравнении с реализацие "в лоб". Замеры будем делать с помощью BenchmarkDotNet с включеным модулем диагностики BenchmarkDotNet.Diagnostics.Windows
. Первым сравним скорость работы метода получения последовательности чисел (аналог Enumerable.Range(start, count)
). В первом случае будет реализация без итератора, во втором с:
public int[] Array(int start, int count) {
var numbers = new int[this.count];
for (var i = start; i < this.count; i++)
numbers[i] = this.start + i;
return numbers;
}
public int[] Iterator(int start, int count) {
return IteratorInternal(start, count).ToArray();
}
private IEnumerable<int> IteratorInternal(int start, int count) {
for (var i = start; i < count; ++i)
yield return start + i;
}
Method | Count | Start | Median | StdDev | Gen 0 | Gen 1 | Gen 2 | Bytes Allocated/Op |
---|---|---|---|---|---|---|---|---|
Array | 100 | 10 | 91.19 ns | 1.25 ns | 385.01 | - | - | 169.18 |
Iterator | 100 | 10 | 1,173.26 ns | 10.94 ns | 1,593.00 | - | - | 700.37 |
Как видно из результатов, реализация Array на порядок быстрее и потребляет в 4 раза меньше памяти. Итератор и отдельный вызов ToArray
сделали свое дело.
Второй тест будет более сложным. Мы сэмулируем работу с потоком данных. Мы будем сначала выбирать записи с нечетным ключем, а затем с ключем кратным 3-м. Как и в предыдущем тесте, первая реализация будет без итератора, вторая с:
public List<Tuple<int, string>> List(int start, int count) {
var odds = new List<Tuple<int, string>>();
foreach (var record in OddsArray(ReadFromDb(start, count)))
if (record.Item1%3 == 0)
odds.Add(record);
return odds;
}
public List<Tuple<int, string>> Iterator(int start, int count) {
return IteratorInternal(start, count).ToList();
}
private IEnumerable<Tuple<int, string>> IteratorInternal(int start, int count) {
foreach (var record in OddsIterator(ReadFromDb(start, count)))
if (record.Item1%3 == 0)
yield return record;
}
private IEnumerable<Tuple<int, string>> OddsIterator(IEnumerable<Tuple<int, string>> records) {
foreach (var record in records)
if (record.Item1%2 != 0)
yield return record;
}
private List<Tuple<int, string>> OddsArray(IEnumerable<Tuple<int, string>> records) {
var odds = new List<Tuple<int, string>>();
foreach (var record in records)
if (record.Item1%2 != 0)
odds.Add(record);
return odds;
}
private IEnumerable<Tuple<int, string>> ReadFromDb(int start, int count) {
for (var i = start; i < count; ++i)
yield return new KeyValuePair<int, string>(start + i, RandomString());
}
private static string RandomString() {
return Guid.NewGuid().ToString("n");
}
Method | Count | Start | Median | StdDev | Gen 0 | Gen 1 | Gen 2 | Bytes Allocated/Op |
---|---|---|---|---|---|---|---|---|
List | 100 | 10 | 43.14 us | 0.14 us | 279.04 | - | - | 4,444.14 |
Iterator | 100 | 10 | 43.22 us | 0.76 us | 231.00 | - | - | 3,760.96 |
В данном случае, скорость выполнения оказалась одинаковой, а потребление памяти yield
оказалось даже ниже. Это связано с тем, что в реализации с итератором коллекция вычислилась только единожды и мы сэкономили память на аллокации одного List<Tuple<int, string>>
.
Беря во внимание все сказанное ранее и приведенные тесты, можно сделать краткий вывод: основной недостаток yield
— это дополнительный класс итератор. Если последовательность конечная, а вызывающая сторона не выполняет сложных манипуляций с элементами, итератор будет медленнее и создаст нежелательную нагрузку на GC. Применять же yield
целесобразно в случаях обработки длинных последовательностей, когда каждое вычисление коллекции приводит к аллокации больших массивов памяти. Ленивая природа yield
позволяет избежать вычисления элементов последовательности, которые могут быть отфильтрованы. Это может радикально сократить потребление памяти и уменьшить нагрузку на процессор.
Автор: superhomek