언어/C#

가비지 없이 foreach 사용하기

tsyang 2024. 1. 21. 22:25

문제

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를 구현하면 된다.