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 returnyield break를 쓸 수 없어요. 또, 대응하는 catch 블록이 있는 try 블록 안에서도 못 써요. 반면 catch 블록 없이 finally 블록만 있는 try 블록에서는 yield returnyield 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 문을 만나면 반복은 완료돼요.

더 알아보기