IEnumerable는 "열거할 수 있는 것"을 의미하는 인터페이스이다
가장 중요한 멤버는 GetEnumerator() 메서드이다
// 비제네릭
public interface IEnumerable
{
IEnumerator GetEnumerator();
}
// 제네릭
public interface IEnumerable<out T> : IEnumerable
{
IEnumerator<T> GetEnumerator();
}
즉 어떤 타입이든 IEnumerable 또는 IEnumerable<T>를 구현하고 있으면
foreach로 돌릴 수 있다는 의미이다
IEnumerator는 "현재 위치를 기억하면서 한 칸씩 이동하는 열거자"를 의미하는 인터페이스이다
핵심 멤버는 다음과 같다
// 비제네릭
public interface IEnumerator
{
bool MoveNext(); // 다음 요소로 이동, 이동 성공 여부 반환
object Current { get; } // 현재 요소
void Reset(); // 처음 위치로 리셋 (요즘은 잘 안씀)
}
// 제네릭
public interface IEnumerator<out T> : IDisposable, IEnumerator
{
new T Current { get; }
}
정리하면 다음과 같다
IEnumerable = 열거자를 만들어주는 쪽이다IEnumerator = 실제로 한 칸씩 움직이며 데이터에 접근하는 객체이다foreach는 결국 내부적으로 IEnumerable.GetEnumerator()로 IEnumerator를 하나 얻고,
그 열거자를 가지고 MoveNext()와 Current를 반복 호출하는 패턴일 뿐이다
C#의 모든 1차원 배열은 다음 인터페이스들을 구현하고 있다
IEnumerableIEnumerable<T>ICollection, IList 등도 구현하지만 여기서는 생략한다
예를 들어 int[] 배열이 있다고 하면, 이 배열은 IEnumerable<int>이다
int[] numbers = { 10, 20, 30 };
// 명시적으로 캐스팅 가능
IEnumerable enumerable = numbers;
IEnumerable<int> genericEnumerable = numbers;
// 물론 foreach는 바로 사용 가능
foreach (var n in numbers)
{
Console.WriteLine(n);
}
즉 배열도 "foreach가 가능한 컬렉션"이라는 점에서는 List와 완전히 동일한 개념이다
단, 구현 방식은 조금 다르며, 배열은 CLR 레벨에서 특별 취급되는 타입이라는 점이 다를 뿐이다
int[] numbers = { 1, 2, 3, 4, 5 };
foreach (int n in numbers)
{
Console.WriteLine(n);
}
이 코드는 우리가 익숙하게 쓰는 형태이다
하지만 컴파일러는 이 코드를 다음과 비슷한 형태로 변환한다
int[] numbers = { 1, 2, 3, 4, 5 };
// 1. GetEnumerator 호출
IEnumerator<int> enumerator = ((IEnumerable<int>)numbers).GetEnumerator();
try
{
// 2. MoveNext로 한 칸씩 이동
while (enumerator.MoveNext())
{
// 3. Current로 현재 요소 읽기
int n = enumerator.Current;
Console.WriteLine(n);
}
}
finally
{
// 4. IDisposable이면 Dispose 호출
if (enumerator is IDisposable d)
{
d.Dispose();
}
}
실제 실제로는 JIT 최적화 때문에 배열의 경우 이런 형태로 최적화된 for 루프로 바뀌는 경우도 많지만,
foreach의 개념 상으로 보면 위 패턴을 따르고 있다고 이해하면 된다
foreach는 "열거자 패턴"을 자동으로 만들어주는 문법 설탕(syntactic sugar)일 뿐이라는 점을 이해하면 된다
int[] numbers = { 10, 20, 30 };
IEnumerator enumerator = numbers.GetEnumerator();
while (enumerator.MoveNext())
{
// Current는 object 타입이라 캐스팅 필요
int value = (int)enumerator.Current;
Console.WriteLine(value);
}
여기서 numbers.GetEnumerator()는 비제네릭 IEnumerator를 반환한다
그래서 Currentobject(int) 캐스팅이 필요하다
int[] numbers = { 10, 20, 30 };
// IEnumerable<int>로 캐스팅해서 제네릭 열거자 얻기
IEnumerable<int> seq = numbers;
IEnumerator<int> enumerator = seq.GetEnumerator();
while (enumerator.MoveNext())
{
int value = enumerator.Current; // 캐스팅 불필요
Console.WriteLine(value);
}
enumerator.Dispose(); // using 또는 try-finally로 감싸는 것이 정석이다
제네릭 버전을 쓰면 Currentint
5. 배열 전용 foreach 최적화와 for 루프 비교
배열은 CLR 레벨에서 특별취급을 받기 때문에, JIT 컴파일러가 foreach를 for 루프로
최적화해버리는 경우가 많다
그래서 다음 두 코드는 보통 성능 차이가 거의 없거나 미미한 수준이다
5-1. foreach 버전
int[] numbers = { 1, 2, 3, 4, 5 };
foreach (int n in numbers)
{
// 작업
}
5-2. for 버전
int[] numbers = { 1, 2, 3, 4, 5 };
for (int i = 0; i < numbers.Length; i++)
{
int n = numbers[i];
// 작업
}
배열만 놓고 보면 가독성 측면에서는 foreach가 더 깔끔하다는 장점이 있다
인덱스가 필요한 경우에는 for를 사용하면 된다
6. 커스텀 컬렉션에서 배열 + IEnumerator 패턴 구현하기
실무에서는 내부적으로 배열을 들고 있으면서 직접 열거 가능한 컬렉션을 만드는 경우가 많다
이때 IEnumerable<T> / IEnumerator<T>를 어떻게 구현하는지 예를 보자
public class IntCollection : IEnumerable<int>
{
private int[] _items;
public IntCollection(int[] items)
{
_items = items;
}
// IEnumerable<int> 구현
public IEnumerator<int> GetEnumerator()
{
// 배열의 열거자를 그대로 반환해도 되고
// yield return으로 커스텀 열거자를 만들어도 된다
foreach (var item in _items)
{
yield return item;
}
}
// IEnumerable (비제네릭) 구현
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator(); // 제네릭 버전 재사용
}
}
// 사용 예
var collection = new IntCollection(new[] { 1, 2, 3 });
foreach (var n in collection)
{
Console.WriteLine(n);
}
위 예제에서 yield return을 사용하면 컴파일러가 내부적으로 IEnumerator<int>를 구현한
상태 머신 클래스를 자동으로 생성해준다
덕분에 직접 MoveNext, Current를 구현할 필요 없이 간단하게 열거자 패턴을 만들 수 있다
7. IEnumerator / 배열 관련 실무에서 자주 하는 실수
7-1. Current를 범위 밖에서 읽으려는 경우
int[] numbers = { 1, 2, 3 };
var e = ((IEnumerable<int>)numbers).GetEnumerator();
// MoveNext 호출 없이 Current 접근 (잘못된 코드)
int value = e.Current; // 정의되지 않은 동작, InvalidOperationException 가능
규칙은 다음과 같다
MoveNext()를 최초 한 번 호출한 뒤에 Current
MoveNext()가 false를 반환하여 열거가 끝난 뒤에는 Current
7-2. 컬렉션을 수정하면서 열거하기
일반적인 컬렉션(List 등)은 foreach로 열거 중에 컬렉션을 수정하면 예외가 발생한다
배열의 길이는 고정이라 조금 다른 성격이지만, 기본 원칙은 열거 중에는 컬렉션 구조를 바꾸지 않는 것이 안전하다
7-3. IDisposable 처리 잊기
IEnumerator<T>는 IDisposable을 상속받기 때문에, 직접 사용할 때는 using 또는
try/finally로 Dispose()를 호출해주는 것이 원칙이다
foreach를 사용하면 컴파일러가 자동으로 Dispose() 호출을 넣어주므로 신경 쓸 필요가 없다
IEnumerable<int> seq = new List<int> { 1, 2, 3 };
using (var e = seq.GetEnumerator())
{
while (e.MoveNext())
{
int value = e.Current;
Console.WriteLine(value);
}
}
8. 정리: 배열과 IEnumerator를 이해하면 보이는 것들
- 배열(
T[])은 IEnumerable / IEnumerable<T>를 구현하고 있는 컬렉션이다
foreach는 결국 GetEnumerator(), MoveNext(), Current를 자동으로 호출해주는 문법 설탕이다
- 배열에서도
GetEnumerator()를 직접 호출하여 IEnumerator를 꺼내 수동으로 순회할 수 있다
- 배열의
foreach는 JIT에 의해 for 루프로 최적화되는 경우가 많아 성능 손해가 거의 없다
- 커스텀 컬렉션을 만들 때는 내부에 배열을 두고
IEnumerable<T>/IEnumerator<T> 패턴을 구현하면 foreach와 완벽히 통합된다
IEnumerator를 직접 사용할 때는 MoveNext() 호출 순서와 Current 사용 규칙, Dispose() 처리에 주의해야 한다