yield 문 - 반복자의 다음 요소를 내놓기
yield 문 - 반복자의 다음 요소를 내놓기
반복자(iterator) 안에서 yield 문을 쓰면 다음 값을 내놓거나 반복이 끝났음을 알릴 수 있어요. 컬렉션을 하나씩 꺼내는 코드를 직접 짜지 않고도, 요소를 "언제, 얼마만큼" 내놓을지를 선언적으로 적어두는 방식이죠. yield 문에는 딱 두 가지 형태가 있어요.
출처: Microsoft Learn
본문
yield return: 반복에서 다음 값을 내놓을 때 써요. 값을 하나 내놓고 나면 실행이 잠시 멈췄다가, 다음 반복이 요청되면 그다음 값을 내놓는 식으로 이어져요.
foreach (int i in ProduceEvenNumbers(9))
{
Console.Write(i);
Console.Write(" ");
}
// Output: 0 2 4 6 8
IEnumerable<int> ProduceEvenNumbers(int upto)
{
for (int i = 0; i <= upto; i += 2)
{
yield return i;
}
}
yield break: 반복의 끝을 명시적으로 알릴 때 써요. 조건을 만나면 그 지점에서 반복을 끝내야 할 때가 있죠. 그럴 때yield break로 반복을 끊어요.
Console.WriteLine(string.Join(" ", TakeWhilePositive(new int[] {2, 3, 4, 5, -1, 3, 4})));
// Output: 2 3 4 5
Console.WriteLine(string.Join(" ", TakeWhilePositive(new int[] {9, 8, 7})));
// Output: 9 8 7
IEnumerable<int> TakeWhilePositive(IEnumerable<int> numbers)
{
foreach (int n in numbers)
{
if (n > 0)
{
yield return n;
}
else
{
yield break;
}
}
}
반복은 제어가 반복자(iterator)의 끝에 도달하면 자연스럽게 끝나기도 해요. yield break는 그런 자연스러운 종료 지점이 오기 전에, 미리 반복을 멈추고 싶을 때 쓰는 거예요. C# 최초 버전부터 사용할 수 있는 문법이에요.
지금까지 본 예시에서 반복자의 반환 타입은 System.Collections.Generic.IEnumerable<T>였죠. 제네릭을 쓰지 않는 경우에는 System.Collections.IEnumerable을 반환 타입으로 쓰면 돼요. 반환 타입을 System.Collections.Generic.IAsyncEnumerable<T>로 바꾸면 반복자가 비동기로 동작해요. 이렇게 만들어진 반복자의 결과를 순회할 때는 await foreach 문을 사용해요.
await foreach (int n in GenerateNumbersAsync(5))
{
Console.Write(n);
Console.Write(" ");
}
// Output: 0 2 4 6 8
async IAsyncEnumerable<int> GenerateNumbersAsync(int count)
{
for (int i = 0; i < count; i++)
{
yield return await ProduceNumberAsync(i);
}
}
async Task<int> ProduceNumberAsync(int seed)
{
await Task.Delay(1000);
return 2 * seed;
}
반환 타입으로 System.Collections.Generic.IEnumerator<T>나 System.Collections.IEnumerator를 쓸 수도 있어요. 이런 반환 타입은 다음 상황에서 GetEnumerator 메서드를 구현할 때 사용해요.
System.Collections.Generic.IEnumerable<T>나System.Collections.IEnumerable인터페이스를 구현하는 타입을 설계할 때.- 인스턴스 또는 확장 메서드(extension method)로
GetEnumerator메서드를 추가해서,foreach문으로 해당 타입의 인스턴스를 순회할 수 있게 하고 싶을 때.
public static void Example()
{
var point = new Point(1, 2, 3);
foreach (int coordinate in point)
{
Console.Write(coordinate);
Console.Write(" ");
}
// Output: 1 2 3
}
public readonly record struct Point(int X, int Y, int Z)
{
public IEnumerator<int> GetEnumerator()
{
yield return X;
yield return Y;
yield return Z;
}
}
그런데 yield 문을 쓸 수 없는 자리도 분명히 있어요. 어디서는 못 쓰는지 한 번 보죠.
in,ref,out한정자가 붙은 매개변수를 가진 메서드 안에서는yield문을 쓸 수 없어요.- 람다 식(lambda expression)과 익명 메서드(anonymous method) 안에서는 쓸 수 없어요.
unsafe블록 안에서는 쓸 수 없어요. 참고로 C# 13 이전에는unsafe블록이 하나라도 포함된 메서드에서는yield자체가 허용되지 않았어요. C# 13부터는unsafe블록이 있는 메서드라도 그 블록 밖에서는yield를 쓸 수 있게 됐고,unsafe블록 안에서만 여전히 금지예요.catch블록과finally블록 안에서는yield return과yield break를 쓸 수 없어요. 또, 대응하는catch블록이 있는try블록 안에서도 못 써요. 반면catch블록 없이finally블록만 있는try블록에서는yield return과yield break를 쓸 수 있어요.
반복자 안의 using 문
반복자 메서드 안에서도 using 문을 쓸 수 있어요. using 문은 컴파일되면서 finally 절(그리고 catch 블록은 없는)을 가진 try 블록으로 변환되는데, 그 구조 덕분에 반복자와 잘 어울려요. 반복자가 실행되는 동안, 확보한 삭제 가능한(disposable) 리소스가 제대로 관리돼요.
Console.WriteLine("=== Using in Iterator Example ===");
// Demonstrate that using statements work correctly in iterators
foreach (string line in ReadLinesFromResource())
{
Console.WriteLine($"Read: {line}");
// Simulate processing only first two items
if (line == "Line 2") break;
}
Console.WriteLine("Iteration stopped early - resource should still be disposed.");
static IEnumerable<string> ReadLinesFromResource()
{
Console.WriteLine("Opening resource...");
using var resource = new StringWriter(); // Use StringWriter as a simple IDisposable
resource.WriteLine("Resource initialized");
// These lines would typically come from the resource (e.g., file, database)
string[] lines = { "Line 1", "Line 2", "Line 3", "Line 4" };
foreach (string line in lines)
{
Console.WriteLine($"About to yield: {line}");
yield return line;
Console.WriteLine($"Resumed after yielding: {line}");
}
Console.WriteLine("Iterator completed - using block will dispose resource.");
}
위 예시에서 using 문으로 확보한 리소스는, 반복자가 yield return 문에서 실행을 멈췄다(보류)가 다시 이어가더라도 반복자 실행 내내 계속 사용할 수 있어요. 리소스는 반복자가 끝날 때(끝에 도달하거나 yield break로 끝날 때), 또는 반복자 자체가 해제될 때(예: 호출자가 반복을 중간에 멈추고 빠져나갔을 때) 삭제돼요.
반복자의 실행 방식
재미있는 점이 하나 있어요. 반복자를 "호출"한다고 해서 그 메서드 안의 코드가 바로 실행되지는 않아요.
var numbers = ProduceEvenNumbers(5);
Console.WriteLine("Caller: about to iterate.");
foreach (int i in numbers)
{
Console.WriteLine($"Caller: {i}");
}
IEnumerable<int> ProduceEvenNumbers(int upto)
{
Console.WriteLine("Iterator: start.");
for (int i = 0; i <= upto; i += 2)
{
Console.WriteLine($"Iterator: about to yield {i}");
yield return i;
Console.WriteLine($"Iterator: yielded {i}");
}
Console.WriteLine("Iterator: end.");
}
// Output:
// Caller: about to iterate.
// Iterator: start.
// Iterator: about to yield 0
// Caller: 0
// Iterator: yielded 0
// Iterator: about to yield 2
// Caller: 2
// Iterator: yielded 2
// Iterator: about to yield 4
// Caller: 4
// Iterator: yielded 4
// Iterator: end.
위 출력을 보면 실행 흐름이 한눈에 보여요. 반복자가 만든 결과를 실제로 순회하기 시작하면, 반복자는 첫 번째 yield return 문에 도달할 때까지 실행돼요. 그 지점에서 반복자 실행이 잠시 멈추고, 호출자는 첫 번째 반복 값을 받아서 처리하죠. 그리고 다음 반복이 요청될 때마다 반복자 실행은 이전에 멈췄던 yield return 문 다음부터 다시 시작해서, 다음 yield return 문에 도달할 때까지 이어져요. 제어가 반복자의 끝에 도달하거나 yield break 문을 만나면 반복은 완료돼요.