Devin.KR

반복자와 LINQ 내부 - yield 로 직접 만드는 연산자

개발자KR 조회 0

이 장에서 배우는 것

LINQ 쿼리는 작성한 시점이 아니라 결과를 꺼내는 시점에 실행된다. 기본서에서 Where 와 Select 를 이어 붙이는 방법은 익혔다. 이 장에서는 그 연산자들이 내부에서 어떻게 원소를 한 개씩 넘기는지, 그 동작이 실무 코드에서 어떤 버그로 나타나는지를 본다. 그리고 yield 로 직접 연산자를 만들어 물류 센터의 스캔 로그 처리에 붙인다.

  • IEnumerable 과 IEnumerator 가 맡는 역할을 구분하고, yield 가 컴파일러가 만드는 상태 기계로 바뀌는 과정을 설명한다.
  • 지연 실행(deferred execution)이 언제 시작되고, 다중 열거(multiple enumeration)가 왜 같은 작업을 반복시키는지 코드로 확인한다.
  • 확장 메서드로 Keep, Limit, Tap, Batch, Scan 같은 사용자 정의 연산자를 만든다.
  • 인자 검증을 즉시 실행되는 바깥 메서드로 분리하고, 조기 종료 때 finally 가 실행되는 것을 확인한다.

문제 상황

택배 분류 센터에서는 스캐너가 남긴 로그를 파일에서 읽어 처리한다. 신입 개발자가 다음과 같은 코드를 작성했다고 하자.

var heavy = ReadScanLog(path).Where(p => p.WeightGrams >= 1000);

if (heavy.Count() > 0)
{
    foreach (var parcel in heavy)
    {
        Send(parcel);
    }
}

코드는 정상적으로 동작하지만 운영 중에 파일이 두 번 열리고, 읽는 도중 로그가 갱신되면 Count 와 foreach 가 서로 다른 결과를 본다. 로그 파일에 부수 효과가 있는 읽기, 예를 들어 오프셋을 기록하는 읽기가 섞이면 같은 줄이 두 번 처리되기도 한다. 원인은 heavy 가 결과 목록이 아니라 실행 계획이라는 점이다. Count 를 부를 때 한 번, foreach 에서 또 한 번 처음부터 실행된다.

반대 방향의 문제도 있다. 수백만 줄의 로그를 100건씩 묶어 전송해야 하는데, 표준 LINQ 에는 원하는 모양의 연산자가 없다면 ToList 로 전부 메모리에 올린 뒤 잘라 쓰기 쉽다. 이 장의 도구를 쓰면 원소를 한 개씩 흘려보내면서 같은 일을 할 수 있다.

yield 와 상태 기계

IEnumerable 은 공장이고 IEnumerator 는 커서다

IEnumerable<T> 는 GetEnumerator 메서드 하나를 가진 인터페이스다. foreach 는 이 메서드를 호출해 IEnumerator<T> 를 얻고, MoveNext 가 true 를 돌려주는 동안 Current 를 읽는다. 열거가 끝나거나 중간에 빠져나오면 Dispose 를 호출한다. 열거 가능한 객체는 열거자를 만들어 주는 쪽이고, 현재 위치를 기억하는 쪽은 열거자다. 그래서 같은 IEnumerable 에서 열거자를 두 개 얻으면 서로 독립된 위치를 가진다.

열거자를 손으로 쓰면 위치를 기억할 상태 변수를 직접 관리해야 한다. 3 에서 1 까지 세는 시퀀스를 손으로 쓰면 다음과 같은 모양이 된다. 완성 코드의 ManualCountdown 이 이 코드다.

public bool MoveNext()
{
    switch (_state)
    {
        case 0:
            if (start < 1) { _state = 2; return false; }
            _current = start; _state = 1; return true;
        case 1:
            if (_current > 1) { _current--; return true; }
            _state = 2; return false;
        default:
            return false;
    }
}

MoveNext 가 호출될 때마다 상태 번호를 보고 어디서 이어 갈지 고른다. 반복문 하나를 열거자로 바꾸는 데도 이만큼의 코드가 든다.

yield return 이 하는 일

같은 시퀀스를 yield 로 쓰면 반복문 한 줄이 된다.

public static IEnumerable<int> Countdown(int start)
{
    for (int n = start; n >= 1; n--) yield return n;
}

컴파일러는 yield return 이 있는 메서드를 별도의 클래스로 바꾼다. 이 클래스는 IEnumerable<T> 와 IEnumerator<T> 를 함께 구현한다. 지역 변수와 매개변수는 필드가 되고, 현재 위치를 나타내는 정수 필드가 하나 생긴다. MoveNext 는 이 정수를 switch 로 분기해 마지막 yield return 다음 줄부터 이어서 실행하고, 다음 yield return 을 만나면 Current 에 값을 담고 true 를 돌려주며 멈춘다. 메서드를 호출하는 순간에는 본문이 한 줄도 실행되지 않고 이 클래스의 인스턴스만 만들어진다. 손으로 쓴 코드와 구조가 같고, 상태 번호와 필드를 컴파일러가 대신 관리한다.

Countdown 의 MoveNext 는 상태 번호에 따라 시작 전, 진행 중, 끝을 오가며 true 또는 false 를 돌려준다

클래스의 정확한 모양은 컴파일러 구현 세부이므로 외우거나 기대어 쓸 대상이 아니다. 기억할 것은 세 가지다. 본문은 첫 MoveNext 에서 시작한다. yield return 마다 실행이 멈추고 호출자에게 돌아간다. 다음 MoveNext 는 멈춘 자리에서 이어진다.

yield 를 쓸 때의 제약

yield return 은 catch 절이 있는 try 블록 안에 둘 수 없고 finally 블록 안에도 둘 수 없다. try-finally 안에는 둘 수 있다. 반복자 메서드에는 ref 나 out 매개변수를 둘 수 없다. 예외 처리가 필요하면 예외가 날 수 있는 부분만 yield 밖으로 빼서 처리한다. 연습 문제에서 이 경우를 다룬다. 자세한 규칙은 yield 문 공식 문서에서 확인할 수 있다.

지연 실행과 다중 열거

원소 한 개씩 끌어당기는 구조

연산자를 이어 붙인 쿼리는 소비자가 MoveNext 를 부를 때 실행된다. 맨 바깥 연산자의 MoveNext 가 안쪽 연산자의 MoveNext 를 부르고, 그 안쪽이 다시 원본을 부른다. 요청은 소비자에서 원본 쪽으로 가고, 값은 원본에서 소비자 쪽으로 돌아온다. 원소 한 개가 파이프라인 끝까지 간 뒤에야 다음 원소를 요청한다. 이를 당김(pull) 방식이라고 부른다.

MoveNext 요청은 소비자에서 원본으로 가고 값은 한 개씩 반대 방향으로 돌아온다

이 구조에서 나오는 결과가 몇 가지 있다. 조건에 맞는 원소를 세 개만 원하면 원본을 세 개가 나올 때까지만 읽는다. 원본이 끝없이 이어지는 시퀀스여도 Limit 같은 연산자로 끊으면 쿼리가 끝난다. 중간 컬렉션이 생기지 않으므로 큰 입력도 원소 한 개 분량의 상태만 들고 처리할 수 있다.

즉시 실행과 지연 실행

모든 LINQ 메서드가 지연 실행은 아니다. 결과가 컬렉션이나 단일 값이면 그 자리에서 실행된다.

표준 LINQ 연산자는 시작 시점과 원본을 읽는 방식이 서로 다르다
연산자실행 시작원본을 읽는 방식
Where, Select, Take첫 MoveNext원소 한 개씩 흘려보낸다
OrderBy, GroupBy, Reverse첫 MoveNext첫 값을 내기 전에 원본을 전부 읽어 버퍼에 담는다
Count, ToList, Sum호출한 즉시끝까지 읽는다
First, Any호출한 즉시필요한 만큼만 읽고 멈춘다

OrderBy 는 지연 실행이지만 첫 값을 돌려주려면 마지막 원소까지 봐야 하므로 버퍼링한다. 지연 실행이라는 말이 항상 한 개씩 흘려보낸다는 뜻은 아니다. 직접 연산자를 만들 때도 이 둘을 구분해서 설계해야 한다.

다중 열거가 만드는 문제

