Menu

Record trong C#: so sánh theo giá trị, biểu thức with, record struct

Record trong C# là gì (từ C# 9): cú pháp vị trí, các thành viên do trình biên dịch sinh ra, so sánh bằng theo giá trị, sao chép không phá hủy bằng with, ToString có sẵn, record struct từ C# 10, kế thừa giữa các record, và lớp tương đương viết tay.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Record là một kiểu có nhiệm vụ chính là chứa dữ liệu, và phép so sánh bằng của nó được xác định bởi chính dữ liệu đó. Record xuất hiện từ C# 9. Bạn viết một dòng, và trình biên dịch sinh ra các thành viên mà một lớp dữ liệu cần: thuộc tính, hàm khởi tạo, Equals và == theo giá trị, GetHashCode, một ToString dễ đọc, Deconstruct, và hỗ trợ sao chép bằng with.

Record cần C# 9 trở lên (.NET 5+), nên code record trên trang này được trình bày dưới dạng C# thông thường, với đầu ra nằm trong chú thích. Phần cuối viết tay các thành viên tương tự bằng C# 7, phần mà bạn có thể chạy.

Record vị trí

Dạng ngắn nhất liệt kê các thuộc tính trong ngoặc tròn ngay sau tên. Mỗi tham số trở thành một thuộc tính public init-only cùng tên:

public record Product(string Sku, string Name, decimal Price);

var mug = new Product("MUG-01", "Mug", 8.50m);
Console.WriteLine(mug.Name);        // Mug
Console.WriteLine(mug);             // Product { Sku = MUG-01, Name = Mug, Price = 8.50 }
// mug.Price = 4m;                  // error CS8852: init-only property

var (sku, name, price) = mug;       // generated Deconstruct
Console.WriteLine($"{sku} {price}"); // MUG-01 8.50

Từ một dòng đó, trình biên dịch sinh ra:

  • một hàm khởi tạo nhận (string Sku, string Name, decimal Price);
  • ba thuộc tính public ... { get; init; };
  • Equals(object), Equals(Product), GetHashCode(), và các toán tử == và !=, tất cả đều so sánh ba thuộc tính;
  • ToString() in ra tên kiểu và mọi thuộc tính public;
  • Deconstruct(out string Sku, out string Name, out decimal Price);
  • một hàm khởi tạo sao chép (protected, hoặc private trong một sealed record) và một phương thức clone ẩn được with sử dụng.

Record cũng có thể viết với phần thân thông thường, tiện khi các thuộc tính cần giá trị mặc định hoặc kiểm tra dữ liệu:

public record Customer
{
    public required string Email { get; init; }   // required: C# 11
    public string Name { get; init; } = "";
}

var c = new Customer { Email = "ana@example.com" };

Và có thể kết hợp cả hai dạng: tham số vị trí cộng thêm các thành viên khác trong cặp ngoặc nhọn.

public record Order(string Id, decimal Subtotal)
{
    public decimal Tax => Subtotal * 0.23m;
    public decimal Total => Subtotal + Tax;
}

So sánh bằng theo giá trị

Với một lớp thông thường, == hỏi "đây có phải cùng một đối tượng không?". Với record, nó hỏi "chúng có cùng các giá trị không?":

var a = new Product("MUG-01", "Mug", 8.50m);
var b = new Product("MUG-01", "Mug", 8.50m);

Console.WriteLine(a == b);                  // True
Console.WriteLine(a.Equals(b));             // True
Console.WriteLine(ReferenceEquals(a, b));   // False: still two objects

GetHashCode được sinh ra cho khớp, nên record hoạt động đúng khi làm khóa của từ điển và trong một HashSet<T>: một record thứ hai có cùng giá trị sẽ tìm thấy mục của record thứ nhất.

Phép so sánh bằng đối chiếu từng trường (với record vị trí, đó là trường đứng sau mỗi thuộc tính) bằng EqualityComparer<T>.Default, vốn gọi Equals của chính kiểu đó. Với một thuộc tính collection, đó là so sánh theo tham chiếu, điều khiến nhiều người bất ngờ:

public record Basket(string Owner, List<string> Items);

var x = new Basket("Ana", new List<string> { "tea" });
var y = new Basket("Ana", new List<string> { "tea" });
Console.WriteLine(x == y);   // False: two different List objects

Nếu một record chứa collection và cần so sánh theo nội dung của nó, hãy ghi đè Equals(Basket other) và GetHashCode(), hoặc dùng một collection bất biến có ngữ nghĩa giá trị do bạn tự viết.

Biểu thức with: thay đổi không phá hủy

