Chuyển tới nội dung chính

5.9 — 8. Tuples và Deconstruction

Tóm tắt

Tuple giải quyết một nhu cầu rất thường gặp: trả về nhiều giá trị mà không phải dựng một class chỉ dùng một lần. ValueTuple của C# 7 là kiểu giá trị, có tên trường, và thay thế hẳn out cho hầu hết trường hợp. Nhưng có một chi tiết cần biết trước khi dùng rộng rãi: tên trường chỉ tồn tại ở mức trình biên dịch — chúng bị xoá khỏi IL và thay bằng Item1, Item2, nên khi đi qua reflection, serialization hay ranh giới assembly thì tên biến mất. Vì vậy tuple hợp với giá trị trả về nội bộ, không hợp với hợp đồng API công khai.

Mục tiêu bài học​

Sau bài này bạn có thể:

  • Dùng tuple có tên để trả nhiều giá trị thay cho out.
  • Phân biệt ValueTuple với Tuple cũ và biết vì sao cái mới thay thế cái cũ.
  • Giải thích vì sao tên trường tuple biến mất trong một số ngữ cảnh.
  • Viết Deconstruct cho kiểu của mình.
  • Quyết định khi nào dùng tuple và khi nào dùng record.

Nội dung bài học​

5.9.1 — Thay thế out​

// CŨ — người gọi phải khai báo biến trước, không nối chuỗi được
public void GetStats(List<Order> orders, out decimal total, out int count)
{
total = orders.Sum(o => o.Total);
count = orders.Count;
}

decimal t; int c;
GetStats(orders, out t, out c);
// MỚI — có tên, đọc được, dùng được trong LINQ
public (decimal Total, int Count) GetStats(List<Order> orders)
=> (orders.Sum(o => o.Total), orders.Count);

var stats = GetStats(orders);
Console.WriteLine(stats.Total);

// Hoặc tách luôn ra hai biến
var (total, count) = GetStats(orders);

Dạng thứ hai gọi là deconstruction — tách một giá trị phức hợp thành nhiều biến.

5.9.2 — ValueTuple và Tuple cũ​