IEnumerable 변수에 담긴 쿼리는 열거할 때마다 처음부터 다시 실행된다. 문제 상황의 코드에서 Count 와 foreach 가 각각 원본을 열었던 이유다. 원본이 메모리의 리스트라면 비용만 늘어나지만, 파일이나 네트워크라면 열 때마다 결과가 달라질 수 있다. 완성 코드의 세 번째 실험은 원본이 열린 횟수를 세어 이를 보여 준다.

해법은 두 가지다. 결과를 여러 번 써야 하면 ToList 나 ToArray 로 한 번만 실행해 스냅숏을 만든다. 한 번만 순회하면 되는 코드라면 IEnumerable 을 매개변수로 받고 한 번만 열거한다. 어느 쪽이든 쿼리 변수를 만들 때 몇 번 열거될지 정하고 쓰는 습관이 필요하다.

지연 실행에는 다른 면도 있다. 쿼리가 참조하는 변수와 원본 컬렉션은 쿼리를 만들 때가 아니라 열거할 때의 값이 쓰인다. 만든 뒤 원본에 원소를 추가하면 결과에 반영된다. 뒤에서 이 경우를 코드로 확인한다.

사용자 정의 연산자 만들기

확장 메서드와 반복자의 조합

표준 연산자와 같은 모양의 연산자는 IEnumerable<T> 를 받아 IEnumerable<T> 를 돌려주는 정적 확장 메서드다. 첫 매개변수 앞에 this 를 붙이면 점(.)으로 이어 붙일 수 있다. 이 장에서 만드는 연산자는 다음과 같다. 이름은 표준 연산자와 겹치지 않게 골랐다.

이 장에서 만드는 연산자와 대응하는 표준 연산자
연산자하는 일비슷한 표준 연산자버퍼링
Keep조건에 맞는 원소만 통과시킨다Where없음
Limit앞에서 n 개만 통과시키고 끊는다Take없음
Tap원소를 바꾸지 않고 동작만 실행한다없음없음
Batchn 개씩 묶는다Chunk묶음 한 개
Scan누적 값을 매 단계 내보낸다Aggregate 의 중간 값 버전없음

인자 검증은 바깥 메서드에서 한다

반복자 메서드의 본문은 첫 MoveNext 에서야 실행된다. 검증 코드를 그 안에 두면 잘못된 인자를 넘겨도 호출 시점에는 예외가 나지 않고, 한참 뒤 foreach 에서 예외가 난다. 스택 트레이스는 호출한 곳이 아니라 열거하는 곳을 가리키므로 원인을 찾기 어렵다. 표준 라이브러리도 같은 이유로 검증하는 바깥 메서드와 yield 를 쓰는 안쪽 메서드를 나눈다.

public static IEnumerable<IReadOnlyList<T>> Batch<T>(this IEnumerable<T> source, int size)
{
    ArgumentNullException.ThrowIfNull(source);
    ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(size, 0);
    return BatchIterator(source, size);
}

바깥 메서드는 yield 를 쓰지 않으므로 일반 메서드처럼 호출 즉시 실행된다. 검증을 통과하면 반복자 인스턴스만 돌려준다.

조기 종료와 Dispose

소비자가 foreach 를 break 로 빠져나오거나 Limit 이 충분히 받았다고 판단해 끊으면 열거자의 Dispose 가 호출된다. 반복자 메서드에서 Dispose 는 아직 실행되지 않은 finally 블록을 실행하는 일을 한다. 파일 핸들이나 연결을 반복자 안의 using 이나 try-finally 로 열어 두면, 소비자가 중간에 끊어도 자원이 정리된다. 완성 코드의 Scanner.Read 는 finally 에서 로그를 남겨 이 동작을 보여 준다.

연산자를 쓰는 쪽에서 IEnumerator 를 직접 다룰 일은 드물다. foreach 와 LINQ 가 Dispose 를 대신 호출하기 때문이다. 반대로 MoveNext 를 직접 부르는 코드를 쓴다면 using 으로 감싸는 책임이 그쪽에 있다.

완성 코드

아래는 위 내용을 한 파일로 모은 프로그램이다. 다섯 개 연산자, 손으로 쓴 열거자, 원본 열림 횟수를 세는 스캐너가 들어 있다.

