Generics cho phép bạn viết một kiểu hoặc phương thức một lần rồi dùng với nhiều kiểu khác nhau mà không mất an toàn kiểu. Thay vì một List chứa object cần ép kiểu và nhận mọi thứ, bạn có List<int> và List<Order>, mỗi cái đều được trình biên dịch kiểm tra. Phần giữ chỗ trong dấu ngoặc nhọn, theo quy ước là T, là tham số kiểu (type parameter); kiểu mà nơi gọi cung cấp là đối số kiểu (type argument).
Vì sao cần generics: vấn đề chúng giải quyết
Trước khi có generics (C# 1), các collection lưu object. Code vẫn biên dịch được, nhưng lỗi chỉ lộ ra lúc chạy, và mỗi int đều bị boxing khi đưa vào:
var prices = new System.Collections.ArrayList();
prices.Add(9.99m);
prices.Add("12.50"); // compiles: anything is an object
decimal total = 0;
foreach (object p in prices)
total += (decimal)p; // InvalidCastException on the string
Với List<decimal>, lệnh Add thứ hai là lỗi biên dịch, vòng lặp không cần ép kiểu, và các số decimal được lưu đúng dạng decimal.
Lớp generic
Khai báo tham số kiểu sau tên lớp, rồi dùng chúng như mọi kiểu khác bên trong lớp:
Đầu ra:
Page 2/3: Chen, Dara, more: True
Page 1/1: 90, 72, 85, more: False
Page<string> và Page<int> là hai kiểu khác nhau được tạo ra từ cùng một định nghĩa. Một lớp có thể nhận nhiều tham số kiểu (Dictionary<TKey, TValue>); đặt tên là T khi chỉ có một và TSomething khi có nhiều.
Phương thức generic và suy luận kiểu
Một phương thức có thể có tham số kiểu riêng, dù lớp chứa nó có generic hay không. Nơi gọi thường bỏ qua đối số kiểu, vì trình biên dịch suy ra từ các đối số:
Đầu ra:
2 1
back front
ho ho ho
4.50 EUR, 12.00 EUR, 0.99 EUR
Suy luận kiểu chỉ dựa vào đối số, không bao giờ dựa vào kiểu trả về. Repeat(0, 3) không cần chú thích kiểu, nhưng một phương thức như T Create<T>() không có gì để suy luận, nên nơi gọi phải viết Create<Customer>(). Phần lớn LINQ là các phương thức generic dựa vào cơ chế suy luận này: prices.Where(p => p > 1) chính là Where<decimal> mà bạn không cần viết ra.
Ràng buộc: T được phép là gì
Bên trong một phương thức generic, trình biên dịch chỉ cho bạn làm những gì hợp lệ với mọi T có thể có. Khi không có ràng buộc, điều đó chẳng hơn mấy so với những gì object hỗ trợ. Để gọi CompareTo, tạo instance hoặc đọc một thuộc tính, hãy cho trình biên dịch biết T phải là gì bằng where:
Đầu ra:
17
plum
19.99
42 Zoe
Các ràng buộc bạn sẽ dùng:
| Ràng buộc | Ý nghĩa | Cho phép bạn |
|---|---|---|
where T : class | kiểu tham chiếu | gán null, dùng as |
where T : struct | kiểu giá trị không nullable | dùng T? như Nullable<T> |
where T : new() | hàm khởi tạo public không tham số | gọi new T() (phải đặt cuối cùng) |
where T : Entity | kế thừa từ Entity | dùng các thành viên của Entity |
where T : IComparable<T> | triển khai interface | gọi CompareTo |
where T : U | kế thừa từ một tham số kiểu khác | liên kết hai tham số |
where T : unmanaged (C# 7.3) | struct không chứa tham chiếu bên trong | con trỏ, stackalloc |
where T : Enum (C# 7.3) | một kiểu enum | truyền vào các phương thức của Enum |
where T : notnull (C# 8) | kiểu không nullable | chú thích nullable reference |
Nhiều ràng buộc trên một tham số được ngăn cách bằng dấu phẩy; nhiều tham số thì mỗi tham số có mệnh đề where riêng: class Cache<TKey, TValue> where TKey : notnull where TValue : class.
Thiếu ràng buộc sẽ hiện thành lỗi biên dịch ngay chỗ bạn dùng thành viên đó, ví dụ CS1061 ('T' does not contain a definition for 'CompareTo') hoặc CS0304 khi gọi new T() mà không có new().
default(T)
Code generic đôi khi cần "giá trị rỗng" của một kiểu mà nó không biết. default(T) cho 0 với số, false với bool, null với kiểu tham chiếu và kiểu nullable, và một struct toàn giá trị 0 trong các trường hợp còn lại:
Đầu ra:
31
0
Porto
True
0001-01-01
Dòng thứ hai cho thấy điểm yếu của cách làm này: với kiểu giá trị, "không tìm thấy" và "tìm thấy số 0" trông giống hệt nhau. Đó là lý do các phương thức của framework dùng dạng TryGet (bool TryGetValue(TKey key, out TValue value)). Từ C# 7.1, bạn có thể viết ngắn gọn return default; khi kiểu đã rõ từ ngữ cảnh.
Thành viên static riêng cho từng kiểu đã dựng
Mỗi kiểu đã dựng (Counter<int>, Counter<string>) có bản sao riêng của các trường static trong lớp:
Đầu ra:
2
1
False
Điều này đôi khi hữu ích (một bộ nhớ đệm theo kiểu, chẳng hạn TypeInfo<T>.Name được tính một lần cho mỗi T) và đôi khi gây bất ngờ khi bạn tưởng chỉ có một bộ đếm dùng chung. Hãy đặt trạng thái dùng chung trong một lớp không generic.
Collection generic
Các collection generic đáp ứng hầu hết nhu cầu: List<T>, Dictionary<TKey, TValue>, HashSet<T>, Queue<T>, Stack<T>, cùng các interface chúng dùng chung là IEnumerable<T>, ICollection<T>, IList<T>, IReadOnlyList<T>. Hãy nhận interface hẹp nhất mà phương thức cần (IEnumerable<T> nếu nó chỉ lặp), và trả về một kiểu cụ thể hoặc kiểu chỉ đọc.
Hiệp biến và nghịch biến
Một Dog là một Animal, nhưng một List<Dog> không phải là List<Animal>:
List<Dog> dogs = new List<Dog>();
List<Animal> animals = dogs;
// error CS0029: Cannot implicitly convert type 'List<Dog>' to 'List<Animal>'
Nếu điều đó được phép, animals.Add(new Cat()) sẽ cho một con mèo vào danh sách chó. Các interface chỉ tạo ra giá trị thì an toàn, và chúng được khai báo là hiệp biến (covariant) bằng out:
IEnumerable<Animal> readOnly = dogs; // fine: IEnumerable<out T>
IReadOnlyList<Animal> alsoFine = dogs; // fine: IReadOnlyList<out T>
Mặt đối xứng, nghịch biến (contravariance), đánh dấu bằng in, áp dụng cho interface và delegate chỉ tiêu thụ giá trị: một Action<Animal> có thể dùng ở chỗ cần Action<Dog>, vì thứ gì xử lý được mọi con vật thì cũng xử lý được một con chó. IComparer<in T> có tính nghịch biến, và Func<in T, out TResult> dùng cả hai dấu hiệu. Tính biến thiên (variance) chỉ áp dụng cho interface và delegate, và chỉ với đối số là kiểu tham chiếu: IEnumerable<int> không chuyển được sang IEnumerable<object>.
Lỗi thường gặp
- Dùng
objectở chỗ đáng lẽ là tham số kiểu. Cách này bỏ mất kiểm tra lúc biên dịch và gây boxing với kiểu giá trị. - So sánh hai giá trị
Tbằng==. Không có ràng buộcclassthì code không biên dịch (CS0019), còn có ràng buộc thì nó so sánh tham chiếu. Hãy dùngEqualityComparer<T>.Default.Equals(a, b), cách này đúng với mọiTvà tôn trọngEqualsriêng của kiểu đó. - Trả về
default(T)để báo "không tìm thấy". Với kiểu giá trị, kết quả này mơ hồ. Hãy dùngbool TryX(..., out T value). - Quá nhiều ràng buộc. Mỗi ràng buộc thu hẹp số người dùng được phương thức. Chỉ ràng buộc những gì phần thân thực sự cần.
- Mong
List<Derived>chuyển được sangList<Base>. Hãy nhậnIEnumerable<Base>hoặcIReadOnlyList<Base>thay vào đó.
Câu hỏi thường gặp
Generics trong C# là gì?
Generics cho phép bạn viết một lớp, interface hoặc phương thức một lần với một kiểu giữ chỗ, T, để nơi gọi điền vào: List<int>, List<Customer>. Trình biên dịch kiểm tra mọi chỗ dùng theo kiểu thật, nên bạn có an toàn kiểu mà không cần ép kiểu, và kiểu giá trị được lưu mà không bị boxing. Các collection trong System.Collections.Generic là ví dụ quen thuộc nhất.
Làm sao viết một phương thức generic trong C#?
Đặt tham số kiểu sau tên phương thức: static T Largest<T>(List<T> items) where T : IComparable<T> { ... }. Nơi gọi thường bỏ qua đối số kiểu vì trình biên dịch tự suy ra từ các đối số: Largest(prices) chạy được khi prices là List<decimal>. Hãy viết rõ ra, Largest<decimal>(...), khi không có gì để suy luận.
where T : class nghĩa là gì trong C#?
Đó là một ràng buộc: T phải là kiểu tham chiếu, nên code generic được phép gán null cho một T và dùng as T. Các ràng buộc khác là where T : struct (kiểu giá trị không nullable), where T : new() (có hàm khởi tạo public không tham số, nên code có thể gọi new T()), where T : SomeBaseClass và where T : ISomeInterface, cho phép code gọi các thành viên của interface đó trên T.
default(T) trong C# là gì?
Là giá trị mặc định của kiểu mà T thực sự là: 0 với số, false với bool, null với kiểu tham chiếu và kiểu nullable, và một struct toàn giá trị 0 với các kiểu giá trị khác. Code generic dùng nó khi cần "không có giá trị" mà không biết kiểu, ví dụ làm giá trị trả về khi tìm kiếm thất bại. C# 7.1 bổ sung literal default ngắn gọn hơn.
Tại sao không gán được List<Dog> cho List<Animal> trong C#?
Vì một List<Animal> chấp nhận bất kỳ con vật nào: nếu phép gán được phép, code có thể thêm một Cat vào thứ thực chất là List<Dog>. Trình biên dịch từ chối (CS0029). Các interface chỉ đọc có tính hiệp biến (covariant), nên IEnumerable<Animal> animals = dogs; và IReadOnlyList<Animal> animals = dogs; đều biên dịch được, vì không thể thêm gì qua chúng.