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 열거자, 무할당) vsIEnumerable<T>foreach(박싱, 할당)의 차이와 게임 루프에서의 함의.- 버전 토큰(
_version)에 의한 열거 중 수정 감지, 그리고 가변 구조체 열거자 복사의 함정.