Tuple<T1,T2> (.NET 4.0)ValueTuple / (T1, T2) (C# 7)
Loại kiểuTham chiếu (cấp phát heap)Giá trị
Tên trườngKhông — chỉ Item1, Item2Có
Có thể thay đổiKhôngCó
Cú phápTuple.Create(a, b)(a, b)

ValueTuple là kiểu giá trị nên không cấp phát trên heap, quan trọng khi trả về trong vòng lặp nóng. Và nó có tên trường, nên stats.Total đọc dễ hơn hẳn stats.Item1.

Trong code mới, gần như không còn lý do dùng Tuple cũ.

5.9.3 — Tên trường chỉ tồn tại lúc biên dịch​

Đây là chi tiết quan trọng nhất của bài.

public (decimal Total, int Count) GetStats() => (100m, 5);

Sau khi biên dịch, kiểu thật là ValueTuple<decimal, int> với hai trường Item1 và Item2. Tên Total và Count được lưu dưới dạng một attribute (TupleElementNames) mà chỉ trình biên dịch đọc.

Hệ quả cụ thể:

// Serialize -> tên biến mất
JsonSerializer.Serialize(GetStats());
// {"Item1":100,"Item2":5} ← không phải Total và Count

// Reflection -> chỉ thấy Item1, Item2
typeof(ValueTuple<decimal,int>).GetFields().Select(f => f.Name);
// Item1, Item2

Vì vậy:

  • Dùng tuple cho giá trị trả về nội bộ, biến cục bộ, khoá tạm.
  • Đừng dùng tuple cho DTO của API, model gửi qua mạng, hay bất cứ thứ gì đi qua serialization.

Với hợp đồng công khai, record là lựa chọn đúng — nó có tên trường thật, tồn tại trong IL, và serialize ra đúng tên.

5.9.4 — Deconstruction cho kiểu của bạn​

public class Customer
{
public string Name { get; init; } = "";
public string Email { get; init; } = "";
public int Age { get; init; }

public void Deconstruct(out string name, out string email)
=> (name, email) = (Name, Email);

// Nạp chồng cho số lượng khác
public void Deconstruct(out string name, out string email, out int age)
=> (name, email, age) = (Name, Email, Age);
}

var (name, email) = customer;
var (n, e, a) = customer;

Chỉ cần một phương thức tên Deconstruct với các tham số out — không cần cài đặt interface nào. record tự sinh sẵn phương thức này cho các tham số vị trí.

Deconstruction ghép rất tốt với pattern matching ở bài 4.9:

var message = customer switch
{
(_, _, < 18) => "Chưa đủ tuổi",
(var nm, _, _) => $"Xin chào {nm}"
};

5.9.5 — Vài cách dùng hữu ích​

// Hoán đổi không cần biến tạm
(a, b) = (b, a);

// Gán nhiều biến cùng lúc
var (x, y, z) = (1, 2, 3);

// Tuple làm khoá Dictionary — so sánh theo giá trị, có sẵn
var cache = new Dictionary<(int TenantId, int CustomerId), Customer>();
cache[(1, 42)] = customer;

// Sắp xếp nhiều tiêu chí
orders.OrderBy(o => (o.Priority, o.CreatedAt));

// Trả về kết quả kèm lý do thất bại, không cần ngoại lệ
public (bool Success, string? Error) Validate(Order o)
=> o.Total < 0 ? (false, "Tổng tiền âm") : (true, null);

Dòng thứ ba đáng chú ý: ValueTuple cài đặt sẵn Equals và GetHashCode theo giá trị, nên nó dùng làm khoá Dictionary được ngay — không gặp vấn đề đã nêu ở bài 1.6.

5.9.6 — Tuple hay record?​

Tiêu chíTuplerecord
Phạm viNội bộ một phương thức, một classCả hệ thống
Tên trường sau khi biên dịchMấtGiữ
Serialize được đúng tênKhôngCó
Gắn được hành viKhôngCó
Chi phí viếtKhông cóMột khai báo

Quy tắc thực dụng:

  • Hai giá trị, dùng ngay trong một phương thức → tuple.
  • Trả ra khỏi class, hoặc có tên nghiệp vụ → record.
  • Ba giá trị trở lên mà người gọi phải nhớ thứ tự → record.

Dấu hiệu nên chuyển từ tuple sang record: bạn thấy mình lặp lại cùng một hình dạng tuple ở ba chỗ, hoặc phải viết comment giải thích các phần tử là gì. Lúc đó nhóm giá trị này đã có một khái niệm nghiệp vụ, và nó xứng đáng có tên.

5.9.7 — Rà lại code của bạn​

Danh sách rà soát tuple

  • •Không còn tham số out nào chỉ để trả nhiều giá trị (trừ mẫu TryXxx).
  • •Không dùng Tuple cũ; mọi chỗ đều là ValueTuple với cú pháp ngoặc.
  • •Mọi tuple trả về đều có TÊN trường, không để Item1, Item2.
  • •Không có tuple nào nằm trong DTO của API hay model serialize qua mạng.
  • •Tuple lặp lại ở ba chỗ trở lên đã được nâng thành record.
  • •Khoá Dictionary phức hợp dùng tuple thay vì nối chuỗi.

Bài tập áp dụng​

Bài 1 — Chứng minh tên trường tuple biến mất​

Viết hàm trả (decimal Total, int Count), tuần tự hoá kết quả bằng System.Text.Json và in ra. Ghi lại tên trường thật trong JSON.

Tiêu chí hoàn thành: bạn giải thích được kết quả, vốn còn bất ngờ hơn "tên bị đổi".

Gợi ý và lời giải — Bài 1

Gợi ý. Tên trường của tuple chỉ tồn tại lúc biên dịch. Ở mức runtime, ValueTuple có các thành viên tên là Item1, Item2. Nhưng có một chi tiết nữa về loại thành viên đó.

Lời giải.

static (decimal Total, int Count) GetStats() => (100m, 3);

Console.WriteLine(JsonSerializer.Serialize(GetStats()));

Kết quả đo thật trên .NET 9:

{}

Một đối tượng rỗng. Không phải {"Total":100,"Count":3}, cũng không phải {"Item1":100,"Item2":3} — mà không có gì cả.

Vì sao. Hai tầng nguyên nhân:

  1. Tên Total và Count không tồn tại lúc chạy. Trình biên dịch lưu chúng trong một thuộc tính siêu dữ liệu tên TupleElementNames gắn vào chữ ký phương thức, chỉ để trình biên dịch và trình soạn thảo dùng. Bản thân kiểu ValueTuple<decimal, int> chỉ có Item1 và Item2.

  2. Item1 và Item2 là field, không phải property. Và System.Text.Json theo mặc định chỉ tuần tự hoá thuộc tính công khai, bỏ qua trường. Vì ValueTuple không có thuộc tính nào, kết quả là một đối tượng rỗng.

Kiểm chứng tầng thứ nhất:

var opts = new JsonSerializerOptions { IncludeFields = true };
Console.WriteLine(JsonSerializer.Serialize(GetStats(), opts));
// {"Item1":100,"Item2":3}

Bật tuần tự hoá trường thì thấy Item1, Item2 — tên nghiệp vụ đã mất hẳn.

Hệ quả thực tế — ba chỗ không được dùng tuple:

ChỗVì sao
Kiểu trả về của API công khaiClient nhận {} hoặc Item1, không dùng được
Đối tượng lưu vào database hoặc cacheTên trường mất, không đọc lại được có nghĩa
Hợp đồng giữa hai dịch vụCùng lý do trên
// SAI — endpoint trả về {}
app.MapGet("/order-stats", () => new { }); // tương đương với trả tuple

// ĐÚNG — record có tên thật
public record OrderStatsDto(decimal Total, int Count);
app.MapGet("/order-stats", () => new OrderStatsDto(100m, 3));
// {"total":100,"count":3}

Vậy tuple dùng ở đâu. Ở những chỗ giá trị không rời khỏi tiến trình:

// Trả nhiều giá trị từ một hàm private
private (bool IsValid, string? Error) Validate(Order o) { }

// Gán nhiều biến cùng lúc
var (min, max) = FindMinMax(values);

// Khoá ghép trong Dictionary
var cache = new Dictionary<(int TenantId, int CustomerId), Customer>();

// Sắp xếp theo nhiều tiêu chí
ds.OrderBy(x => (x.Region, x.Name));

Quy tắc chọn giữa tuple và record:

Dùng tupleDùng record
Kết quả chỉ dùng ngay tại chỗ gọiGiá trị đi qua nhiều tầng
Là chi tiết nội bộ của một phương thứcLà một khái niệm nghiệp vụ có tên
Phương thức privatePhương thức public
Không cần tuần tự hoáCần tuần tự hoá, lưu trữ, hoặc trả ra API

Ranh giới đơn giản: tuple cho nội bộ, record cho hợp đồng.

Bài 2 — Bỏ out, so sánh ba phiên bản​

Tìm một hàm dùng từ hai tham số out trở lên. Viết lại bằng tuple có tên, rồi bằng record. So sánh chỗ gọi của ba phiên bản.

Tiêu chí hoàn thành: bạn nêu được tiêu chí chọn, và một trường hợp mà out vẫn là lựa chọn đúng.

Gợi ý và lời giải — Bài 2

Gợi ý. Bài này nối tiếp bài tập ở bài 1.5. Điểm mới ở đây là tìm ra ngoại lệ chính đáng cho quy tắc "đừng dùng out".

Lời giải — ba phiên bản:

// 1 — out
public void Analyze(IEnumerable<Order> orders,
out int count, out decimal total, out decimal average) { }

// 2 — tuple có tên
public (int Count, decimal Total, decimal Average) Analyze(IEnumerable<Order> orders) { }

// 3 — record
public record OrderStats(int Count, decimal Total, decimal Average);
public OrderStats Analyze(IEnumerable<Order> orders) { }

Chỗ gọi:

// 1 — phải khai báo biến trước, không dùng được trong biểu thức
Analyze(orders, out int count, out decimal total, out decimal average);
if (average > 1_000_000m) { }

// 2 — một dòng, tháo ra ngay
var (count, total, average) = Analyze(orders);
if (Analyze(orders).Average > 1_000_000m) { } // dùng được trong biểu thức

// 3 — truyền tiếp được, tuần tự hoá được, so sánh được
var stats = Analyze(orders);
await SaveReportAsync(stats, ct);
if (stats == previousStats) { } // record so sánh theo giá trị

Ba nhược điểm cụ thể của out:

  1. Không dùng được với async. Đây là nhược điểm nghiêm trọng nhất — phương thức async không được có tham số out hoặc ref. Từ Module 6 trở đi, phần lớn phương thức trong hệ thống là bất đồng bộ.
  2. Không dùng được trong biểu thức. Phải khai báo biến rồi mới gọi, nên không viết được dạng một dòng.
  3. Dễ hoán đổi nhầm. Ba tham số out liền nhau, trong đó hai cái cùng kiểu decimal — trình biên dịch không phát hiện nếu bạn đặt sai thứ tự.

Trường hợp out vẫn đúng — mẫu Try:

if (dict.TryGetValue(key, out var value)) { }
if (int.TryParse(s, out var n)) { }
if (DateTime.TryParse(s, out var d)) { }

Ba lý do khiến mẫu này chính đáng:

  • Trả về hai thứ khác bản chất: một cờ thành công và một giá trị. Chúng không phải "hai phần của một kết quả" mà là "có hay không, và nếu có thì là gì".
  • Là quy ước đã ăn sâu trong .NET. Ai đọc TryXxx(..., out var x) cũng hiểu ngay ngữ nghĩa.
  • Tránh cấp phát. Không phải tạo một tuple hay đối tượng chỉ để báo thất bại — quan trọng trong đường dẫn nóng.

Khi tự viết, hãy theo đúng quy ước: tên bắt đầu bằng Try, trả về bool, và không ném ngoại lệ:

public bool TryLayKhachHang(int id, [NotNullWhen(true)] out Customer? kh)
{
kh = _cache.GetValueOrDefault(id);
return kh is not null;
}

Thuộc tính [NotNullWhen(true)] nói với trình biên dịch rằng khi hàm trả true thì kh chắc chắn khác null — nhờ vậy bạn dùng kh ngay sau if mà không bị cảnh báo. Đây chính là kỹ thuật ở bài 5.7.

Tiêu chí chọn, tóm gọn:

Tình huốngChọn
Mẫu TryXxxout
Kết quả dùng ngay tại chỗ, phương thức privateTuple có tên
Kết quả đi xa, cần tên, cần tuần tự hoárecord
Cần biểu diễn cả thành công lẫn lỗi có thông điệpResult<T>

Bài 3 — Tuple làm khoá Dictionary​

Thay một Dictionary<string, T> đang dùng khoá nối chuỗi kiểu $"{a}-{b}" bằng Dictionary<(int, int), T>. So sánh độ rõ ràng và đo hiệu năng tra cứu.

Tiêu chí hoàn thành: bạn nêu được ba vấn đề của khoá nối chuỗi, trong đó có một vấn đề về tính đúng đắn chứ không phải hiệu năng.

Gợi ý và lời giải — Bài 3

Gợi ý. Đo hiệu năng là phần dễ. Phần đáng giá là tìm ra vấn đề tính đúng đắn — hãy thử tìm hai cặp giá trị khác nhau cho ra cùng một chuỗi khoá.

Lời giải.

// Trước
var cache = new Dictionary<string, Customer>();
cache[$"{tenantId}-{customerId}"] = kh;

// Sau
var cache = new Dictionary<(int TenantId, int CustomerId), Customer>();
cache[(tenantId, customerId)] = kh;

Đo hiệu năng — một triệu lần tra cứu trên .NET 9:

khoá chuỗi nối : 177.1 ms
khoá tuple : 99.2 ms (1.8x nhanh hơn)

Ba vấn đề của khoá nối chuỗi:

1. Cấp phát bộ nhớ. Mỗi lần tra cứu tạo một chuỗi mới trên heap. Một triệu lần tra là một triệu chuỗi rác, gây áp lực lên bộ thu gom rác — chi phí này không nằm trong con số 177 ms mà phân tán ra toàn ứng dụng.

2. Chi phí băm chuỗi. Tính mã băm của một chuỗi phải duyệt qua từng ký tự. Với tuple hai số nguyên, mã băm được tính từ hai giá trị cố định, nhanh hơn hẳn.

3. Nhập nhằng khoá — vấn đề về tính đúng đắn. Đây là vấn đề nghiêm trọng nhất:

$"{1}-{23}"    // "1-23"
$"{12}-{3}" // "12-3"

Hai cặp này cho hai chuỗi khác nhau, nên có vẻ ổn. Nhưng đổi dấu phân tách hoặc dùng dữ liệu chuỗi thì hỏng ngay:

// Ký tự phân tách xuất hiện trong chính dữ liệu
$"{"HN-01"}-{"A"}" // "HN-01-A"
$"{"HN"}-{"01-A"}" // "HN-01-A" <- TRÙNG KHOÁ

Hai cặp giá trị hoàn toàn khác nhau ánh xạ về cùng một khoá. Bộ đệm trả về dữ liệu của tổ chức này cho tổ chức khác — một lỗi rò rỉ dữ liệu, và nó không bao giờ xuất hiện trong kiểm thử với dữ liệu mẫu sạch sẽ.

Với tuple, ("HN-01", "A") và ("HN", "01-A") là hai khoá khác nhau, vì so sánh diễn ra trên từng thành phần chứ không trên chuỗi đã ghép.

Lợi ích thứ tư: rõ nghĩa. Tuple có tên biến chỗ tra cứu thành tự giải thích:

var cache = new Dictionary<(int TenantId, int CustomerId), Customer>();
cache[(tenantId: 1, customerId: 42)] = kh;

So với cache["1-42"], người đọc biết ngay hai số đó nghĩa là gì.

Khi nào dùng record struct thay vì tuple. Khi khoá là một khái niệm nghiệp vụ được dùng ở nhiều nơi:

public readonly record struct CustomerKey(int TenantId, int CustomerId);
var cache = new Dictionary<CustomerKey, Customer>();

Cách này cho phép đặt tên kiểu, thêm phương thức kiểm tra, và tránh nhầm với một tuple (int, int) khác mang ý nghĩa khác — đúng tinh thần value object ở bài 4.8.

Lưu ý về tuple làm khoá. Cả ValueTuple lẫn record struct đều đã có Equals và GetHashCode theo giá trị sẵn, nên chúng hoạt động đúng làm khoá ngay mà không cần viết thêm gì — khác hẳn với class thường, như bài 1.6 đã chứng minh.

Tự kiểm tra​

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

ValueTuple khác Tuple cũ ở đâu?

ValueTuple là kiểu giá trị nên không cấp phát trên heap, và nó có tên trường thay vì chỉ Item1 và Item2. Tuple cũ là kiểu tham chiếu và không có tên. Trong code mới gần như không còn lý do dùng Tuple cũ.

Vì sao tên trường của tuple biến mất khi serialize?

Vì tên chỉ tồn tại ở mức trình biên dịch. Kiểu thật sau khi biên dịch là ValueTuple với hai trường Item1 và Item2; tên bạn đặt được lưu trong một attribute mà chỉ trình biên dịch đọc. Nên serializer và reflection chỉ thấy Item1, Item2.

Vậy khi nào KHÔNG nên dùng tuple?

Khi nó đi qua ranh giới serialization hoặc là một phần hợp đồng API công khai — DTO, model gửi qua mạng, kiểu trả về của endpoint. Ở những chỗ đó dùng record, vì record có tên trường thật tồn tại trong IL và serialize ra đúng tên.

Làm sao cho kiểu của mình deconstruct được?

Chỉ cần viết một phương thức tên Deconstruct với các tham số out, không cần cài đặt interface nào. Có thể nạp chồng nhiều phiên bản cho số lượng phần tử khác nhau. Record với tham số vị trí tự sinh sẵn phương thức này.

Tuple dùng làm khoá Dictionary được không?

Được, và rất tiện. ValueTuple đã cài đặt sẵn Equals và GetHashCode theo giá trị, nên hai tuple cùng nội dung là cùng một khoá. Cách này rõ ràng hơn nhiều so với nối chuỗi thành khoá, và không có nguy cơ trùng khoá do ký tự phân cách.

Khi nào nên chuyển từ tuple sang record?

Khi bạn thấy mình lặp lại cùng một hình dạng tuple ở ba chỗ trở lên, hoặc phải viết comment giải thích các phần tử là gì, hoặc có từ ba giá trị mà người gọi phải nhớ thứ tự. Lúc đó nhóm giá trị này đã trở thành một khái niệm nghiệp vụ và xứng đáng có tên riêng.

Kết luận​

Ba điều đáng nhớ nhất:

  1. Tuple thay out cho hầu hết trường hợp. Có tên, đọc được, dùng được trong LINQ.
  2. Tên trường biến mất sau khi biên dịch. Đừng cho tuple đi qua serialization.
  3. Lặp lại ba lần là dấu hiệu nên làm record. Nhóm giá trị đó đã có một khái niệm.

Tham khảo​

Điều hướng​