Code Review: Enum.ToString()–wydajność

Dzisiaj zaciekawiła mnie informacje, znaleziona w sieci, że ToString() na enum jest bardzo powolne. Postanowiłem to sprawdzić samemu. Załóżmy,  że mamy:

public enum Month
{
   January,
   February,
   March,
   April,
   May,
   June,
   July,
   August,
   September,
   October,
   November,
   December
};

Prosty benchmark, może wyglądać następująco:

int n = 100000;

Stopwatch stopwatch = Stopwatch.StartNew();
string text = null;

for (int i = 0; i < n; i++)
{
 var month = (Month)(i % 12);
 text = month.ToString();
}

Console.WriteLine(stopwatch.ElapsedTicks);

Wynik:

image

Taki wynik za wiele nam nie mówi. Spróbujmy napisać drugi przykład, który zamiast enum.ToString(), będzie korzystał z tablicy napisów tzn.:

string[] names = new[]
       {
           "January",
           "February",
           "March",
           "April",
           "May",
           "June",
           "July",
           "August",
           "September",
           "October",
           "November",
           "December"
       };

Wartość enum’a może zostać przeskalowana na indeks w tej tablicy:

stopwatch = Stopwatch.StartNew();
text = null;

for (int i = 0; i < n; i++)
{
 var month = (Month) (i%12);
 text = names[(int)month];                    
}

Console.WriteLine(stopwatch.ElapsedTicks);

Uruchamiając oba benchmarki, zobaczymy:

image

Różnica podobno jest na tyle duża, że nie można tego traktować jako mikro-optymalizacji. Jeśli mamy kod w jakieś pętli (serwer), to stanowi to zwykłe marnowanie zasobów.

No dobra, to odpowiedzmy sobie na pytanie, skąd taka duża różnica? Zaglądamy oczywiście do IL:

// loop start (head: IL_0025)
IL_0010: ldloc.2
IL_0011: ldc.i4.s 12
IL_0013: rem
IL_0014: stloc.3
IL_0015: ldloc.3
IL_0016: box ConsoleApplication2.Month
IL_001b: callvirt instance string [mscorlib]System.Object::ToString()
IL_0020: pop
IL_0021: ldloc.2
IL_0022: ldc.i4.1
IL_0023: add
IL_0024: stloc.2

IL_0025: ldloc.2
IL_0026: ldloc.0
IL_0027: blt.s IL_0010
// end loop

Widzimy, że ToString jest metodą wirtualną (należącą do Object) a enum jest value type. Oznacza to, że będziemy mieli boxing, co widać po IL. Dla porównania drugie rozwiązanie nie zawiera żadnego boxingu:

// loop start (head: IL_00ce)
IL_00bb: ldloc.s i
IL_00bd: ldc.i4.s 12
IL_00bf: rem
IL_00c0: stloc.s month
IL_00c2: ldloc.s names
IL_00c4: ldloc.s month
IL_00c6: ldelem.ref
IL_00c7: pop
IL_00c8: ldloc.s i
IL_00ca: ldc.i4.1
IL_00cb: add
IL_00cc: stloc.s i

IL_00ce: ldloc.s i
IL_00d0: ldloc.0
IL_00d1: blt.s IL_00bb
// end loop

Niestety to nie wszystko. Zagłębmy się dalej do Enum.ToString():

//Enum.ToString()
public override string ToString()
{
    return Enum.InternalFormat((RuntimeType)base.GetType(), this.GetValue());
}

Następnie do Enum.InternalFormat:

// System.Enum
private static string InternalFormat(RuntimeType eT, object value)
{
    if (eT.IsDefined(typeof(FlagsAttribute), false))
    {
        return Enum.InternalFlagsFormat(eT, value);
    }
    string name = Enum.GetName(eT, value);
    if (name == null)
    {
        return value.ToString();
    }
    return name;
}

W zależności czy Enum jest opatrzony atrybutem Flags, mamy dwie różne implementacje. O flagach już kiedyś pisałem (zapraszam do lektury).

W naszym przypadku, przechodzimy do Enum.GetName():

public static string GetName(Type enumType, object value)
{
    if (enumType == null)
    {
        throw new ArgumentNullException("enumType");
    }
    return enumType.GetEnumName(value);
}

Nic tu ciekawego nie ma więc zaglądamy teraz do GetEnumName:

// System.Type
/// <summary>Returns the name of the constant that has the specified value, for the current enumeration type.</summary>
/// <returns>The name of the member of the current enumeration type that has the specified value, or null if no such constant is found.</returns>
/// <param name="value">The value whose name is to be retrieved.</param>
/// <exception cref="T:System.ArgumentException">The current type is not an enumeration.-or-<paramref name="value" /> is neither of the current type nor does it have the same underlying type as the current type.</exception>
/// <exception cref="T:System.ArgumentNullException">
///   <paramref name="value" /> is null.</exception>
public virtual string GetEnumName(object value)
{
    if (value == null)
    {
        throw new ArgumentNullException("value");
    }
    if (!this.IsEnum)
    {
        throw new ArgumentException(Environment.GetResourceString("Arg_MustBeEnum"), "enumType");
    }
    Type type = value.GetType();
    if (!type.IsEnum && !Type.IsIntegerType(type))
    {
        throw new ArgumentException(Environment.GetResourceString("Arg_MustBeEnumBaseTypeOrEnum"), "value");
    }
    Array enumRawConstantValues = this.GetEnumRawConstantValues();
    int num = Type.BinarySearch(enumRawConstantValues, value);
    if (num >= 0)
    {
        string[] enumNames = this.GetEnumNames();
        return enumNames[num];
    }
    return null;
}

Widzimy znów masę if’ow. Chcemy się jednak bliżej przyjrzeć BinarySearch oraz GetEnumNames. Pierwsza z nich daje nam indeks, a druga nazwy enumów.  GetEnumNames wykonuje kilka ifów i wywoła GetEnumData:

// System.Type
private void GetEnumData(out string[] enumNames, out Array enumValues)
{
    FieldInfo[] fields = this.GetFields(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
    object[] array = new object[fields.Length];
    string[] array2 = new string[fields.Length];
    for (int i = 0; i < fields.Length; i++)
    {
        array2[i] = fields[i].Name;
        array[i] = fields[i].GetRawConstantValue();
    }
    IComparer @default = Comparer.Default;
    for (int j = 1; j < array.Length; j++)
    {
        int num = j;
        string text = array2[j];
        object obj = array[j];
        bool flag = false;
        while (@default.Compare(array[num - 1], obj) > 0)
        {
            array2[num] = array2[num - 1];
            array[num] = array[num - 1];
            num--;
            flag = true;
            if (num == 0)
            {
                break;
            }
        }
        if (flag)
        {
            array2[num] = text;
            array[num] = obj;
        }
    }
    enumNames = array2;
    enumValues = array;
}

Pierwsza rzecz jaką tu widzimy to refleksja, o której wiemy, że nie jest szybka i spowalnia kod. Widzimy, metoda jest bardzo uniwersalna, ale to skutkuje, że jest wolna dla najprostszych przykładów (brak flag i przerw między enumami). Ponadto, drugi parametr wyjściowy, kompletnie jest niepotrzebny dla naszego przypadku (w zasadzie jest ignorowany przez GetEnumNames):

public virtual string[] GetEnumNames()
{
    if (!this.IsEnum)
    {
        throw new ArgumentException(Environment.GetResourceString("Arg_MustBeEnum"), "enumType");
    }
    string[] result;
    Array array;
    this.GetEnumData(out result, out array);
    return result;
}

BinarySearch znajduje daną wartość korzystając oczywiście z przeszukiwania binarnego:

ulong[] array2 = new ulong[array.Length];
for (int i = 0; i < array.Length; i++)
{
array2[i] = Enum.ToUInt64(array.GetValue(i));
}
ulong value2 = Enum.ToUInt64(value);
return Array.BinarySearch<ulong>(array2, value2);

Widzimy zatem, że kod wykonuje bardzo dużo rzeczy (refleksja, warunki, binarne przeszukiwanie, znajdowanie nazwy, boxing). W drugim rozwiązaniu, nie jest potrzebna żadna pętla aby znaleźć konkretną wartość (w końcu już ją mamy).

Warto mieć  uwadze  powyższe uwagi, wrzucając Enum.ToString gdzieś na serwer. Często przecież ma to charakter niejawny tzn.:

Console.WriteLine(month);
// powyzsza linia jest rownowazna z:
Console.WriteLine(month.ToString());

Leave a Reply

Your email address will not be published.