← 문제로

13. foreach의 열거자 패턴, struct 열거자 박싱, 버전 토큰

난이도 중
내 리뷰 · C#
해설 · C#

해설 — foreach의 열거자 패턴, struct 열거자 박싱, 버전 토큰

난이도: 중상

요약

foreach는 인터페이스가 아니라 패턴(duck typing) 으로 풀린다: GetEnumerator()MoveNext()/Current → (있으면) Dispose(). List<T>.GetEnumerator()구조체 List<T>.Enumerator를 돌려주므로, 구체 타입을 직접 foreach 하면 스택에서 돌아 힙 할당이 없다. 반면 같은 리스트를 IEnumerable<T>로 받아 foreach 하면 열거자가 박싱되어 매번 힙 할당이 생긴다. 또 List<T>는 열거 중 컬렉션이 바뀌면 버전 토큰으로 감지해 InvalidOperationException을 던진다.

핵심 개념 — foreach 가 풀리는 모습

var e = xs.GetEnumerator();        // 패턴 매칭: 인터페이스 불필요
try {
    while (e.MoveNext()) {
        var x = e.Current;
        // 본문
    }
} finally {
    (e as IDisposable)?.Dispose();  // 열거자가 IDisposable 이면 호출
}

컴파일러는 먼저 그 타입에 맞는 GetEnumerator 를 찾는다(확장 메서드 포함). 그게 구조체 열거자를 반환하면 그대로 값으로 사용한다. IEnumerable/IEnumerator 구현은 "필수"가 아니라, 패턴이 안 맞을 때의 대안일 뿐이다.

단계별 내부 동작

  • (1)(2) 구체 타입 vs 인터페이스 — 할당 차이
    • SumConcrete(List<int>): List<int>.GetEnumerator()List<int>.Enumerator(값 타입 struct)를 반환. foreach가 이 구조체를 스택에 두고 MoveNext/Current를 호출 → 힙 할당 0, 박싱 0. (또한 인라인 친화적.)
    • SumInterface(IEnumerable<int>): 컴파일러가 아는 정적 타입이 인터페이스라 IEnumerable<int>.GetEnumerator()를 호출 → 반환 타입이 IEnumerator<int>. 구조체 열거자가 인터페이스로 박싱되어 힙에 올라간다 → 호출마다 1회 할당 + 가상 호출 비용. 매 틱 수천 컬렉션을 도는 게임 루프에서 이 박싱이 누적되면 Gen0 GC 압박이 된다.
  • (3) (A) 열거 중 수정 — 버전 토큰: List<T>는 내부에 _version 필드를 둔다. Add/Remove/Clear 등 구조적 변경마다 _version++. List<T>.Enumerator는 생성 시 _version을 스냅샷해 두고, MoveNext에서 현재 _version과 비교 → 다르면 InvalidOperationException("Collection was modified; enumeration operation may not execute"). (Current는 버전을 재검사하지 않고 캐시된 값을 그대로 반환하므로, 변경은 다음 MoveNext 호출에서 잡힌다.) 즉시 실패(fail-fast)로, 일관성 깨진 열거를 조용히 진행하지 않는다. (x==2에서 Add 후 다음 MoveNext가 던진다.) — 항목을 지우며 순회하려면 인덱스 역방향 for, 또는 RemoveAll, 또는 별도 리스트로 수집 후 일괄 처리.
  • (4) (B) struct 열거자 복사 함정: List<T>.Enumerator가변 구조체다. e2 = e1값 복사라 내부 인덱스/현재값이 통째로 복제된다. e2.MoveNext()는 e2의 복사본 인덱스만 전진시키고 e1은 그대로다. 가변 구조체를 변수/필드/인자로 복사해 쓰면 "전진했는데 원본은 안 움직이는" 혼란이 생긴다. foreach가 이를 피하는 이유는 열거자를 한 군데(숨은 지역 변수)에서만 다루기 때문. 그래서 열거자는 복사하지 말고 그 자리에서만 쓰는 게 안전하다.

흔한 오해·함정

  • "foreach 하려면 IEnumerable 을 구현해야 한다" → 아니다. GetEnumerator 패턴만 맞으면 된다(C# 9부터는 확장 GetEnumerator도 허용).
  • "List 든 IEnumerable 이든 똑같다" → 정적 타입이 인터페이스면 열거자가 박싱된다. 핫패스에선 구체 타입(List/배열/Span)으로 받아 박싱을 피하라. LINQ 체인도 열거자/클로저/델리게이트를 할당하므로 틱 루프에서 피한다.
  • "열거 중에 살짝 추가하는 정도는 괜찮겠지" → _version 검사로 바로 예외. 의도적 수정은 인덱스 for/RemoveAll/스냅샷으로.
  • 배열 T[]의 foreach는 열거자조차 안 만들고 인덱스 루프로 컴파일된다(가장 저렴).

면접 포인트

  • foreach가 인터페이스가 아니라 패턴(GetEnumerator/MoveNext/Current/Dispose)으로 풀린다는 점.
  • List<T> 구체 타입 foreach(struct 열거자, 무할당) vs IEnumerable<T> foreach(박싱, 할당)의 차이와 게임 루프에서의 함의.
  • 버전 토큰(_version)에 의한 열거 중 수정 감지, 그리고 가변 구조체 열거자 복사의 함정.