Record thường là bất biến, nên bạn "thay đổi" một record bằng cách tạo một bản sao đã chỉnh sửa. with sao chép mọi thuộc tính, rồi áp dụng các phép gán trong cặp ngoặc nhọn:

var mug = new Product("MUG-01", "Mug", 8.50m);
var sale = mug with { Price = 6.00m };

Console.WriteLine(sale);        // Product { Sku = MUG-01, Name = Mug, Price = 6.00 }
Console.WriteLine(mug.Price);   // 8.50: the original is untouched

Bản sao là sao chép nông. Một thuộc tính kiểu tham chiếu được sao chép dưới dạng tham chiếu, nên cả hai record dùng chung đối tượng đó:

public record Customer { public List<string> Tags { get; init; } = new(); /* ... */ }

var c1 = new Customer { Email = "ana@example.com", Tags = { "vip" } };
var c2 = c1 with { Name = "Ana" };
c2.Tags.Add("newsletter");

Console.WriteLine(string.Join(",", c1.Tags));   // vip,newsletter

Hoặc giữ các thuộc tính của record bất biến đến tận cùng (IReadOnlyList<T> được điền một lần, hoặc ImmutableList<T>), hoặc tạo một danh sách mới trong with: c1 with { Tags = new List<string>(c1.Tags) }.

ToString

ToString được sinh ra in tên kiểu và mọi thuộc tính public, giúp record dễ đọc trong log và trình gỡ lỗi:

Console.WriteLine(new Product("MUG-01", "Mug", 8.50m));
// Product { Sku = MUG-01, Name = Mug, Price = 8.50 }

