4.12 - Bug so sánh đối tượng: Equals, GetHashCode và record
Tóm tắt: Một bug rất hay gặp và rất khó tin khi mới gặp: hai đối tượng có dữ liệu giống hệt nhau nhưng
==trả vềfalse, vàlist.Contains(x)không tìm thấy dùxrõ ràng nằm trong danh sách. Nguyên nhân: vớiclass, C# so sánh địa chỉ trong bộ nhớ, không so sánh nội dung. Hai đối tượng khác nhau thì luôn khác nhau, dù mọi thuộc tính đều bằng. Cách sửa đơn giản nhất là dùngrecord— một từ khoá thay cho hàng chục dòng code. Nhưng có một cái bẫy ít người biết: nếu tự ghi đèEqualsmà quênGetHashCode,DictionaryvàHashSetsẽ hoạt động sai một cách im lặng — không lỗi, chỉ là không tìm thấy thứ rõ ràng có ở đó.
Mục tiêu bài học
Sau bài này bạn có thể:
- Giải thích vì sao hai đối tượng cùng dữ liệu lại không bằng nhau.
- Chọn giữa
class,recordvàstructtheo tình huống. - Ghi đè
EqualsvàGetHashCodeđúng cách khi cần. - Nhận ra bug do quên
GetHashCodehoặc do sửa thuộc tính dùng để băm.
Cần biết trước: record và value object ở bài 4.7; HashSet, Dictionary và điều kiện của khoá ở bài 1.5; deferred execution của LINQ ở bài 1.8. Bài này là phần thực hành của bài 4.7: tái hiện bug bằng code chạy được, rồi sửa.
Nội dung bài học
4.12.1 — Bug
// Address.cs
public class Address
{
public string HouseNumber { get; set; } = "";
public string Street { get; set; } = "";
public string District { get; set; } = "";
}// Program.cs
var a1 = new Address { HouseNumber = "12", Street = "Nguyễn Huệ", District = "1" };
var a2 = new Address { HouseNumber = "12", Street = "Nguyễn Huệ", District = "1" };
Console.WriteLine(a1 == a2); // False (!!)
Console.WriteLine(a1.Equals(a2)); // False (!!)
var list = new List<Address> { a1 };
Console.WriteLine(list.Contains(a2)); // False — dù dữ liệu giống hệtDán vào một file Program.cs thì khối Program.cs đặt lên đầu, khối khai báo đặt xuống cuối (C# bắt buộc, nếu không sẽ báo lỗi CS8803).
Trong code thật, bug này biểu hiện như sau:
// Kiểm tra địa chỉ giao hàng đã tồn tại chưa
if (!customer.Addresses.Contains(newAddress))
customer.Addresses.Add(newAddress);
// Kết quả: danh sách đầy địa chỉ TRÙNG NHAU4.12.2 — Vì sao
Với class, C# so sánh tham chiếu — tức hai biến có trỏ vào cùng một ô nhớ hay không.
var a1 = new Address { ... }; // tạo đối tượng ở ô nhớ A
var a2 = new Address { ... }; // tạo đối tượng ở ô nhớ B (KHÁC)
a1 == a2 // so sánh A với B -> False
var a3 = a1; // a3 trỏ vào CÙNG ô nhớ A
a1 == a3 // TrueMặc định này đúng cho phần lớn trường hợp: hai khách hàng cùng tên không phải cùng một người. Nhưng với những thứ chỉ mang giá trị — địa chỉ, số tiền, khoảng thời gian — thì so sánh theo nội dung mới đúng.
4.12.3 — Sửa bằng record
// Address.cs
public record Address(string HouseNumber, string Street, string District);// Program.cs
var a1 = new Address("12", "Nguyễn Huệ", "1");
var a2 = new Address("12", "Nguyễn Huệ", "1");
Console.WriteLine(a1 == a2); // True
Console.WriteLine(new List<Address> { a1 }.Contains(a2)); // True
var moved = a1 with { District = "3" }; // bản sao, chỉ đổi DistrictMột từ khoá. Trình biên dịch tự sinh Equals, GetHashCode, ToString, toán tử == và !=, cùng phần hỗ trợ with để tạo bản sao có sửa đổi (bài 4.7).
Positional record như trên còn bất biến — thuộc tính chỉ có init, sau khi tạo không đổi được. Đây là điều bạn muốn với value object: nếu địa chỉ thay đổi thì đó là một địa chỉ khác, không phải cùng địa chỉ đã bị sửa. Lưu ý record struct positional thì không bất biến (thuộc tính có set), phải viết readonly record struct.
4.12.4 — Cái bẫy: quên GetHashCode
Nếu vì lý do nào đó bạn không dùng được record và phải tự ghi đè:
// SAI — ghi đè Equals nhưng QUÊN GetHashCode
public class Address
{
public string HouseNumber { get; init; } = "";
public string Street { get; init; } = "";
public override bool Equals(object? obj)
=> obj is Address other && HouseNumber == other.HouseNumber && Street == other.Street;
// GetHashCode: KHÔNG ghi đè -> vẫn dùng mặc định theo tham chiếu
}Hậu quả, và nó rất khó chẩn đoán:
var set = new HashSet<Address>();
set.Add(a1);
Console.WriteLine(a1.Equals(a2)); // True
Console.WriteLine(set.Contains(a2)); // False (!!)HashSet và Dictionary tìm phần tử theo hai bước: trước hết dùng GetHashCode để xác định "ngăn" chứa nó, sau đó mới dùng Equals trong ngăn đó. Nếu GetHashCode khác nhau, chúng rơi vào hai ngăn khác nhau và Equals không bao giờ được gọi.
Quy tắc bắt buộc: ghi đè Equals thì phải ghi đè GetHashCode, và hai cái phải dùng cùng bộ thuộc tính.
public override int GetHashCode() => HashCode.Combine(HouseNumber, Street);HashCode.Combine xử lý đúng cả trường hợp null và phân bố giá trị tốt — không tự viết bằng cách nhân số nguyên tố nữa.
Trình biên dịch có cảnh báo về việc này (CS0659), nên đừng bỏ qua cảnh báo.
4.12.5 — Cái bẫy thứ hai: thuộc tính thay đổi được
// Address.cs
// NGUY HIỂM — thuộc tính dùng để băm lại đổi được
public class Address
{
public string Street { get; set; } = ""; // set được!
public override bool Equals(object? obj) => obj is Address o && o.Street == Street;
public override int GetHashCode() => HashCode.Combine(Street);
}// Program.cs
var a = new Address { Street = "Nguyễn Huệ" };
var set = new HashSet<Address> { a };
a.Street = "Lê Lợi"; // đổi SAU khi đã thêm vào tập
Console.WriteLine(set.Contains(a)); // False — dù a ĐANG nằm trong tập!Đối tượng vẫn ở trong HashSet, nhưng nó nằm ở ngăn tương ứng với mã băm cũ. Tìm theo mã băm mới không tới đúng ngăn đó.
Đây là lý do value object phải bất biến. Nếu dùng class với Equals tự viết, hãy đặt mọi thuộc tính tham gia so sánh là init hoặc chỉ có get.
4.12.6 — Chọn class, record hay struct
class | record (class) | struct | |
|---|---|---|---|
Equals | Tham chiếu | Giá trị, sinh sẵn | Giá trị, nhưng qua reflection nên chậm |
== | Tham chiếu | Giá trị, sinh sẵn | Không có sẵn (lỗi CS0019, mục 4.1.5) |
| Lưu ở đâu | Heap | Heap | Nằm ngay trong biến chứa nó (thường trên stack) |
| Bất biến mặc định | Không | Có với positional record | Không (readonly record struct thì có) |
| Dùng cho | Thực thể có định danh | Value object | Giá trị nhỏ, dùng rất nhiều |
Quy tắc chọn:
- Có định danh riêng, hai đối tượng cùng dữ liệu vẫn là hai thứ khác nhau →
class. Ví dụ:Customer,Order,Lead. - Chỉ mang giá trị, cùng dữ liệu nghĩa là cùng một thứ →
record. Ví dụ:Address,Money,DateRange. - Rất nhỏ (dưới 16 byte), tạo rất nhiều lần, và giá trị mặc định cũng hợp lệ →
readonly record struct.
// Thực thể — có định danh
public class Customer { public CustomerId Id { get; init; } /* ... */ }
// Value object — chỉ mang giá trị
public record Money(decimal Amount, string Currency);
// Value object nhỏ, dùng nhiều
public readonly record struct CustomerId(Guid Value);Dòng cuối là mẫu strongly-typed ID: nó vừa cho so sánh theo giá trị, vừa không cấp phát trên heap, vừa khiến trình biên dịch bắt lỗi truyền nhầm id của loại này sang loại khác (bài 16.13).
4.12.7 — Rà lại code của bạn
Danh sách rà soát so sánh đối tượng
- Lớp chỉ mang giá trị được khai báo là record.
- Không dùng Contains hay Distinct trên class chưa ghi đè Equals.
- Nếu ghi đè Equals thì cũng ghi đè GetHashCode với cùng bộ thuộc tính.
- Dùng HashCode.Combine thay vì tự viết thuật toán băm.
- Thuộc tính dùng để băm là init hoặc chỉ có get, không đổi được.
- Cảnh báo CS0659, CS0660, CS0661 được biến thành lỗi.
- Thực thể có định danh dùng class, không dùng record.
- Id của entity dùng readonly record struct.
Bài tập áp dụng
Bài 1 — Tái hiện bug
Tạo một class AddressClass (hai thuộc tính Province, District) và một record AddressRecord cùng dữ liệu. Với mỗi kiểu, tạo hai object cùng dữ liệu và kiểm tra bốn thứ: Equals, List.Contains, HashSet.Contains, và Distinct().Count().
Tiêu chí hoàn thành: bạn thấy bốn phép kiểm tra cùng đổi kết quả khi chuyển sang record, và giải thích được record sinh ra những gì.
Gợi ý và lời giải — Bài 1
Gợi ý. Đừng chỉ thử Equals. Thử cả List.Contains, HashSet.Contains và Distinct — chúng cho thấy phạm vi ảnh hưởng.
Lời giải:
// AddressTypes.cs
class AddressClass(string province, string district)
{
public string Province { get; } = province;
public string District { get; } = district;
}
record AddressRecord(string Province, string District);// Program.cs
var c1 = new AddressClass("Hà Nội", "Cầu Giấy");
var c2 = new AddressClass("Hà Nội", "Cầu Giấy");
Console.WriteLine($"class : c1.Equals(c2) -> {c1.Equals(c2)}");
Console.WriteLine($"class : List.Contains -> {new List<AddressClass> { c1 }.Contains(c2)}");
Console.WriteLine($"class : HashSet.Contains -> {new HashSet<AddressClass> { c1 }.Contains(c2)}");
Console.WriteLine($"class : Distinct đếm -> {new[] { c1, c2 }.Distinct().Count()}");
Console.WriteLine();
var r1 = new AddressRecord("Hà Nội", "Cầu Giấy");
var r2 = new AddressRecord("Hà Nội", "Cầu Giấy");
Console.WriteLine($"record : r1.Equals(r2) -> {r1.Equals(r2)}");
Console.WriteLine($"record : List.Contains -> {new List<AddressRecord> { r1 }.Contains(r2)}");
Console.WriteLine($"record : HashSet.Contains -> {new HashSet<AddressRecord> { r1 }.Contains(r2)}");
Console.WriteLine($"record : Distinct đếm -> {new[] { r1, r2 }.Distinct().Count()}");Kết quả chạy thật trên .NET 10:
class : c1.Equals(c2) -> False
class : List.Contains -> False
class : HashSet.Contains -> False
class : Distinct đếm -> 2
record : r1.Equals(r2) -> True
record : List.Contains -> True
record : HashSet.Contains -> True
record : Distinct đếm -> 1Bốn phép kiểm tra, bốn kết quả ngược nhau — chỉ vì một từ khoá.
record sinh ra những gì:
| Thành viên | Tác dụng |
|---|---|
Equals(T?) và Equals(object?) | So sánh từng thuộc tính, không so sánh tham chiếu |
GetHashCode() | Băm từ tất cả thuộc tính |
operator == và operator != | Gọi Equals thay vì so sánh tham chiếu |
IEquatable<T> | Cho phép so sánh không cần đóng hộp |
ToString() | In ra dạng AddressRecord { Province = Hà Nội, District = Cầu Giấy } |
Deconstruct | Cho phép var (province, district) = address; |
<Clone>$ | Hỗ trợ cú pháp with |
Với class, mọi thứ trong bảng trên phải tự viết — và tự viết đúng cả Equals lẫn GetHashCode là chỗ hay sai, xem bài 2.
Vì sao List.Contains và HashSet.Contains đều đổi:
List.Contains -> gọi EqualityComparer<T>.Default.Equals(a, b) cho từng phần tử
-> chỉ cần Equals
HashSet.Contains -> tính GetHashCode trước, rồi mới Equals
-> cần CẢ HAI đúngĐây là lý do phải thử cả hai: chúng đi qua hai cơ chế khác nhau, và một cài đặt sai có thể làm đúng cái này mà hỏng cái kia — chính xác là tình huống ở bài 2.
Giới hạn của record: nó so sánh theo thuộc tính ở mức nông, nên một List bên trong record vẫn so sánh theo tham chiếu. Bài tập 1 của bài 4.7 đã tái hiện và sửa bẫy này.
Quy tắc đơn giản: dùng record cho giá trị (địa chỉ, tiền, khoảng thời gian, DTO), dùng class cho thực thể có định danh (một Lead là chính nó, dù đổi tên thì vẫn là Lead đó).
Bài 2 — Tái hiện bẫy GetHashCode
Ghi đè Equals mà không ghi đè GetHashCode, rồi thử List.Contains, HashSet.Contains, tra Dictionary và thêm cả hai object vào một HashSet. Sau đó viết một test xUnit bắt được lỗi này.
Tiêu chí hoàn thành: bạn thấy Equals trả True nhưng HashSet.Contains trả False, giải thích được vì sao hai cấu trúc dữ liệu cho hai kết quả khác nhau, và test của bạn đỏ với lớp sai, xanh với record.
Gợi ý và lời giải — Bài 2
Gợi ý. Trình biên dịch có cảnh báo cho đúng trường hợp này. Xem output khi build.
Lời giải:
// AddressEqualsOnly.cs
class AddressEqualsOnly(string province, string district)
{
public string Province { get; } = province;
public string District { get; } = district;
public override bool Equals(object? o) =>
o is AddressEqualsOnly k && k.Province == Province && k.District == District;
// CỐ Ý không ghi đè GetHashCode
}// Program.cs
var e1 = new AddressEqualsOnly("Hà Nội", "Cầu Giấy");
var e2 = new AddressEqualsOnly("Hà Nội", "Cầu Giấy");
Console.WriteLine($"Equals -> {e1.Equals(e2)}");
Console.WriteLine($"List.Contains -> {new List<AddressEqualsOnly> { e1 }.Contains(e2)}");
Console.WriteLine($"HashSet.Contains -> {new HashSet<AddressEqualsOnly> { e1 }.Contains(e2)}");
Console.WriteLine($"Dictionary tra cứu -> {new Dictionary<AddressEqualsOnly, int> { [e1] = 1 }.ContainsKey(e2)}");
Console.WriteLine($"HashCode e1 / e2 -> {e1.GetHashCode()} / {e2.GetHashCode()}");
Console.WriteLine($"HashSet thêm cả hai -> số phần tử = {new HashSet<AddressEqualsOnly> { e1, e2 }.Count}");Trình biên dịch cảnh báo ngay:
warning CS0659: 'AddressEqualsOnly' overrides Object.Equals(object o) but does not override Object.GetHashCode()Kết quả chạy thật trên .NET 10 (hai mã băm là số ngẫu nhiên, mỗi lần chạy một khác):
Equals -> True
List.Contains -> True
HashSet.Contains -> False
Dictionary tra cứu -> False
HashCode e1 / e2 -> 28672654 / 37200188
HashSet thêm cả hai -> số phần tử = 2Equals nói hai đối tượng bằng nhau. HashSet nói không. Cả hai đều "đúng" theo cách chúng làm việc.
Vì sao List đúng mà HashSet sai:
List.Contains: duyệt tuần tự, gọi Equals cho từng phần tử
-> chỉ cần Equals -> ĐÚNG
HashSet.Contains: bước 1 — tính GetHashCode để tìm ĐÚNG NGĂN
bước 2 — trong ngăn đó, gọi Equals
GetHashCode mặc định dựa trên danh tính object
-> hai đối tượng cho hai mã băm khác nhau
-> tìm ở hai NGĂN khác nhau
-> bước 2 không bao giờ chạy -> trả FalseNói cách khác: HashSet không bao giờ so sánh hai đối tượng đó, vì nó tin rằng chúng nằm ở hai chỗ khác nhau.
Hợp đồng giữa hai phương thức, phát biểu chính xác:
a.Equals(b) == true => a.GetHashCode() == b.GetHashCode() BẮT BUỘC
Chiều ngược lại KHÔNG bắt buộc:
hai mã băm bằng nhau mà đối tượng khác nhau là bình thường (va chạm)
-> HashSet xử lý được, vì nó còn gọi Equals ở bước 2Vi phạm hợp đồng thì HashSet, Dictionary, Distinct, GroupBy, ToLookup, Join và mọi thứ dựa trên băm đều sai — và sai âm thầm: không ngoại lệ, chỉ là kết quả không đúng. Dòng cuối của kết quả cho thấy rõ nhất: một HashSet lẽ ra phải loại trùng giờ chứa hai phần tử "bằng nhau", nên mọi giả định về tính duy nhất dựa trên nó đều sai.
Cách viết đúng:
public override int GetHashCode() => HashCode.Combine(Province, District);HashCode.Combine nhận tới 8 tham số và xử lý null an toàn. Và quan trọng hơn: dùng đúng những thuộc tính đã dùng trong Equals. Nếu GetHashCode chỉ dùng Province thì không vi phạm hợp đồng (bằng nhau vẫn cùng mã băm), nhưng mọi địa chỉ cùng tỉnh rơi vào một ngăn, HashSet suy biến thành danh sách và tra cứu chậm từ O(1) thành O(n).
Cách phòng, theo thứ tự nên làm:
1. Dùng record thay vì tự viết — nó sinh cả hai, luôn khớp nhau.
2. Nếu phải dùng class, biến các cảnh báo liên quan thành lỗi:
<PropertyGroup>
<WarningsAsErrors>$(WarningsAsErrors);CS0659;CS0660;CS0661</WarningsAsErrors>
</PropertyGroup>CS0659: ghi đè Equals mà không ghi đè GetHashCode
CS0660: định nghĩa operator == hoặc != mà không ghi đè Equals
CS0661: định nghĩa operator == hoặc != mà không ghi đè GetHashCode3. Viết một test chung cho mọi kiểu có so sánh theo giá trị. Trong project test xUnit (bài 0.4), dùng [Theory] với MemberData để chạy cùng một test cho nhiều cặp object:
public class EqualityContractTests
{
// Mỗi dòng: hai object khác nhau nhưng cùng dữ liệu, nên phải bằng nhau
public static TheoryData<object, object> EqualPairs => new()
{
{ new AddressRecord("Hà Nội", "Cầu Giấy"), new AddressRecord("Hà Nội", "Cầu Giấy") },
{ new AddressEqualsOnly("Hà Nội", "Cầu Giấy"), new AddressEqualsOnly("Hà Nội", "Cầu Giấy") },
};
[Theory]
[MemberData(nameof(EqualPairs))]
public void EqualObjects_MustHaveSameHashCode(object a, object b)
{
Assert.True(a.Equals(b));
Assert.Equal(a.GetHashCode(), b.GetHashCode()); // hai object bằng nhau phải cùng mã băm
Assert.Contains(b, new HashSet<object> { a });
Assert.True(new Dictionary<object, int> { [a] = 1 }.ContainsKey(b));
}
}Chạy dotnet test (cùng file với hai kiểu AddressRecord, AddressEqualsOnly ở trên): cặp record xanh, cặp AddressEqualsOnly đỏ đúng ở dòng so mã băm:
Assert.Equal() Failure: Values differ
Expected: 49382823
Actual: 15225125Thêm GetHashCode đúng vào AddressEqualsOnly thì cả hai xanh. Mỗi kiểu mới có so sánh theo giá trị chỉ cần thêm một dòng vào EqualPairs.
Bài 3 — Tái hiện bẫy thuộc tính đổi được
Viết một class có Equals và GetHashCode đúng, nhưng thuộc tính có set. Thêm một object vào HashSet, đổi một thuộc tính dùng để băm, rồi thử tìm lại, đếm, duyệt và xoá nó.
Tiêu chí hoàn thành: bạn thấy HashSet không tìm được chính đối tượng nó đang chứa, và nhận ra Remove cũng thất bại — nghĩa là phần tử đó không bao giờ xoá được.
Gợi ý và lời giải — Bài 3
Gợi ý. Thử cả Contains, Count, duyệt bằng foreach (hoặc Any), và Remove. Bốn phép này cho một bức tranh đầy đủ hơn một phép.
Lời giải:
// MutableAddress.cs
class MutableAddress
{
public string Province { get; set; } = ""; // set — đổi được
public string District { get; set; } = "";
public override bool Equals(object? o) =>
o is MutableAddress k && k.Province == Province && k.District == District;
public override int GetHashCode() => HashCode.Combine(Province, District);
}// Program.cs
var m = new MutableAddress { Province = "Hà Nội", District = "Cầu Giấy" };
var set = new HashSet<MutableAddress> { m };
Console.WriteLine($"trước khi đổi: Contains -> {set.Contains(m)}");
m.District = "Đống Đa"; // đổi thuộc tính dùng để băm
Console.WriteLine($"sau khi đổi : Contains -> {set.Contains(m)}");
Console.WriteLine($"sau khi đổi : Count -> {set.Count}");
Console.WriteLine($"duyệt thấy nó không -> {set.Any(x => ReferenceEquals(x, m))}");
Console.WriteLine($"Remove có thành công -> {set.Remove(m)}");
Console.WriteLine($"Count sau Remove -> {set.Count}");Kết quả chạy thật trên .NET 10:
trước khi đổi: Contains -> True
sau khi đổi : Contains -> False
sau khi đổi : Count -> 1
duyệt thấy nó không -> True
Remove có thành công -> False
Count sau Remove -> 1Đọc bốn dòng cuối cùng nhau, bức tranh mới đầy đủ:
Count = 1 -> phần tử VẪN nằm trong tập
duyệt thấy nó -> duyệt tuần tự không dùng mã băm -> thấy
Contains = False -> tra cứu theo mã băm -> tìm sai ngăn -> không thấy
Remove = False -> cũng tra cứu theo mã băm -> không xoá đượcPhần tử đó bị kẹt: không tìm được, không xoá được, nhưng vẫn chiếm chỗ. Cách duy nhất để lấy nó ra là dựng lại toàn bộ tập: set = new HashSet<MutableAddress>(set.Where(x => !ReferenceEquals(x, m)));.
Vì sao xảy ra:
Lúc thêm vào: GetHashCode() = H1 -> lưu ở ngăn tương ứng H1
Đổi District : GetHashCode() = H2
Lúc tìm : tính H2 -> đi tới ngăn của H2 -> ngăn đó RỖNG
HashSet KHÔNG được thông báo khi thuộc tính đối tượng thay đổi.
Nó không có cách nào biết để xếp lại.Đây là lý do khoá của Dictionary và phần tử của HashSet nên BẤT BIẾN:
// ĐÚNG — không đổi được sau khi tạo
record Address(string Province, string District);
// ĐÚNG — class với init, không có setter công khai
class AddressWithInit
{
public required string Province { get; init; }
public required string District { get; init; }
}init cho phép gán lúc khởi tạo đối tượng, cấm gán sau đó, nên đối tượng không thể đổi mã băm giữa chừng. Lỗi này biến mất về mặt cấu trúc, không cần ai phải nhớ.
Cùng một lỗi ở hai chỗ khác, khó thấy hơn:
1. Khoá Dictionary là một đối tượng đổi được.
var leadsByAddress = new Dictionary<MutableAddress, string>();
var address = new MutableAddress { Province = "Hà Nội", District = "Cầu Giấy" };
leadsByAddress[address] = "Nguyễn Văn An";
address.District = "Đống Đa"; // ai đó sửa ở nơi khác
var owner = leadsByAddress[address]; // KeyNotFoundExceptionKeyNotFoundException: The given key 'MutableAddress' was not present in the dictionary.2. Entity làm khoá Dictionary. Entity như Lead có định danh và được phép đổi dữ liệu (đổi tên, đổi địa chỉ). Nếu ai đó tự cài Equals/GetHashCode cho Lead theo tên, thì đổi tên là làm hỏng mọi Dictionary đang dùng Lead làm khoá. Với entity, cách đúng là dùng Id làm khoá: new Dictionary<int, decimal> { [lead.Id] = 0 }. Id không đổi trong vòng đời của entity nên an toàn.
Liên hệ: dữ liệu đổi được gặp deferred execution. Truy vấn LINQ không có .ToList() chỉ là một công thức, chưa chạy cho tới khi bạn duyệt nó (bài 1.8). Nếu dữ liệu đổi giữa lúc viết truy vấn và lúc duyệt, kết quả phản ánh dữ liệu lúc duyệt:
// Lead.cs — dùng lại MutableAddress ở trên
record Lead(string Name, MutableAddress Address);// Program.cs
var leads = new List<Lead>
{
new("An", new MutableAddress { Province = "HN", District = "Cầu Giấy" }),
new("Bình", new MutableAddress { Province = "HN", District = "Cầu Giấy" }),
};
var snapshot = leads.GroupBy(l => l.Address.District).ToList(); // ToList: chạy NGAY, chụp kết quả
var deferred = leads.GroupBy(l => l.Address.District); // chưa chạy
leads[0].Address.District = "Đống Đa"; // sửa sau đó
Console.WriteLine("ToList : " + string.Join(", ", snapshot.Select(g => $"{g.Key}={g.Count()}")));
Console.WriteLine("Hoãn : " + string.Join(", ", deferred.Select(g => $"{g.Key}={g.Count()}")));ToList : Cầu Giấy=2
Hoãn : Đống Đa=1, Cầu Giấy=1Cùng một câu GroupBy, hai kết quả khác nhau tuỳ lúc nó thật sự chạy. Ghép với dữ liệu đổi được, đây là loại lỗi rất khó lần.
Quy tắc gói gọn, đáng thuộc: bất cứ thứ gì làm khoá của một cấu trúc dựa trên băm thì phải bất biến suốt thời gian nó nằm trong cấu trúc đó. Cách chắc chắn nhất là làm cho nó không thể đổi được.
Nối lại ba bài: bài 1 cho thấy record sinh sẵn so sánh theo giá trị, bài 2 cho thấy tự viết dễ sai chỗ nào, bài 3 cho thấy ngay cả khi viết đúng, dữ liệu đổi được vẫn phá vỡ mọi thứ. Cả ba đều dẫn về cùng một kết luận của mục 4.12.6: với kiểu mang giá trị, record với thuộc tính init là mặc định đúng.
Tự kiểm tra
Vì sao hai đối tượng class cùng dữ liệu lại không bằng nhau?
Vì với class, C# so sánh tham chiếu, tức hai biến có trỏ vào cùng một ô nhớ hay không. Hai lần new tạo ra hai ô nhớ khác nhau nên luôn khác nhau, dù mọi thuộc tính đều bằng.
record giải quyết vấn đề này thế nào?
Trình biên dịch tự sinh Equals, GetHashCode, ToString, toán tử == và phần hỗ trợ with, tất cả theo giá trị các thuộc tính. Positional record còn bất biến (thuộc tính init), đúng với ý nghĩa của value object; record struct thì phải thêm readonly mới bất biến.
Vì sao quên GetHashCode lại gây bug khó chẩn đoán?
Vì HashSet và Dictionary tìm phần tử theo hai bước: dùng GetHashCode xác định ngăn, rồi dùng Equals trong ngăn đó. Nếu mã băm khác nhau, hai đối tượng rơi vào hai ngăn và Equals không bao giờ được gọi. Không có lỗi nào, chỉ là không tìm thấy, trong khi List.Contains vẫn đúng.
Vì sao thuộc tính dùng để băm không được thay đổi?
Vì đối tượng đã nằm ở ngăn tương ứng với mã băm cũ. Đổi thuộc tính làm mã băm đổi, nên tìm theo mã băm mới không tới đúng ngăn đó: Contains trả về false và Remove thất bại dù đối tượng vẫn nằm trong tập.
CS0659, CS0660 và CS0661 báo điều gì?
CS0659: ghi đè Equals mà không ghi đè GetHashCode. CS0660: định nghĩa operator == hoặc != mà không ghi đè Equals. CS0661: định nghĩa operator == hoặc != mà không ghi đè GetHashCode. Cả ba đáng biến thành lỗi bằng WarningsAsErrors.
readonly record struct phù hợp cho gì?
Cho strongly-typed ID và các giá trị nhỏ mà giá trị mặc định cũng hợp lệ. Nó vừa so sánh theo giá trị, vừa không cấp phát trên heap, vừa khiến trình biên dịch bắt lỗi truyền nhầm id giữa các loại entity.
Kết luận
Ba điều đáng nhớ nhất:
classso sánh tham chiếu,recordso sánh giá trị. Chọn theo ý nghĩa của kiểu.- Ghi đè
Equalsthì phải ghi đèGetHashCode— quên là bug im lặng. - Value object phải bất biến, nếu không
HashSetvàDictionarysẽ mất dấu nó.
Tham khảo
- Kiểu record
- Object.GetHashCode — ghi chú cho người cài đặt
- Collections trong .NET
- Equality operators —
==với class, struct và record
Điều hướng
- Bài trước: 4.11 — Case study: mô hình hoá sản phẩm bằng composition
- Bài tiếp theo: 4.13 — Ôn tập và đánh giá Module 4
- Về module: Trang mục lục