>
>
>
Подводные камни при работе с enum в C#

Сергей Васильев
Статей: 96

Подводные камни при работе с enum в C#

C# имеет низкий порог вхождения и прощает многое. Серьёзно, на этом языке преспокойно можно писать, не особо понимая, как всё работает под капотом, и не забивать голову. Однако со временем приходится сталкиваться с разными нюансами. Сегодня рассмотрим один из них — работу с перечислениями.

Вообще маловероятно, что найдётся такой разработчик, который бы не сталкивался с перечислениями. Тем не менее допустить ошибку при их использовании можно. Особенно если:

  • это и не ошибка как таковая, а просто не совсем оптимальная работа приложения (например, из-за доп. нагрузки на GC);
  • приходится писать много кода и нет времени вникать во все нюансы языка.

Более того, на практике описанные ниже проблемы могут и не быть проблемами для вашего приложения. Однако, если подобный код будет многократно исполняться (например, десятки миллионов раз) и начнёт доставлять неудобства, вы уже будете знать, с чем имеете дело.

Примечание. Все исследования, которые мы будем проводить ниже, выполнялись для .NET Framework. Это важно. Про .NET поговорим немного позже.

Неожиданная нагрузка на GC

С описываемой проблемой я столкнулся не так давно, когда занимался различными оптимизациями C# анализатора PVS-Studio. Да, у нас уже была одна статья на эту тему, но, думаю, будет ещё.

В процессе работы я исправлял различные места в коде. Как показала практика, даже маленькие правки могут дать неплохие результаты, если сделаны они в 'узких' местах приложения.

В какой-то момент по результатам профилирования я вышел на класс VariableAnnotation. Его упрощённый вариант и рассмотрим.

enum OriginType
{
  Field,
  Parameter,
  Property,
  ....
}

class VariableAnnotation<T> where T : Enum
{
  public T Type { get; }

  public SyntaxNode OriginatingNode { get; }

  public VariableAnnotation(SyntaxNode originatingNode, T type)
  {
    OriginatingNode = originatingNode;
    Type = type;
  }

  public override bool Equals(object obj)
  {
    if (obj is null)
      return false;

    if (obj is not VariableAnnotation<T> other)
      return false;

    return    Enum.Equals(this.Type, other.Type)
           && this.OriginatingNode == other.OriginatingNode;
  }

  public override int GetHashCode()
  {
    return   this.OriginatingNode.GetHashCode() 
           ^ this.Type.GetHashCode();
  }
}

А теперь напишем два простых метода, в которых:

  • в цикле сравниваются экземпляры типа VariableAnnotation<OriginType>;
  • создаётся экземпляр типа VariableAnnotation<OriginType> и у него в цикле вычисляется хеш-код.

Соответствующие методы:

static void EqualsTest()
{
  var ann1 = new VariableAnnotation<OriginType>(new SyntaxNode(), 
                                                OriginType.Parameter);
  var ann2 = new VariableAnnotation<OriginType>(new SyntaxNode(), 
                                                OriginType.Parameter);

  while (true)
  {
    var eq = Enum.Equals(ann1, ann2);
  }
}

static void GetHashCodeTest()
{
  var ann = new VariableAnnotation<OriginType>(new SyntaxNode(), 
                                               OriginType.Parameter);

  while (true)
  {
    var hashCode = ann.GetHashCode();
  }
}

Если запустить любой из этих методов и понаблюдать за приложением в динамике, можно отметить неприятную особенность: оно даёт нагрузку на GC.

Например, это можно увидеть в окне "Diagnostic tools" Visual Studio.

Или в Process Hacker на вкладке ".NET performance" информации о процессе.

По этим примерам несложно вычислить, что виновника два:

  • Enum.Equals(ann1, ann2);
  • ann.GetHashCode().

Разберёмся с ними поочерёдно.

Enum.Equals

Будем исследовать следующий код:

static void EnumEqTest(OriginType originLhs, OriginType originRhs)
{
  while (true)
  {
    var eq = Enum.Equals(originLhs, originRhs);
  }
}

Первое, на что обратят внимание знатоки (IDE в этом поможет, кстати) — никакого Enum.Equals нет. В данном случае происходит вызов метода Object.Equals(object objA, object objB).

На это намекает сама IDE:

Так как мы работаем с экземплярами значимого типа, а для вызова метода нам нужны ссылочные, перед вызовом будет произведена упаковка. Кстати, если заглянуть в IL код, можно найти эти самые команды упаковки:

.method private hidebysig static void
EnumEqTest(valuetype EnumArticle.Program/OriginType originLhs,
           valuetype EnumArticle.Program/OriginType originRhs) cil managed
{
  // Code size       20 (0x14)
  .maxstack  8
  IL_0000:  ldarg.0
  IL_0001:  box        EnumArticle.Program/OriginType
  IL_0006:  ldarg.1
  IL_0007:  box        EnumArticle.Program/OriginType
  IL_000c:  call       bool [mscorlib]System.Object::Equals(object,
                                                            object)
  IL_0011:  pop
  IL_0012:  br.s       IL_0000
}

Здесь мы чётко видим вызов метода System.Object::Equals(object, object), а также команды предварительной упаковки аргументов - box (IL_0001, IL_0007).

Так как мы упаковываем объекты только для вызова метода, соответствующие ссылки никуда не сохраняются, следовательно, упакованные объекты будут очищены при сборке мусора.

Примечание. Кто-то может сказать — всем очевидно, что Enum.Equals == Object.Equals. Вон, даже IDE подсветку делает. Ответ — нет, нет и ещё раз нет. Самое простое этому доказательство состоит в том, что такой код был написан. И я уверен, что некоторые разработчики используют подобный способ сравнения. По поводу "очевидности" — очень часто люди попадают в ловушку, думая, что если что-то очевидно им, то это очевидно всем. На самом деле это не так.

Если мы поменяем вызов Enum.Equals (по факту — Object.Equals) на сравнение через '==', то избавимся от ненужной упаковки:

var eq = originLhs == originRhs;

Однако следует помнить, что обобщённый вариант кода (тип VariableAnnotation был обобщённым) не скомпилируется:

static void EnumEq<T>(T originLhs, T originRhs) where T : Enum
{
  while (true)
  {
    // error CS0019: Operator '==' cannot be applied 
    // to operands of type 'T' and 'T'
    var eq = originLhs == originRhs; 
  }
}

Вызовы экземплярных методов Enum.Equals и Enum.CompareTo нам не подойдут, так как влекут за собой упаковку.

Выходом может стать использование обобщённого типа EqualityComparer<T>. Например, можно вполне спокойно воспользоваться дефолтным компаратором. Код примет примерно следующий вид:

static void EnumEq<T>(T originLhs, T originRhs) where T : Enum
{
  while (true)
  {
    var eq = EqualityComparer<T>.Default.Equals(originLhs, originRhs);
  }
}

Метод EqualityComparer<T>.Equals(T x, T y) принимает аргументы обобщённого типа, а следовательно, не требует упаковки (по крайней мере, перед своим вызовом). Внутри вызова методов всё тоже нормально.

Из кода IL команды упаковки пропали:

.method private hidebysig static void
EnumEq<([mscorlib]System.Enum) T>(!!T originLhs,
                                  !!T originRhs) cil managed
{
  // Code size       15 (0xf)
  .maxstack  8
  IL_0000:  call
    class [mscorlib]System.Collections.Generic.EqualityComparer`1<!0> 
    class [mscorlib]System.Collections.Generic.EqualityComparer`1<!!T>
                      ::get_Default()
  IL_0005:  ldarg.0
  IL_0006:  ldarg.1
  IL_0007:  callvirt   
    instance bool class 
    [mscorlib]System.Collections.Generic.EqualityComparer`1<!!T>::Equals(!0,
                                                                         !0)
  IL_000c:  pop
  IL_000d:  br.s       IL_0000
}

Профилировщик Visual Studio не фиксирует на таком коде событий сборки мусора.

Process Hacker говорит о том же.

Вам может стать интересно, а как же устроен внутри EqualityComparer<T> (мне, например, стало). Исходный код этого типа можно посмотреть, например, на referencesource.microsoft.com.

Enum.GetHashCode

Теперь же рассмотрим, что у нас с методом Enum.GetHashCode. Начнём со следующего кода:

static void EnumGetHashCode(OriginType origin)
{
  while (true)
  {
    var hashCode = origin.GetHashCode();
  }
}

Возможно, вы будете удивлены, но здесь происходит упаковка и, как следствие, нагрузка на GC, о чём опять же наглядно свидетельствуют профилировщик и Process Hacker.

А давайте-ка поддадимся ностальгии? Скомпилируем этот код через Visual Studio 2010 и посмотрим, какой IL код получится. Примерно такой:

.method private hidebysig static void  EnumGetHashCode(valuetype 
EnumArticleVS2010.Program/OriginType origin) cil managed
{
  // Code size       14 (0xe)
  .maxstack  8
  IL_0000:  ldarg.0
  IL_0001:  box        EnumArticleVS2010.Program/OriginType
  IL_0006:  callvirt   instance int32 [mscorlib]System.Object::GetHashCode()
  IL_000b:  pop
  IL_000c:  br.s       IL_0000
}

Кажется, всё ожидаемо: команда box на месте (IL_0001). Это отвечает на вопрос, откуда упаковка и нагрузка на GC.

Вернёмся в современный мир и теперь скомпилируем код через Visual Studio 2019. Получился такой IL код:

.method private hidebysig static void  
EnumGetHashCode(valuetype EnumArticle.Program/OriginType origin) cil managed
{
  // Code size       16 (0x10)
  .maxstack  8
  IL_0000:  ldarga.s   origin
  IL_0002:  constrained. EnumArticle.Program/OriginType
  IL_0008:  callvirt   instance int32 [mscorlib]System.Object::GetHashCode()
  IL_000d:  pop
  IL_000e:  br.s       IL_0000
}

Неожиданно команда box испарилась (прямо как карандаш в "Тёмном рыцаре"), а вот упаковка и нагрузка на GC остались. Здесь я решил посмотреть реализацию Enum.GetHashCode() на referencesource.microsoft.com.

[System.Security.SecuritySafeCritical]
public override unsafe int GetHashCode()
{
  // Avoid boxing by inlining GetValue()
  // return GetValue().GetHashCode();
 
  fixed (void* pValue = &JitHelpers.GetPinningHelper(this).m_data)
  {
    switch (InternalGetCorElementType())
    {
      case CorElementType.I1:
        return (*(sbyte*)pValue).GetHashCode();
      case CorElementType.U1:
        return (*(byte*)pValue).GetHashCode();
      case CorElementType.Boolean:
        return (*(bool*)pValue).GetHashCode();
      ....
      default:
        Contract.Assert(false, "Invalid primitive type");
        return 0;
    }
  }
}

Самая интересная часть здесь — комментарий "Avoid boxing ...". Как будто что-то не сходится...

Итак, вроде бы упаковки не должно быть, команды box в IL коде также нет, но выделение памяти в управляемой куче и события сборки мусора на месте.

Давайте что ли посмотрим в спецификацию CIL, чтобы получше разобраться с IL кодом. Ниже ещё раз приведу вызов метода, чтобы он был перед глазами:

ldarga.s   origin
constrained. EnumArticle.Program/OriginType
callvirt   instance int32 [mscorlib]System.Object::GetHashCode()

С инструкцией ldarga.s всё просто — адрес аргумента метода загружается на evaluation stack.

Далее идёт префикс constrained. Формат префикса:

constrained. thisType

Stack transition:

..., ptr, arg1, ... argN -> ..., ptr, arg1, ... arg

В зависимости от того, чем является thisType, отличается способ обработки управляемого указателя ptr:

  • если thisType — ссылочный тип, ptr разыменовывается и используется как this-указатель для вызова метода;
  • если thisType — значимый тип, который имплементирует вызываемый метод, ptr передаётся в этот метод в качестве this-указателя как есть;
  • если thisType — значимый тип, который не имплементирует вызываемый метод, тогда указатель ptr разыменовывается, производится упаковка объекта, после чего полученный указатель используется как this-указатель при вызове метода.

Как отмечено в спецификации, последний случай возможен только тогда, когда метод объявлен в System.Object, System.ValueType и System.Enum и не переопределяется в дочернем типе.

Второй кейс из списка выше позволяет исключить упаковку объекта при вызове метода, если это возможно. Но мы с вами столкнулись с третьим случаем. GetHashCode переопределён в System.Enum. System.Enum является базовым типом для OriginType. Однако само перечисление не переопределяет методы из System.Enum, отсюда упаковка при их вызове.

Подчеркну, что это актуально для любых значимых типов. Если вы не переопределяете метод базового типа, для его вызова будет выполнена упаковка объекта.

struct MyStructBoxing
{
  private int _field;
}

struct MyStructNoBoxing
{
  private int _field;

  public override int GetHashCode()
  {
    return _field;
  }
}

static void TestStructs(MyStructBoxing myStructBoxing, 
                        MyStructNoBoxing myStructNoBoxing)
{
  while (true)
  {
    var hashCode1 = myStructBoxing.GetHashCode();   // boxing
    var hashCode2 = myStructNoBoxing.GetHashCode(); // no boxing
  }
}

Но вернёмся к перечислениям. Как же быть с ними, ведь мы не можем переопределить метод в перечислении?

На выручку может прийти уже упоминавшийся ранее тип System.Collections.Generic.EqualityComparer<T>, который содержит обобщённый метод GetHashCode - public abstract int GetHashCode(T obj):

var hashCode = EqualityComparer<OriginType>.Default.GetHashCode(_origin);

Разница в рассмотренных примерах между .NET и .NET Framework

Как я упоминал ранее, всё сказанное выше было актуально для .NET Framework. Посмотрим, как обстоят дела в .NET?

Equals

Упаковка, ожидаемо, никуда не делась. Неудивительно, ведь нам всё так же нужно вызывать метод Object.Equals(object, object). Так что сравнивать элементы перечисления таким образом в любом случае не стоит.

Если же говорить про экземплярный метод Enum.Equals, то здесь также остаётся необходимость в упаковке аргумента.

GetHashCode

А вот здесь меня ждал приятный сюрприз!

Вспомним пример кода:

static void GetHashCodeTest(OriginType origin)
{
  while (true)
  {
    var hashCode = origin.GetHashCode();
  }
}

Напоминаю, что при исполнении данного кода в .NET Framework из-за упаковки создаются временные объекты, как следствие — дополнительная нагрузка на GC.

Однако при использовании .NET (и .NET Core) ничего подобного не происходит! Никаких временных объектов, никакой нагрузки GC.

Производительность

Ладно, с упаковкой вроде разобрались. Давайте посмотрим, что у нас по быстродействию. Заодно сравним скорость работы одного и того же кода для .NET Framework и .NET.

Весь код для сравниваемых методов одинаков, отличаться будут только способы сравнения элементов перечисления и получения хеш-кодов.

Equals

Описание способов сравнения, используемых в методах:

  • ObjectEquals: Object.Equals(lhs, rhs);
  • Enum.Equals: lhs.Equals(rhs);
  • Enum.CompareTo: lhs.CompareTo(rhs) == 0;
  • EqualityComparerEquals: EqualityComparer<T>.Default.Equals(lhs, rhs);
  • DirectComparison: lhs == rhs.

Ниже приводится сравнение времени исполнения.

.NET Framework 4.8

.NET 5

Меня очень порадовали результаты работы EqualityComparer<T> на .NET 5, где по скорости получилось примерно такое же время, как при прямом сравнении элементов перечисления. Стоит отдать должное Microsoft — не изменяя C# кода, вы из коробки получаете оптимизацию при обновлении целевого фреймворка / рантайма.

GetHashCode

Описание способов получения хеш-кодов, используемых в методах:

  • EnumGetHashCode: _origin.GetHashCode();
  • UnderlyingValue: (int)_origin;
  • UnderlyingValueGetHashCode: ((int)_origin).GetHashCode();
  • EqualityComparerGetHashCode: EqualityComparer<OriginType>.Default.GetHashCode(_origin).

С первым и последним пунктом всё понятно. Второй и третий — 'хаки' для получения хеш-кода, навеянные реализацией Enum.GetHashCode и Int32.GetHashCode. Да, неустойчивые к изменениям underlying типа и не очень очевидные. Не призываю так писать, но ради интереса добавил в тесты.

Ниже приводится сравнение времени исполнения.

.NET Framework 4.8

.NET 5

Сразу 2 хорошие новости:

  • в .NET убрали упаковку при прямом вызове GetHashCode;
  • EqualityComparer<T>, как и в случае с Equals, опять стал работать лучше.

Заключение

C# — классный. Можно много лет писать на нём и не знать о нюансах, связанных с какими-то базовыми вещами: почему out-параметры можно не инициализировать, почему результатом упаковки nullable-значения может быть null, почему при вызове GetHashCode для перечислений может происходить упаковка. А когда всё же приходится сталкиваться с чем-то подобным, бывает интересно вникнуть в суть. Я от этого кайфую. Надеюсь, вы тоже.

Как обычно, приглашаю подписываться на мой Twitter, чтобы не пропустить ничего интересного.