using System.Collections;

var parcels = new List<Parcel>
{
    new("P-101", "서울", 1200),
    new("P-102", "부산", 800),
    new("P-103", "서울", 2500),
    new("P-104", "대구", 400),
    new("P-105", "부산", 1800),
};

Console.WriteLine("[1] 손으로 쓴 상태 기계와 yield");
Console.WriteLine($"수동: {string.Join(",", new ManualCountdown(3))}");
Console.WriteLine($"yield: {string.Join(",", Scanner.Countdown(3))}");
Console.WriteLine($"빈 경우: {new ManualCountdown(0).Count()}, {Scanner.Countdown(0).Count()}");

Console.WriteLine();
Console.WriteLine("[2] 지연 실행과 조기 종료");
var log = new List<string>();
var query = Scanner.Read(parcels, log).Keep(p => p.WeightGrams >= 1000);
Console.WriteLine($"쿼리 생성 직후 로그: {log.Count}건");
foreach (var found in query)
{
    Console.WriteLine($"받음 {found.Id}");
    break;
}
Console.WriteLine($"로그: {string.Join(", ", log)}");

Console.WriteLine();
Console.WriteLine("[3] 다중 열거");
var openLog = new List<string>();
var heavy = Scanner.Read(parcels, openLog).Keep(p => p.WeightGrams >= 1000);
int heavyCount = heavy.Count();
string firstId = heavy.First().Id;
Console.WriteLine($"개수 {heavyCount}, 첫 건 {firstId}, 원본 열림 {openLog.Count(s => s == "열림")}회");

var snapLog = new List<string>();
var snapshot = Scanner.Read(parcels, snapLog).Keep(p => p.WeightGrams >= 1000).ToList();
Console.WriteLine($"개수 {snapshot.Count}, 첫 건 {snapshot[0].Id}, 원본 열림 {snapLog.Count(s => s == "열림")}회");

Console.WriteLine();
Console.WriteLine("[4] 원소 단위로 흐르는 파이프라인");
var order = new List<string>();
var pipeline = parcels
    .Tap(p => order.Add($"A:{p.Id}"))
    .Keep(p => p.WeightGrams >= 1000)
    .Tap(p => order.Add($"B:{p.Id}"))
    .Limit(2);
foreach (var shown in pipeline)
{
    order.Add($"C:{shown.Id}");
}
Console.WriteLine(string.Join(" ", order));

Console.WriteLine();
Console.WriteLine("[5] 무한 시퀀스");
var serials = Scanner.Serials(100).Keep(n => n % 7 == 0).Limit(3);
Console.WriteLine(string.Join(", ", serials));

Console.WriteLine();
Console.WriteLine("[6] 사용자 정의 연산자");
string batches = string.Join(" ", parcels.Batch(2).Select(b => "[" + string.Join(", ", b.Select(p => p.Id)) + "]"));
Console.WriteLine($"묶음: {batches}");
Console.WriteLine($"누적 무게: {string.Join(", ", parcels.Select(p => p.WeightGrams).Scan(0, (sum, w) => sum + w))}");
try
{
    _ = parcels.Batch(0);
    Console.WriteLine("예외 없음");
}
catch (ArgumentOutOfRangeException ex)
{
    Console.WriteLine($"호출 시점 예외: {ex.ParamName}");
}

record Parcel(string Id, string Region, int WeightGrams);

static class Scanner
{
    public static IEnumerable<Parcel> Read(IReadOnlyList<Parcel> source, List<string> log)
    {
        log.Add("열림");
        try
        {
            foreach (var parcel in source)
            {
                log.Add($"읽음 {parcel.Id}");
                yield return parcel;
            }
        }
        finally
        {
            log.Add("닫힘");
        }
    }

    public static IEnumerable<int> Serials(int start)
    {
        for (int n = start; ; n++)
        {
            yield return n;
        }
    }

    public static IEnumerable<int> Countdown(int start)
    {
        for (int n = start; n >= 1; n--) yield return n;
    }
}

sealed class ManualCountdown(int start) : IEnumerable<int>
{
    public IEnumerator<int> GetEnumerator() => new Cursor(start);

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

    private sealed class Cursor(int start) : IEnumerator<int>
    {
        private int _state;
        private int _current;

        public int Current => _current;

        object IEnumerator.Current => _current;

        public bool MoveNext()
        {
            switch (_state)
            {
                case 0:
                    if (start < 1) { _state = 2; return false; }
                    _current = start; _state = 1; return true;
                case 1:
                    if (_current > 1) { _current--; return true; }
                    _state = 2; return false;
                default:
                    return false;
            }
        }

        public void Reset() => throw new NotSupportedException();

        public void Dispose() { }
    }
}

static class LazyOps
{
    public static IEnumerable<T> Keep<T>(this IEnumerable<T> source, Func<T, bool> predicate)
    {
        ArgumentNullException.ThrowIfNull(source);
        ArgumentNullException.ThrowIfNull(predicate);
        return KeepIterator(source, predicate);
    }

    private static IEnumerable<T> KeepIterator<T>(IEnumerable<T> source, Func<T, bool> predicate)
    {
        foreach (var item in source)
        {
            if (predicate(item)) yield return item;
        }
    }

    public static IEnumerable<T> Limit<T>(this IEnumerable<T> source, int count)
    {
        ArgumentNullException.ThrowIfNull(source);
        ArgumentOutOfRangeException.ThrowIfNegative(count);
        return LimitIterator(source, count);
    }

    private static IEnumerable<T> LimitIterator<T>(IEnumerable<T> source, int count)
    {
        if (count == 0) yield break;
        int taken = 0;
        foreach (var item in source)
        {
            yield return item;
            taken++;
            if (taken == count) yield break;
        }
    }

    public static IEnumerable<T> Tap<T>(this IEnumerable<T> source, Action<T> action)
    {
        ArgumentNullException.ThrowIfNull(source);
        ArgumentNullException.ThrowIfNull(action);
        return TapIterator(source, action);
    }

    private static IEnumerable<T> TapIterator<T>(IEnumerable<T> source, Action<T> action)
    {
        foreach (var item in source)
        {
            action(item);
            yield return item;
        }
    }

    public static IEnumerable<IReadOnlyList<T>> Batch<T>(this IEnumerable<T> source, int size)
    {
        ArgumentNullException.ThrowIfNull(source);
        ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(size, 0);
        return BatchIterator(source, size);
    }

    private static IEnumerable<IReadOnlyList<T>> BatchIterator<T>(IEnumerable<T> source, int size)
    {
        var bucket = new List<T>(size);
        foreach (var item in source)
        {
            bucket.Add(item);
            if (bucket.Count == size)
            {
                yield return bucket;
                bucket = new List<T>(size);
            }
        }
        if (bucket.Count > 0) yield return bucket;
    }

    public static IEnumerable<TAcc> Scan<T, TAcc>(this IEnumerable<T> source, TAcc seed, Func<TAcc, T, TAcc> step)
    {
        ArgumentNullException.ThrowIfNull(source);
        ArgumentNullException.ThrowIfNull(step);
        return ScanIterator(source, seed, step);
    }

    private static IEnumerable<TAcc> ScanIterator<T, TAcc>(IEnumerable<T> source, TAcc seed, Func<TAcc, T, TAcc> step)
    {
        var acc = seed;
        foreach (var item in source)
        {
            acc = step(acc, item);
            yield return acc;
        }
    }
}

줄별 해설

[1] 상태 기계. ManualCountdown 은 IEnumerable 이고 안쪽 Cursor 가 열거자다. GetEnumerator 가 호출될 때마다 새 Cursor 를 만들므로 같은 객체를 두 번 열거해도 위치가 섞이지 않는다. Cursor 는 _state 로 시작 전, 진행 중, 끝을 구분하고, 끝 상태에서는 MoveNext 가 계속 false 를 돌려준다. Scanner.Countdown 은 같은 결과를 for 문 한 줄로 낸다. 빈 시퀀스도 둘이 같은 결과를 낸다. 시작 값이 1 보다 작으면 첫 MoveNext 에서 바로 false 가 되기 때문이다.

