Skip to main content

5.3 — 2. Generics

Summary

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ộc where, 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: static field trong lớp generic tách riêng cho từng kiểu, nên Cache<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ới in (contravariant) và biết khi nào dùng.
  • Dự đoán đúng hành vi của static field trong lớp generic.
  • Viết phương thức generic để trình biên dịch tự suy kiểu.

Nội dung bài học​

5.3.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ẠY

Ba 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ểu

Generics 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.3.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ộcNghĩa
where T : structKiểu giá trị
where T : classKiểu tham chiếu
where T : notnullKhông cho phép null
where T : IComparable<T>Phải cài đặt interface đó
where T : BaseClassPhải kế thừa từ lớp đó
where T : new()Phải có constructor không tham số
where T : UT 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.3.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.3.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ỊCH

Nế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ào

Contravariance (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 Dog

Cá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ó).

Trong .NET, IEnumerable<out T>, IReadOnlyList<out T>, Func<out TResult> là covariant; IComparer<in T>, Action<in T> là contravariant.

5.3.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:

public class Counter<T>
{
public static int Count;
}

Counter<int>.Count = 5;
Counter<string>.Count = 10;

Console.WriteLine(Counter<int>.Count); // 5
Console.WriteLine(Counter<string>.Count); // 10 — biến KHÁC

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: đây là cách cài đặt cache theo kiểu rất nhanh, vì tra cứu được thực hiện lúc JIT chứ không phải lúc chạy.

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.3.6 — Generic trong code backend thật​

// 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.5. Dùng generic cho phần thật sự chung, và viết interface riêng cho phần đặc thù.

5.3.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.

const int N = 1_000_000;

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: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:F1} ms {(GC.GetTotalMemory(false)-m0)/1024/1024} MB");

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

ArrayList  :   135.2 ms     34 MB
List<int> : 17.3 ms 6 MB
tỉ lệ : 7.8x thời gian, 5.8x bộ nhớ

Hai nguồn chi phí của boxing:

  1. Cấp phát trên heap. Mỗi int 4 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à ArrayList còn phải giữ thêm một mảng con trỏ.

  2. Áp lực lên bộ thu gom rác. Một triệu đối tượng nhỏ, sống ngắn, khiến GC thế hệ 0 chạy liên tục. Chi phí này không nằm trong con số 135 ms ở trên — nó phân tán ra toàn bộ ứng dụng dưới dạng những lần tạm dừng, và đó chính là lý do boxing gây hại nhiều hơn con số đo được.

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. Chuỗi nội suy với kiểu giá trị (đã được cải thiện nhiều từ .NET 6)
object o = 42; // boxing rõ ràng

// 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 Hashtable(); ht.Add(1, "x"); // boxing cả khoá lẫn giá trị

Cách phát hiện. Bật bộ phân tích hiệu năng của .NET; quy tắc CA1815 và các quy tắc nhóm hiệu năng cảnh báo nhiều trường hợp boxing không cần thiết. Với đoạn code nóng, dùng dotnet-counters xem số lần thu gom thế hệ 0 — tăng bất thường thường là dấu hiệu cấp phát nhiều đối tượng ngắn hạn.

Đừ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 7,8 lần trên một thao tác vài chục nano-giây 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.

public class Animal { }
public class Dog : Animal { }
public class Cat : Animal { }

List<Dog> chos = new();

List<Animal> ds = chos; // LỖI BIÊN DỊCH
IEnumerable<Animal> ie = chos; // HỢP LỆ

Lỗi:

error CS0029: Cannot implicitly convert type 'List<Dog>' to 'List<Animal>'

Vì sao List<T> không thể covariant — ví dụ cụ thể:

List<Animal> ds = chos;     // giả sử dòng này được phép
ds.Add(new Cat()); // hợp lệ, vì Cat LÀ Animal
Dog d = chos[0]; // SỤP ĐỔ — chos thật ra chứa một con mèo

Nế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 = covariant

Từ 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> soSanhAnimal = new AnimalComparer();
IComparer<Dog> soSanhDog = soSanhAnimal; // 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ĩaT xuất hiện ởVí dụ
out TCovariant — con thay được chaChỉ đầu raIEnumerable<T>, IReadOnlyList<T>
in TContravariant — cha thay được conChỉ đầu vàoIComparer<T>, Action<T>
TBất biếnCả haiList<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.3, 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ờ covariance

Mộ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>, vì chuyển đổi đó cần boxing — 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.

public class Counter<T> { public static int Value; }

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ả đo thật trên .NET 9:

Counter<int>=10 Counter<string>=20 Counter<Dog>=30

Ba 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:

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á, không cần tính mã băm, và JIT thường nội tuyến luôn được. Reflection 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.2.

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​

Frequently asked questions

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, vì việc tra cứu diễn ra lúc JIT chứ không phải lúc chạy.

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:

  1. Ràng buộc là cách mở khoá, không phải hạn chế. Mỗi where thêm vào là thêm một nhóm thao tác dùng được.
  2. out ra, in vào. Nhớ chiều dữ liệu là nhớ được covariance và contravariance.
  3. static trong 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​

Điều hướng​