1. Giới thiệu
Trong lập trình, tụi mình so sánh mọi thứ suốt: số, chuỗi, object. Nhưng thực ra "bằng nhau" giữa các object trong C# là gì nhỉ? Không phải lúc nào cũng rõ ràng đâu. Hôm nay tụi mình sẽ mổ xẻ hai method quan trọng quyết định cách so sánh object: Equals() và GetHashCode(). Hiểu hai cái này là cực kỳ quan trọng để code chạy đúng, nhất là khi xài collection kiểu Dictionary hoặc HashSet.
Trong C# có hai kiểu bằng nhau chính:
Bằng nhau về tham chiếu (Reference Equality):Nghĩa là hai biến kiểu tham chiếu cùng trỏ tới cùng một object trong bộ nhớ. Kiểm tra bằng toán tử == cho reference type.
MyClass obj1 = new MyClass();
MyClass obj2 = new MyClass();
MyClass obj3 = obj1;
Console.WriteLine(obj1 == obj2); // false (khác object trong bộ nhớ)
Console.WriteLine(obj1 == obj3); // true (cả hai trỏ cùng một object)
Bằng nhau về giá trị (Value Equality):
Nghĩa là hai object khác nhau (hoặc hai value type) có nội dung giống nhau (giá trị các field/property giống nhau). Đây là cái tụi mình thường muốn khi so sánh object. Dùng method Equals() cho việc này.
// Giả sử có class Point
Point p1 = new Point(10, 20);
Point p2 = new Point(10, 20);
Point p3 = new Point(30, 40);
// p1 và p2 là hai object khác nhau, nhưng tụi mình muốn coi là "bằng nhau" về giá trị
Console.WriteLine(p1.Equals(p2)); // ? Tùy vào cách cài Equals()
Console.WriteLine(p1.Equals(p3)); // ?
2. Method Equals()
Method Equals() được định nghĩa trong class gốc System.Object, tất cả các type trong C# đều kế thừa từ đây. Nhiệm vụ chính của nó là xác định hai object có bằng nhau về giá trị không.
Hành vi mặc định của Equals()
Với value type (struct, int, bool v.v.): Mặc định Equals() (kế thừa từ System.ValueType) sẽ so sánh từng bit của tất cả field. Nếu tất cả field giống nhau thì coi là bằng nhau. Thường thì đúng như mong đợi.
Với reference type (class, string, array v.v.): Mặc định Equals() (kế thừa từ System.Object) sẽ kiểm tra bằng nhau về tham chiếu. Tức là obj1.Equals(obj2) mặc định trả về true chỉ khi obj1 và obj2 trỏ cùng một object trong bộ nhớ.class Person // Reference type
{
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 }; // Object khác, nhưng nội dung giống
Console.WriteLine(person1.Equals(person2)); // false (mặc định so sánh tham chiếu)
Như bạn thấy, mặc định cho reference type thường không như ý mình muốn! Mình muốn hai "Alice" 30 tuổi coi là bằng nhau, dù là hai object khác nhau trong bộ nhớ.
Override Equals() cho class tự định nghĩa
Để so sánh bằng giá trị cho class của bạn, bạn PHẢI override method Equals().
Quy tắc override Equals() (contract):
- Phản xạ: x.Equals(x) luôn true.
- Đối xứng: Nếu x.Equals(y) là true, thì y.Equals(x) cũng phải true.
- Bắc cầu: Nếu x.Equals(y) và y.Equals(z) đều true, thì x.Equals(z) cũng phải true.
- Nhất quán: Gọi x.Equals(y) nhiều lần phải ra cùng kết quả, miễn là object không đổi.
- Null-compatible: x.Equals(null) luôn false.
Ví dụ: Override Equals() cho class Person
class Person
{
public string Name { get; set; }
public int Age { get; set; }
public Person(string name, int age)
{
Name = name;
Age = age;
}
// Override Equals()
public override bool Equals(object? obj)
{
// 1. Kiểm tra null
if (obj == null) return false;
// 2. Kiểm tra cùng type
if (obj.GetType() != this.GetType()) return false;
// 3. Ép kiểu
Person other = (Person)obj;
// 4. So sánh field theo giá trị
return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase) &&
Age == other.Age;
}
}
// Sử dụng:
Person person1 = new Person("Alice", 30);
Person person2 = new Person("Alice", 30);
Person person3 = new Person("Bob", 25);
Console.WriteLine(person1.Equals(person2)); // true (giờ so sánh theo giá trị!)
Console.WriteLine(person1.Equals(person3)); // false
Console.WriteLine(person1.Equals(null)); // false
3. Method GetHashCode()
Method GetHashCode() cũng được định nghĩa trong System.Object. Nó trả về một số nguyên (hash code) giúp nhận diện object nhanh và càng unique càng tốt.
Dùng GetHashCode() để làm gì?
Hash code dùng để tối ưu thao tác với collection dựa trên hash table. Ví dụ:
- Dictionary<TKey, TValue> (hash code dùng để tìm key nhanh)
- HashSet<T> (hash code dùng để kiểm tra phần tử có unique không)
- Hashtable
Khi bạn add object vào HashSet hoặc dùng làm key trong Dictionary, collection sẽ tính hash code của object trước. Nhờ đó nó "nhảy" thẳng tới "bucket" hoặc nhóm phần tử có cùng hash code, khỏi phải duyệt hết. Sau đó, trong "bucket" đó, nó mới dùng Equals() để so sánh chính xác.
Quy tắc override GetHashCode() (contract):
Nếu bạn override Equals(), BẮT BUỘC phải override cả GetHashCode()! Đây là một trong những luật quan trọng nhất của C#.
- Nhất quán: Nếu Equals() trả về true cho hai object, thì GetHashCode() của hai object đó phải trả về cùng giá trị. (Chiều ngược lại không bắt buộc: object khác nhau có thể cùng hash code — gọi là "collision".)
- Ổn định: GetHashCode() phải trả về cùng giá trị cho cùng một object, miễn là các field dùng để so sánh không đổi.
- Nhanh: GetHashCode() nên chạy nhanh và không tốn nhiều tính toán.
Tại sao quan trọng vậy? Nếu bạn override Equals() mà không override GetHashCode(), các collection (nhất là hash collection) sẽ hoạt động sai:
- Dictionary sẽ không tìm thấy key của bạn.
- HashSet sẽ add trùng lặp vì tưởng là unique.
Lý do là mặc định GetHashCode() trả về hash dựa trên tham chiếu object (với reference type). Nếu Equals() giờ so sánh theo giá trị, thì object cùng giá trị nhưng khác tham chiếu sẽ có hash code khác nhau, collection sẽ không coi là giống nhau.
Override GetHashCode() cho class Person
Best practice là tạo hash code dựa trên đúng các field bạn dùng trong Equals(). .NET có method static HashCode.Combine() rất tiện cho việc này.
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;
}
// Override GetHashCode()
public override int GetHashCode()
{
// Dùng HashCode.Combine để kết hợp hash của các field.
return HashCode.Combine(Name.ToLowerInvariant(), Age);
}
}
// Dùng trong collection:
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 coi là giống p1 về giá trị, không add nữa Console.WriteLine($"Số người unique: {uniquePeople.Count}"); // Output: 1 uniquePeople.Add(p3); Console.WriteLine($"Số người unique: {uniquePeople.Count}"); // Output: 2 } }
Lưu ý quan trọng: Trong GetHashCode() với field kiểu string mà bạn so sánh không phân biệt hoa thường (StringComparison.OrdinalIgnoreCase trong Equals), bạn cũng phải lấy hash code không phân biệt hoa thường (ví dụ chuyển về lower trước khi hash, như Name.ToLowerInvariant()). Nếu không, Equals() trả về true (Alice == alice), còn GetHashCode() lại khác nhau, vi phạm contract.
4. Overload toán tử == và !=
Với class (reference type), toán tử == mặc định kiểm tra bằng nhau về tham chiếu. Bạn có thể overload để nó kiểm tra bằng nhau về giá trị, giống như Equals().
Quy tắc overload ==:
- Nếu overload ==, bắt buộc phải overload cả !=.
- Nên để overload == có hành vi giống Equals().
- Cũng cần override GetHashCode() và Equals() khi overload ==.
class Person
{
public string Name { get; set; }
public int Age { get; set; }
// Constructor, Equals, GetHashCode như trên
// Overload toán tử ==
public static bool operator ==(Person? left, Person? right)
{
if (ReferenceEquals(left, null)) // Kiểm tra left có null không
{
return ReferenceEquals(right, null); // Nếu cả hai null thì bằng nhau
}
return left.Equals(right); // Không thì dùng Equals() đã override
}
// Overload toán tử != (bắt buộc khi overload ==)
public static bool operator !=(Person? left, Person? right)
{
return !(left == right);
}
}
// Sử dụng:
Person p1 = new Person("Alice", 30);
Person p2 = new Person("Alice", 30);
Person p3 = null;
Person p4 = null;
Console.WriteLine(p1 == p2); // true (giờ dùng toán tử == đã overload)
Console.WriteLine(p1 == p3); // false
Console.WriteLine(p3 == p4); // true
5. record — so sánh tự động
Từ C# 9, có type record. Đây là reference type, nhưng tự động so sánh bằng giá trị (và override Equals(), GetHashCode(), ToString() và toán tử ==/!=) dựa trên tất cả field/property của nó. Rất hợp cho object data bất biến.
public record PersonRecord(string Name, int Age);
// Sử dụng:
PersonRecord r1 = new PersonRecord("Bob", 25);
PersonRecord r2 = new PersonRecord("Bob", 25);
PersonRecord r3 = new PersonRecord("Charlie", 40);
Console.WriteLine(r1 == r2); // true (so sánh tự động theo giá trị!)
Console.WriteLine(r1.Equals(r2)); // true
Console.WriteLine(r1.GetHashCode() == r2.GetHashCode()); // true
Console.WriteLine(r1 == r3); // false
record giúp cuộc sống dễ thở hơn nhiều khi bạn cần hành vi kiểu value type cho reference type.
6. Khuyến nghị
Nếu override Equals(), luôn override cả GetHashCode()! Không làm vậy sẽ gây bug khó lường trong hash collection.
Equals() và GetHashCode() phải dùng cùng field. Field nào làm object "bằng nhau" về giá trị thì cũng phải dùng để tính hash.
Cẩn thận với type có thể thay đổi (mutable). Nếu field dùng trong Equals() và GetHashCode() có thể đổi sau khi tạo object, hash code của object cũng đổi theo. Điều này rất nguy hiểm cho hash collection, vì object có thể "mất tích" sau khi đổi (hash code đổi, collection không tìm thấy trong "bucket" nữa). Với hash collection, tốt nhất dùng type bất biến (immutable) làm key hoặc element.
Với object data bất biến, hãy cân nhắc dùng record. Sẽ đơn giản hóa việc so sánh bằng giá trị rất nhiều.
Chỉ overload == và != cho reference type khi thực sự cần. Với value type, == đã so sánh theo giá trị rồi. Nếu overload, nhớ đảm bảo hành vi giống Equals().
GO TO FULL VERSION