C# 的 class 默认按引用比较——即使两个对象的每个字段完全相同,== 和 Equals 也会返回 false。想解决这个问题,通常需要在每个类里手写 Equals 和 GetHashCode,代码一多就很烦。
作者 Tore Aurstad 实现了一个 GenericEqualityComparer<T>,利用反射发现成员、再把访问器编译成委托缓存起来,一次初始化后就能高效地按值比较任意类。本文基于他的原文和 GitHub 仓库,完整介绍这个工具的设计、用法和适用场景。
源码仓库:https://github.com/toreaurstadboss/GenericEqualityComparer
问题背景
var car1 = new Car { Make = "Toyota", Model = "Camry", Year = 2020 }; var car2 = new Car { Make = "Toyota", Model = "Camry", Year = 2020 }; Console.WriteLine(car1 == car2); // False — 不同引用 Console.WriteLine(car1.Equals(car2)); // False — 同上两个内容完全相同的 Car 实例被判定为不相等,因为 class 的默认相等语义是引用相等。
record 和 struct 已经内置了值语义,但对于不能改动的第三方类、大量 POCO/DTO、或者只想快速加一层值比较的场景,重新实现一遍 Equals/GetHashCode 代价不小。
GenericEqualityComparer 的核心思路
GenericEqualityComparer<T> 实现 IEqualityComparer<T>,在构造时通过反射收集 T 的成员,再把每个成员的访问器编译成 Func<T, object> 委托并缓存。后续的每次 Equals 调用都走委托而非反射,开销可控。
public class GenericEqualityComparer<T> : IEqualityComparer<T> where T : class { private List<Func<T, object>> _propertyGetters = new(); private List<Func<T, object>> _fieldGetters = new(); public GenericEqualityComparer( bool includeFields = false, bool includePrivateProperties = false, bool includePrivateFields = false) { CreatePropertyGetters(includePrivateProperties); if (includeFields || includePrivateFields) { CreateFieldGetters(includePrivateFields); } } private void CreatePropertyGetters(bool includePrivateProperties) { var bindingFlags = BindingFlags.Instance | BindingFlags.Public; if (includePrivateProperties) { bindingFlags |= BindingFlags.NonPublic; } var props = typeof(T).GetProperties(bindingFlags) .Where(m => m.GetMethod != null).ToList(); foreach (var prop in props) { ParameterExpression parameter = Expression.Parameter(typeof(T), "p"); MemberExpression propertyExpression = Expression.Property(parameter, prop.Name); Expression boxed = Expression.Convert(propertyExpression, typeof(object)); Expression<Func<T, object>> getter = Expression.Lambda<Func<T, object>>(boxed, parameter); _propertyGetters.Add(getter.Compile()); } } // CreateFieldGetters 结构类似,改用 Expression.Field }Equals 方法先处理空值和引用相等的快捷路径,再逐一用委托取出属性/字段值并比较:
public bool Equals(T? x, T? y) { if (x == null || y == null) return false; if (ReferenceEquals(x, y)) return true; if (x.GetType() != y.GetType()) return false; foreach (var accessor in _propertyGetters) { if (!accessor(x).Equals(accessor(y))) return false; } foreach (var accessor in _fieldGetters) { if (!accessor(x).Equals(accessor(y))) return false; } return true; }GetHashCode 用 HashCode.Combine 把所有成员值合并成一个哈希,每次最多 8 个以符合 HashCode.Combine 的参数限制。
快速上手
比较公有属性
var comparer = new GenericEqualityComparer<Car>(); var car1 = new Car { Make = "Toyota", Model = "Camry", Year = 2020 }; var car2 = new Car { Make = "Toyota", Model = "Camry", Year = 2020 }; var car3 = new Car { Make = "Toyota", Model = "Corolla", Year = 2020 }; Console.WriteLine(comparer.Equals(car1, car2)); // True — 所有属性匹配 Console.WriteLine(comparer.Equals(car1, car3)); // False — Model 不同与 LINQ 或集合配合
因为 GenericEqualityComparer<T> 实现了 IEqualityComparer<T>,可以直接传给 LINQ 方法:
var cars = new List<Car> { new Car { Make = "Toyota", Model = "Camry", Year = 2020 }, new Car { Make = "Toyota", Model = "Camry", Year = 2020 }, // 重复 new Car { Make = "Toyota", Model = "Corolla", Year = 2021 }, }; var comparer = new GenericEqualityComparer<Car>(); var unique = cars.Distinct(comparer).ToList(); // 2 项 var grouped = cars.GroupBy(c => c, comparer);构造参数说明
includeFields | ||
includePrivateProperties | ||
includePrivateFields |
三个参数默认均为 false,即只比较公有实例属性。
场景:包含私有字段
public class Car { public string Make { get; set; } = string.Empty; public string Model { get; set; } = string.Empty; public int Year { get; set; } private string _secretAssemblyNumber = string.Empty; public void SetSecretAssemblyNumber(string number) => _secretAssemblyNumber = number; } var ford1 = new Car { Make = "Ford", Model = "Focus", Year = 2022 }; var ford2 = new Car { Make = "Ford", Model = "Focus", Year = 2022 }; ford1.SetSecretAssemblyNumber("ASM-001"); ford2.SetSecretAssemblyNumber("ASM-999"); var defaultComparer = new GenericEqualityComparer<Car>(); Console.WriteLine(defaultComparer.Equals(ford1, ford2)); // True(忽略私有字段) var deepComparer = new GenericEqualityComparer<Car>(includePrivateFields: true); Console.WriteLine(deepComparer.Equals(ford1, ford2)); // False(检测到差异)场景:包含私有属性
var defaultComparer = new GenericEqualityComparer<Bicycle>(); Console.WriteLine(defaultComparer.Equals(bike1, bike2)); // True var deepComparer = new GenericEqualityComparer<Bicycle>(includePrivateProperties: true); Console.WriteLine(deepComparer.Equals(bike1, bike2)); // False
EqualityWrapper 和 == / != 操作符
C# 不允许在外部比较器类中重载泛型类型参数 T 的 == / !=。为此,GenericEqualityComparer<T> 提供了 For(value) 方法,返回一个 EqualityWrapper<T> 结构体。这个 wrapper 同时持有值和比较器,因此它的 == / != 会委托给比较器,而不是走引用相等。
public readonly struct EqualityWrapper<T> where T : class { private readonly T _value; private readonly GenericEqualityComparer<T> _comparer; internal EqualityWrapper(T value, GenericEqualityComparer<T> comparer) { _value = value; _comparer = comparer; } public static bool operator ==(EqualityWrapper<T> left, EqualityWrapper<T> right) => left._comparer.Equals(left._value, right._value); public static bool operator !=(EqualityWrapper<T> left, EqualityWrapper<T> right) => !(left == right); public override int GetHashCode() => _comparer.GetHashCode(_value); }基础操作符用法
var comparer = new GenericEqualityComparer<Car>(); bool same = comparer.For(car1) == comparer.For(car2); // True bool different = comparer.For(car1) != comparer.For(car3); // True
检测私有成员差异
var deepComparer = new GenericEqualityComparer<Car>(includePrivateFields: true); if (deepComparer.For(ford1) != deepComparer.For(ford2)) { Console.WriteLine("Cars differ (private field detected)"); }一致的哈希
EqualityWrapper<T> 重写了 GetHashCode(),与 == 保持一致,因此可以安全用作字典键或放入 HashSet:
int hash1 = comparer.For(car1).GetHashCode(); int hash2 = comparer.For(car2).GetHashCode(); Console.WriteLine(hash1 == hash2); // True — 相等对象,哈希相等
使用总结
new GenericEqualityComparer<T>() | |
new GenericEqualityComparer<T>(includeFields: true) | |
new GenericEqualityComparer<T>(includePrivateProperties: true) | |
new GenericEqualityComparer<T>(includePrivateFields: true) | |
== / != | comparer.For(a) == comparer.For(b) |
list.Distinct(comparer)list.GroupBy(x => x, comparer) |
不适合的场景
性能敏感热路径
:初始化时编译委托有一次开销,每次调用也比手写实现稍慢,不适合写入紧密循环或高频调用。 record 和 struct
:内置值语义,直接用 ==就够了。你完全掌控的类
:首选显式实现 Equals/GetHashCode或IEquatable<T>,更可读,性能更好。
这个工具最合适的场景是:第三方类无法修改、大量自动生成的 POCO / DTO 需要快速加值比较、或者在测试代码中验证两个对象是否"内容一致"。
框架兼容性说明
当前实现依赖 HashCode.Combine,要求 .NET Standard 2.1 或 .NET Core 2.1 及更高版本。如果目标框架是 .NET Framework 4.8 或更早,可以用两个质数(初始值 17,乘数 31)自己实现 GetHashCode:
private static int Combine(params object[] values) { unchecked { int hash = 17; foreach (var v in values) { int h = v?.GetHashCode() ?? 0; hash = hash * 31 + h; } return hash; } }选 17 和 31 是为了在属性数量和值分布上提供足够的哈希扩散,避免对称值 (a, b) 与 (b, a) 产生相同哈希。