C#에서 enum을 boxing 없이 int로 변환하기 - 두 번째 이야기
이전 글에,
C#에서 enum을 boxing 없이 int로 변환하기
; https://www.sysnet.pe.kr/2/0/11270
다음과 같은 덧글이 달렸군요.
참고하신 블로그의 다음 글로 https://libsora.so/posts/csharp-dictionary-enum-key-without-gc/ 이 올라왔는데요. 해당 글을 참고한다면 우회 방법을 사용하신 static Dictionary에서도 결국 박싱이 발생하는 것 아닐까요?
링크한 "C# Dictionary + enum (https://libsora.so/posts/csharp-dictionary-enum-key-without-gc)" 글을 보면 Dictionary.ContainsKey 메서드와 indexer에 enum 값을 전달하면 메서드 내부에서 호출되는 DefaultComparer.Equals와 DefaultComparer.GetHashCode의 메모리 할당 문제로 인해 결국 박싱이 일어난다는 것입니다. 왜냐하면, 제 코드에서도 어차피 Dictionary의 indexer를 이용한 접근을 하기 때문에,
class WrapperObject<TEnum, TValue>
{
TValue[] data;
static Dictionary<TEnum, int> _enumKey = new Dictionary<TEnum, int>();
...[생략]...
public WrapperObject(int count)
{
data = new TValue[count];
}
public TValue this[TEnum key]
{
get { return data[_enumKey[key]]; }
set { data[_enumKey[key]] = value; }
}
}
박싱이 일어날 거라는 덧글입니다.
그런데, 질문이 다소 잘못되었습니다. DefaultComparer.Equals와 DefaultComparer.GetHashCode 내부에서 어떤 작업을 하는지는 알 수 없으나 그것이 boxing인지, 다른 이유로 인해 발생하는 것인지 알 수 없기 때문입니다. 즉, 덧글의 질문은 다음과 같이 바뀌어야 합니다.
참고하신 블로그의 다음 글로 https://libsora.so/posts/csharp-dictionary-enum-key-without-gc/ 이 올라왔는데요. 해당 글을 참고한다면 우회 방법을 사용하신 static Dictionary에서도 결국 GC가 발생하는 것 아닐까요?
그런데, 이건 유니티가 사용하는 Mono 플랫폼의 문제입니다. .NET 4.0 환경에서 테스트하면 인덱서 내부에서의 동작에 힙 할당이 전혀 발생하지 않습니다. 확인은 다음과 같이 할 수 있습니다.
using System;
using System.Collections.Generic;
using System.Threading;
namespace ConsoleApp1
{
class Program
{
interface IState
{
string GetMessage();
}
class State_Wait : IState
{
public string GetMessage()
{
return "wait";
}
}
class State_Run : IState
{
public string GetMessage()
{
return "run";
}
}
enum States
{
Wait,
Run,
}
static void Main(string[] args)
{
Thread t = new Thread(reportGC);
t.IsBackground = true;
t.Start();
WrapperObject<States, IState> states = new WrapperObject<States, IState>(2);
states[States.Run] = new State_Wait();
states[States.Wait] = new State_Run();
while (true)
{
states[States.Run].GetMessage();
}
}
private static void reportGC()
{
while (true)
{
int count = GC.CollectionCount(0) +
GC.CollectionCount(1) +
GC.CollectionCount(2);
Console.WriteLine(count);
Thread.Sleep(1000);
}
}
class WrapperObject<TEnum, TValue>
{
TValue[] data;
static Dictionary<TEnum, int> _enumKey = new Dictionary<TEnum, int>();
static WrapperObject()
{
int[] intValues = Enum.GetValues(typeof(TEnum)) as int[];
TEnum[] enumValues = Enum.GetValues(typeof(TEnum)) as TEnum[];
for (int i = 0; i < intValues.Length; i++)
{
_enumKey.Add(enumValues[i], intValues[i]);
}
}
public WrapperObject(int count)
{
data = new TValue[count];
}
public TValue this[TEnum key]
{
get { return data[_enumKey[key]]; }
set { data[_enumKey[key]] = value; }
}
}
}
}
실행해 보면, GC가 전혀 발생하지 않습니다. 재미있는 것은 .NET 3.5로 빌드하면 이번에는 GC가 발생하는 것을 볼 수 있습니다. 즉, 내부 코드가 어떻게 작성되어 있느냐에 따라 Dictionary 타입의 indexer 사용 시 힙 할당 여부가 결정됩니다.
어쨌든 중요한 것은, 저 코드로 작성하게 되면 Unity3D 환경의 경우 GC가 발생하게 됩니다.
그렇다면, WrapperObject 타입의 내부 컬렉션을 BCL의 Dictionary가 아닌, GC 힙을 할당하지 않는 사용자 정의 컬렉션으로 교체하면 어떨까요? 그런데, 이게 좀 재미있습니다. Dictionary와 같은 객체를 최소한의 구성으로 다음과 같이 만들어 보았는데요.
class WrapperObject<TEnum, TValue> where TEnum : struct
{
TValue[] data;
MyIntDict<TEnum> _enumKey = new MyIntDict<TEnum>();
public WrapperObject(int count)
{
data = new TValue[count];
}
public TValue this[TEnum key]
{
get { return data[_enumKey[key]]; }
set { data[_enumKey[key]] = value; }
}
}
// 이 타입은 힙 메모리 사용을 없애기 위해 최소한의 사전형 구현체를 만든 것으로
// 너무 많은 가정을 포함하므로 현실적으로 사용할 수 없음.
class MyIntDict<TEnum> where TEnum : struct
{
int[] _data;
public MyIntDict()
{
int elemCount = Enum.GetValues(typeof(TEnum)).Length;
_data = new int[elemCount];
}
// 혹시... key.GetHashCode 이외에 indexer로 전달된 값을 hash하는 방법이 있을까요?
// 또는 꼭 사전 형식이 아니더라도 현실성 있게 heap 할당을 피할 수 있는 방법이 있을까요?
public unsafe int this[TEnum key]
{
get
{
int idx = key.GetHashCode();
return _data[idx];
}
set
{
int idx = key.GetHashCode();
_data[idx] = value;
}
}
}
단순히 key.GetHashCode() 만으로도 내부적으로 힙을 사용해 GC가 발생하게 됩니다. 그렇다면, 도대체 .NET 4.0의 Dictionary 타입은 어떻게 구현했길래 힙 메모리 사용이 없는 걸까요? 우선 indexer를 시작으로,
[__DynamicallyInvokable]
public TValue this[TKey key]
{
[__DynamicallyInvokable]
get
{
int index = this.FindEntry(key);
if (index >= 0)
{
return this.entries[index].value;
}
ThrowHelper.ThrowKeyNotFoundException();
return default(TValue);
}
[__DynamicallyInvokable]
set
{
this.Insert(key, value, false);
}
}
private int FindEntry(TKey key)
{
if (key == null)
{
ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key);
}
if (this.buckets != null)
{
int num = this.comparer.GetHashCode(key) & 0x7fffffff;
for (int i = this.buckets[num % this.buckets.Length]; i >= 0; i = this.entries[i].next)
{
if ((this.entries[i].hashCode == num) && this.comparer.Equals(this.entries[i].key, key))
{
return i;
}
}
}
return -1;
}
위의 코드에 사용된 this.comparer를 추적해 보면 특별히 enum 타입에 대해 RuntimeTypeHandle.CreateInstanceForAnotherGenericParameter(...); 메서드를 이용해 동적으로 생성하고 있습니다.
[SecuritySafeCritical]
private static EqualityComparer<T> CreateComparer()
{
// ...[생략]...
if (c.IsEnum)
{
switch (Type.GetTypeCode(Enum.GetUnderlyingType(c)))
{
case TypeCode.SByte:
return (EqualityComparer<T>) RuntimeTypeHandle.CreateInstanceForAnotherGenericParameter((RuntimeType) typeof(SByteEnumEqualityComparer<sbyte>), c);
case TypeCode.Byte:
case TypeCode.UInt16:
case TypeCode.Int32:
case TypeCode.UInt32:
return (EqualityComparer<T>) RuntimeTypeHandle.CreateInstanceForAnotherGenericParameter((RuntimeType) typeof(EnumEqualityComparer<int>), c);
case TypeCode.Int16:
return (EqualityComparer<T>) RuntimeTypeHandle.CreateInstanceForAnotherGenericParameter((RuntimeType) typeof(ShortEnumEqualityComparer<short>), c);
case TypeCode.Int64:
case TypeCode.UInt64:
return (EqualityComparer<T>) RuntimeTypeHandle.CreateInstanceForAnotherGenericParameter((RuntimeType) typeof(LongEnumEqualityComparer<long>), c);
}
}
// ...[생략]...
}
이것은 "GC없이 C# Dictionary에서 enum을 key로 쓰기 (https://libsora.so/posts/csharp-dictionary-enum-key-without-gc)" 글에서 언급한 "Generic EnumComparer"와 같이 내부적으로 dynamic method 생성을 하는 식으로 처리하는 것과 방식이 유사합니다. 즉, .NET 4.0의 경우 enum의 경우까지도 고려해 동적으로 생성한 메서드 덕분에 GC 힙 사용을 피해 간 것입니다. 그렇다면, 사용자 정의 Dictionary 타입 등으로 우회하고 싶어도 결국 동적 메서드 생성 이외에는 답이 없는 것처럼 보입니다.
그런데, 갑자기 C#의 특수한 예약어가 생각났습니다.
Fun With __makeref
; http://benbowen.blog/post/fun_with_makeref/
그렇습니다. 저 예약어를 이용하면 enum 타입을 boxing 없이 int로 변경할 수 있습니다. 이렇게!
class WrapperObject<TEnum, TValue>
{
TValue[] data;
public WrapperObject(int count)
{
data = new TValue[count];
}
public TValue this[TEnum key]
{
get { return data[ConvertToIndex(key)]; }
set { data[ConvertToIndex(key)] = value; }
}
// 이 코드는 enum의 기반 타입을 int로 가정
unsafe int ConvertToIndex(TEnum key)
{
System.TypedReference reference = __makeref(key);
System.TypedReference* pRef = &reference;
int* valuePtr = (int*)*((IntPtr*)&reference);
return *valuePtr;
}
/*
int ConvertToIndex(TEnum key)
{
System.TypedReference reference = __makeref(key);
return __refvalue(reference, int); // System.InvalidCastException: 'Specified cast is not valid.'
}
*/
}
일단, Visual Studio와 Unity3d 개발 환경에서는 빌드 및 실행이 잘 됩니다. 단지, iOS 빌드를 위한 IL2CPP 환경에서 빌드/실행이 잘 되는지는 확인을 못했습니다. 그나저나, 설령 잘 된다고 해도, 저런 키워드를 써가면서까지 enum 타입을 (int)로 명시적인 형 변환을 필요 없게 만드는 것이 얼마나 큰 장점이 있을지는... 생각해 봐야 할 문제입니다. ^^
(
첨부 파일은 이 글의 예제 코드를 포함합니다.)
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]