가비지 없이 foreach 사용하기
문제
foreach문은 syntax sugar이다. 내부적으로 해당 타입의 Enumerator를 받아 루프를 수행한다.
public class SomeGenericClass<T> : IEnumerable<T>
{
public class SomeEnum : IEnumerator<T>
{
public SomeEnum()
{
Console.WriteLine("Created");
}
public bool MoveNext()
{
return false;
}
public void Reset()
{
throw new NotImplementedException();
}
public T Current { get; }
object IEnumerator.Current => Current;
public void Dispose()
{
}
}
public IEnumerator<T> GetEnumerator()
{
return new SomeEnum();
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
}
가령 위와 같이 IEnumerator를 구현한 타입에 대해서 아래와 같이 foreach문을 수행한다고 해보자.
var someGenericClass = new SomeGenericClass<int>();
foreach (var elem in someGenericClass)
{
}
위 코드의 IL코드를 살펴보면
IL_005c: ldloc.s someGenericClass
IL_005e: callvirt instance class [System.Runtime]System.Collections.Generic.IEnumerator`1<!0/*int32*/> class SomeGenericClass`1<int32>::GetEnumerator()
IL_0063: stloc.s V_9
IL_0065: br.s IL_0072
// start of loop, entry point: IL_0072
// [130 18 - 130 26]
IL_0067: ldloc.s V_9
IL_0069: callvirt instance !0/*int32*/ class [System.Runtime]System.Collections.Generic.IEnumerator`1<int32>::get_Current()
IL_006e: stloc.s elem_V_10
// [131 9 - 131 10]
IL_0070: nop
// [133 9 - 133 10]
IL_0071: nop
// [130 27 - 130 29]
IL_0072: ldloc.s V_9
IL_0074: callvirt instance bool [System.Runtime]System.Collections.IEnumerator::MoveNext()
IL_0079: brtrue.s IL_0067
// end of loop
IL_007b: leave.s IL_008a
내부적으로 IEnumeartor.GetEnumerator(), IEnumerator.MoveNext(), IEnumerator.get_Current()를 호출하는 것을 볼 수 있다. (Dispose에 관한 코드는 제거했다.)
그런데 여기서 불편함이 생긴다. foreach문을 사용할 땐 GetEnumerator가 호출이 되고, IEnumeartor.GetEnumerator()의 반환형은 인터페이스이기 때문에 항상 메모리 할당이 발생한다는 것이다. (값 타입으로 반환해도 박싱이 일어남)
그러면?
정답은 굳이 IEnumerator를 구현하지 않아도 된다는 것이다.
public class MyClass<T>
{
public struct MyEnumerator
{
private T _val;
public T Current => _val;
public bool MoveNext()
{
return true;
}
}
public MyEnumerator GetEnumerator()
{
return new MyEnumerator();
}
}
가령 위와 같은 타입이 있다고 가정하자.
위 타입에 대해서 굳이 foreach를 사용하지 않더라도 아래와 같이 코드를 작성하여 똑같은 동작을 수행하게 할 수 있다.
var myClass = new MyClass<int>();
int sum = 0;
var iter = myClass.GetEnumerator();
while (iter.MoveNext())
{
var elem = iter.Current;
sum += elem;
}
C# 컴파일러는 위와 같이 어떤 클래스가 GetEnumerator()를 통해 특정 인스턴스를 반환하고, 해당 인스턴스의 타입에 bool을 반환하는 MoveNext() 메서드와 값을 반환하는 Current 프로퍼티가 있다면 foreach문에 맞는 코드를 생성해준다. 따라서 위 코드를 아래와 같이 foreach를 통해 작성하여도 컴파일 에러가 발생하지 않는다.
foreach (var elem in myClass)
{
sum += elem;
}
위 코드의 IL코드를 보면
IL_0011: br.s IL_0021
// start of loop, entry point: IL_0021
// [121 18 - 121 26]
IL_0013: ldloca.s V_2
IL_0015: call instance !0/*int32*/ valuetype MyClass`1/MyEnumerator<int32>::get_Current()
IL_001a: stloc.3 // elem
// [122 9 - 122 10]
IL_001b: nop
// [123 13 - 123 25]
IL_001c: ldloc.1 // sum
IL_001d: ldloc.3 // elem
IL_001e: add
IL_001f: stloc.1 // sum
// [124 9 - 124 10]
IL_0020: nop
// [121 27 - 121 29]
IL_0021: ldloca.s V_2
IL_0023: call instance bool valuetype MyClass`1/MyEnumerator<int32>::MoveNext()
IL_0028: brtrue.s IL_0013
// end of loop
IL_002a: leave.s IL_003b
컴파일러가 알아서 MyEnumerator를 사용하게 IL코드를 생성했음을 알 수 있다. 이는 while문으로 작성한 코드와 거의 동일하다. (foreach를 사용한 경우가 스택에 변수를 로드하는 과정이 하나 짧다.)
만약 GetEnumerator의 반환 타입이 IDisposable 인터페이스를 구현하고 있다면, 알아서 try/finally Dispose()도 호출해준다. MyClass.MyEnumerator가 IDisposable을 구현하게 한 뒤 foreach문의 IL코드를 보면 다음과 같다.
.try
{
IL_0011: br.s IL_0021
// start of loop, entry point: IL_0021
// [121 18 - 121 26]
IL_0013: ldloca.s V_2
IL_0015: call instance !0/*int32*/ valuetype MyClass`1/MyEnumerator<int32>::get_Current()
IL_001a: stloc.3 // elem
// [122 9 - 122 10]
IL_001b: nop
// [123 13 - 123 25]
IL_001c: ldloc.1 // sum
IL_001d: ldloc.3 // elem
IL_001e: add
IL_001f: stloc.1 // sum
// [124 9 - 124 10]
IL_0020: nop
// [121 27 - 121 29]
IL_0021: ldloca.s V_2
IL_0023: call instance bool valuetype MyClass`1/MyEnumerator<int32>::MoveNext()
IL_0028: brtrue.s IL_0013
// end of loop
IL_002a: leave.s IL_003b
} // end of .try
finally
{
IL_002c: ldloca.s V_2
IL_002e: constrained. valuetype MyClass`1/MyEnumerator<int32>
IL_0034: callvirt instance void [System.Runtime]System.IDisposable::Dispose()
IL_0039: nop
IL_003a: endfinally
} // end of finally
MyClass가 IEnumerator를 구현한 상태라 하더라도 컴파일러는 IEnumerator.GetEnumerator()가 아닌, 해당 메서드의GetEnumerator()를 호출한다.
참고로 C#의 제네릭 컬렉션들은 이런 방식으로 struct 타입의 Enumerator들을 사용한다.
1줄요약
MoveNext(), Current라는 이름의 메서드와 프로퍼티가 있는 struct를 반환하는 GetEnumerator를 구현하면 된다.