1. 引言
在编程里我们老是在比较东西:数字、字符串、对象。但在C#里,“对象相等”到底是啥意思?其实没你想的那么简单。今天我们要聊两个关键方法,它们决定了对象怎么比较:Equals() 和 GetHashCode()。理解这俩方法对你写的程序能不能正常跑,尤其是用 Dictionary 或 HashSet 这种集合时,特别重要。
C#里有两种主要的相等方式:
引用相等 (Reference Equality):意思是两个引用类型变量指向内存里的同一个对象。这个用 == 操作符来检查。
MyClass obj1 = new MyClass();
MyClass obj2 = new MyClass();
MyClass obj3 = obj1;
Console.WriteLine(obj1 == obj2); // false(内存里是两个不同对象)
Console.WriteLine(obj1 == obj3); // true(俩引用都指向同一个对象)
值相等 (Value Equality):
意思是两个不同的对象(或者两个值类型)字段/属性的内容完全一样。这才是我们平时想要的对象比较。用 Equals() 方法搞定。
// 假设有个 Point 类
Point p1 = new Point(10, 20);
Point p2 = new Point(10, 20);
Point p3 = new Point(30, 40);
// p1 和 p2 是不同对象,但我们希望它们“值相等”
Console.WriteLine(p1.Equals(p2)); // ? 取决于 Equals() 的实现
Console.WriteLine(p1.Equals(p3)); // ?
2. Equals() 方法
Equals() 方法定义在基类 System.Object 里,所有C#类型都继承它。它的主要作用就是判断两个对象值是不是相等。
Equals() 的默认行为
对于值类型(struct、int、bool 等): 默认的 Equals()(继承自 System.ValueType)会按位比较所有字段。只要所有字段都一样,对象就算相等。一般来说,这就是你想要的效果。
对于引用类型(class、string、array 等): 默认的 Equals()(继承自 System.Object)只检查 引用相等。也就是说 obj1.Equals(obj2) 默认只有在 obj1 和 obj2 指向 同一个内存对象时才会返回 true。class Person // 引用类型
{
public string Name { get; set; }
public int Age { get; set; }
}
Person person1 = new Person { Name = "Alice", Age = 30 };
Person person2 = new Person { Name = "Alice", Age = 30 }; // 不同对象,但内容一样
Console.WriteLine(person1.Equals(person2)); // false(默认比较引用)
你看,对于引用类型,默认行为经常不是我们想要的!我们希望两个30岁的“Alice”就算是不同对象也能算相等。
自定义类重写 Equals()
要让你自己的类支持值相等,你必须重写 Equals() 方法。
重写 Equals() 的规则(契约):
- 自反性: x.Equals(x) 永远 true。
- 对称性: 如果 x.Equals(y) 是 true,那 y.Equals(x) 也得是 true。
- 传递性: 如果 x.Equals(y) 和 y.Equals(z) 都是 true,那 x.Equals(z) 也得是 true。
- 一致性: 多次调用 x.Equals(y),只要对象没变,结果都一样。
- Null兼容: x.Equals(null) 永远 false。
例子:为 Person 类重写 Equals()
class Person
{
public string Name { get; set; }
public int Age { get; set; }
public Person(string name, int age)
{
Name = name;
Age = age;
}
// 重写 Equals()
public override bool Equals(object? obj)
{
// 1. 检查 null
if (obj == null) return false;
// 2. 检查类型
if (obj.GetType() != this.GetType()) return false;
// 3. 类型转换
Person other = (Person)obj;
// 4. 按值比较字段
return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase) &&
Age == other.Age;
}
}
// 用法:
Person person1 = new Person("Alice", 30);
Person person2 = new Person("Alice", 30);
Person person3 = new Person("Bob", 25);
Console.WriteLine(person1.Equals(person2)); // true(现在按值比较!)
Console.WriteLine(person1.Equals(person3)); // false
Console.WriteLine(person1.Equals(null)); // false
3. GetHashCode() 方法
GetHashCode() 方法也定义在 System.Object 里。它返回一个整数(哈希码),能尽量快又唯一地标识一个对象。
GetHashCode() 有啥用?
哈希码用来优化基于哈希表的集合操作。比如:
- Dictionary<TKey, TValue>(哈希码用来快速查找key)
- HashSet<T>(哈希码用来判断元素唯一性)
- Hashtable
你把对象加进 HashSet 或当 Dictionary 的key用时,集合会先算出对象的哈希码。这样它能直接跳到某个“桶”里(哈希码一样的元素分一组),不用遍历所有元素。然后在这个“桶”里再用 Equals() 精确比较。
重写 GetHashCode() 的规则(契约):
如果你重写了 Equals(),你也必须重写 GetHashCode()! 这是C#里最重要的规则之一。
- 一致性: 如果 Equals() 对两个对象返回 true,那 GetHashCode() 也必须返回一样的值。(反过来不一定:不同对象哈希码可以一样,这叫“冲突”。)
- 恒定性: GetHashCode() 对同一个对象(只要参与比较的字段没变)必须一直返回同一个值。
- 速度: GetHashCode() 必须很快,不能太耗性能。
为啥这么重要? 如果你只重写了 Equals(),没重写 GetHashCode(),你的集合(尤其是哈希集合)会出问题:
- Dictionary 找不到你的key。
- HashSet 会加重复元素,因为它以为它们不一样。
因为默认的 GetHashCode() 对引用类型返回的是对象引用的哈希。如果 Equals() 已经按值比较了,那值一样但引用不同的对象哈希码就不一样,集合就不会把它们当成一样的。
为 Person 类重写 GetHashCode()
好习惯是用和 Equals() 一样的字段来生成哈希码。.NET有个静态方法 HashCode.Combine(),用起来很方便。
class Person
{
public string Name { get; set; }
public int Age { get; set; }
public Person(string name, int age)
{
Name = name;
Age = age;
}
public override bool Equals(object? obj)
{
if (obj == null || obj.GetType() != this.GetType()) return false;
Person other = (Person)obj;
return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase) &&
Age == other.Age;
}
// 重写 GetHashCode()
public override int GetHashCode()
{
// 用 HashCode.Combine 合并字段哈希
return HashCode.Combine(Name.ToLowerInvariant(), Age);
}
}
// 在集合里用:
public class Program
{
public static void Main(string[] args)
{
Person p1 = new Person("Alice", 30);
Person p2 = new Person("Alice", 30);
Person p3 = new Person("Bob", 25);
HashSet
uniquePeople = new HashSet
(); uniquePeople.Add(p1); uniquePeople.Add(p2); // p2 按值等于 p1,不会被加进去 Console.WriteLine($"唯一人数: {uniquePeople.Count}"); // 输出: 1 uniquePeople.Add(p3); Console.WriteLine($"唯一人数: {uniquePeople.Count}"); // 输出: 2 } }
注意: 在 GetHashCode() 里,如果字符串字段在 Equals 里是忽略大小写比较(StringComparison.OrdinalIgnoreCase),那算哈希时也要忽略大小写(比如先转小写 Name.ToLowerInvariant())。不然 Equals() 可能返回 true(Alice == alice),但 GetHashCode() 却不一样,契约就被破坏了。
4. 重载 == 和 != 操作符
对于类(引用类型),== 默认只检查引用相等。你可以重载它,让它像 Equals() 一样比较值相等。
重载 == 的规则:
- 如果你重载了 ==,必须也重载 !=。
- 建议重载后的 == 行为和 Equals() 一样。
- 重载 == 时,也要重写 GetHashCode() 和 Equals()。
class Person
{
public string Name { get; set; }
public int Age { get; set; }
// 构造函数、Equals、GetHashCode 同上
// 重载 == 操作符
public static bool operator ==(Person? left, Person? right)
{
if (ReferenceEquals(left, null)) // 检查 left 是否为 null
{
return ReferenceEquals(right, null); // 俩都是 null 就相等
}
return left.Equals(right); // 否则用我们重写的 Equals()
}
// 重载 != 操作符(重载 == 时必须重载)
public static bool operator !=(Person? left, Person? right)
{
return !(left == right);
}
}
// 用法:
Person p1 = new Person("Alice", 30);
Person p2 = new Person("Alice", 30);
Person p3 = null;
Person p4 = null;
Console.WriteLine(p1 == p2); // true(现在用重载的 ==)
Console.WriteLine(p1 == p3); // false
Console.WriteLine(p3 == p4); // true
5. record —— 自动值相等
从C# 9开始,有了 record 类型。它是引用类型,但自动实现了值相等(自动重写 Equals()、GetHashCode()、ToString() 和 ==/!= 操作符),基于所有字段/属性。对于不可变的数据对象,record 超级好用。
public record PersonRecord(string Name, int Age);
// 用法:
PersonRecord r1 = new PersonRecord("Bob", 25);
PersonRecord r2 = new PersonRecord("Bob", 25);
PersonRecord r3 = new PersonRecord("Charlie", 40);
Console.WriteLine(r1 == r2); // true(自动按值比较!)
Console.WriteLine(r1.Equals(r2)); // true
Console.WriteLine(r1.GetHashCode() == r2.GetHashCode()); // true
Console.WriteLine(r1 == r3); // false
record 让你在需要引用类型但又想要值类型行为时,写代码特别省心。
6. 建议
如果你重写了 Equals(),一定要重写 GetHashCode()! 不然哈希集合会出各种莫名其妙的问题。
Equals() 和 GetHashCode() 要用同样的字段。 只有那些让对象“值相等”的字段才应该参与哈希计算。
小心可变(mutable)类型。 如果 Equals() 和 GetHashCode() 用到的字段在对象创建后还能变,哈希码就会变。这对哈希集合来说很糟糕,对象会“丢失”(哈希码变了,集合找不到它了)。哈希集合最好用不可变(immutable)类型当key或元素。
对于不可变的数据对象,考虑用 record。 这样实现值相等会简单很多。
只有在引用类型确实需要时才重载 == 和 !=。 值类型的 == 已经按值比较了。重载时要保证行为和 Equals() 一致。
GO TO FULL VERSION