[2] 지연 실행. Scanner.Read 는 첫 줄에서 log 에 "열림"을 남기지만, 이 줄은 첫 MoveNext 전에는 실행되지 않는다. 그래서 쿼리를 만든 직후 로그는 0건이다. foreach 는 첫 원소 P-101 을 받고 break 한다. 이때 Keep 의 열거자가 Dispose 되고, 그 안의 foreach 가 Read 의 열거자를 Dispose 해서 finally 의 "닫힘"이 기록된다. P-102 이후는 읽지 않았다.

[3] 다중 열거. heavy.Count() 가 한 번, heavy.First() 가 또 한 번 원본을 연다. 개수는 같아도 원본 열림은 2회다. 뒤쪽 실험은 ToList 로 한 번만 실행해 결과를 리스트에 담았으므로 열림이 1회이고, 이후 Count 와 인덱서는 메모리의 리스트를 읽는다.

[4] 파이프라인 순서. A 는 Keep 앞의 Tap, B 는 Keep 뒤의 Tap, C 는 foreach 본문이다. P-101 은 A, B, C 를 차례로 지난다. P-102 는 무게가 1000 미만이라 Keep 에서 걸러져 A 만 남는다. P-103 이 두 번째 통과 원소가 되고 Limit(2) 가 그 뒤에 끊는다. P-104 와 P-105 는 A 조차 실행되지 않았다. Limit 은 필요한 개수를 다 내보낸 직후에 yield break 하므로 원본을 한 개 더 당기지 않는다. 이 순서가 반대로 A 를 전부 먼저 실행하고 B 를 실행하는 방식이 아니라는 점이 그림 2 의 내용이다.

[5] 무한 시퀀스. Serials 는 끝나지 않는 반복자다. Keep 이 7 의 배수만 걸러 내고 Limit(3) 이 세 개에서 끊으므로 105, 112, 119 가 나오고 프로그램은 끝난다. 끊는 연산자가 없으면 이 쿼리는 끝나지 않는다.

[6] 연산자. Batch(2) 는 원소가 5개이므로 두 개짜리 묶음 둘과 한 개짜리 묶음 하나를 낸다. 묶음마다 새 리스트를 만들어 넘기므로 소비자가 받은 묶음을 보관해도 나중 묶음이 덮어쓰지 않는다. Scan 은 누적 합을 매 단계 내보낸다. 마지막으로 Batch(0) 는 반복자를 열거하지 않았는데도 호출 시점에 예외를 던진다. 검증이 바깥 메서드에 있기 때문이며 ParamName 은 size 다.

연산자 구조. 모든 연산자는 검증을 하는 공개 메서드와 yield 를 쓰는 비공개 Iterator 메서드의 쌍이다. 비공개 메서드는 상태를 필드로 가진 클래스로 컴파일된다. LimitIterator 에서 count 가 0 일 때 yield break 로 원본을 열지 않는 것도 눈여겨볼 만하다. 원본을 열지 않아도 되는 경우를 먼저 걸러 내면 불필요한 자원을 열지 않는다.

실행 결과

$ dotnet run
[1] 손으로 쓴 상태 기계와 yield
수동: 3,2,1
yield: 3,2,1
빈 경우: 0, 0

[2] 지연 실행과 조기 종료
쿼리 생성 직후 로그: 0건
받음 P-101
로그: 열림, 읽음 P-101, 닫힘

[3] 다중 열거
개수 3, 첫 건 P-101, 원본 열림 2회
개수 3, 첫 건 P-101, 원본 열림 1회

[4] 원소 단위로 흐르는 파이프라인
A:P-101 B:P-101 C:P-101 A:P-102 A:P-103 B:P-103 C:P-103

[5] 무한 시퀀스
105, 112, 119

[6] 사용자 정의 연산자
묶음: [P-101, P-102] [P-103, P-104] [P-105]
누적 무게: 1200, 2000, 4500, 4900, 6700
호출 시점 예외: size

실무에서 자주 틀리는 것

1. 같은 쿼리를 여러 번 열거한다

틀린 코드는 Any 로 확인한 뒤 같은 쿼리를 다시 순회한다. 원본이 파일이면 두 번 읽는다.

var heavy = Scanner.Read(parcels, log).Keep(p => p.WeightGrams >= 1000);
if (heavy.Any())
{
    foreach (var parcel in heavy) Send(parcel);
}

고친 코드는 한 번 실행해 결과를 보관하거나, 한 번의 순회 안에서 처리한다.

var heavy = Scanner.Read(parcels, log).Keep(p => p.WeightGrams >= 1000).ToList();
if (heavy.Count > 0)
{
    foreach (var parcel in heavy) Send(parcel);
}

결과가 아주 커서 보관하고 싶지 않다면 foreach 하나에서 처리하고, 처리한 개수를 세어 비어 있었는지 판단한다.

2. 반복자 메서드 안에서 인자를 검증한다

public static IEnumerable<T> Keep<T>(this IEnumerable<T> source, Func<T, bool> predicate)
{
    ArgumentNullException.ThrowIfNull(predicate);   // 첫 MoveNext 까지 실행되지 않는다
    foreach (var item in source)
    {
        if (predicate(item)) yield return item;
    }
}

var q = parcels.Keep(null!);   // 여기서는 예외가 없다
foreach (var x in q) { }       // 이 줄에서야 예외가 난다

검증만 하는 바깥 메서드와 yield 를 쓰는 안쪽 메서드로 나눈다. 완성 코드의 Keep 과 KeepIterator 가 그 형태다. 이렇게 하면 잘못된 호출이 호출한 줄에서 바로 드러난다. 안쪽을 메서드 안의 static 로컬 함수로 둬도 같다.

3. 쿼리를 만든 뒤 원본이나 캡처 변수가 바뀐다

var list = new List<Parcel>(parcels);
var heavy = list.Where(p => p.WeightGrams >= 1000);   // 이 시점의 3건이 아니다
list.Add(new("P-106", "인천", 3000));
Console.WriteLine(heavy.Count());                      // 4

쿼리는 열거하는 시점의 list 를 읽으므로 3이 아니라 4가 나온다. 람다가 캡처한 지역 변수를 나중에 바꿔도 마찬가지로 바뀐 값이 쓰인다. 쿼리를 만든 시점의 결과를 고정해야 하면 그 자리에서 ToList 를 호출한다.

var heavy = list.Where(p => p.WeightGrams >= 1000).ToList();   // 만든 시점에 고정
list.Add(new("P-106", "인천", 3000));
Console.WriteLine(heavy.Count);                                 // 3

4. Batch 에서 같은 버퍼를 재사용한다

메모리를 아끼려고 묶음 리스트를 비우고 다시 쓰면 소비자가 받은 묶음이 나중에 바뀐다.

var bucket = new List<T>();
foreach (var item in source)
{
    bucket.Add(item);
    if (bucket.Count == size)
    {
        yield return bucket;
        bucket.Clear();          // 소비자가 아직 이 리스트를 들고 있을 수 있다
    }
}
if (bucket.Count > 0) yield return bucket;

var chunks = parcels.Batch(2).ToList();
Console.WriteLine(string.Join(" ", chunks.Select(c => c.Count)));   // 1 1 1

세 묶음이 모두 같은 리스트 객체를 가리키고, 마지막 상태인 한 건짜리 내용이 세 번 보인다. 완성 코드처럼 묶음마다 새 리스트를 만들어 넘기면 결과를 보관해도 안전하다. 재사용은 소비자가 묶음을 바로 쓰고 버린다고 문서에 명시한 경우에만 선택한다.

한눈에 보기

이 장의 핵심 개념과 확인 방법
주제규칙확인하는 방법
IEnumerable 과 IEnumerator열거자를 만드는 쪽과 위치를 기억하는 쪽이 다르다같은 컬렉션을 두 번 열거해 보기
yield컴파일러가 상태 번호와 필드를 가진 클래스로 바꾼다본문에 로그를 넣고 첫 MoveNext 전후 비교
지연 실행열거할 때 실행되고 원소 한 개씩 끝까지 흐른다Tap 으로 순서를 남겨 보기
다중 열거열거할 때마다 처음부터 다시 실행된다원본 열림 횟수 세기
인자 검증yield 를 쓰지 않는 바깥 메서드에서 한다잘못된 인자로 호출만 해 보기
조기 종료break 나 끊는 연산자가 finally 를 실행시킨다finally 안에 로그 남기기
버퍼넘긴 객체를 나중에 바꾸지 않는다결과를 ToList 로 모아 내용 확인

