Menu

Generics trong C#: lớp generic, phương thức và ràng buộc

Cách generics hoạt động trong C#: viết lớp và phương thức generic với tham số kiểu, suy luận kiểu, ràng buộc bằng where (class, struct, new(), lớp cơ sở, interface), default(T), thành viên static theo từng kiểu và hiệp biến với IEnumerable<out T>.

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.

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ĩaCho phép bạn
where T : classkiểu tham chiếugán null, dùng as
where T : structkiểu giá trị không nullabledù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 : Entitykế thừa từ Entitydùng các thành viên của Entity
where T : IComparable<T>triển khai interfacegọi CompareTo
where T : Ukế thừa từ một tham số kiểu khácliên kết hai tham số
where T : unmanaged (C# 7.3)struct không chứa tham chiếu bên trongcon trỏ, stackalloc
where T : Enum (C# 7.3)một kiểu enumtruyền vào các phương thức của Enum
where T : notnull (C# 8)kiểu không nullablechú 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ị T bằng ==. Không có ràng buộc class thì 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ùng EqualityComparer<T>.Default.Equals(a, b), cách này đúng với mọi T và tôn trọng Equals riê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ùng bool 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 sang List<Base>. Hãy nhận IEnumerable<Base> hoặc IReadOnlyList<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.

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