yield return 은 만들다 말고 멈춘다
Esc

    yield return 은 만들다 말고 멈춘다

    –C#

    앞에서 foreach 로 여러 가지를 돌았습니다.

    foreach (int n in 배열) { ... }
    foreach (string s in 리스트) { ... }
    foreach (char c in "안녕") { ... }
    foreach (var kv in 사전) { ... }

    넷은 안이 전혀 다르게 생겼습니다. 배열은 붙어 있는 칸이고, Dictionary 는 해시로 흩어져 있고, 문자열은 문자를 담은 배열입니다. 그런데 foreach 한 문법으로 다 돕니다.

    foreach 편에서 “무엇을 돌 수 있나” 를 다루면서 목록만 주고 넘어갔습니다. 이제 그 조건이 무엇인지 봅니다.

    foreach 는 문법이 아니라 약속입니다. GetEnumerator() 하나만 있으면 돌 수 있습니다.

    foreach 는 이렇게 풀린다

    컴파일러는 foreach 를 보면 while 로 바꿔 씁니다.

    GetEnumerator() 로 열거자를 하나 받고, MoveNext() 로 한 칸 옮기고, Current 로 지금 것을 읽습니다. 셋뿐입니다.

    여기서 나오는 사실이 두 가지 있습니다.

    하나. 앞에서 “도는 동안 고치면 터진다” 고 했는데, 터지는 곳은 MoveNext() 입니다. 열거자는 만들어질 때 컬렉션의 상태를 기억해 두고, MoveNext() 마다 달라졌는지 확인합니다.

    둘. 앞에서 “읽기 전용이다” 라고 한 것도 여기서 나옵니다. Current 는 읽기만 되는 프로퍼티입니다. n 에 대입해도 그건 사본이라 원본이 안 바뀝니다.

    인터페이스를 안 붙여도 된다

    foreach 가 요구하는 것은 이름이 맞는 메서드뿐입니다. IEnumerable 을 구현했는지는 안 봅니다.

    class 카운터
    {
        public 열거자 GetEnumerator() => new 열거자();
    }
    
    class 열거자
    {
        private int n = 0;
        public int Current => n;
        public bool MoveNext() => ++n <= 3;
    }
    
    foreach (int x in new 카운터())
        Console.WriteLine(x);       // 1, 2, 3

    카운터 는 아무 인터페이스도 안 붙였는데 돕니다.

    그렇다고 인터페이스가 쓸모없는 것은 아닙니다. IEnumerable<T> 를 붙여야 LINQ 를 쓸 수 있고, 다른 사람이 그 타입을 받을 수 있습니다. 붙이는 편이 낫습니다. 다만 foreach 가 도는 이유는 인터페이스가 아니라 메서드 이름입니다.

    직접 만들면 이렇게 귀찮다

    1, 2, 3 을 내놓는 것을 제대로 만들면 클래스 두 개에 메서드 다섯 개가 나옵니다. 상태(지금 몇 번째인지)를 필드로 들고 있어야 하고, 처음으로 되감는 Reset 도 있어야 하고, IDisposable 도 붙습니다.

    세 개를 내놓는 데 그만큼 쓰는 것은 말이 안 됩니다. 그래서 문법이 하나 생겼습니다.

    yield return 은 그 전부를 대신한다

    IEnumerable<int> 셋() 
    {
        yield return 1;
        yield return 2;
        yield return 3;
    }
    
    foreach (int x in 셋())
        Console.WriteLine(x);       // 1, 2, 3

    이게 끝입니다. 컴파일러가 위에서 손으로 쓴 그 클래스를 만들어 줍니다. 상태를 기억하는 필드도, MoveNext 도 전부 자동으로 생깁니다.

    진짜 중요한 것은 멈춘다는 점이다

    return 은 메서드를 끝냅니다. yield return 은 값을 하나 내놓고 그 자리에서 멈춥니다. 다음에 MoveNext() 가 오면 멈춘 자리부터 다시 갑니다.

    그래서 실행 순서가 왔다 갔다 합니다.

    셋() 을 부른 줄에서 아무 일도 안 일어나는 것에 주목할 만합니다. yield 가 든 메서드는 부른다고 실행되지 않습니다. 돌기 시작해야 실행됩니다.

    이걸 지연 실행이라고 합니다. 그래서 이런 것도 됩니다.

    IEnumerable<int> 무한()
    {
        int n = 0;
        while (true)
            yield return n++;
    }
    
    foreach (int x in 무한())
    {
        if (x > 5) break;
        Console.Write(x + " ");     // 0 1 2 3 4 5
    }

    while (true) 인데 안 멈춥니다. 필요한 만큼만 만들고 멈춰 있기 때문입니다. 전부 미리 만들었다면 이 코드는 영원히 안 끝났을 겁니다.

    그래서 생기는 함정

    안 돌면 아무 일도 안 일어납니다.

    IEnumerable<int> 저장하고내놓기(List<int> 값들)
    {
        foreach (int v in 값들)
        {
            파일에저장(v);          // 부수 효과
            yield return v;
        }
    }
    
    저장하고내놓기(목록);          // 아무것도 저장 안 됩니다

    부르기만 하고 안 돌면 한 줄도 실행되지 않습니다. 그래서 yield 가 든 메서드에는 부수 효과를 넣지 않는 편이 안전합니다. 값을 내놓는 일만 시킵니다.

    돌 때마다 다시 계산합니다.

    var 결과 = 무거운계산();
    foreach (var x in 결과) { ... }   // 계산 한 번
    foreach (var x in 결과) { ... }   // 또 계산

    결과 는 계산해 둔 값이 아니라 “부르면 계산해 주는 것” 입니다. 두 번 이상 쓸 것이면 ToList() 로 한 번 굳혀 둡니다.

    Unity 의 코루틴이 이것이다

    IEnumerator 페이드아웃()
    {
        for (float t = 1f; t > 0f; t -= Time.deltaTime)
        {
            투명도설정(t);
            yield return null;      // 다음 프레임까지 멈춤
        }
    }

    코루틴은 특별한 기능이 아닙니다. IEnumerator 를 돌려주는 메서드이고, 유니티가 프레임마다 MoveNext() 를 한 번씩 불러 줄 뿐입니다.

    yield return null 은 “값 없이 한 번 멈춘다” 는 뜻입니다. yield return new WaitForSeconds(1f) 는 내놓은 값을 보고 유니티가 언제 다시 부를지 정하는 것입니다.

    멈췄다가 그 자리에서 다시 가는 성질 하나가 그 전체를 떠받치고 있습니다.

    정리

    • foreach 는 GetEnumerator() / MoveNext() / Current 세 개로 풀립니다
    • 인터페이스가 아니라 메서드 이름이 맞으면 돕니다. 그래도 붙이는 편이 낫습니다
    • yield return 은 값을 내놓고 그 자리에서 멈춥니다. 다음에 부르면 이어서 갑니다
    • yield 가 든 메서드는 부른다고 실행되지 않습니다. 돌아야 실행됩니다
    • 그래서 무한 수열도 되고, 반대로 안 돌면 아무 일도 안 일어납니다
    • 유니티 코루틴이 이것입니다