연습 문제

  1. 연속으로 같은 값이 나오면 하나만 남기는 연산자 DistinctAdjacent 를 만든다. 서울, 서울, 부산, 서울 을 넣으면 서울, 부산, 서울 이 나와야 한다. 인자 검증은 호출 시점에 이뤄지게 한다.
  2. 다음 코드가 화면에 출력하는 내용을 실행하지 않고 쓴다. Tap 과 Keep 은 이 장의 것이다.
    var q = new[] { 1, 2, 3, 4 }
        .Tap(v => Console.Write($"a{v} "))
        .Keep(v => v % 2 == 0)
        .Tap(v => Console.Write($"b{v} "));
    foreach (var x in q) Console.Write($"c{x} ");
  3. 다음 코드는 Read 가 파일을 읽는 함수일 때 파일을 몇 번 읽는지 세고, 한 번만 읽도록 고친다.
    var lines = Read(path).Where(l => l.Length > 0);
    Console.WriteLine(lines.Count());
    Console.WriteLine(lines.First());
    foreach (var l in lines) Console.WriteLine(l);
  4. 다음 메서드는 컴파일 오류가 난다. 이유를 설명하고, 숫자로 바꿀 수 없는 줄은 건너뛰도록 고친다.
    static IEnumerable<int> ParseAll(IEnumerable<string> lines)
    {
        foreach (var line in lines)
        {
            try { yield return int.Parse(line); }
            catch (FormatException) { }
        }
    }

정답과 해설

1. 이전 원소를 기억하는 상태를 반복자 안에 둔다. 비교는 EqualityComparer 의 기본 비교자를 쓴다.

public static IEnumerable<T> DistinctAdjacent<T>(this IEnumerable<T> source)
{
    ArgumentNullException.ThrowIfNull(source);
    return Iterator(source);

    static IEnumerable<T> Iterator(IEnumerable<T> src)
    {
        var comparer = EqualityComparer<T>.Default;
        bool hasPrev = false;
        T prev = default!;
        foreach (var item in src)
        {
            if (!hasPrev || !comparer.Equals(prev, item))
            {
                yield return item;
                prev = item;
                hasPrev = true;
            }
        }
    }
}

첫 원소는 hasPrev 가 false 이므로 항상 통과한다. 검증은 바깥 메서드에 있어 null 을 넘기면 호출 즉시 예외가 난다. 값을 버퍼에 모으지 않으므로 스트리밍으로 동작한다.

2. 출력은 a1 a2 b2 c2 a3 a4 b4 c4 이다. 1 은 Keep 에서 걸러져 a1 만 남는다. 2 는 a2, b2, c2 를 차례로 지난 뒤에야 3 을 요청한다. 3 은 걸러지고, 4 는 a4, b4, c4 를 지난다. 모든 a 를 먼저 출력하고 b 를 출력하는 방식이 아닌 것이 핵심이다.

3. Count, First, foreach 가 각각 처음부터 실행하므로 파일을 세 번 읽는다. 한 번만 읽도록 ToList 로 스냅숏을 만든다.

var lines = Read(path).Where(l => l.Length > 0).ToList();
Console.WriteLine(lines.Count);
Console.WriteLine(lines[0]);
foreach (var l in lines) Console.WriteLine(l);

파일이 클 때는 Count 와 First 가 필요 없는 구조로 바꿔 foreach 한 번에 처리하는 편이 낫다. 첫 줄은 루프에서 첫 반복을 표시하는 변수로 잡는다.

4. yield return 은 catch 절이 있는 try 블록 안에 둘 수 없기 때문이다. 예외에 의존하지 말고 TryParse 로 변환하고 yield 는 try 밖에 둔다.

static IEnumerable<int> ParseAll(IEnumerable<string> lines)
{
    foreach (var line in lines)
    {
        if (!int.TryParse(line, out var value)) continue;
        yield return value;
    }
}

예외를 꼭 써야 하는 API 라면 try-catch 안에서는 값만 받아 두고 yield return 을 그 바깥에서 실행하는 식으로 나눈다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.