Для получения триального ключа
заполните форму ниже
Team License (базовая версия)
Enterprise License (расширенная версия)
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

** На сайте установлена reCAPTCHA и применяются
Политика конфиденциальности и Условия использования Google.
Запросите информацию о ценах
Новая лицензия
Продление лицензии
--Выберите валюту--
USD
EUR
GBP
RUB
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

** На сайте установлена reCAPTCHA и применяются
Политика конфиденциальности и Условия использования Google.
Для получения лицензии для вашего открытого
проекта заполните, пожалуйста, эту форму
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

** На сайте установлена reCAPTCHA и применяются
Политика конфиденциальности и Условия использования Google.
Для получения лицензии для вашего открытого
проекта заполните, пожалуйста, эту форму
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

** На сайте установлена reCAPTCHA и применяются
Политика конфиденциальности и Условия использования Google.
Мне интересно попробовать плагин на:
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

** На сайте установлена reCAPTCHA и применяются
Политика конфиденциальности и Условия использования Google.
Ваше сообщение отправлено.

Мы ответим вам на


Если вы так и не получили ответ, пожалуйста, проверьте папку
Spam/Junk и нажмите на письме кнопку "Не спам".
Так Вы не пропустите ответы от нашей команды.

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

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

21 Июл 2021

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

0844_EnumBoxing_ru/image1.png

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

  • это и не ошибка как таковая, а просто не совсем оптимальная работа приложения (например, из-за доп. нагрузки на 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.

0844_EnumBoxing_ru/image2.png

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

0844_EnumBoxing_ru/image3.png

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

  • 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:

0844_EnumBoxing_ru/image4.png

Так как мы работаем с экземплярами значимого типа, а для вызова метода нам нужны ссылочные, перед вызовом будет произведена упаковка. Кстати, если заглянуть в 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 не фиксирует на таком коде событий сборки мусора.

0844_EnumBoxing_ru/image5.png

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

0844_EnumBoxing_ru/image6.png

Вам может стать интересно, а как же устроен внутри 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.

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

.NET 5

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 и Int32.GetHashCode. Да, неустойчивые к изменениям underlying типа и не очень очевидные. Не призываю так писать, но ради интереса добавил в тесты.

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

.NET Framework 4.8

0844_EnumBoxing_ru/image10.png

.NET 5

0844_EnumBoxing_ru/image11.png

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

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

Заключение

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

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

Популярные статьи по теме
Эффект последней строки

Дата: 31 Май 2014

Автор: Андрей Карпов

Я изучил множество ошибок, возникающих в результате копирования кода. И утверждаю, что чаще всего ошибки допускают в последнем фрагменте однотипного кода. Ранее я не встречал в книгах описания этого …
PVS-Studio для Java

Дата: 17 Янв 2019

Автор: Андрей Карпов

В седьмой версии статического анализатора PVS-Studio мы добавили поддержку языка Java. Пришло время немного рассказать, как мы начинали делать поддержку языка Java, что у нас получилось и какие дальн…
Характеристики анализатора PVS-Studio на примере EFL Core Libraries, 10-15% ложных срабатываний

Дата: 31 Июл 2017

Автор: Андрей Карпов

После большой статьи про проверку операционной системы Tizen мне было задано много вопросов о проценте ложных срабатываний и о плотности ошибок (сколько ошибок PVS-Studio выявляет на 1000 строк кода)…
Бесплатный PVS-Studio для тех, кто развивает открытые проекты

Дата: 22 Дек 2018

Автор: Андрей Карпов

В канун празднования нового 2019 года команда PVS-Studio решила сделать приятный подарок всем контрибьюторам open-source проектов, хостящихся на GitHub, GitLab или Bitbucket. Им предоставляется возмо…
Статический анализ как часть процесса разработки Unreal Engine

Дата: 27 Июн 2017

Автор: Андрей Карпов

Проект Unreal Engine развивается - добавляется новый код и изменятся уже написанный. Неизбежное следствие развития проекта - появление в коде новых ошибок, которые желательно выявлять как можно раньш…
PVS-Studio ROI

Дата: 30 Янв 2019

Автор: Андрей Карпов

Время от времени нам задают вопрос, какую пользу в денежном эквиваленте получит компания от использования анализатора PVS-Studio. Мы решили оформить ответ в виде статьи и привести таблицы, которые по…
Любите статический анализ кода!

Дата: 16 Окт 2017

Автор: Андрей Карпов

Я в шоке от возможностей статического анализа кода, хотя сам участвую в разработке инструмента PVS-Studio. На днях я был искренне удивлён тому, что анализатор оказался умнее и внимательнее меня.
Главный вопрос программирования, рефакторинга и всего такого

Дата: 14 Апр 2016

Автор: Андрей Карпов

Вы угадали, ответ - "42". Здесь приводится 42 рекомендации по программированию, которые помогут избежать множества ошибок, сэкономить время и нервы. Автором рекомендаций выступает Андрей Карпов - тех…
Зло живёт в функциях сравнения

Дата: 19 Май 2017

Автор: Андрей Карпов

Возможно, читатели помнят мою статью под названием "Эффект последней строки". В ней идёт речь о замеченной мной закономерности: ошибка чаще всего допускается в последней строке однотипных блоков текс…
Как и почему статические анализаторы борются с ложными срабатываниями

Дата: 20 Мар 2017

Автор: Андрей Карпов

В своей предыдущей статье я писал, что мне не нравится подход, при котором статические анализаторы кода оцениваются с помощью синтетических тестов. В статье приводился пример, воспринимаемый анализат…

Комментарии (0)

Следующие комментарии

На сайте установлена reCAPTCHA и применяются
Политика конфиденциальности и Условия использования Google.
Этот сайт использует куки и другие технологии, чтобы предоставить вам более персонализированный опыт. Продолжая просмотр страниц нашего веб-сайта, вы принимаете условия использования этих файлов. Если вы не хотите, чтобы ваши данные обрабатывались, пожалуйста, покиньте данный сайт. Подробнее →
Принять