在C#中,值类型的默认相等性判断逻辑是逐字段比较,但当我们的自定义值类型包含复杂逻辑,或者需要忽略某些字段进行相等性判断时,就需要手动重写Equals和GetHashCode方法来实现符合业务需求的判断规则。
为什么需要重写Equals和GetHashCode
System.ValueType作为所有值类型的基类,已经重写了Equals方法,实现了逐字段比较的逻辑,但这种默认实现存在两个问题。首先是性能问题,默认实现会使用反射来获取类型的所有字段,反射操作会带来额外的性能开销。其次是灵活性问题,如果我们的自定义值类型有特殊的相等判断规则,比如只需要比较部分字段,默认实现就无法满足需求。
而GetHashCode方法的作用是生成对象的哈希值,当值类型实例被用作字典的键或者放入哈希集合时,哈希值的正确性直接影响这些集合的正常工作。如果只重写Equals而不重写GetHashCode,会导致两个相等的实例生成不同的哈希值,进而引发哈希集合的判断错误。
重写Equals方法的核心规则
重写Equals方法需要遵循以下核心规则,保证逻辑的正确性和一致性:
- 自反性:实例与自身比较必须返回true,即x.Equals(x) == true
- 对称性:如果x.Equals(y)返回true,那么y.Equals(x)也必须返回true
- 传递性:如果x.Equals(y)和y.Equals(z)都返回true,那么x.Equals(z)也必须返回true
- 一致性:如果两个实例的状态没有被修改,多次调用Equals的结果必须一致
- 与null比较必须返回false:值类型不能为null,所以重写时需要处理传入参数为null的情况
Equals方法的正确重写步骤
我们以自定义的坐标值类型Point为例,需求是两个Point的X和Y字段都相等时,判定两个实例相等,下面是正确的重写实现:
using System;
public struct Point : IEquatable<Point>
{
public int X { get; }
public int Y { get; }
public Point(int x, int y)
{
X = x;
Y = y;
}
// 重写Object.Equals方法
public override bool Equals(object obj)
{
// 值类型obj不可能是null,所以只需要判断类型是否匹配
if (!(obj is Point))
{
return false;
}
// 转换为Point类型后调用泛型版本的Equals
return Equals((Point)obj);
}
// 实现IEquatable<Point>接口的泛型Equals方法,避免装箱操作
public bool Equals(Point other)
{
// 比较所有需要参与相等性判断的字段
return X == other.X && Y == other.Y;
}
}
这里额外实现了IEquatable<Point>接口,定义泛型版本的Equals方法,这样可以避免值类型在比较时发生装箱操作,提升性能。当调用Equals(object)方法时,内部会转换为调用泛型版本,保证逻辑统一。
重写GetHashCode方法的核心规则
GetHashCode的重写需要遵循一个核心原则:如果两个实例通过Equals方法判断相等,那么它们的GetHashCode返回值必须相等。反之则不要求,不同的实例可以返回相同的哈希值,但应该尽量降低哈希冲突的概率。
常见的哈希值计算方式是组合所有参与相等性判断的字段的哈希值,使用乘法和异或运算组合,避免简单的加法导致更多冲突。比如上面的Point类型,GetHashCode可以这样实现:
public struct Point : IEquatable<Point>
{
public int X { get; }
public int Y { get; }
public Point(int x, int y)
{
X = x;
Y = y;
}
public override bool Equals(object obj)
{
if (!(obj is Point))
{
return false;
}
return Equals((Point)obj);
}
public bool Equals(Point other)
{
return X == other.X && Y == other.Y;
}
// 重写GetHashCode方法
public override int GetHashCode()
{
// 组合X和Y的哈希值,使用质数相乘减少冲突
unchecked
{
int hash = 17;
hash = hash * 23 + X.GetHashCode();
hash = hash * 23 + Y.GetHashCode();
return hash;
}
}
}
这里使用unchecked关键字是因为哈希计算过程中可能出现整数溢出,而溢出在哈希计算中是允许的,不需要抛出异常。17和23是常用的质数,用来降低不同字段组合产生相同哈希值的概率。
完整示例与验证
下面是完整的Point类型代码,以及验证相等性判断逻辑的测试代码:
using System;
using System.Collections.Generic;
public struct Point : IEquatable<Point>
{
public int X { get; }
public int Y { get; }
public Point(int x, int y)
{
X = x;
Y = y;
}
public override bool Equals(object obj)
{
if (!(obj is Point))
{
return false;
}
return Equals((Point)obj);
}
public bool Equals(Point other)
{
return X == other.X && Y == other.Y;
}
public override int GetHashCode()
{
unchecked
{
int hash = 17;
hash = hash * 23 + X.GetHashCode();
hash = hash * 23 + Y.GetHashCode();
return hash;
}
}
// 可选:重写==和!=运算符,让值类型的相等判断更符合使用习惯
public static bool operator ==(Point left, Point right)
{
return left.Equals(right);
}
public static bool operator !=(Point left, Point right)
{
return !(left == right);
}
}
class Program
{
static void Main()
{
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
Point p3 = new Point(3, 4);
// 验证Equals判断
Console.WriteLine(p1.Equals(p2)); // 输出True
Console.WriteLine(p1.Equals(p3)); // 输出False
// 验证哈希值一致
Console.WriteLine(p1.GetHashCode() == p2.GetHashCode()); // 输出True
// 验证哈希集合的正常使用
Dictionary<Point, string> dict = new Dictionary<Point, string>();
dict.Add(p1, "第一个点");
Console.WriteLine(dict.ContainsKey(p2)); // 输出True,因为p1和p2相等,哈希值也相同
}
}
可以看到,重写后的Point类型可以正确判断相等性,也可以正常作为字典的键使用。另外,可选重写==和!=运算符,让值类型的相等判断更符合开发者的使用习惯,注意运算符的重写必须和Equals方法的逻辑保持一致。
常见误区提醒
- 不要只重写Equals而不重写GetHashCode,否则值类型实例作为哈希集合键时会出现逻辑错误
- GetHashCode中使用的字段必须和Equals中比较的字段完全一致,否则会违反相等实例哈希值必须相等的规则
- 不要在GetHashCode中引入可变字段,如果值类型的字段是可变的,修改字段后哈希值会变化,导致哈希集合中无法找到该实例
- 值类型的Equals重写不需要判断obj是否为null,因为值类型本身不能为null,强制转换时如果obj是null会抛出异常,但我们的代码中先判断了类型,所以不会出现这个问题
C#EqualsGetHashCode值类型相等性判断修改时间:2026-07-02 23:18:19