CodeGym /课程 /C# SELF /C#中的对象比较: Equals 和 ...

C#中的对象比较: EqualsGetHashCode()

C# SELF
第 34 级 , 课程 3
可用

1. 引言

在编程里我们老是在比较东西:数字、字符串、对象。但在C#里,“对象相等”到底是啥意思?其实没你想的那么简单。今天我们要聊两个关键方法,它们决定了对象怎么比较:Equals()GetHashCode()。理解这俩方法对你写的程序能不能正常跑,尤其是用 DictionaryHashSet 这种集合时,特别重要。

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() 的默认行为

对于值类型(structintbool 等): 默认的 Equals()(继承自 System.ValueType)会按位比较所有字段。只要所有字段都一样,对象就算相等。一般来说,这就是你想要的效果。

对于引用类型(classstringarray 等): 默认的 Equals()(继承自 System.Object)只检查 引用相等。也就是说 obj1.Equals(obj2) 默认只有在 obj1obj2 指向 同一个内存对象时才会返回 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() 可能返回 trueAlice == 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() 一致。

评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION