Yield return в C#: когда код работает «лениво» и это круто
Представьте, что вы пишете программу, которая должна обработать миллион строк из лог-файла. Вы честно загружаете всё в список List<string>, передаёте его в метод фильтрации, а потом — бац! — программа зависает, память утекает, а пользователь нервно жмёт Ctrl+Alt+Del.
Знакомо? Если вы хотя бы раз сталкивались с такой ситуацией, значит, вам пора познакомиться с «ленивой магией» C# — оператором yield return. Это не просто синтаксический сахар, а мощный инструмент, который может кардинально изменить то, как вы работаете с коллекциями.
В этой статье мы разберём правильное использование yield return на реальных примерах, заглянем под капот и выясним, когда его применять, а когда лучше держаться подальше. Поехали!
Что такое yield return и как он работает?
Если объяснять на пальцах, yield return — это способ сказать компилятору: «Эй, я не хочу возвращать сразу всю коллекцию. Давай я буду отдавать элементы по одному, по мере необходимости». Такой подход называется ленивыми вычислениями (lazy evaluation).
Вот как выглядит классический пример:
public IEnumerable<int> GetNumbers()
{
yield return 1;
yield return 2;
yield return 3;
}
Вызывая этот метод, вы не получаете массив [1, 2, 3]. Вместо этого вы получаете итератор, который будет отдавать числа по одному, когда вы попросите. Каждый вызов MoveNext() продвигает выполнение метода до следующего yield return.
Это похоже на конвейер: элементы производятся только тогда, когда они нужны потребителю. Никакого лишнего копирования, никаких гигантских списков в памяти — только чистая, эффективная работа.
Почему yield return — это не просто «возврат списка»?
Многие новички думают: «Зачем мне yield return, если я могу просто собрать List и вернуть его?». Давайте разберёмся на конкретном примере.
Представьте, что вам нужно получить первые 10 чётных чисел из бесконечной последовательности. С yield return это делается элегантно:
public IEnumerable<int> GetEvenNumbers()
{
int i = 0;
while (true)
{
yield return i;
i += 2;
}
}
// Использование
var firstTenEven = GetEvenNumbers().Take(10).ToList();
Попробуйте сделать то же самое с List<int> — и вы либо уйдёте в бесконечный цикл, либо будете хранить все сгенерированные числа в памяти. Yield return даёт нам возможность работать с потенциально бесконечными последовательностями, не забивая оперативную память.
Практический пример: чтение большого файла
Вернёмся к нашему лог-файлу на миллион строк. Вот как обычно пишут новички:
public List<string> ReadLogLines(string path)
{
var lines = new List<string>();
using (var reader = new StreamReader(path))
{
string line;
while ((line = reader.ReadLine()) != null)
{
lines.Add(line);
}
}
return lines;
}
Проблема: весь файл загружается в память. Если файл весит 500 МБ, то и список будет весить примерно столько же. А если нужно только отфильтровать строки с ошибками? Вы всё равно загрузите всё.
А вот правильное использование yield return:
public IEnumerable<string> ReadLogLines(string path)
{
using (var reader = new StreamReader(path))
{
string line;
while ((line = reader.ReadLine()) != null)
{
yield return line;
}
}
}
// Фильтрация без загрузки всего файла
var errorLines = ReadLogLines("log.txt")
.Where(line => line.Contains("ERROR"))
.Take(100);
Теперь в памяти хранится только одна строка за раз. Экономия памяти — колоссальная. И это именно то, ради чего стоит изучать yield return C#.
Yield break: как остановить итерацию
Иногда нужно не просто вернуть элемент, а завершить итерацию досрочно. Для этого существует yield break. Он работает как return для метода, возвращающего IEnumerable.
Пример: допустим, мы ищем первое число, которое делится на 3 и на 5, но не хотим уходить в бесконечность:
public IEnumerable<int> FindDivisibleNumbers(int limit)
{
for (int i = 1; i <= limit; i++)
{
if (i % 3 == 0 && i % 5 == 0)
{
yield return i;
yield break; // Нашли первое — хватит
}
}
}
yield break немедленно завершает итератор. Это особенно полезно в рекурсивных алгоритмах или при обработке больших данных, когда нужно остановиться по условию.
Подводные камни: когда yield return может навредить
Как и любой мощный инструмент, правильное использование yield return требует понимания его ограничений. Вот несколько ситуаций, где он может создать проблемы:
1. Многократная итерация — убийца производительности
Посмотрите на этот код:
var numbers = GetExpensiveSequence();
var first = numbers.First();
var count = numbers.Count(); // Последовательность вычисляется заново!
Каждый раз, когда вы вызываете foreach или LINQ-метод на итераторе, метод выполняется с начала. Если генерация элементов — дорогая операция (например, запрос к БД), это может привести к серьёзным проблемам.
Решение: если нужно использовать последовательность несколько раз, материализуйте её:
var numbers = GetExpensiveSequence().ToList(); // Теперь это список в памяти
2. Побочные эффекты и отложенное выполнение
Ещё одна классическая ловушка — использование yield return с внешними ресурсами. Представьте:
public IEnumerable<string> ReadLines(string path)
{
using (var reader = new StreamReader(path))
{
string line;
while ((line = reader.ReadLine()) != null)
{
yield return line;
}
} // reader закрыт после первого элемента!
}
Если вы попытаетесь прочитать второй элемент, StreamReader уже будет закрыт. Исключение гарантировано. Поэтому правильное использование yield return требует, чтобы ресурсы управлялись вызывающим кодом, или используйте using внутри метода, который гарантирует последовательное чтение.
3. Не используйте yield return для маленьких коллекций
Если у вас список из 10 элементов, который вы и так загрузите в память, не усложняйте код. yield return добавляет накладные расходы на создание state-машины. Для маленьких данных проще и быстрее вернуть List или массив.