Tuple gói một số lượng nhỏ, cố định các giá trị thành một giá trị duy nhất mà không cần khai báo lớp riêng. ("Ana", 31) là một tuple gồm một string và một int. Tuple là cách viết quen thuộc để trả về hai hoặc ba thứ từ một phương thức và để giữ các cặp giá trị dùng tạm bên trong một phương thức.
Tạo một tuple
Kiểu của tuple cũng được viết bằng dấu ngoặc tròn: (string, int) là một kiểu tuple có phần tử đầu là string và phần tử thứ hai là int.
Đầu ra:
Ana is 31
(3, 4)
179.70
(Ana, 32)
Khi không đặt tên, các phần tử là Item1, Item2, Item3 và cứ thế. Các tuple này là struct System.ValueTuple, nên gán một tuple cho biến khác sẽ sao chép các giá trị, và sửa bản sao không ảnh hưởng tới bản gốc.
Đặt tên phần tử
Item1 không nói gì về ý nghĩa của giá trị. Hãy đặt tên cho các phần tử, trong kiểu hoặc trong literal:
Đầu ra:
Ana, 31
Desk lamp costs 24.99
Ana
Ana
Tên chỉ là tiện ích lúc biên dịch. Lúc chạy, cả hai tuple ở trên đều là giá trị ValueTuple<string, int> bình thường, vì thế gán person cho một tuple có tên khác vẫn biên dịch được: chỉ cần kiểu và thứ tự các phần tử khớp nhau.
Từ C# 7.1, tên còn được suy ra từ các biến trong literal, nên var t = (name, age); cho bạn t.name và t.age mà không phải viết lại. Trong C# 7.0, các phần tử đó chỉ là Item1 và Item2.
Trả về nhiều giá trị từ một phương thức
Đây là lý do chính tuple được thêm vào ngôn ngữ. Trước đó, các lựa chọn là tham số out, một lớp dùng một lần, hoặc System.Tuple với Item1 vô danh.
Đầu ra:
Lowest 60, highest 95
Average 78.75
Tên phần tử trong kiểu trả về trở thành tên mà nơi gọi nhìn thấy. So sánh với cùng phương thức đó viết bằng tham số out:
static void Stats(int[] scores, out int min, out int max, out double average) { ... }
Stats(scores, out int min, out int max, out double avg);
Tham số out vẫn là quy ước cho kiểu TryParse, nơi một bool cho biết thao tác có thành công không và giá trị được trả kèm theo. Còn với kiểu "tính vài thứ rồi trả về", tuple dễ đọc hơn. Xem ref và out.
Deconstruction
Deconstruction tách một tuple thành các biến riêng trong một câu lệnh:
Đầu ra:
Lima 24.5
Lima
24.5
2 1
Tên biến trong var (city, temp) là do bạn đặt; chúng không cần trùng với tên phần tử của tuple, vì deconstruction đi theo vị trí. Phép hoán đổi (a, b) = (b, a) dựng một tuple từ các giá trị cũ trước rồi mới gán, nên không giá trị nào bị mất.
Lớp của chính bạn cũng có thể hỗ trợ deconstruction bằng cách khai báo phương thức Deconstruct với các tham số out. Từ C# 7 với .NET Core 2.0 trở lên, KeyValuePair cũng có phương thức này, nên một vòng lặp qua dictionary có thể tách từng mục ngay ở phần đầu vòng lặp:
class Point
{
public int X { get; }
public int Y { get; }
public Point(int x, int y) { X = x; Y = y; }
public void Deconstruct(out int x, out int y) { x = X; y = Y; }
}
var (x, y) = new Point(3, 4);
foreach (var (name, score) in scoresByName) // Dictionary<string, int>
{
Console.WriteLine($"{name}: {score}");
}
Cách viết foreach (var (a, b) in listOfTuples) cũng dùng được cho một list tuple. Lấy cả tuple làm một biến lặp rồi đọc các phần tử có tên của nó, như phần tiếp theo, cũng tốt không kém.
Tuple trong list, LINQ và dictionary
Một list các named tuple là cách gọn nhẹ để giữ các hàng dữ liệu bên trong một phương thức:
Đầu ra:
Chloe 95
Ana 88
Ben 72
(Ana, B), (Chloe, A)
Ben
False
ValueTuple cài đặt Equals và GetHashCode theo từng phần tử, nên (2, 7) được tạo ở hai nơi khác nhau vẫn tìm thấy cùng một mục trong dictionary. Điều đó khiến tuple trở thành khóa tổng hợp đơn giản nhất: không cần lớp, không cần tự viết hàm băm, không cần nối chuỗi kiểu row + ":" + col.
So sánh tuple
Equals so sánh theo từng phần tử và đã dùng được từ khi tuple ra đời:
Đầu ra:
True
False
C# 7.3 thêm == và != cho tuple, được biên dịch thành cùng phép so sánh từng phần tử và cũng bỏ qua tên:
if ((order.Status, order.Paid) == ("shipped", true)) { ... }
Console.WriteLine((1, "x") == (1, "x")); // True
System.Tuple và ValueTuple
.NET 4 giới thiệu System.Tuple, một lớp được tạo bằng Tuple.Create. Nó vẫn có trong framework và vẫn xuất hiện trong code và API cũ, vì vậy khi tìm "tuple C#" bạn thường gặp nó.
ValueTuple (C# 7) | System.Tuple (.NET 4) | |
|---|---|---|
| Cú pháp | (1, "a"), (int, string) | Tuple.Create(1, "a"), Tuple<int, string> |
| Loại | struct (kiểu giá trị) | class (kiểu tham chiếu) |
| Tên phần tử | Có | Không, chỉ có Item1, Item2 |
| Thay đổi được | Có, các phần tử là field | Không, thuộc tính chỉ đọc |
| Deconstruction | Có | Có, qua extension method |
Đầu ra:
Ben 25
(Ben, 25)
ToValueTuple() và ToTuple() chuyển đổi qua lại giữa hai loại. Trong code mới hãy ưu tiên value tuple: chúng không cấp phát gì trên heap và có thể mang tên.
Khi nào nên dùng lớp hoặc record
Tuple phù hợp nhất khi nhóm giá trị mang tính cục bộ và dễ hiểu: một phương thức trả về hai con số, một truy vấn LINQ chuyển một cặp sang bước tiếp theo, một dictionary cần khóa gồm hai phần. Hãy chuyển sang một kiểu có tên khi:
- Cùng một dạng xuất hiện trong nhiều chữ ký phương thức public.
(string, string, int)ở năm chỗ là một lớp chưa được viết ra. - Các giá trị cần hành vi, cần kiểm tra hợp lệ, hoặc có nhiều hơn ba bốn phần tử.
- Dữ liệu đi qua một ranh giới như serialization. Tên không tồn tại lúc chạy, nên Newtonsoft.Json ghi ra
Item1vàItem2, cònSystem.Text.Jsonghi ra{}vì phần tử của tuple là field, thứ mà nó bỏ qua theo mặc định.
Record của C# 9 cho bạn phần lớn sự tiện lợi của tuple cùng với một cái tên thật. record Score(string Name, int Points); có sẵn so sánh theo giá trị, ToString dễ đọc và deconstruction. Xem record.
Lỗi thường gặp
- Mong tên còn giữ được lúc chạy. Reflection và các serializer chỉ thấy
Item1,Item2(hoặc không thấy gì cả). - Sửa một bản sao. Tuple là struct:
var t2 = t1; t2.Item1 = 5;không làm thay đổit1. - Tuple có quá nhiều phần tử. Quá ba hoặc bốn phần tử thì một kiểu có tên sẽ dễ đọc hơn.
- Nhầm thứ tự khi deconstruction. Nó đi theo vị trí: với tuple
(Name, Age),var (age, name) = person;đặt tên người vàoage, và vìvartự suy ra kiểu nên không có cảnh báo nào.
Câu hỏi thường gặp
Làm sao trả về nhiều giá trị từ một phương thức trong C#?
Trả về một tuple: khai báo kiểu trả về là (int Min, int Max) và viết return (lowest, highest);. Nơi gọi đọc result.Min và result.Max, hoặc deconstruct bằng var (min, max) = MinMax(data);. Các cách khác là tham số out, hợp với kiểu TryParse, hoặc một lớp hay record nhỏ khi kết quả có ý nghĩa riêng.
Named tuple trong C# là gì?
Là tuple có các phần tử được đặt tên thay vì Item1, Item2: (string Name, int Age) person = ("Ana", 31); cho phép bạn viết person.Name. Các tên này chỉ tồn tại lúc biên dịch; bên dưới nó vẫn là một ValueTuple<string, int>, và Item1 vẫn dùng được.
Tuple và ValueTuple trong C# khác nhau thế nào?
System.Tuple (từ .NET 4) là một lớp: được cấp phát trên heap, không thay đổi được, và các phần tử chỉ có tên Item1, Item2. System.ValueTuple (C# 7) là một struct với các field thay đổi được, hỗ trợ tên phần tử và cú pháp (a, b), và là thứ mà mọi tuple literal tạo ra. Hãy dùng value tuple trong code mới.
Deconstruction tuple trong C# hoạt động thế nào?
var (name, age) = person; khai báo hai biến và gán các phần tử của tuple cho chúng theo thứ tự. Bạn cũng có thể gán vào các biến có sẵn bằng (a, b) = (b, a), cách này hoán đổi hai biến, và bỏ qua phần tử bằng discard _: var (_, age) = person;.
Có thể so sánh tuple bằng == trong C# không?
Từ C# 7.3, == và != so sánh tuple theo từng phần tử: (1, "a") == (1, "a") là true. Tên bị bỏ qua; chỉ vị trí và giá trị là quan trọng. Trước C# 7.3, hãy dùng Equals, được value tuple cài đặt theo cùng cách.