5.2 - Generics
Tóm tắt: Generics giải quyết một mâu thuẫn: tái sử dụng code mà không mất kiểu. Trước generics, một danh sách dùng chung phải chứa
object, nghĩa là mọi giá trị số bị boxing (cấp phát trên heap) và mọi lần lấy ra phải ép kiểu — sai kiểu thì nổ lúc chạy chứ không phải lúc biên dịch. Bài này đi qua ràng buộcwhere, hai khái niệm hay bị né tránh là covariance và contravariance (giải thích bằng câu hỏi "chiều dữ liệu đi ra hay đi vào"), và một chi tiết ít ai biết:staticfield trong lớp generic tách riêng cho từng kiểu, nênCache<int>vàCache<string>có hai biến đếm khác nhau.
Mục tiêu bài học
Sau bài này bạn có thể:
- Giải thích generics tránh được boxing và ép kiểu như thế nào.
- Dùng ràng buộc
wheređể mở khoá thao tác trên kiểu generic. - Phân biệt
out(covariant) vớiin(contravariant) và biết khi nào dùng. - Dự đoán đúng hành vi của
staticfield trong lớp generic. - Viết phương thức generic để trình biên dịch tự suy kiểu.
Cần biết trước: bài 1.5 (List, Dictionary), bài 4.3 (kế thừa), bài 4.4 (interface).
Nội dung bài học
5.2.1 — Vấn đề trước khi có generics
// .NET 1.x — danh sách chứa object
var list = new ArrayList();
list.Add(42); // BOXING: int -> object, cấp phát trên heap
list.Add("xin chào"); // không ai ngăn được
int x = (int)list[0]; // ép kiểu, UNBOXING
int y = (int)list[1]; // InvalidCastException lúc CHẠYBa vấn đề, và cả ba đều biến mất với generics:
var list = new List<int>();
list.Add(42); // không boxing
// list.Add("xin chào"); // LỖI BIÊN DỊCH
int x = list[0]; // không ép kiểuGenerics trong .NET được thực thi ở mức runtime, không phải bị xoá đi như Java. JIT sinh mã máy riêng cho từng kiểu giá trị (List<int> và List<long> có mã khác nhau) và dùng chung mã cho mọi kiểu tham chiếu. Nhờ vậy bạn có cả an toàn kiểu lẫn hiệu năng.
5.2.2 — Ràng buộc where
Không có ràng buộc, T gần như không làm được gì — trình biên dịch chỉ biết nó là object.
public T Max<T>(T a, T b) where T : IComparable<T>
=> a.CompareTo(b) >= 0 ? a : b; // CompareTo mở khoá nhờ ràng buộc| Ràng buộc | Nghĩa |
|---|---|
where T : struct | Kiểu giá trị |
where T : class | Kiểu tham chiếu |
where T : notnull | Không cho phép null |
where T : IComparable<T> | Phải cài đặt interface đó |
where T : BaseClass | Phải kế thừa từ lớp đó |
where T : new() | Phải có constructor không tham số |
where T : U | T phải kế thừa hoặc cài đặt U |
Kết hợp nhiều ràng buộc:
public class Repository<T> where T : class, IEntity, new()
{
public T Create() => new T();
public int GetId(T item) => item.Id; // IEntity mở khoá thuộc tính Id
}Ràng buộc không phải hạn chế — nó là cách mở khoá khả năng. Mỗi ràng buộc thêm vào là thêm một nhóm thao tác dùng được trên T.
5.2.3 — Phương thức generic và suy kiểu
public static class Guard
{
public static T NotNull<T>(T? value, string paramName) where T : class
=> value ?? throw new ArgumentNullException(paramName);
}
// Không cần viết <Customer> — trình biên dịch tự suy từ đối số
var customer = Guard.NotNull(maybeCustomer, nameof(maybeCustomer));Suy kiểu chỉ hoạt động với đối số, không hoạt động với kiểu trả về:
public static T Parse<T>(string input) { /* ... */ }
var x = Parse("42"); // LỖI — không suy được T từ đâu
var y = Parse<int>("42"); // phải viết rõĐây là lý do các API kiểu JsonSerializer.Deserialize<T>(json) luôn bắt bạn viết tham số kiểu.
5.2.4 — Covariance và contravariance
Hai từ đáng sợ cho một ý đơn giản: dữ liệu đi ra hay đi vào?
// Trực giác nói được, nhưng...
List<Dog> dogs = new();
List<Animal> animals = dogs; // LỖI BIÊN DỊCHNếu cho phép dòng trên, bạn sẽ làm được điều này:
animals.Add(new Cat()); // vừa nhét mèo vào danh sách chóNên List<T> không cho gán như vậy — nó vừa đọc vừa ghi.
Covariance (out) — chỉ đi ra, nên an toàn:
public interface IEnumerable<out T> { } // chỉ trả T ra, không nhận T vào
IEnumerable<Dog> dogs = new List<Dog>();
IEnumerable<Animal> animals = dogs; // HỢP LỆ
// Chỉ đọc được, nên không có cách nào nhét Cat vàoContravariance (in) — chỉ đi vào, nên ngược chiều:
public interface IComparer<in T> { int Compare(T x, T y); }
IComparer<Animal> animalComparer = new AnimalComparer();
IComparer<Dog> dogComparer = animalComparer; // HỢP LỆ
// Thứ so sánh được mọi Animal thì chắc chắn so sánh được DogCách nhớ: out = ra = cụ thể gán cho tổng quát (chó là động vật). in = vào = tổng quát gán cho cụ thể (xử lý được động vật thì xử lý được chó).
Hai chiều gán, đặt cạnh nhau:
Trong .NET, IEnumerable<out T>, IReadOnlyList<out T>, Func<out TResult> là covariant; IComparer<in T>, Action<in T> là contravariant.
5.2.5 — static trong lớp generic: một biến cho mỗi kiểu
Chi tiết ít người biết nhưng gây bất ngờ khi gặp:
// Counter.cs
public class Counter<T>
{
public static int Count;
}// Program.cs
Counter<int>.Count = 5;
Counter<string>.Count = 10;
Console.WriteLine(Counter<int>.Count); // 5
Console.WriteLine(Counter<string>.Count); // 10 — biến KHÁCDán vào một file Program.cs thì khối Program.cs đặt lên đầu, khối khai báo đặt xuống cuối (C# bắt buộc, nếu không sẽ báo lỗi CS8803).
Mỗi kiểu đóng (Counter<int>, Counter<string>) là một kiểu riêng biệt ở runtime, nên mỗi cái có bộ static field riêng.
Tính chất này dùng được để làm cache theo kiểu rất nhanh: đọc TypeCache<Customer>.Name gần giống đọc một field tĩnh thường, không phải tính mã băm hay khoá như khi tra Dictionary<Type, ...>. Giá trị được tính đúng một lần, khi kiểu TypeCache<Customer> được dùng lần đầu.
internal static class TypeCache<T>
{
// Tính đúng một lần cho mỗi T, không cần Dictionary
public static readonly string Name = typeof(T).Name;
}5.2.6 — Generic trong code backend thật
Task<T?> dưới đây là kiểu trả về của phương thức bất đồng bộ (học ở bài 6.2). Ở bài này bạn chỉ cần để ý phần T: một interface dùng được cho mọi entity.
// Repository chung — nhưng cẩn thận, xem lưu ý bên dưới
public interface IRepository<T> where T : class, IEntity
{
Task<T?> GetByIdAsync(int id, CancellationToken ct);
Task AddAsync(T entity, CancellationToken ct);
}
// Kết quả có thể thất bại, không dùng ngoại lệ
public readonly record struct Result<T>(bool IsSuccess, T? Value, string? Error)
{
public static Result<T> Ok(T value) => new(true, value, null);
public static Result<T> Fail(string error) => new(false, default, error);
}Một lưu ý thực dụng về IRepository<T> chung: nó hấp dẫn nhưng hay dẫn tới interface chung chung tới mức vô dụng. Mỗi thực thể thường có nhu cầu truy vấn riêng (GetOverdueOrders, FindByEmail), và nhồi hết vào một interface generic thì quay lại đúng vấn đề interface quá lớn ở bài 4.4. Dùng generic cho phần thật sự chung, và viết interface riêng cho phần đặc thù.
5.2.7 — Rà lại code của bạn
Danh sách rà soát generics
- Không còn ArrayList, Hashtable hay collection không generic nào.
- Mỗi tham số kiểu đều có ràng buộc where phù hợp, không để trần khi cần thao tác.
- Interface chỉ trả dữ liệu ra đã cân nhắc khai out để covariant.
- Không có IRepository generic nào phình thành interface chung chung vô dụng.
- Hiểu rằng static field trong lớp generic tách riêng theo từng kiểu đóng.
- Phương thức generic đặt tham số sao cho trình biên dịch suy được kiểu.
Bài tập áp dụng
Bài 1 — Đo giá của boxing
Thêm một triệu số nguyên vào ArrayList và vào List<int>, đo thời gian và bộ nhớ. Ghi lại tỉ lệ.
Tiêu chí hoàn thành: bạn giải thích được hai nguồn chi phí khác nhau mà boxing gây ra.
Gợi ý và lời giải — Bài 1
Gợi ý. ArrayList lưu object. Khi bạn thêm một int — vốn là kiểu giá trị — runtime phải làm gì để nó trở thành một object?
Lời giải.
Tạo project bằng dotnet new console, thay nội dung Program.cs bằng đoạn dưới và chạy dotnet run -c Release. ArrayList nằm trong namespace System.Collections và Stopwatch nằm trong System.Diagnostics, hai namespace này không có sẵn trong implicit usings nên phải using tường minh.
using System.Collections; // ArrayList
using System.Diagnostics; // Stopwatch
const int N = 1_000_000;
for (int round = 1; round <= 3; round++) // chạy 3 lần, lần đầu có chi phí JIT
{
var m0 = GC.GetTotalMemory(true);
var sw = Stopwatch.StartNew();
var al = new ArrayList();
for (int i = 0; i < N; i++) al.Add(i); // mỗi lần Add là một lần boxing
sw.Stop();
Console.WriteLine($"ArrayList : {sw.Elapsed.TotalMilliseconds,6:F1} ms {(GC.GetTotalMemory(false) - m0) / 1024 / 1024} MB");
m0 = GC.GetTotalMemory(true);
sw.Restart();
var lg = new List<int>();
for (int i = 0; i < N; i++) lg.Add(i); // không boxing
sw.Stop();
Console.WriteLine($"List<int> : {sw.Elapsed.TotalMilliseconds,6:F1} ms {(GC.GetTotalMemory(false) - m0) / 1024 / 1024} MB");
GC.KeepAlive(al); GC.KeepAlive(lg);
}Kết quả lần chạy thứ ba, .NET 10, chế độ Release (con số tuyệt đối khác nhau giữa các máy, tỉ lệ thì ổn định hơn):
ArrayList : 25,5 ms 34 MB
List<int> : 1,3 ms 8 MBArrayList chậm hơn khoảng 20 lần và tốn gấp 4 lần bộ nhớ. List<int> báo 8 MB chứ không phải 4 MB (một triệu số int 4 byte) vì nó tăng dung lượng bằng cách cấp phát mảng gấp đôi rồi chép sang, các mảng cũ chưa bị thu gom lúc đo.
Hai nguồn chi phí của boxing:
Cấp phát trên heap. Mỗi
int4 byte bị bọc thành một đối tượng trên heap. Đối tượng đó cần thêm phần tiêu đề — con trỏ bảng phương thức và chỗ cho khoá đồng bộ — khoảng 16 byte trên nền 64 bit. Vậy 4 byte dữ liệu chiếm khoảng 24 byte bộ nhớ, vàArrayListcòn phải giữ thêm một mảng con trỏ (8 byte mỗi phần tử).Áp lực lên bộ thu gom rác. Một triệu đối tượng nhỏ khiến GC thế hệ 0 phải chạy nhiều hơn. Trong ứng dụng thật, chi phí này không nằm gọn trong con số đo của vòng lặp — nó phân tán ra toàn bộ ứng dụng dưới dạng những lần tạm dừng.
Cộng thêm một chi phí thứ ba khi đọc: lấy giá trị ra phải ép kiểu, tức là kiểm tra kiểu lúc chạy rồi sao chép ngược trở lại.
Vì sao generics không có chi phí này. Với kiểu giá trị, trình biên dịch JIT sinh mã máy riêng cho từng kiểu đóng. List<int> bên trong dùng thẳng một mảng int[], không có đối tượng trung gian nào. Với kiểu tham chiếu thì mã được dùng chung, cũng không phát sinh chi phí.
Đây là khác biệt quan trọng so với generics của Java, nơi kiểu bị xoá lúc biên dịch và List<Integer> vẫn phải boxing.
Ba chỗ boxing vẫn lén xuất hiện trong code hiện đại:
// 1. Gán kiểu giá trị cho biến object
object o = 42; // boxing
// 2. Gọi phương thức của interface trên struct qua biến kiểu interface
IComparable c = 42; // boxing
// 3. Dùng struct trong collection không generic, hoặc ép về object
var ht = new System.Collections.Hashtable();
ht.Add(1, "x"); // khoá 1 là int, bị boxing thành objectCách phát hiện. Trình biên dịch không cảnh báo boxing. Muốn thấy nó, dùng công cụ đo cấp phát: dotnet-counters xem số lần thu gom thế hệ 0 (tăng bất thường là dấu hiệu cấp phát nhiều đối tượng ngắn hạn), hoặc công cụ ".NET Object Allocation Tracking" trong Visual Studio để thấy dòng code nào cấp phát System.Int32.
Đừng tối ưu sớm. Boxing chỉ đáng quan tâm trong vòng lặp chạy hàng triệu lần hoặc trong đường dẫn nóng của API. Với code chạy vài chục lần mỗi request, chênh lệch vài chục nano-giây mỗi thao tác là hoàn toàn không đáng kể.
Bài 2 — Tự chứng minh covariance
Viết class Animal và class Dog : Animal. Thử gán List<Dog> cho List<Animal> và ghi lỗi biên dịch. Làm lại với IEnumerable<Dog> và giải thích vì sao nó hợp lệ.
Tiêu chí hoàn thành: bạn giải thích được vì sao List<T> không thể covariant, bằng một ví dụ cụ thể chứ không bằng lý thuyết.
Gợi ý và lời giải — Bài 2
Gợi ý. Giả sử C# cho phép gán List<Dog> cho List<Animal>. Bạn có thể viết dòng nào tiếp theo để phá vỡ hệ thống kiểu?
Lời giải.
// Animal.cs
public class Animal { }
public class Dog : Animal { }
public class Cat : Animal { }// Program.cs
List<Dog> dogs = new();
List<Animal> animals = dogs; // LỖI BIÊN DỊCH
IEnumerable<Animal> sequence = dogs; // HỢP LỆLỗi:
error CS0029: Cannot implicitly convert type 'System.Collections.Generic.List<Dog>' to 'System.Collections.Generic.List<Animal>'Vì sao List<T> không thể covariant — ví dụ cụ thể:
List<Animal> animals = dogs; // giả sử dòng này được phép
animals.Add(new Cat()); // hợp lệ, vì Cat LÀ Animal
Dog d = dogs[0]; // SỤP ĐỔ — dogs thật ra chứa một con mèoNếu covariance được phép cho List<T>, bạn thêm được một Cat vào một danh sách mà kiểu thật của nó là List<Dog>. Hệ thống kiểu bị phá vỡ, và lỗi chỉ lộ ra lúc chạy. Trình biên dịch chặn ngay từ đầu.
Vì sao IEnumerable<T> thì được. Vì nó chỉ cho phép lấy ra, không cho phép đưa vào:
public interface IEnumerable<out T> : IEnumerable { }
// ^^^ out = covariantTừ khoá out nói rằng T chỉ xuất hiện ở vị trí đầu ra — kiểu trả về. Không có phương thức nào nhận T làm tham số, nên không có cách nào đưa một Cat vào. Đọc một Dog như một Animal thì luôn an toàn.
Chiều ngược lại — contravariance:
public interface IComparer<in T> { int Compare(T x, T y); }
// ^^ in = contravariant
IComparer<Animal> animalComparer = new AnimalComparer();
IComparer<Dog> dogComparer = animalComparer; // HỢP LỆThứ so sánh được mọi Animal thì chắc chắn so sánh được Dog. Ở đây T chỉ xuất hiện ở vị trí đầu vào.
Bảng ghi nhớ:
| Từ khoá | Nghĩa | T xuất hiện ở | Ví dụ |
|---|---|---|---|
out T | Covariant — con thay được cha | Chỉ đầu ra | IEnumerable<T>, IReadOnlyList<T> |
in T | Contravariant — cha thay được con | Chỉ đầu vào | IComparer<T>, Action<T> |
T | Bất biến | Cả hai | List<T>, IList<T> |
Ứng dụng thực tế. Đây là lý do nên trả về IEnumerable<T> hoặc IReadOnlyList<T> thay vì List<T> từ API công khai — ngoài lý do đóng gói ở bài 4.2, nó còn cho người gọi sự linh hoạt về kiểu:
public IReadOnlyList<Animal> GetAll() => _dogs; // trả List<Dog> được, nhờ covarianceMột giới hạn cần biết. Covariance chỉ áp dụng cho kiểu tham chiếu. IEnumerable<int> không gán được cho IEnumerable<object> (lỗi CS0266), vì chuyển đổi đó cần boxing từng phần tử — và boxing thì không phải phép chuyển đổi tham chiếu đơn thuần.
Bài 3 — static trong lớp generic
Cài Counter<T>, gán giá trị khác nhau cho ba kiểu đóng và in ra. Giải thích kết quả bằng lời của bạn.
Tiêu chí hoàn thành: bạn nêu được một ứng dụng hữu ích của đặc điểm này, và một cái bẫy của nó.
Gợi ý và lời giải — Bài 3
Gợi ý. Counter<int> và Counter<string> có phải cùng một lớp không? Hãy nghĩ về việc trình biên dịch JIT sinh mã riêng cho từng kiểu đóng.
Lời giải.
// Counter.cs
public class Counter<T> { public static int Value; }
public class Dog { }// Program.cs
Counter<int>.Value = 10;
Counter<string>.Value = 20;
Counter<Dog>.Value = 30;
Console.WriteLine($"Counter<int>={Counter<int>.Value} " +
$"Counter<string>={Counter<string>.Value} " +
$"Counter<Dog>={Counter<Dog>.Value}");Kết quả chạy trên .NET 10:
Counter<int>=10 Counter<string>=20 Counter<Dog>=30Ba giá trị độc lập hoàn toàn, không cái nào ghi đè cái nào.
Giải thích. Counter<T> không phải một lớp — nó là một khuôn để sinh ra lớp. Mỗi kiểu đóng Counter<int>, Counter<string>, Counter<Dog> là một lớp riêng biệt ở mức runtime, và mỗi lớp có vùng dữ liệu tĩnh riêng của nó.
Nói cách khác: static trong lớp generic nghĩa là một biến cho mỗi kiểu đóng, không phải một biến cho tất cả.
Ứng dụng hữu ích — bộ nhớ đệm theo kiểu. Đây là mẫu được dùng rộng rãi trong các thư viện hiệu năng cao:
using System.ComponentModel.DataAnnotations.Schema; // TableAttribute
using System.Reflection; // PropertyInfo, GetCustomAttribute
public static class TypeCache<T>
{
// Tính một lần cho mỗi kiểu, không cần khoá, không cần Dictionary
public static readonly PropertyInfo[] Properties = typeof(T).GetProperties();
public static readonly string TableName =
typeof(T).GetCustomAttribute<TableAttribute>()?.Name ?? typeof(T).Name;
}
// Dùng: TypeCache<Customer>.Properties — tra cứu gần như miễn phíSo với một Dictionary<Type, PropertyInfo[]> dùng chung, cách này không cần khoá và không cần tính mã băm: runtime bảo đảm khởi tạo field tĩnh đúng một lần, kể cả khi nhiều luồng cùng truy cập. Reflection (đọc thông tin kiểu lúc chạy, xem bài 5.7) vốn chậm, nên việc chỉ chạy nó một lần cho mỗi kiểu là khác biệt lớn. Đây là kỹ thuật mà các thư viện ánh xạ và tuần tự hoá đều dùng.
Cái bẫy — trạng thái có thể thay đổi. Chính vì mỗi kiểu có một biến riêng, người ta dễ nhầm rằng nó "an toàn hơn" biến tĩnh thường. Không phải:
public class Counter<T>
{
public static int Value; // vẫn dùng chung giữa MỌI luồng, MỌI request
}Counter<Customer>.Value là một biến duy nhất cho toàn bộ ứng dụng. Hai request đồng thời cùng tăng nó sẽ gặp đúng vấn đề đua dữ liệu đã chứng minh ở bài 4.1.
Quy tắc giữ nguyên: trạng thái tĩnh chỉ an toàn khi nó bất biến và chỉ đọc. Generic không thay đổi điều đó, nó chỉ chia nhỏ phạm vi dùng chung theo kiểu.
Một cái bẫy nhỏ nữa — số lượng mã sinh ra. Mỗi kiểu giá trị đóng sinh ra một bản mã máy riêng. Lớp generic lớn dùng với hàng chục kiểu giá trị khác nhau làm tăng kích thước mã và thời gian JIT. Với kiểu tham chiếu thì không, vì chúng dùng chung một bản mã.
Tự kiểm tra
Generics giải quyết vấn đề gì so với dùng object?
Ba vấn đề. Không còn boxing khi lưu kiểu giá trị nên không cấp phát thừa trên heap. Không phải ép kiểu khi lấy ra. Và sai kiểu trở thành lỗi biên dịch thay vì InvalidCastException lúc chạy. Generics trong .NET được thực thi ở mức runtime nên có cả an toàn kiểu lẫn hiệu năng.
Vì sao cần ràng buộc where?
Vì không có ràng buộc thì trình biên dịch chỉ biết T là object, nên bạn gần như không làm được gì với nó. Ràng buộc không phải hạn chế mà là cách mở khoá: thêm where T IComparable thì dùng được CompareTo, thêm where T new thì tạo được instance mới.
Vì sao List<Dog> không gán được cho List<Animal>?
Vì List vừa đọc vừa ghi. Nếu cho phép gán thì sau đó thêm được một Cat vào danh sách vốn là danh sách chó, phá vỡ an toàn kiểu. Chỉ những interface chỉ đọc như IEnumerable mới được khai covariant, vì không có cách nào nhét phần tử sai vào.
out và in khác nhau thế nào?
out là covariance, nghĩa là kiểu chỉ đi ra, nên gán được từ cụ thể sang tổng quát — IEnumerable của Dog gán cho IEnumerable của Animal. in là contravariance, kiểu chỉ đi vào, nên ngược chiều: IComparer của Animal gán được cho IComparer của Dog, vì thứ so sánh được mọi động vật thì chắc chắn so sánh được chó.
static field trong lớp generic hoạt động ra sao?
Mỗi kiểu đóng là một kiểu riêng biệt ở runtime, nên Counter của int và Counter của string có hai biến static hoàn toàn khác nhau. Tính chất này dùng được để cài cache theo kiểu rất nhanh: đọc giá trị gần như đọc một field tĩnh, không cần băm hay khoá, và giá trị chỉ tính một lần khi kiểu được dùng lần đầu.
IRepository generic có vấn đề gì?
Nó hấp dẫn nhưng hay dẫn tới một interface chung chung tới mức vô dụng, vì mỗi thực thể có nhu cầu truy vấn riêng. Nhồi hết vào một interface generic thì quay lại đúng vấn đề interface quá lớn. Nên dùng generic cho phần thật sự chung và viết interface riêng cho phần đặc thù.
Kết luận
Ba điều đáng nhớ nhất:
- Ràng buộc là cách mở khoá, không phải hạn chế. Mỗi
wherethêm vào là thêm một nhóm thao tác dùng được. outra,invào. Nhớ chiều dữ liệu là nhớ được covariance và contravariance.statictrong lớp generic tách theo từng kiểu. Bất ngờ nếu không biết, hữu dụng nếu biết.
Tham khảo
- Generics in C#
- where — generic type constraint
- Covariance and contravariance — chi tiết
invàout - Collections and data structures
Điều hướng
- Bài trước: 5.1 — SOLID Principles
- Bài tiếp theo: 5.3 — LINQ
- Về module: Trang mục lục