[Из песочницы] Сравни меня полностью. Рефлексия на службе .NET разработчика
Недавно передо мной встала следующая задача: необходимо сравнить множество пар объектов. Но есть один нюанс: объекты — самые что ни на есть object
'ы, а сравнивать нужно по всему набору публичных свойств. Причём совершенно необязательно, что типы сравниваемых объектов реализуют интерфейс IEquatable<T>
.
Было очевидно, что следует использовать рефлексию. Однако при реализации я столкнулся со множеством тонкостей и в конечном счёте прибегнул к чёрной магии динамической генерации IL. В этой статье я хотел бы поделиться опытом и рассказать о том, как я решал эту задачу. Если Вас заинтересовала тема, то прошу под кат!
Часть 1. Объект в отражении
«Подать сюда MFC IEqualityComparer!», — кричал он, топая всеми четырьмя лапами
В .NET принято, что классы, выполняющие сравнение объектов, реализуют интерфейс IEqualityComparer<T>. Это позволяет внедрять их в классы коллекций, например, списки и словари, и использовать кастомную проверку равенства при поиске. Мы не будем отступать от этого соглашения и приступим к реализации интерфейса IEqualityComparer<object>
, использующей механизм рефлексии. Напомним, что рефлексией называется инспектирование метаданных и скомпилированного кода в процессе выполнения программы (читателю, незнакомому с рефлексией, настоятельно рекомендуется ознакомиться с главой «Рефлексия и метаданные» книги Джозефа и Бена Албахари «C# 5.0. Справочник. Полное описание языка»).
public class ReflectionComparer : IEqualityComparer<object>
{
public new bool Equals(object x, object y)
{
public new bool Equals(object x, object y)
{
return CompareObjectsInternal(x?.GetType(), x, y);
}
}
public int GetHashCode(object obj)
{
return obj.GetHashCode();
}
private bool CompareObjectsInternal(Type type, object x, object y)
{
throw new NotImplementedException();
}
}
Заметим, что метод Equals
мы отметили как new
. Это сделано потому, что он перекрывает метод Object.Equals(object, object)
.
Теперь сделаем шаг назад и точнее определим, что именно мы будем сравнивать. Объекты могут быть сколь угодно сложными, всё нужно учесть. Комбинируя следующие типы свойств мы сможем покрыть широкий класс входных данных:
- все примитивные типы (Boolean, Byte, SByte, Int16, UInt16, Int32, UInt32, Int64, UInt64, IntPtr, UIntPtr, Char, Double, Single);
- строки;
- массивы (для простоты будем рассматривать только одномерные массивы);
- перечисления;
- коллекции, реализующие интерфейс IEnumerable<T>;
- структуры;
- классы.
Напишем каркас метода CompareObjectsInternal
, а затем рассмотрим некоторые частные случаи.
private bool CompareObjectsInternal(Type type, object x, object y)
{
// Если ссылки указывают на один и тот же объект
if (ReferenceEquals(x, y)) return true;
// Один из объектов равен null
if (ReferenceEquals(x, null) != ReferenceEquals(y, null)) return false;
// Объекты имеют разные типы
if (x.GetType() != y.GetType()) return false;
// Строки
if (Type.GetTypeCode(type) == TypeCode.String) return ((string)x).Equals((string)y);
// Массивы
if (type.IsArray) return CompareArrays(type, x, y);
// Коллекции
if (type.IsImplementIEnumerable()) return CompareEnumerables(type, x, y);
// Ссылочные типы
if (type.IsClass || type.IsInterface) return CompareAllProperties(type, x, y);
// Примитивные типы или типы перечислений
if (type.IsPrimitive || type.IsEnum) return x.Equals(y);
// Обнуляемые типы
if (type.IsNullable()) return CompareNullables(type, x, y);
// Структуры
if (type.IsValueType) return CompareAllProperties(type, x, y);
return x.Equals(y);
}
Приведённый выше код достаточно понятен: сначала проверяем объекты на ссылочное равенство и равенство null
, затем проверяем типы, а после этого рассматриваем различные случаи. Причём для строк, примитивных типов и типов перечислений мы, не мудрствуя лукаво, вызываем метод Equals
. Для проверки типов на принадлежность к обнуляемым типам и типам коллекций мы используем методы расширения IsNullable
и IsImplementIEnumerable
, исходный код которых можно посмотреть ниже.
public static bool IsImplementIEnumerable(this Type type) => type.GetInterface("IEnumerable`1") != null;
public static Type GetIEnumerableInterface(this Type type) => type.GetInterface("IEnumerable`1");
public static bool IsNullable(this Type type) => type.IsGenericType && type.GetGenericTypeDefinition() == typeof (Nullable<>);
В случае классов, интерфейсов и структур мы сравниваем все соответствующие свойства. Заметим, что индексаторы в .NET также представляют собой свойства, однако мы ограничимся простыми (parameterless) свойствами, имеющими геттеры, а сравнение коллекций будем обрабатывать отдельно. Проверить, является ли свойство индексатором мы можем с помощью метода PropertyInfo.GetIndexParameters. Если метод вернул массив ненулевой длины, значит мы имеем дело с индексатором.
private bool CompareAllProperties(Type type, object x, object y)
{
var properties = type.GetProperties(BindingFlags.Instance | BindingFlags.Public);
var readableNonIndexers = properties.Where(p => p.CanRead && p.GetIndexParameters().Length == 0);
foreach (PropertyInfo propertyInfo in readableNonIndexers)
{
var a = propertyInfo.GetValue(x, null);
var b = propertyInfo.GetValue(y, null);
if (!CompareObjectsInternal(propertyInfo.PropertyType, a, b)) return false;
}
return true;
}
Следующий случае — сравнение объектов nullable-типов. Если нижележащий тип примитивный, то мы можем сравнить значения методом Equals
. Если же внутри объектов лежат структуры, тогда необходимо сначала извлечь внутренние знаения, а затем сравнить их по всем публичным свойствам. Мы безопасно можем обращаться к свойству Value
, поскольку проверка на равенства уже была выполнена ранее в методе CompareObjectsInternal
.
private bool CompareNullables(Type type, object x, object y)
{
Type underlyingTypeOfNullableType = Nullable.GetUnderlyingType(type);
if (underlyingTypeOfNullableType.IsPrimitive)
{
return x.Equals(y);
}
var valueProperty = type.GetProperty("Value");
var a = valueProperty.GetValue(x, null);
var b = valueProperty.GetValue(y, null);
return CompareAllProperties(underlyingTypeOfNullableType, a, b);
}
Перейдём к сравнению коллекций. Реализацию можно выполнить как минимум двумя способами: написать свой необобщённый метод, работающий с IEnumerable, либо использовать метод расширения Enumerable.SequenceEqual<TSource>. Для коллекций, элементами которых являются тип-значения, очевидно, что необобщённый метод будет работать медленее, т.к. ему постоянно придётся выполнять упаковку/распаковку значений. При использовании же метода LINQ нам нужно сначала подставить тип элементов коллекции в параметр-типа TSource с помощью метода MakeGenericMethod), а затем вызвать метод передав две сравниваемые коллекции. Причём, если коллекция содержит элементы непримитивных типов, то мы можем передать дополнительный аргумент — компарер — текущий экземпляр нашего класса ReflectionComparer
(не зря же мы реализовывали интерфейс IEqualityComparer<object>
!). Поэтому для сравнения коллекций выбираем LINQ:
private static MethodInfo GenericSequenceEqualWithoutComparer = typeof(Enumerable)
.GetMethods(BindingFlags.Public | BindingFlags.Static)
.First(m => m.Name == "SequenceEqual" && m.GetParameters().Length == 2);
private static MethodInfo GenericSequenceEqualWithComparer = typeof(Enumerable)
.GetMethods(BindingFlags.Public | BindingFlags.Static)
.First(m => m.Name == "SequenceEqual" && m.GetParameters().Length == 3);
private bool CompareEnumerables(Type collectionType, object x, object y)
{
Type enumerableInterface = collectionType.GetIEnumerableInterface();
Type elementType = enumerableInterface.GetGenericArguments()[0];
MethodInfo sequenceEqual;
object[] arguments;
if (elementType.IsPrimitive)
{
sequenceEqual = GenericSequenceEqualWithoutComparer;
arguments = new[] {x, y};
}
else
{
sequenceEqual = GenericSequenceEqualWithComparer;
arguments = new[] {x, y, this};
}
var sequenceEqualMethod = sequenceEqual.MakeGenericMethod(elementType);
return (bool)sequenceEqualMethod.Invoke(null, arguments);
}
Последний случай — сравнение массивов — во многом похож на сравнение коллекций. Отличие состоит в том, что вместо использования стандартного метода LINQ мы используем самописный обобщённый метод. Реализация сравнения массивов представлена ниже:
private static MethodInfo GenericCompareArraysMethod =
typeof(ReflectionComparer).GetMethod("GenericCompareArrays", BindingFlags.NonPublic | BindingFlags.Static);
private static bool GenericCompareArrays<T>(T[] x, T[] y, IEqualityComparer<T> comparer)
{
var comp = comparer ?? EqualityComparer<T>.Default;
for (int i = 0; i < x.Length; ++i)
{
if (!comp.Equals(x[i], y[i])) return false;
}
return true;
}
private bool CompareArrays(Type type, object x, object y)
{
var elementType = type.GetElementType();
int xLength, yLength;
if (elementType.IsValueType)
{
// Массивы типов-значений не приводятся к массиву object, поэтому используем Array
xLength = ((Array) x).Length;
yLength = ((Array) y).Length;
}
else
{
xLength = ((object[]) x).Length;
yLength = ((object[]) y).Length;
}
if (xLength != yLength) return false;
var compareArraysPrimitive = GenericCompareArraysMethod.MakeGenericMethod(elementType);
var arguments = elementType.IsPrimitive ? new[] {x, y, null} : new[] {x, y, this};
return (bool) compareArraysPrimitive.Invoke(null, arguments);
}
Итак, все части пазла собраны — наш рефлексивный компарер готов. Ниже представлены результаты сравнения производительности рефлексивного компарера по сравнению с ручной реализацией на «тестовых» данных. Очевидно, что время выполнения сравнения очень сильно зависит от типа сравниваемых объектов. В качестве примера здесь сравнивались объекты двух типов — условно «посложнее» и «попроще». Для оценки времени выполнения использовалась библиотека BenchmarkDotNet, за которую особую благодарность хочется выразить Андрею Акиньшину DreamWalker.
public struct Struct
{
private int m_a;
private double m_b;
private string m_c;
public int A => m_a;
public double B => m_b;
public string C => m_c;
public Struct(int a, double b, string c)
{
m_a = a;
m_b = b;
m_c = c;
}
}
public class SimpleClass
{
public int A { get; set; }
public Struct B { get; set; }
}
public class ComplexClass
{
public int A { get; set; }
public IntPtr B { get; set; }
public UIntPtr C { get; set; }
public string D { get; set; }
public SimpleClass E { get; set; }
public int? F { get; set; }
public int[] G { get; set; }
public List<int> H { get; set; }
public double I { get; set; }
public float J { get; set; }
}
[BenchmarkTask(platform: BenchmarkPlatform.X86, jitVersion: BenchmarkJitVersion.LegacyJit)]
[BenchmarkTask(platform: BenchmarkPlatform.X64, jitVersion: BenchmarkJitVersion.LegacyJit)]
[BenchmarkTask(platform: BenchmarkPlatform.X64, jitVersion: BenchmarkJitVersion.RyuJit)]
public class ComparisonTest
{
private static int[] MakeArray(int count)
{
var array = new int[count];
for (int i = 0; i < array.Length; ++i)
array[i] = i;
return array;
}
private static List<int> MakeList(int count)
{
var list = new List<int>(count);
for (int i = 0; i < list.Count; ++i)
list.Add(i);
return list;
}
private ComplexClass x = new ComplexClass
{
A = 2,
B = new IntPtr(2),
C = new UIntPtr(2),
D = "abc",
E = new SimpleClass { A = 42, B = new Struct(42, 3.14, "meow") },
F = 1,
G = MakeArray(100),
H = MakeList(100),
I = double.MaxValue,
J = float.MaxValue
};
private ComplexClass y = new ComplexClass
{
A = 2,
B = new IntPtr(2),
C = new UIntPtr(2),
D = "abc",
E = new SimpleClass { A = 42, B = new Struct(42, 3.14, "meow") },
F = 1,
G = MakeArray(100),
H = MakeList(100),
I = double.MaxValue,
J = float.MaxValue
};
private ReflectionComparer comparer = new ReflectionComparer();
[Benchmark]
public void ReflectionCompare()
{
var _ = comparer.Equals(x, y);
}
[Benchmark]
public void ManualCompare()
{
var _ = CompareComplexObjects();
}
private bool CompareComplexObjects()
{
if (x == y) return true;
if (x.A != y.A) return false;
if (x.B != y.B) return false;
if (x.C != y.C) return false;
if (x.D != y.D) return false;
if (x.E != y.E)
{
if (x.E.A != y.E.A) return false;
var s1 = x.E.B;
var s2 = y.E.B;
if (s1.A != s2.A) return false;
if (!s1.B.Equals(s2.B)) return false;
if (s1.C != s2.C) return false;
}
if (x.F != y.F) return false;
if (x.G != y.G)
{
if (x.G?.Length != y.G?.Length) return false;
int[] a = x.G, b = y.G;
for (int i = 0; i < a.Length; ++i)
{
if (a[i] != b[i]) return false;
}
}
if (x.H != y.H)
{
if (!x.H.SequenceEqual(y.H)) return false;
}
if (!x.I.Equals(y.I)) return false;
if (!x.J.Equals(y.J)) return false;
return true;
}
}
[BenchmarkTask(platform: BenchmarkPlatform.X86, jitVersion: BenchmarkJitVersion.LegacyJit)]
[BenchmarkTask(platform: BenchmarkPlatform.X64, jitVersion: BenchmarkJitVersion.LegacyJit)]
[BenchmarkTask(platform: BenchmarkPlatform.X64, jitVersion: BenchmarkJitVersion.RyuJit)]
public class SimpleComparisonTest
{
private SimpleClass x = new SimpleClass { A = 42, B = new Struct(42, 3.14, "meow") };
private SimpleClass y = new SimpleClass { A = 42, B = new Struct(42, 3.14, "meow") };
private ReflectionComparer comparer = new ReflectionComparer();
[Benchmark]
public void ReflectionCompare()
{
var _ = comparer.Equals(x, y);
}
[Benchmark]
public void ManualCompare()
{
var _ = CompareSimpleObjects();
}
private bool CompareSimpleObjects()
{
if (x == y) return true;
if (x.A != y.A) return false;
var s1 = x.B;
var s2 = y.B;
if (s1.A != s2.A) return false;
if (!s1.B.Equals(s2.B)) return false;
if (s1.C != s2.C) return false;
return true;
}
}
OS=Microsoft Windows NT 6.2.9200.0
Processor=Intel Core(TM) i5-2410M CPU @ 2.30GHz, ProcessorCount=4
HostCLR=MS.NET 4.0.30319.42000, Arch=64-bit [RyuJIT]
Результаты сравнения объектов ComplexClass
Method | Platform | Jit | AvrTime | StdDev | op/s |
---|---|---|---|---|---|
ManualCompare | X64 | LegacyJit | 1,364.3835 ns | 47.6975 ns | 732,941.68 |
ReflectionCompare | X64 | LegacyJit | 36,779.9097 ns | 3,080.9738 ns | 27,188.92 |
ManualCompare | X64 | RyuJit | 930.8761 ns | 43.6018 ns | 1,074,294.12 |
ReflectionCompare | X64 | RyuJit | 36,909.7334 ns | 3,762.0698 ns | 27,093.98 |
ManualCompare | X86 | LegacyJit | 936.3367 ns | 38.3831 ns | 1,067,992.54 |
ReflectionCompare | X86 | LegacyJit | 32,446.6969 ns | 1,687.8442 ns | 30,819.81 |
Результаты сравнения объектов SimpleClass
Method | Platform | Jit | AvrTime | StdDev | op/s |
---|---|---|---|---|---|
Handwritten | X64 | LegacyJit | 131.5205 ns | 4.9045 ns | 7,603,376.64 |
ReflectionComparer | X64 | LegacyJit | 3,859.7102 ns | 269.8845 ns | 259,087.15 |
Handwritten | X64 | RyuJit | 61.2438 ns | 1.9025 ns | 16,328,222.24 |
ReflectionComparer | X64 | RyuJit | 3,841.4645 ns | 374.0006 ns | 260,317.46 |
Handwritten | X86 | LegacyJit | 71.5982 ns | 5.4304 ns | 13,966,823.95 |
ReflectionComparer | X86 | LegacyJit | 3,636.7963 ns | 241.3940 ns | 274,967.76 |
Естественно, прозводительность рефлексивного компарера ниже самописного. Рефлексия медленна и её API работает с object
'ами, что незамедлительно сказывается на производительности, если в сравниваемых объектах присутствуют типы-значения, из-за необходимости выполнять boxing/unboxing. И тут уже необходимо исходить из собственных потребностей. Если рефлексия используется не часто, то с ней можно жить. Но в конкретном моём случае, рефлексивный компарер был недостаточно быстр. «Давай по новой, Миш», — сказал я себе и принялся за второй вариант.Часть 2. Игра в emitацию
Можно жить так, но лучше ускориться
Группа Ленинград, «Мне бы в небо»Нео: И ты это читаешь?
Сайфер: Приходится. Со временем привыкаешь. Я даже не замечаю код. Я вижу блондинку, брюнетку, рыженькую.
Фильм «Матрица» (The Matrix)
Рефлексия позволяет нам извлечь всю необходимую информацию о типах сравниваемых объектов. Но эти типы мы не можем использовать напрямую для получения нужных нам свойств или вызова требуемого метода, что приводит нас к использованию медленного API рефлексии (PropertyInfo.GetValue, MethodInfo.Invoke и т.д.). А что, если бы мы могли, используя информацию о типах, единожды сгенерировать код сравнения объектов и вызывать его каждый раз, не прибегая более к рефлексии? И, к счастью, мы можем это сделать! Пространство имён System.Reflection.Emit предоставляет нам средства для создания динамических методов — DynamicMethod с помощью генератора IL — ILGenerator. Именно такой же подход используется, к примеру, для компиляции регулярных выражений в самом .NET Framework (реализацию можно посмотреть тут).
О генерации IL на Хабре уже писали. Поэтому лишь вкратце напомним об особенностях IL.
Intermediate Language (IL) представляет собой объектно-ориентированный язык ассемблера, используемый платформой .NET и Mono. Высокоуровневые языки, например, C#, VB.NET, F#, компилируются в IL, а IL в свою очередь компилируется JIT'ом в машинный код. Из-за своего промежуточного положения в этой цепочке язык и имеет своё название. IL использует стековую модель вычислений, то есть все входные и выходные данные передаются через стек, а не через регистры, как во многих процессорных архитектурах. IL поддерживает инструкции загрузки и сохранения локальных переменных и аргументов, преобразование типов, создание и манипулирование объектами, передачу управления, вызов методов, исключения и многие другие.
К примеру, инкрементация целочисленной переменной будет на IL записана следующим образом:
ldloc.0 // Загружаем переменную
ldc.i4.1 // Загружаем единицу
add // Складываем
stloc.0 // Сохраняем результат обратно в переменную
Посмотреть результат комплияции C# в IL можно посмотреть различными средствами, например, ILDasm (утилита, поставляемая вместе с Visual Studio) или ILSpy. Но мой любимый способ — это с помощью замечательного веб-приложения Try Roslyn, написанного Андреем Щёкиным ashmind.
Вернёмся к нашей задаче. Мы опять будем делать делать реализацию интерфейса IEqualityComparer<object>
:
public class DynamicCodeComparer : IEqualityComparer<object>
{
// Делегат для сравнения объектов
private delegate bool Comparer(object x, object y);
// Кэш сгенерированных компареров
private static Dictionary<Type, Comparer> ComparerCache = new Dictionary<Type, Comparer>();
public new bool Equals(object x, object y)
{
// Если ссылки указывают на один и тот же объект
if (ReferenceEquals(x, y)) return true;
// Один из объектов равен null
if (ReferenceEquals(x, null) != ReferenceEquals(y, null)) return false;
Type xType = x.GetType();
// Объекты имеют разные типы
if (xType != y.GetType()) return false;
//
// Проверяем наличие компарера в кэше. Если нет, то создаём его и сохраняем в кэш
//
Comparer comparer;
if (!ComparerCache.TryGetValue(xType, out comparer))
{
ComparerCache[xType] = comparer = new ComparerDelegateGenerator().Generate(xType);
}
return comparer(x, y);
}
public int GetHashCode(object obj)
{
return obj.GetHashCode();
}
}
Метод Equals
использует словарь для поддержки кэша сгенерированных делегатов — компареров, выполняющих сравнение объектов. Если компарера для определённого типа ещё нет в словаре, то мы генерируем новый делегат. Вся логика по динамической генерации делегата будет сосредоточена в классе ComparerDelegateGenerator
.
class ComparerDelegateGenerator
{
// Генератор кода динамического метода
private ILGenerator il;
public Comparer Generate(Type type)
{
// Создаём динамический метод в том же модуле, что и сравниваемый тип
var dynamicMethod = new DynamicMethod("__DynamicCompare", typeof(bool), new[] { typeof(object), typeof(object) },
type.Module);
il = dynamicMethod.GetILGenerator();
//
// Загружаем аргументы и прикастовываем их к типу времени выполнения
//
il.LoadFirstArg();
var arg0 = il.CastToType(type);
il.LoadSecondArg();
var arg1 = il.CastToType(type);
// Сравниваем объекты
CompareObjectsInternal(type, arg0, arg1);
// Если управление дошло до этого места, значит объекты равны
il.ReturnTrue();
// Создаём делегат для выполнения динамического метода
return (Comparer)dynamicMethod.CreateDelegate(typeof(Comparer));
}
}
В методе Generate
выше есть один маленький нюанс. Нюанс состоит в том, чтобы создавать динамический метод в том же модуле, что и тип сравниваемых объектов. В противном случае код динамического метода не сможет получить доступ к типу и получит исключение TypeAccessException
. Класс ILGenerator
позволяет генерировать инструкции с помощью метода Emit(OpCode), принимающего в качестве аргумента код команды. Но, чтобы не засорять наш класс такими деталями мы будем использовать методы расширения, из названия которых будет понятно, что они делают. Код методов расширения LoadFirstArg, LoadSecondArg, CastToType
и ReturnTrue
представлен ниже. Следует пояснить, что вызываемый в Generate
метод CompareObjectsInternal
будет генерировать return false
сразу же, как только встретит отличающиеся значения. Поэтому последним операторам динамического метода будет return true
, чтобы обрабатать ту ситуацию, когда объекты равны.
// Загружает в стек первый аргумент текущего метода
public static void LoadFirstArg(this ILGenerator il) => il.Emit(OpCodes.Ldarg_0);
// Загружает в стек второй аргумент текущего метода
public static void LoadSecondArg(this ILGenerator il) => il.Emit(OpCodes.Ldarg_1);
// Извлекает из стека значение и приводит его к заданному типу
public static LocalBuilder CastToType(this ILGenerator il, Type type)
{
var x = il.DeclareLocal(type);
// В случае типов-значений и примитивных типов выполняем распаковку
if (type.IsValueType || type.IsPrimitive)
{
il.Emit(OpCodes.Unbox_Any, type);
}
// В случае ссылочных типов выполняем приведение
else
{
il.Emit(OpCodes.Castclass, type);
}
il.SetLocal(x);
return x;
}
// Загружает в стек ноль (он же false)
public static void LoadZero(this ILGenerator il) => il.Emit(OpCodes.Ldc_I4_0);
// Загружает в стек единицу (она же true)
public static void LoadOne(this ILGenerator il) => il.Emit(OpCodes.Ldc_I4_1);
// Возвращает из метода значение false
public static void ReturnFalse(this ILGenerator il)
{
il.LoadZero();
il.Emit(OpCodes.Ret);
}
// Возвращает из метода значение true
public static void ReturnTrue(this ILGenerator il)
{
il.LoadOne();
il.Emit(OpCodes.Ret);
}
Далее рассмотрим метод CompareObjectsInternal
, который будет генерировать различный код сравнения в зависимости от типа объектов:
private void CompareObjectsInternal(Type type, LocalBuilder x, LocalBuilder y)
{
// Объявляем метку, на которую будем прыгать в случае, если объекты равны
var whenEqual = il.DefineLabel();
// Если объекты не являются типами-значений
if (!type.IsValueType)
{
// Тут же возвращаем true, если ссылки равны между собой
JumpIfReferenceEquals(x, y, whenEqual);
// Если один из объектов равен null, а второй нет возвращаем false
ReturnFalseIfOneIsNull(x, y);
// Массивы
if (type.IsArray)
{
CompareArrays(type.GetElementType(), x, y);
}
// Классы или интерфейсы
else if (type.IsClass || type.IsInterface)
{
// Строки
if (Type.GetTypeCode(type) == TypeCode.String)
{
CompareStrings(x, y, whenEqual);
}
// Коллекции
else if (type.IsImplementIEnumerable())
{
CompareEnumerables(type, x, y);
}
// Любые другие классы или интерфейсы
else
{
CompareAllProperties(type, x, y);
}
}
}
// Обнуляемые типы
else if (type.IsNullable())
{
CompareNullableValues(type, x, y, whenEqual);
}
// Примитивные типы или перечисления
else if (type.IsPrimitive || type.IsEnum)
{
ComparePrimitives(type, x, y, whenEqual);
}
// Структуры
else
{
CompareAllProperties(type, x, y);
}
// Ставим метку, на которую будем прыгать в случае, если объекты равны
il.MarkLabel(whenEqual);
}
Как видно из кода, мы обрабатываем те же ситуации, что и в рефлексивном компарере, но в немного другом порядке. Это связано с тем, что сравнение с null
генерируется только для ссылочных типов, но не для обнуляемых. В случае обнуляемых объектов вместо этого генерируется проверка свойства HasValue
.
Подробно разбирать каждый случай в статье не будем, в конце я дам ссылку на исходники. Но представим код сравнения массивов, чтобы прочувствовать, что из себя представляет разработка в условиях примитивных команд.
private void CompareArrays(Type elementType, LocalBuilder x, LocalBuilder y)
{
var loop = il.DefineLabel(); // Объявляем метку начала цикла сравнения элементов
il.LoadArrayLength(x); // Загружаем длину первого массива
il.LoadArrayLength(y); // Загружаем длину второго массива
il.JumpWhenEqual(loop); // Если длины равны, то переходим к циклу
il.ReturnFalse(); // Иначе возвращаем false
il.MarkLabel(loop); // Отмечаем метку начала цикла
var index = il.DeclareLocal(typeof(int)); // Объявляем счётчик цикла - индекс
var loopCondition = il.DefineLabel(); // Объявляем метку на проверку условия выхода из цикла
var loopBody = il.DefineLabel(); // Объявляем метку на тело цикла
il.LoadZero(); //
il.SetLocal(index); // Обнуляем индекс
il.Jump(loopCondition); // Прыгаем на проверку условия цикла
il.MarkLabel(loopBody); // Отмечаем начало тела цикла
{
var xElement = il.GetArrayElement(elementType, x, index); // Получаем элемент первого массива
var yElement = il.GetArrayElement(elementType, y, index); // Получаем элемент второго массива
CompareObjectsInternal(elementType, xElement, yElement); // Сравниваем элементы
il.Increment(index); // Увеличиваем счётчик
}
il.MarkLabel(loopCondition); // Отмечаем метку проверки условия выхода из цикла
{
il.LoadLocal(index); // Загружаем текущее значение индекса
il.LoadArrayLength(x); // Загружаем длину массива
il.JumpWhenLess(loopBody); // Если индекс не вышел за пределы диапазона, то прыгаем в тело цикла
}
}
Как мы уже отмечали выше, чтобы не травмировать психику засорять исходный текст кодами команд, мы будем использовать метода расширения. Поэтому представленный выше код выглядит не так уж и страшно. Как можно заметить в самом начале CompareArrays
сравнивает длины массив и переходит к дальнейшему сравнению только в том случае, если они равны. Далее выполняется цикл поэлементного сравнения массивов, но так как в IL нет таких высокоуровневых операторов, как циклы, то мы определяем в нашем коде несколько базовых блоков: инициализацию счётчика, тело и проверку условия выхода из цикла. Переход же между этими блоками осуществляется с помощью инструкций условного перехода (Jump, JumpWhenLess
) на метки loopBody, loopCondition
.
// Загружает в стек значение заданной переменной
public static void LoadLocal(this ILGenerator il, LocalBuilder x) => il.Emit(OpCodes.Ldloc, x);
// Извлекает из стека значение и присваивает его заданной переменной
public static void SetLocal(this ILGenerator il, LocalBuilder x) => il.Emit(OpCodes.Stloc, x);
// Загружает в стек длину заданного массива
public static void LoadArrayLength(this ILGenerator il, LocalBuilder array)
{
il.LoadLocal(array);
il.Emit(OpCodes.Ldlen);
il.Emit(OpCodes.Conv_I4);
}
// Извлекает из стека массив и индекса, а загружает в стек элемент массива с заданным индексом
public static void LoadArrayElement(this ILGenerator il, Type type)
{
if (type.IsEnum)
{
type = Enum.GetUnderlyingType(type);
}
if (type.IsPrimitive)
{
if (type == typeof (IntPtr) || type == typeof (UIntPtr))
{
il.Emit(OpCodes.Ldelem_I);
}
else
{
OpCode opCode;
switch (Type.GetTypeCode(type))
{
case TypeCode.Boolean:
case TypeCode.Int32:
opCode = OpCodes.Ldelem_I4;
break;
case TypeCode.Char:
case TypeCode.UInt16:
opCode = OpCodes.Ldelem_U2;
break;
case TypeCode.SByte:
opCode = OpCodes.Ldelem_I1;
break;
case TypeCode.Byte:
opCode = OpCodes.Ldelem_U1;
break;
case TypeCode.Int16:
opCode = OpCodes.Ldelem_I2;
break;
case TypeCode.UInt32:
opCode = OpCodes.Ldelem_U4;
break;
case TypeCode.Int64:
case TypeCode.UInt64:
opCode = OpCodes.Ldelem_I8;
break;
case TypeCode.Single:
opCode = OpCodes.Ldelem_R4;
break;
case TypeCode.Double:
opCode = OpCodes.Ldelem_R8;
break;
default:
throw new ArgumentOutOfRangeException();
}
il.Emit(opCode);
}
}
else if (type.IsValueType)
{
il.Emit(OpCodes.Ldelema, type);
}
else
{
il.Emit(OpCodes.Ldelem_Ref);
}
}
// Возвращает новую переменную, содержащую элемент массива с заданным индексом
public static LocalBuilder GetArrayElement(this ILGenerator il, Type elementType, LocalBuilder array, LocalBuilder index)
{
var x = il.DeclareLocal(elementType);
il.LoadLocal(array);
il.LoadLocal(index);
il.LoadArrayElement(elementType);
il.SetLocal(x);
return x;
}
// Увеличивает значение заданной переменной на единицу
public static void Increment(this ILGenerator il, LocalBuilder x)
{
il.LoadLocal(x);
il.LoadOne();
il.Emit(OpCodes.Add);
il.SetLocal(x);
}
Попробуем применить DynamicCodeComparer
к типу SimpleClass
, который мы использовали для сравнения прозводительности рефлексивного компарера:
.method public static
bool __DynamicCompare (
object '',
object ''
) cil managed
{
// Method begins at RVA 0x2050
// Code size 215 (0xd7)
.maxstack 15
.locals init (
[0] class SimpleClass,
[1] class SimpleClass,
[2] int32,
[3] int32,
[4] valuetype Struct,
[5] valuetype Struct,
[6] int32,
[7] int32,
[8] float64,
[9] float64,
[10] string,
[11] string
)
IL_0000: ldarg.0
IL_0001: castclass SimpleClass
IL_0006: stloc.0
IL_0007: ldarg.1
IL_0008: castclass SimpleClass
IL_000d: stloc.1
IL_000e: ldloc.0
IL_000f: ldloc.1
IL_0010: beq IL_00d5
IL_0015: ldloc.0
IL_0016: ldnull
IL_0017: ceq
IL_0019: ldloc.1
IL_001a: ldnull
IL_001b: ceq
IL_001d: beq IL_0024
IL_0022: ldc.i4.0
IL_0023: ret
IL_0024: ldloc.0
IL_0025: callvirt instance int32 SimpleClass::get_A()
IL_002a: stloc.2
IL_002b: ldloc.1
IL_002c: callvirt instance int32 SimpleClass::get_A()
IL_0031: stloc.3
IL_0032: ldloc.2
IL_0033: ldloc.3
IL_0034: beq IL_003b
IL_0039: ldc.i4.0
IL_003a: ret
IL_003b: ldloc.0
IL_003c: callvirt instance valuetype Struct SimpleClass::get_B()
IL_0041: stloc.s 4
IL_0043: ldloc.1
IL_0044: callvirt instance valuetype Struct SimpleClass::get_B()
IL_0049: stloc.s 5
IL_004b: ldloca.s 4
IL_004d: call instance int32 Struct::get_A()
IL_0052: stloc.s 6
IL_0054: ldloca.s 5
IL_0056: call instance int32 Struct::get_A()
IL_005b: stloc.s 7
IL_005d: ldloc.s 6
IL_005f: ldloc.s 7
IL_0061: beq IL_0068
IL_0066: ldc.i4.0
IL_0067: ret
IL_0068: ldloca.s 4
IL_006a: call instance float64 Struct::get_B()
IL_006f: stloc.s 8
IL_0071: ldloca.s 5
IL_0073: call instance float64 Struct::get_B()
IL_0078: stloc.s 9
IL_007a: ldloc.s 8
IL_007c: call bool [mscorlib]System.Double::IsNaN(float64)
IL_0081: ldloc.s 9
IL_0083: call bool [mscorlib]System.Double::IsNaN(float64)
IL_0088: and
IL_0089: brtrue IL_0099
IL_008e: ldloc.s 8
IL_0090: ldloc.s 9
IL_0092: beq IL_0099
IL_0097: ldc.i4.0
IL_0098: ret
IL_0099: ldloca.s 4
IL_009b: call instance string Struct::get_C()
IL_00a0: stloc.s 10
IL_00a2: ldloca.s 5
IL_00a4: call instance string Struct::get_C()
IL_00a9: stloc.s 11
IL_00ab: ldloc.s 10
IL_00ad: ldloc.s 11
IL_00af: beq IL_00d5
IL_00b4: ldloc.s 10
IL_00b6: ldnull
IL_00b7: ceq
IL_00b9: ldloc.s 11
IL_00bb: ldnull
IL_00bc: ceq
IL_00be: beq IL_00c5
IL_00c3: ldc.i4.0
IL_00c4: ret
IL_00c5: ldloc.s 10
IL_00c7: ldloc.s 11
IL_00c9: call instance bool [mscorlib]System.String::Equals(string)
IL_00ce: brtrue IL_00d5
IL_00d3: ldc.i4.0
IL_00d4: ret
IL_00d5: ldc.i4.1
IL_00d6: ret
} // end of method Test::__DynamicCompare
public static bool __DynamicCompare(object obj, object obj2)
{
SimpleClass simpleClass = (SimpleClass)obj;
SimpleClass simpleClass2 = (SimpleClass)obj2;
if (simpleClass != simpleClass2)
{
if (simpleClass == null != (simpleClass2 == null))
{
return false;
}
int a = simpleClass.A;
int a2 = simpleClass2.A;
if (a != a2)
{
return false;
}
Struct b = simpleClass.B;
Struct b2 = simpleClass2.B;
int a3 = b.get_A();
int a4 = b2.get_A();
if (a3 != a4)
{
return false;
}
double b3 = b.get_B();
double b4 = b2.get_B();
if (!(double.IsNaN(b3) & double.IsNaN(b4)) && b3 != b4)
{
return false;
}
string c = b.get_C();
string c2 = b2.get_C();
if (c != c2)
{
if (c == null != (c2 == null))
{
return false;
}
if (!c.Equals(c2))
{
return false;
}
}
}
return true;
}
Декомпилированный метод выглядит достаточно понятно, чтобы проверить, что он корректно выполняет сравнение. Единственное к чему можно было бы придраться, так это обилие временных переменных, но это, как говорится, издержки производства.Результаты
Ниже представлены результаты сравнения производительности DynamicCodeComparer, ReflectionComparer
и написанного вручную сравнения на тех же входных данных, на которых мы проводили наш микробенчмарк для рефлексивного компарера. Как видно генерация IL позволяет получить гораздо более эффективную реализацию по сравнению с рефлексией.
public struct Struct
{
private int m_a;
private double m_b;
private string m_c;
public int A => m_a;
public double B => m_b;
public string C => m_c;
public Struct(int a, double b, string c)
{
m_a = a;
m_b = b;
m_c = c;
}
}
public class SimpleClass
{
public int A { get; set; }
public Struct B { get; set; }
}
public class ComplexClass
{
public int A { get; set; }
public IntPtr B { get; set; }
public UIntPtr C { get; set; }
public string D { get; set; }
public SimpleClass E { get; set; }
public int? F { get; set; }
public int[] G { get; set; }
public List<int> H { get; set; }
public double I { get; set; }
public float J { get; set; }
}
[BenchmarkTask(platform: BenchmarkPlatform.X86, jitVersion: BenchmarkJitVersion.LegacyJit)]
[BenchmarkTask(platform: BenchmarkPlatform.X64, jitVersion: BenchmarkJitVersion.LegacyJit)]
[BenchmarkTask(platform: BenchmarkPlatform.X64, jitVersion: BenchmarkJitVersion.RyuJit)]
public class ComplexComparisonTest
{
private static int[] MakeArray(int count)
{
var array = new int[count];
for (int i = 0; i < array.Length; ++i)
array[i] = i;
return array;
}
private static List<int> MakeList(int count)
{
var list = new List<int>(count);
for (int i = 0; i < list.Count; ++i)
list.Add(i);
return list;
}
private ComplexClass x = new ComplexClass
{
A = 2,
B = new IntPtr(2),
C = new UIntPtr(2),
D = "Habrahabr!",
E = new SimpleClass { A = 42, B = new Struct(42, 3.14, "meow") },
F = 1,
G = MakeArray(100),
H = MakeList(100),
I = double.MaxValue,
J = float.MaxValue
};
private ComplexClass y = new ComplexClass
{
A = 2,
B = new IntPtr(2),
C = new UIntPtr(2),
D = "Habrahabr!",
E = new SimpleClass { A = 42, B = new Struct(42, 3.14, "meow") },
F = 1,
G = MakeArray(100),
H = MakeList(100),
I = double.MaxValue,
J = float.MaxValue
};
private ReflectionComparer reflectionComparer = new ReflectionComparer();
private DynamicCodeComparer dynamicCodeComparer = new DynamicCodeComparer();
[Benchmark]
public void ReflectionCompare()
{
var _ = reflectionComparer.Equals(x, y);
}
[Benchmark]
public void DynamicCodeCompare()
{
var _ = dynamicCodeComparer.Equals(x, y);
}
[Benchmark]
public void ManualCompare()
{
var _ = CompareComplexObjects();
}
private bool CompareComplexObjects()
{
if (x == y) return true;
if (x.A != y.A) return false;
if (x.B != y.B) return false;
if (x.C != y.C) return false;
if (x.D != y.D) return false;
if (x.E != y.E)
{
if (x.E.A != y.E.A) return false;
var s1 = x.E.B;
var s2 = y.E.B;
if (s1.A != s2.A) return false;
if (!s1.B.Equals(s2.B)) return false;
if (s1.C != s2.C) return false;
}
if (x.F != y.F) return false;
if (x.G != y.G)
{
if (x.G?.Length != y.G?.Length) return false;
int[] a = x.G, b = y.G;
for (int i = 0; i < a.Length; ++i)
{
if (a[i] != b[i]) return false;
}
}
if (x.H != y.H)
{
if (!x.H.SequenceEqual(y.H)) return false;
}
if (!x.I.Equals(y.I)) return false;
if (!x.J.Equals(y.J)) return false;
return true;
}
}
[BenchmarkTask(platform: BenchmarkPlatform.X86, jitVersion: BenchmarkJitVersion.LegacyJit)]
[BenchmarkTask(platform: BenchmarkPlatform.X64, jitVersion: BenchmarkJitVersion.LegacyJit)]
[BenchmarkTask(platform: BenchmarkPlatform.X64, jitVersion: BenchmarkJitVersion.RyuJit)]
public class SimpleComparisonTest
{
private SimpleClass x = new SimpleClass { A = 42, B = new Struct(42, 3.14, "meow") };
private SimpleClass y = new SimpleClass { A = 42, B = new Struct(42, 3.14, "meow") };
private ReflectionComparer reflectionComparer = new ReflectionComparer();
private DynamicCodeComparer dynamicCodeComparer = new DynamicCodeComparer();
[Benchmark]
public void ReflectionCompare()
{
var _ = reflectionComparer.Equals(x, y);
}
[Benchmark]
public void DynamicCodeCompare()
{
var _ = dynamicCodeComparer.Equals(x, y);
}
[Benchmark]
public void ManualCompare()
{
var _ = CompareSimpleObjects();
}
private bool CompareSimpleObjects()
{
if (x == y) return true;
if (x.A != y.A) return false;
var s1 = x.B;
var s2 = y.B;
if (s1.A != s2.A) return false;
if (!s1.B.Equals(s2.B)) return false;
if (s1.C != s2.C) return false;
return true;
}
}
Результаты сравнения объектов ComplexClass
Method | Platform | Jit | AvrTime | StdDev | op/s |
---|---|---|---|---|---|
DynamicCodeComparer | X64 | LegacyJit | 1,104.7155 ns | 32.9474 ns | 905,210.51 |
Handwritten | X64 | LegacyJit | 1,360.3273 ns | 39.9703 ns | 735,117.32 |
ReflectionComparer | X64 | LegacyJit | 38,043.3600 ns | 2,261.3159 ns | 26,290.11 |
DynamicCodeComparer | X64 | RyuJit | 834.8742 ns | 58.1986 ns | 1,197,785.93 |
Handwritten | X64 | RyuJit | 968.3789 ns | 33.1622 ns | 1,032,653.82 |
ReflectionComparer | X64 | RyuJit | 37,751.3104 ns | 1,763.3172 ns | 26,489.20 |
DynamicCodeComparer | X86 | LegacyJit | 776.0265 ns | 22.8038 ns | 1,288,615.79 |
Handwritten | X86 | LegacyJit | 915.5713 ns | 26.0536 ns | 1,092,214.32 |
ReflectionComparer | X86 | LegacyJit | 32,382.2746 ns | 1,748.4016 ns | 30,881.10 |
Результаты сравнения объектов SimpleClass
Method | Platform | Jit | AvrTime | StdDev | op/s |
---|---|---|---|---|---|
DynamicCodeComparer | X64 | LegacyJit | 215.7626 ns | 8.2063 ns | 4,634,725.08 |
Handwritten | X64 | LegacyJit | 160.4945 ns | 6.8949 ns | 6,230,741.94 |
ReflectionComparer | X64 | LegacyJit | 6,654.3290 ns | 380.7790 ns | 150,278.15 |
DynamicCodeComparer | X64 | RyuJit | 168.4194 ns | 9.4654 ns | 5,937,569.56 |
Handwritten | X64 | RyuJit | 87.8513 ns | 3.3118 ns | 11,382,874.20 |
ReflectionComparer | X64 | RyuJit | 6,954.6437 ns | 387.1803 ns | 143,789.85 |
DynamicCodeComparer | X86 | LegacyJit | 180.4105 ns | 6.5036 ns | 5,542,914.59 |
Handwritten | X86 | LegacyJit | 93.0846 ns | 4.0584 ns | 10,742,923.17 |
ReflectionComparer | X86 | LegacyJit | 6,431.5783 ns | 314.5633 ns | 155,483.09 |
Заключение
Фред Брукс в своей знаменитой статье «No silver bullet» подчёркивает разницу между ненужными случайными сложностями (accidental complexity) и имманентными сложностями (essential complexity), внутренне присущими самой решаемой задаче. Использование рефлексии зачастую является примером такой ненужной сложности, возникшей из-за того, что в какой-то момент дизайну программной системы не было уделено достаточно внимания. Поэтому прежде чем кидаться скорее использовать рефексию или кодогенерацию проверьте, может быть ещё не поздно подкорректировать проектное решение. Конкретно в моём случае сравнение могло было бы выполняться одной строчкой, если бы все сравниваемые объекты реализовывали бы интерфейс IEquatable<T>
. Тем не менее я получил решение, которое возможно кому-нибудь ещё окажется полезным.
Засим откланиваюсь, дорогие читатели. Приятного программирования!
Полезные ресурсы: