Подводные Камни При Работе С Перечислением В C#



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

C# имеет низкий порог входа и очень щаден.

Серьезно, вы можете легко писать на этом языке, толком не понимая, как все работает под капотом, и не беспокоиться об этом.

Однако со временем приходится сталкиваться с разными нюансами.

Сегодня мы рассмотрим один из них — работу с перечислениями.

В общем, вряд ли найдется разработчик, который бы не сталкивался с перечислениями.

Однако при их использовании можно допустить ошибки.

Особенно если:

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

Более того, на практике описанные ниже проблемы могут не являться проблемами для вашего приложения.

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

Примечание .

Все исследования, которые мы проведем ниже, проводились для .

NET Framework. Это важно.

О .

NET мы поговорим чуть позже.



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

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

По ходу работы исправлял разные места в коде.

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

В какой-то момент по результатам профилирования я поступил в класс ПеременнаяАннотация .

Рассмотрим его упрощенную версию.

  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
   

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(); } }

Теперь напишем два простых метода, в которых:
  • цикл сравнивает экземпляры типа ПеременнаяАннотация ;
  • создается экземпляр типа ПеременнаяАннотация и хэш-код вычисляется в цикле.

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

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. Например, это можно увидеть в окне «Инструменты диагностики» Visual Studio.

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

Или в Process Hacker на вкладке «Производительность .

NET» информации о процессе.



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

Из этих примеров легко понять, что виновников двое:

  • Enum.Equals(ann1, ann2) ;
  • анн.

    GetHashCode() .

Давайте разберемся с ними один за другим.



Перечисление.

Равно

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

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

Первое, на что обратят внимание специалисты (в этом, кстати, поможет IDE), — это отсутствие Перечисление.

Равно Нет. В этом случае метод называется Object.Equals(объект objA, объект objB) .

Сама IDE намекает на это:

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

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

Кстати, если заглянуть в 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(объект, объект) , а также команды для предварительной упаковки аргументов — коробка (IL_0001, IL_0007).

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

Примечание .

Кто-то может сказать - всем очевидно, что Перечисление.

Равно == Объект.Равно .

Посмотрите, даже в IDE предусмотрена подсветка.

Ответ: нет, нет и еще раз нет. Простейшим доказательством этого является то, что такой код был написан.

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

По поводу «очевидности» — очень часто люди попадают в ловушку, думая, что если что-то очевидно для них, то это очевидно для всех.

На самом деле это не так.

Если мы изменим вызов Перечисление.

Равно (фактически - Объект.Равно ) для сравнения через '==', то избавляемся от ненужной упаковки:

var eq = originLhs == originRhs;

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

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.CompareTo нам не подойдут, так как предполагают упаковку.

Решением может быть использование универсального типа.

Средство сравнения равенства .

Например, вы совершенно спокойно можете использовать компаратор по умолчанию.

Код будет выглядеть примерно так:

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

Default.Equals(originLhs, originRhs); } }

Метод Средство сравнения равенства .

Равно(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 не фиксирует события сборки мусора для такого кода.



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

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



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

Вам может быть интересно, как это работает внутри Средство сравнения равенства (например, так случилось со мной).

Исходный код этого типа можно посмотреть, например, по адресу 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 }

Вроде бы все ожидаемо: команда коробка на месте (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 }

Внезапно команда коробка испарился (как и карандаш в «Тёмном рыцаре»), но упаковка и нагрузка на ГК остались.

Вот решил посмотреть на реализацию Перечисление.

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; } } }

Самое интересное здесь это комментарий " Избегайте бокса.

«Как будто что-то не складывается.

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

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

С инструкциями ldarga.s Все просто — адрес аргумента метода загружается в стек вычислений.

Далее идет префикс сдержанный .

Формат префикса:

constrained. thisType

Переход стека:

.

, ptr, arg1, .

argN -> .

, ptr, arg1, .

arg

В зависимости от того, что это такое этот тип , способ обработки управляемого указателя отличается ПТР :

  • Если этот тип - тип ссылки, ПТР разыменовывается и используется как этот — указатель на вызов метода;
  • Если этот тип — значимый тип, реализующий вызываемый метод, ПТР передается этому методу как этот - указатель как есть;
  • Если этот тип — это тип значения, который не реализует вызываемый метод, тогда указатель ПТР разыменовывается, объект помещается в коробку, после чего полученный указатель используется как этот — указатель при вызове метода.

Как отмечено в спецификации, последний случай возможен только в том случае, если метод объявлен в Система.

Объект , System.ValueType И System.Enum и не переопределяется в дочернем типе.

Второй случай из списка выше позволяет исключить упаковку объектов при вызове метода, если это возможно.

Но мы столкнулись с третьим случаем.

GetHashCode переопределено в System.Enum .

System.Enum является базовым типом для Тип происхождения .

Однако само перечисление не переопределяет методы из 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 , который содержит общий метод GetHashCode общедоступный абстрактный int GetHashCode (T obj) :

var hashCode = EqualityComparer<OriginType>.

Default.GetHashCode(_origin);



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

NET и .

NET Framework

Как я упоминал ранее, все сказанное выше относится и к .

NET Framework. Посмотрим, как обстоят дела в .

NET?

Равно

Упаковка, как и ожидалось, никуда не делась.

Неудивительно, ведь нам еще нужно вызвать метод Object.Equals(объект, объект) .

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

Равно , то остается необходимость в упаковке аргументов.



GetHashCode

Но тут меня ждал приятный сюрприз! Давайте вспомним пример кода:

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

Напомню, что при выполнении этого кода в .

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

NET (и .

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

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



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

Ладно, с упаковкой мы, кажется, разобрались.

Посмотрим, что мы имеем с точки зрения производительности.

Заодно сравним скорость одного и того же кода для .

NET Framework и .

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



Равно

Описание методов сравнения, используемых в методах:
  • ObjectEquals: Object.Equals(левый, правый) ;
  • Enum.Equals: lhs.Equals(rhs) ;
  • Enum.CompareTo: lhs.CompareTo(rhs) == 0 ;
  • EqualityComparerEquals: EqualityComparer .

    Default.Equals(левый, правый) ;

  • Прямое сравнение: левое == правое .

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

.

NET Framework 4.8

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

.

NET 5

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

Я остался очень доволен результатами работы Средство сравнения равенства на .

NET 5, где скорость была примерно такой же, как и при прямом сравнении элементов перечисления.

Стоит отдать должное Microsoft — не меняя код C#, вы получаете оптимизацию «из коробки» при обновлении целевого фреймворка/рантайма.



GetHashCode

Описание методов получения хэш-кодов, используемых в методах:
  • EnumGetHashCode : _origin.GetHashCode() ;
  • Базовое значение : (целое)_origin ;
  • Базовое значениеGetHashCode : ((int)_origin).

    GetHashCode() ;

  • РавенствоComparerGetHashCode : Средство сравнения равенства .

    Default.GetHashCode(_origin) .

С первым и последним пунктом все понятно.

Второй и третий — «хаки» для получения хэш-кода, вдохновленные реализацией Enum.GetHashCode И Int32.GetHashCode .

Да, неустойчив к изменениям основного типа и не очень очевиден.

Я не призываю вас так писать, но ради интереса добавил это в тесты.

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

.

NET Framework 4.8

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

.

NET 5

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

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

  • в .

    NET упаковка была удалена при прямом вызове GetHashCode ;

  • Средство сравнения равенства , как и в случае с Равно , снова стал работать лучше.



Заключение

C# — это круто.

На нем можно писать много лет и не знать о нюансах, связанных с некоторыми базовыми вещами: зачем вне -параметры может быть не инициализирован , Почему результат упаковки значения, допускающего значение NULL, может быть нулевой почему при звонке GetHashCode При пересылке может происходить упаковка.

А когда вам все же придется иметь дело с чем-то подобным, может быть интересно докопаться до сути.

Мне нравится это.

Я надеюсь, что вы тоже.

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

Если вы хотите поделиться этой статьей с англоязычной аудиторией, воспользуйтесь ссылкой для перевода: Сергей Васильев.

Перечисления в C#: скрытые подводные камни .

Теги: #C++ #.

NET #CLI #.

net core #il #CIL #C# enum

Вместе с данным постом часто просматривают:

Автор Статьи


Зарегистрирован: 2019-12-10 15:07:06
Баллов опыта: 0
Всего постов на сайте: 0
Всего комментарий на сайте: 0
Dima Manisha

Dima Manisha

Эксперт Wmlog. Профессиональный веб-мастер, SEO-специалист, дизайнер, маркетолог и интернет-предприниматель.