
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 | bool | 包含公有实例字段 |
includePrivateProperties | bool | 包含私有实例属性 |
includePrivateFields | bool | 包含私有实例字段(同时开启公有字段) |
三个参数默认均为 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) |
| 与 LINQ 配合 | 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) 产生相同哈希。