C# имеет низкий порог вхождения и прощает многое. Серьёзно, на этом языке преспокойно можно писать, не особо понимая, как всё работает под капотом, и не забивать голову. Однако со временем приходится сталкиваться с разными нюансами. Сегодня рассмотрим один из них — работу с перечислениями.
Вообще маловероятно, что найдётся такой разработчик, который бы не сталкивался с перечислениями. Тем не менее допустить ошибку при их использовании можно. Особенно если:
Более того, на практике описанные ниже проблемы могут и не быть проблемами для вашего приложения. Однако, если подобный код будет многократно исполняться (например, десятки миллионов раз) и начнёт доставлять неудобства, вы уже будете знать, с чем имеете дело.
Примечание. Все исследования, которые мы будем проводить ниже, выполнялись для .NET Framework. Это важно. Про .NET поговорим немного позже.
С описываемой проблемой я столкнулся не так давно, когда занимался различными оптимизациями 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();
}
}
А теперь напишем два простых метода, в которых:
Соответствующие методы:
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" информации о процессе.
По этим примерам несложно вычислить, что виновника два:
Разберёмся с ними поочерёдно.
Будем исследовать следующий код:
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. Начнём со следующего кода:
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:
Как отмечено в спецификации, последний случай возможен только тогда, когда метод объявлен в 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 Framework. Посмотрим, как обстоят дела в .NET?
Упаковка, ожидаемо, никуда не делась. Неудивительно, ведь нам всё так же нужно вызывать метод Object.Equals(object, object). Так что сравнивать элементы перечисления таким образом в любом случае не стоит.
Если же говорить про экземплярный метод Enum.Equals, то здесь также остаётся необходимость в упаковке аргумента.
А вот здесь меня ждал приятный сюрприз!
Вспомним пример кода:
static void GetHashCodeTest(OriginType origin)
{
while (true)
{
var hashCode = origin.GetHashCode();
}
}
Напоминаю, что при исполнении данного кода в .NET Framework из-за упаковки создаются временные объекты, как следствие — дополнительная нагрузка на GC.
Однако при использовании .NET (и .NET Core) ничего подобного не происходит! Никаких временных объектов, никакой нагрузки GC.
Ладно, с упаковкой вроде разобрались. Давайте посмотрим, что у нас по быстродействию. Заодно сравним скорость работы одного и того же кода для .NET Framework и .NET.
Весь код для сравниваемых методов одинаков, отличаться будут только способы сравнения элементов перечисления и получения хеш-кодов.
Описание способов сравнения, используемых в методах:
Ниже приводится сравнение времени исполнения.
.NET Framework 4.8
.NET 5
Меня очень порадовали результаты работы EqualityComparer<T> на .NET 5, где по скорости получилось примерно такое же время, как при прямом сравнении элементов перечисления. Стоит отдать должное Microsoft — не изменяя C# кода, вы из коробки получаете оптимизацию при обновлении целевого фреймворка / рантайма.
Описание способов получения хеш-кодов, используемых в методах:
С первым и последним пунктом всё понятно. Второй и третий — 'хаки' для получения хеш-кода, навеянные реализацией Enum.GetHashCode и Int32.GetHashCode. Да, неустойчивые к изменениям underlying типа и не очень очевидные. Не призываю так писать, но ради интереса добавил в тесты.
Ниже приводится сравнение времени исполнения.
.NET Framework 4.8
.NET 5
Сразу 2 хорошие новости:
C# — классный. Можно много лет писать на нём и не знать о нюансах, связанных с какими-то базовыми вещами: почему out-параметры можно не инициализировать, почему результатом упаковки nullable-значения может быть null, почему при вызове GetHashCode для перечислений может происходить упаковка. А когда всё же приходится сталкиваться с чем-то подобным, бывает интересно вникнуть в суть. Я от этого кайфую. Надеюсь, вы тоже.
Как обычно, приглашаю подписываться на мой Twitter, чтобы не пропустить ничего интересного.