Collection được in ra dưới dạng tên kiểu của nó (System.Collections.Generic.List`1[System.String]), còn các record lồng nhau được in đệ quy. Bạn có thể thay toàn bộ kết quả bằng cách ghi đè ToString:

public record Money(decimal Amount, string Currency)
{
    public override string ToString() => $"{Amount:F2} {Currency}";
}

record struct (C# 10)

Riêng record có nghĩa là record class: một kiểu tham chiếu. C# 10 bổ sung record struct, một kiểu giá trị có cùng các thành viên được sinh ra:

public readonly record struct Point(int X, int Y);

var p = new Point(3, 4);
var q = p with { Y = 10 };
Console.WriteLine(p == new Point(3, 4));   // True
Console.WriteLine(q);                      // Point { X = 3, Y = 10 }

Khác biệt về mặc định này đáng ghi nhớ: một record struct vị trí có các thuộc tính có thể thay đổi ({ get; set; }), giống cách struct thường hoạt động, còn readonly record struct và record class có thuộc tính init-only. Hãy chọn giữa chúng như khi chọn giữa struct và class: các giá trị nhỏ được sao chép thoải mái hợp với readonly record struct; mọi thứ còn lại, dùng record.

Kế thừa

Một record có thể kế thừa từ một record khác (không phải từ một lớp, và một lớp cũng không thể kế thừa từ record). Các tham số vị trí được truyền cho lớp cơ sở giống như đối số của hàm khởi tạo:

public abstract record Shape(string Color);
public record Circle(string Color, double Radius) : Shape(Color);
public record Square(string Color, double Side) : Shape(Color);

Shape a = new Circle("red", 2);
Shape b = new Circle("red", 2);
Shape c = new Square("red", 2);

Console.WriteLine(a == b);   // True
Console.WriteLine(a == c);   // False: different runtime types are never equal
Console.WriteLine(a);        // Circle { Color = red, Radius = 2 }

Phép so sánh bằng có tính cả kiểu lúc chạy, thông qua một thuộc tính EqualityContract được sinh ra. Đó là lý do một Circle không bao giờ bằng một Square có cùng Color, dù cả hai được so sánh qua Shape, và cũng là lý do ToString và with hoạt động trên kiểu dẫn xuất kể cả khi biến có kiểu của lớp cơ sở.

Điều tương tự trong C# 7: một lớp so sánh theo giá trị

Record sinh ra code mà bạn có thể tự viết, và nhìn thấy code đó giải thích được cách record hoạt động. Đây là một lớp C# 7 tương đương với public record Point(int X, int Y);: các thuộc tính chỉ có get, một hàm khởi tạo, Deconstruct, so sánh theo giá trị, mã băm khớp với nó, ==, ToString, và một phương thức With thay cho biểu thức with.

Đầu ra:

True
False
Point { X = 3, Y = 10 }
Point { X = 3, Y = 4 }
x=3, y=10
True
False

Khoảng 30 dòng cho hai thuộc tính, và mỗi thuộc tính mới đồng nghĩa với việc phải sửa lại hàm khởi tạo, Deconstruct, Equals, GetHashCode và ToString. Quên một trong số đó là bug kinh điển (hai điểm == nhau nhưng có mã băm khác nhau, khiến HashSet mất dấu chúng). Công sức bảo trì đó chính là thứ record loại bỏ.

Lớp này được đánh dấu sealed có chủ ý: so sánh theo giá trị kết hợp với kế thừa cần thêm phép kiểm tra kiểu mà record sinh ra qua EqualityContract, và sealed giúp tránh hẳn vấn đề đó.

Khi nào nên dùng record

Record phù hợp với dữ liệu được xác định bởi các giá trị của nó và không thay đổi sau khi tạo:

  • model request và response cho web API;
  • thông điệp, lệnh và sự kiện được truyền giữa các phần của một hệ thống;
  • các đối tượng cấu hình và tùy chọn;
  • khóa từ điển gồm nhiều thành phần (record CacheKey(string Region, int Year));
  • kết quả của một phép tính (record PriceQuote(decimal Net, decimal Tax)).

Chúng không hợp ở những chỗ danh tính quan trọng hơn giá trị: một entity của Entity Framework vẫn là "khách hàng 42" kể cả sau khi tên đã đổi, và cơ chế theo dõi thay đổi của EF Core dựa vào danh tính tham chiếu. Ở đó hãy dùng class.

Lỗi thường gặp

  • Kỳ vọng so sánh sâu cho collection. Một thuộc tính List<T> so sánh theo tham chiếu. Hai record có danh sách trông giống nhau vẫn không bằng nhau.
  • Kỳ vọng with sao chép sâu. Các đối tượng lồng nhau và collection được dùng chung giữa bản gốc và bản sao.
  • Vô tình tạo record struct vị trí có thể thay đổi. Hãy thêm readonly trừ khi bạn muốn các thuộc tính gán lại được.
  • Dùng record làm entity của EF Core. So sánh theo giá trị và việc sao chép xung đột với cơ chế theo dõi thay đổi.
  • Thêm record vào một dự án C# 8. Record cần C# 9 (mặc định từ .NET 5 trở lên). Với các nền tảng cũ hơn, hãy tự viết lớp như ví dụ ở trên.

Câu hỏi thường gặp

Record trong C# là gì?

Record (C# 9) là một lớp, hoặc với record struct (C# 10) là một struct, mà trình biên dịch tự sinh cho nó phép so sánh bằng theo giá trị, một ToString() dễ đọc, một phương thức Deconstruct và hỗ trợ sao chép bằng with. public record Product(string Sku, decimal Price); là một kiểu hoàn chỉnh có hai thuộc tính init-only. Hai record có giá trị thuộc tính bằng nhau thì bằng nhau, dù chúng là hai đối tượng khác nhau.

Record và class trong C# khác nhau thế nào?

Bên dưới, record là một lớp, nên nó là kiểu tham chiếu và có thể kế thừa từ các record khác. Khác biệt nằm ở các thành viên được sinh ra: record so sánh theo giá trị (== và Equals kiểm tra mọi trường), in ra các thuộc tính từ ToString() và hỗ trợ with. Một lớp thông thường so sánh theo tham chiếu và in ra tên kiểu, trừ khi bạn tự viết các thành viên đó.

Biểu thức with trong C# làm gì?

var sale = product with { Price = 6.00m }; tạo một record mới sao chép mọi thuộc tính của product rồi gán lại các thuộc tính được liệt kê. Bản gốc không thay đổi. Bản sao là sao chép nông: một thuộc tính List<T> được dùng chung bởi cả hai record, nên thêm phần tử qua record này thì record kia cũng thấy.

record struct trong C# là gì?

record struct (C# 10) là một kiểu giá trị có cùng các thành viên được sinh ra như record class: so sánh theo giá trị, ToString, Deconstruct và with. Khác với record class, các thuộc tính vị trí của nó mặc định có thể thay đổi; hãy khai báo readonly record struct để chúng thành init-only. Dùng nó cho các giá trị nhỏ như tọa độ hay số tiền.

Khi nào nên dùng record trong C#?

Cho dữ liệu mà danh tính của nó chính là các giá trị: DTO, model request và response của API, thông điệp và sự kiện, cấu hình, và khóa của từ điển. Tránh dùng record cho các thực thể thay đổi theo thời gian và được định danh bằng id, như entity của Entity Framework, vì so sánh theo giá trị và các bản sao with đi ngược với cơ chế theo dõi thay đổi.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu