반복기

작성하는 거의 모든 프로그램에서 컬렉션을 반복해야 하는 경우가 있습니다. 컬렉션에 있는 모든 항목을 조사하는 코드를 작성합니다.

또한 해당 클래스의 요소에 대해 반복기를 생성하는 메서드인 반복기 메서드를 만듭니다. 반복기는 컨테이너, 특히 목록을 트래버스하는 개체입니다. 반복기는 다음과 같은 경우에 사용할 수 있습니다.

  • 컬렉션의 각 항목에 대한 작업 수행.
  • 사용자 지정 컬렉션 열거.
  • LINQ 또는 다른 라이브러리 확장.
  • 데이터가 반복기 메서드를 통해 효율적으로 흐르는 데이터 파이프라인 만들기.

C# 언어는 시퀀스를 생성하고 사용하는 기능을 제공합니다. 이러한 시퀀스는 동기적 또는 비동기적으로 생성 및 사용될 수 있습니다. 이 문서에서는 해당 기능에 대한 개요를 제공합니다.

foreach로 반복 처리

컬렉션 열거는 간단합니다. foreach 키워드는 컬렉션을 열거하여 컬렉션의 각 요소에 대해 포함된 문을 한 번 실행합니다.

foreach (var item in collection)
{
    Console.WriteLine(item?.ToString());
}

그게 다입니다. 컬렉션의 모든 내용을 반복하려면 foreach 문만 있으면 됩니다. 하지만 foreach 문이 마법은 아닙니다. 이 명령문은 컬렉션을 반복하는 데 필요한 코드를 생성하기 위해 .NET core 라이브러리에 정의된 두 개의 제네릭 인터페이스인 IEnumerable<T>IEnumerator<T>를 사용합니다. 이 메커니즘은 아래에 더 자세히 설명되어 있습니다.

이러한 인터페이스 둘 다에는 제네릭이 아닌 인터페이스 IEnumerableIEnumerator도 있습니다. 최신 코드에는 제네릭 버전이 기본적으로 사용됩니다.

시퀀스를 비동기적으로 생성하는 경우 await foreach 문을 사용하여 비동기적으로 시퀀스를 사용할 수 있습니다.

await foreach (var item in asyncSequence)
{
Console.WriteLine(item?.ToString());
}

시퀀스가 System.Collections.Generic.IEnumerable<T>인 경우 foreach를 사용합니다. 시퀀스가 System.Collections.Generic.IAsyncEnumerable<T>인 경우 await foreach를 사용합니다. 후자의 경우 시퀀스는 비동기식으로 생성됩니다.

반복기 메서드를 사용하는 열거형 소스

C# 언어의 또 다른 유용한 기능을 통해 열거형 소스를 만드는 메서드를 작성할 수 있습니다. 이러한 메서드를 반복기 메서드라고 합니다. 반복기 메서드는 요청될 때 시퀀스에서 개체를 생성하는 방법을 정의합니다. yield return 상황별 키워드를 사용하여 반복기 메서드를 정의합니다.

이 메서드를 작성하여 0에서 9 사이의 정수 시퀀스를 생성할 수 있습니다.

public IEnumerable<int> GetSingleDigitNumbers()
{
    yield return 0;
    yield return 1;
    yield return 2;
    yield return 3;
    yield return 4;
    yield return 5;
    yield return 6;
    yield return 7;
    yield return 8;
    yield return 9;
}

위의 코드에서는 반복기 메서드에서 여러 개의 고유 yield return 문을 사용할 수 있다는 사실을 강조하기 위해 고유 yield return 문을 보여 줍니다. 다른 언어 구문을 사용하여 반복기 메서드의 코드를 단순화할 수 있으며 종종 그렇게 합니다. 아래의 메서드 정의는 정확히 동일한 시퀀스의 숫자를 생성합니다.

public IEnumerable<int> GetSingleDigitNumbersLoop()
{
    int index = 0;
    while (index < 10)
        yield return index++;
}

둘 중 하나를 결정할 필요가 없습니다. 메서드의 요구를 충족하는 데 필요한 만큼 yield return 문을 사용할 수 있습니다.

public IEnumerable<int> GetSetsOfNumbers()
{
    int index = 0;
    while (index < 10)
        yield return index++;

    yield return 50;

    index = 100;
    while (index < 110)
        yield return index++;
}

위의 모든 예제에는 비동기 대응 항목이 있습니다. 각 경우에서 IEnumerable<T>의 반환 형식을 IAsyncEnumerable<T>로 바꿉니다. 예를 들어 앞의 예제에는 다음과 같은 비동기 버전이 있습니다.

public async IAsyncEnumerable<int> GetSetsOfNumbersAsync()
{
    int index = 0;
    while (index < 10)
        yield return index++;

    await Task.Delay(500);

    yield return 50;

    await Task.Delay(500);

    index = 100;
    while (index < 110)
        yield return index++;
}

이는 동기 및 비동기 반복기 모두에 대한 구문입니다. 실제 예제를 생각해 보겠습니다. IoT 프로젝트를 진행하고 있고 디바이스 센서는 매우 큰 데이터 스트림을 생성한다고 가정합니다. 데이터를 파악하려면 N번째 데이터 요소마다 샘플링하는 메서드를 작성할 수 있습니다. 이 작은 반복기 메서드면 충분합니다.

public static IEnumerable<T> Sample<T>(this IEnumerable<T> sourceSequence, int interval)
{
    int index = 0;
    foreach (T item in sourceSequence)
    {
        if (index++ % interval == 0)
            yield return item;
    }
}

IoT 디바이스에서 읽기가 비동기 시퀀스를 생성하는 경우 다음 메서드가 보여 주는 것처럼 메서드를 수정합니다.

public static async IAsyncEnumerable<T> Sample<T>(this IAsyncEnumerable<T> sourceSequence, int interval)
{
    int index = 0;
    await foreach (T item in sourceSequence)
    {
        if (index++ % interval == 0)
            yield return item;
    }
}

반복기 메서드에는 한 가지 중요한 제한이 있습니다. return 문과 yield return 문 둘 다를 동일한 메서드에서 사용할 수 없습니다. 다음 코드는 컴파일되지 않습니다.

public IEnumerable<int> GetSingleDigitNumbers()
{
    int index = 0;
    while (index < 10)
        yield return index++;

    yield return 50;

    // generates a compile time error:
    var items = new int[] {100, 101, 102, 103, 104, 105, 106, 107, 108, 109 };
    return items;
}

일반적으로 이 제한은 문제가 되지 않습니다. 메서드 전체에서 yield return을 사용하거나, 원래 메서드를 여러 메서드로 분리하여 일부는 return을 사용하고 일부는 yield return을 사용할 수 있습니다.

모든 위치에서 yield return을 사용하기 위해 마지막 메서드를 약간 수정할 수 있습니다.

public IEnumerable<int> GetFirstDecile()
{
    int index = 0;
    while (index < 10)
        yield return index++;

    yield return 50;

    var items = new int[] {100, 101, 102, 103, 104, 105, 106, 107, 108, 109 };
    foreach (var item in items)
        yield return item;
}

경우에 따라 반복기 메서드를 두 개의 다른 메서드로 분할하는 것이 정답일 수 있습니다. 하나는 return을 사용하고 다른 하나는 yield return을 사용합니다. 부울 인수에 따라 빈 컬렉션 또는 처음 5개의 홀수를 반환하려는 상황을 가정해 보세요. 다음과 같은 두 메서드로 작성할 수 있습니다.

public IEnumerable<int> GetSingleDigitOddNumbers(bool getCollection)
{
    if (getCollection == false)
        return new int[0];
    else
        return IteratorMethod();
}

private IEnumerable<int> IteratorMethod()
{
    int index = 0;
    while (index < 10)
    {
        if (index % 2 == 1)
            yield return index;
        index++;
    }
}

위의 메서드를 살펴보세요. 첫 번째 메서드는 표준 return 문을 사용하여 빈 컬렉션 또는 두 번째 메서드에서 만든 반복기를 반환합니다. 두 번째 메서드는 yield return 문을 사용하여 요청된 시퀀스를 만듭니다.

foreach 심층 분석

foreach 문은 IEnumerable<T>IEnumerator<T> 인터페이스를 사용하여 컬렉션의 모든 요소에서 반복하는 표준 관용구로 확장됩니다. 또한 개발자가 리소스를 제대로 관리하지 못해 발생하는 오류를 최소화합니다.

컴파일러는 첫 번째 예제에 표시된 foreach 루프를 다음과 유사한 구문으로 변환합니다.

IEnumerator<int> enumerator = collection.GetEnumerator();
while (enumerator.MoveNext())
{
    var item = enumerator.Current;
    Console.WriteLine(item.ToString());
}

컴파일러에서 생성되는 정확한 코드는 더 복잡하며 GetEnumerator()에서 반환된 개체가 IDisposable 인터페이스를 구현하는 상황을 처리합니다. 전체 확장에서는 다음과 더 유사한 코드를 생성합니다.

{
    var enumerator = collection.GetEnumerator();
    try
    {
        while (enumerator.MoveNext())
        {
            var item = enumerator.Current;
            Console.WriteLine(item.ToString());
        }
    }
    finally
    {
        // dispose of enumerator.
    }
}

컴파일러는 첫 번째 비동기 샘플을 다음과 유사한 구문으로 변환합니다.

{
    var enumerator = collection.GetAsyncEnumerator();
    try
    {
        while (await enumerator.MoveNextAsync())
        {
            var item = enumerator.Current;
            Console.WriteLine(item.ToString());
        }
    }
    finally
    {
        // dispose of async enumerator.
    }
}

열거자가 삭제되는 방식의 enumerator 형식의 특성에 따라 달라집니다. 일반 동기 사례에서 finally 절은 다음과 같이 확장됩니다.

finally
{
   (enumerator as IDisposable)?.Dispose();
}

일반 비동기 사례는 다음과 같이 확장됩니다.

finally
{
    if (enumerator is IAsyncDisposable asyncDisposable)
        await asyncDisposable.DisposeAsync();
}

그러나 enumerator의 형식이 sealed 형식이고 enumerator의 형식에서 IDisposable 또는 IAsyncDisposable로의 암시적 변환이 없는 경우 finally 절은 빈 블록으로 확장됩니다.

finally
{
}

enumerator의 형식에서 IDisposable로의 암시적 변환이 있고 enumerator 형식이 nullable이 아닌 값 형식인 경우 finally 절은 다음과 같이 확장됩니다.

finally
{
   ((IDisposable)enumerator).Dispose();
}

다행히도 이러한 세부 정보를 모두 기억할 필요가 없습니다. foreach 문에서 이러한 차이를 모두 처리합니다. 컴파일러는 이러한 구문에 대한 올바른 코드를 생성합니다.