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

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

![0844_EnumBoxing_ru/image1.png](https://import.viva64.com/docx/blog/0844_EnumBoxing_ru/image1.png)

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

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

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

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

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

С описываемой проблемой я столкнулся не так давно, когда занимался различными оптимизациями C\# анализатора PVS\-Studio\. Да, у нас уже была одна [статья на эту тему](https://pvs-studio.ru/ru/blog/posts/csharp/0836/), но, думаю, будет ещё\. 

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

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

```cpp
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\>_ и у него в цикле вычисляется хеш\-код\.

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

```cpp
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\.

![0844_EnumBoxing_ru/image2.png](https://import.viva64.com/docx/blog/0844_EnumBoxing_ru/image2.png)

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

![0844_EnumBoxing_ru/image3.png](https://import.viva64.com/docx/blog/0844_EnumBoxing_ru/image3.png)

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

* _Enum\.Equals\(ann1, ann2\)_;
* _ann\.GetHashCode\(\)_\.

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

## Enum\.Equals

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

```cpp
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:

![0844_EnumBoxing_ru/image4.png](https://import.viva64.com/docx/blog/0844_EnumBoxing_ru/image4.png)

Так как мы работаем с экземплярами значимого типа, а для вызова метода нам нужны ссылочные, перед вызовом будет произведена упаковка\. Кстати, если заглянуть в [IL код](https://pvs-studio.ru/ru/blog/terms/7006/), можно найти эти самые команды упаковки:

```cpp
.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_\) на сравнение через '\=\=', то избавимся от ненужной упаковки:

```cpp
var eq = originLhs == originRhs;
```

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

```cpp
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\>_\. Например, можно вполне спокойно воспользоваться дефолтным компаратором\. Код примет примерно следующий вид:

```cpp
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 команды упаковки пропали:

```cpp
.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 не фиксирует на таком коде событий сборки мусора\.

![0844_EnumBoxing_ru/image5.png](https://import.viva64.com/docx/blog/0844_EnumBoxing_ru/image5.png)

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

![0844_EnumBoxing_ru/image6.png](https://import.viva64.com/docx/blog/0844_EnumBoxing_ru/image6.png)

Вам может стать интересно, а как же устроен внутри _EqualityComparer<T\>_ \(мне, например, стало\)\. Исходный код этого типа можно посмотреть, например, на [referencesource\.microsoft\.com](https://bit.ly/2SSW4qh)\.

## Enum\.GetHashCode

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

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

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

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

```cpp
.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 код:

```cpp
.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](https://bit.ly/2Uvlafc)\.

```cpp
[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](https://www.ecma-international.org/publications-and-standards/standards/ecma-335/), чтобы получше разобраться с IL кодом\. Ниже ещё раз приведу вызов метода, чтобы он был перед глазами:

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

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

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

```cpp
constrained. thisType
```

Stack transition:

```cpp
..., 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_, отсюда упаковка при их вызове\.

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

```cpp
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\)_:

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

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

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

### Equals 

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

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

### GetHashCode

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

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

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

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

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

![0844_EnumBoxing_ru/image7.png](https://import.viva64.com/docx/blog/0844_EnumBoxing_ru/image7.png)

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

Ладно, с упаковкой вроде разобрались\. Давайте посмотрим, что у нас по быстродействию\. Заодно сравним скорость работы одного и того же кода для \.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**

![0844_EnumBoxing_ru/image8.png](https://import.viva64.com/docx/blog/0844_EnumBoxing_ru/image8.png)

**\.NET 5**

![0844_EnumBoxing_ru/image9.png](https://import.viva64.com/docx/blog/0844_EnumBoxing_ru/image9.png)

Меня очень порадовали результаты работы _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](https://bit.ly/3xBoK6x) и [Int32\.GetHashCode](https://bit.ly/2TKDdht)\. Да, неустойчивые к изменениям underlying типа и не очень очевидные\. Не призываю так писать, но ради интереса добавил в тесты\.

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

**\.NET Framework 4\.8**

![0844_EnumBoxing_ru/image10.png](https://import.viva64.com/docx/blog/0844_EnumBoxing_ru/image10.png)

**\.NET 5**

![0844_EnumBoxing_ru/image11.png](https://import.viva64.com/docx/blog/0844_EnumBoxing_ru/image11.png)

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

* в \.NET убрали упаковку при прямом вызове _GetHashCode_;
* _EqualityComparer<T\>_, как и в случае с _Equals_, опять стал работать лучше\.

## Заключение

C\# — классный\. Можно много лет писать на нём и не знать о нюансах, связанных с какими\-то базовыми вещами: почему _out_\-параметры [можно не инициализировать](https://pvs-studio.ru/ru/blog/posts/csharp/0800/), почему [результатом упаковки nullable\-значения может быть _null_](https://pvs-studio.ru/ru/blog/posts/csharp/0772/), почему при вызове _GetHashCode_ для перечислений может происходить упаковка\. А когда всё же приходится сталкиваться с чем\-то подобным, бывает интересно вникнуть в суть\. Я от этого кайфую\. Надеюсь, вы тоже\.

Как обычно, приглашаю подписываться на [мой Twitter](https://twitter.com/_SergVasiliev_), чтобы не пропустить ничего интересного\.