Skip to content

Instantly share code, notes, and snippets.

@overing
Last active October 26, 2023 02:25
Show Gist options
  • Save overing/57da080d07a79ade1a671633509c52c6 to your computer and use it in GitHub Desktop.
Save overing/57da080d07a79ade1a671633509c52c6 to your computer and use it in GitHub Desktop.
物件值比較器, 0 GC Alloc, 比 Json 比法快了最少 16x, 亦可調整成比較 field 而不是 property, 寫再多成員都只要呼叫 Comparer.IsEqual(x, y) 即可
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using static System.Linq.Expressions.Expression;
public static class Comparer
{
static readonly Dictionary<Type, Func<object, object, bool>> s_cache = new();
public static bool IsEqual(object? x, object? y)
{
if ((x == default && y != default) || (x != default && y == default))
return false;
if (ReferenceEquals(x, y))
return true;
var type = x!.GetType();
if (type != y!.GetType())
return false;
if (!s_cache.TryGetValue(type, out var func))
{
lock (s_cache)
{
if (!s_cache.TryGetValue(type, out func))
{
func = Shareds.Build(type);
s_cache[type] = func;
}
}
}
return func(x, y);
}
}
public sealed class Comparer<T> : IEqualityComparer<T>
{
static readonly Func<T?, T?, bool> s_equalFunc = Shareds.Build<T>();
public static Comparer<T> Default { get; } = new();
public bool IsEqual(T? x, T? y) => s_equalFunc(x, y);
bool IEqualityComparer<T>.Equals(T? x, T? y) => s_equalFunc(x, y);
int IEqualityComparer<T>.GetHashCode(T obj) => throw new NotSupportedException();
}
static class Shareds
{
static readonly MethodInfo s_sequenceEqualMethod =
typeof(Shareds).GetMethod(nameof(SequenceEqual), BindingFlags.Static | BindingFlags.NonPublic)!;
static readonly MethodInfo s_referenceEqualsMethod = typeof(object).GetMethod(nameof(object.ReferenceEquals))!;
static readonly MethodInfo s_getTypeMethod = typeof(object).GetMethod(nameof(object.GetType))!;
static readonly LabelTarget s_returnTarget = Label(typeof(bool));
static readonly Expression s_returnTrue = Return(s_returnTarget, Constant(true));
static readonly Expression s_returnFalse = Return(s_returnTarget, Constant(false));
static readonly Expression s_returnLabel = Label(s_returnTarget, Constant(false));
static bool SequenceEqual<TElement>(IEnumerable<TElement> x, IEnumerable<TElement> y, IEqualityComparer<TElement> c)
=> Enumerable.SequenceEqual(x, y, c);
static void GetComparer(Type type, out Expression comparer, out MethodInfo method)
{
var comparerType = typeof(Comparer<>).MakeGenericType(type);
var comparerProperty = comparerType.GetProperty(nameof(Comparer<object>.Default))!;
comparer = Property(null, comparerProperty);
method = comparerType.GetMethod(nameof(Comparer<object>.IsEqual), new[] { type, type })!;
}
static Expression ComparePublicInstanceProperties(Type targetType, Expression xParam, Expression yParam)
=> targetType.GetProperties(BindingFlags.Instance | BindingFlags.Public)
.Where(p => p.GetGetMethod() is not null)
.Aggregate(s_returnTrue, (previous, property) =>
{
GetComparer(property.PropertyType, out var comparer, out var method);
return IfThen(Call(comparer, method, Property(xParam, property), Property(yParam, property)), previous);
});
static Expression CompareAllInstanceFields(Type targetType, Expression xParam, Expression yParam)
=> targetType.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
.Aggregate(s_returnTrue, (previous, field) =>
{
GetComparer(field.FieldType, out var comparer, out var method);
return IfThen(Call(comparer, method, Field(xParam, field), Field(yParam, field)), previous);
});
public static Func<T?, T?, bool> Build<T>()
{
if (typeof(IEquatable<T>).IsAssignableFrom(typeof(T)) &&
EqualityComparer<T>.Default is EqualityComparer<T> defaultComparer)
return defaultComparer.Equals;
var type = typeof(T);
var (xParam, yParam) = (Parameter(type), Parameter(type));
Expression compare;
if (type.GetGenericArguments().ElementAtOrDefault(0) is Type elementType &&
typeof(IEnumerable<>).MakeGenericType(elementType) is Type enumerableType &&
enumerableType.IsAssignableFrom(type))
{
GetComparer(elementType, out var comparer, out _);
var sequenceEqual = s_sequenceEqualMethod.MakeGenericMethod(elementType);
var callSequenceEqual = Call(null, sequenceEqual, xParam, yParam, comparer);
compare = IfThen(callSequenceEqual, s_returnTrue);
}
else
// compare = CompareAllInstanceFields(type, xParam, yParam);
compare = ComparePublicInstanceProperties(type, xParam, yParam);
var getType = s_getTypeMethod;
var compareType = Equal(Call(xParam, getType), Call(yParam, getType));
compare = IfThenElse(IsFalse(compareType), s_returnFalse, compare);
var callReferenceEquals = Call(null, s_referenceEqualsMethod, xParam, yParam);
compare = IfThenElse(callReferenceEquals, s_returnTrue, compare);
var xIsDefault = Equal(xParam, Default(type));
var yIsDefault = Equal(yParam, Default(type));
var onlyXIsDefault = AndAlso(xIsDefault, IsFalse(yIsDefault));
var onlyYIsDefault = AndAlso(IsFalse(xIsDefault), yIsDefault);
var themOneIsDefault = OrElse(onlyXIsDefault, onlyYIsDefault);
compare = IfThenElse(themOneIsDefault, s_returnFalse, compare);
return Lambda<Func<T?, T?, bool>>(Block(compare, s_returnLabel), new[] { xParam, yParam }).Compile();
}
public static Func<object?, object?, bool> Build(Type type)
{
GetComparer(type, out var comparer, out var method);
var (xParam, yParam) = (Parameter(typeof(object)), Parameter(typeof(object)));
var compare = Call(comparer, method, Convert(xParam, type), Convert(yParam, type));
return Lambda<Func<object?, object?, bool>>(compare, xParam, yParam).Compile();
}
}
/*
// * Summary *
BenchmarkDotNet=v0.13.5, OS=Windows 11 (10.0.22621.2428/22H2/2022Update/SunValley2)
12th Gen Intel Core i5-12400, 1 CPU, 12 logical and 6 physical cores
.NET SDK=6.0.413
[Host] : .NET 6.0.21 (6.0.2123.36311), X64 RyuJIT AVX2
DefaultJob : .NET 6.0.21 (6.0.2123.36311), X64 RyuJIT AVX2
| Method | Mean | Error | StdDev | Ratio | RatioSD | Rank | Gen0 | Allocated | Alloc Ratio |
|----------------------- |----------:|---------:|---------:|------:|--------:|-----:|-------:|----------:|------------:|
| CompareJsonTyped | 490.20 ns | 7.199 ns | 6.734 ns | 1.00 | 0.00 | 4 | 0.0792 | 752 B | 1.00 |
| CompareJsonAnon | 446.05 ns | 8.433 ns | 7.888 ns | 0.91 | 0.02 | 3 | 0.1206 | 1136 B | 1.51 |
| CompareExpressionTyped | 29.49 ns | 0.269 ns | 0.239 ns | 0.06 | 0.00 | 2 | - | - | 0.00 |
| CompareExpressionAnon | 23.62 ns | 0.379 ns | 0.355 ns | 0.05 | 0.00 | 1 | - | - | 0.00 |
*/
using System;
using BenchmarkDotNet.Attributes;
namespace Benchmarks
{
[MemoryDiagnoser]
[RankColumn]
public class TestDynamicComparer
{
public static void Main(string[] args) => BenchmarkDotNet.Running.BenchmarkRunner.Run<TestDynamicComparer>();
Sample? obj1;
Sample? obj2;
object? anon1;
object? anon2;
[GlobalSetup]
public void Setup()
{
obj1 = new Sample { IntVal = 1, Text = "c0", Id = Guid.Empty, EndTime = DateTime.UnixEpoch };
obj2 = new Sample { IntVal = 1, Text = string.Join("", 'c', '0'), EndTime = DateTime.UnixEpoch };
anon1 = new { v = 3, o = new { d = "c0" } };
anon2 = new { v = 2 + 1, o = new { d = "c0" } };
}
[Benchmark(Baseline = true)]
public bool CompareJsonTyped() => JsonSerializer.Serialize(obj1) == JsonSerializer.Serialize(obj2);
[Benchmark]
public bool CompareJsonAnon() => JsonSerializer.Serialize(anon1) == JsonSerializer.Serialize(anon2);
[Benchmark]
public bool CompareExpressionTyped() => Comparer.IsEqual(obj1, obj2);
[Benchmark]
public bool CompareExpressionAnon() => Comparer.IsEqual(anon1, anon2);
}
}
using System;
using System.Collections.Generic;
var obj1 = new Sample { IntVal = 1, Text = "c0", Id = Guid.Empty, EndTime = DateTime.UnixEpoch };
var obj2 = new Sample { IntVal = 1, Text = string.Join("", 'c', '0'), EndTime = DateTime.UnixEpoch };
var obj3 = new Sample { };
var obj4 = default(Sample);
var anon1 = new { v = 3, o = new { d = obj1.Text } };
var anon2 = new { v = 2 + 1, o = new { d = "c0" } };
var list1 = new List<ushort> { 1, 3, 5, 7 };
var list2 = new List<ushort> { 2, 4, 6, 8 };
var list3 = list2.ConvertAll(i => (ushort)(i - 1));
var array1 = new ushort[] { 1, 3, 5, 7 };
var unixEpochManual = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
Action<object> output = Console.WriteLine;
Func<object?, object?, bool> equals = Comparer.IsEqual;
output("\n===== Typed =====");
output($"equals(obj1, obj1): {equals(obj1, obj1)}"); // true
output($"equals(obj1, obj2): {equals(obj1, obj2)}"); // true
output($"equals(obj1, obj3): {equals(obj1, obj3)}"); // false
output($"equals(obj1, obj4): {equals(obj1, obj4)}"); // false
output($"equals(obj2, obj2): {equals(obj2, obj2)}"); // true
output($"equals(obj2, obj3): {equals(obj2, obj3)}"); // false
output($"equals(obj2, obj4): {equals(obj2, obj4)}"); // false
output($"equals(obj3, obj3): {equals(obj3, obj3)}"); // true
output($"equals(obj3, obj4): {equals(obj3, obj4)}"); // false
output($"equals(obj4, obj4): {equals(obj4, obj4)}"); // true
output("\n===== Struct =====");
output($"equals(1f / 8f, .125f): {equals(1f / 8f, .125f)}"); // true
output($"equals(DateTime.UnixEpoch, unixEpochManual): {equals(DateTime.UnixEpoch, unixEpochManual)}"); // true
output($"equals(MathF.PI, Math.PI): {equals(MathF.PI, Math.PI)}"); // false, 位數精度不同
output($"equals((a: 74737, b: 'msg'), (74737, 'msg')): {equals((a: 74737, b: "msg"), (74737, "msg"))}"); // true
output("\n===== Anonymous class =====");
output($"equals(anon1, anon2): {equals(anon1, anon2)}"); // true
output($"equals(new {{ s = 3 }}, new {{ s = 0 }}): {equals(new { s = 3 }, new { s = 0 })}"); // false
output("\n===== Collection =====");
output($"equals(list1, list1): {equals(list1, list1)}"); // true
output($"equals(list1, list2): {equals(list1, list2)}"); // false
output($"equals(list1, list3): {equals(list1, list3)}"); // true
output($"equals(list1, list2): {equals(list1, array1)}"); // false
output("\n(press any key to exit)");
Console.ReadKey();
public sealed class Sample
{
public int IntVal { get; set; }
public string? Text { get; set; }
public Guid Id { get; set; }
public DateTime EndTime { get; init; }
}
@overing
Copy link
Author

overing commented Oct 25, 2023

image

看起來大部分的狀況都搞定了 :D

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment