99 Ngày Java — Ngày 39: Generics cơ bản
Suốt phần Collection ta viết List<String>, Map<String, Integer> như chuyện đương nhiên, mà chưa dừng lại hỏi cặp ngoặc nhọn đó thực sự làm gì. Hôm nay mở nó ra: Generics tồn tại để đẩy lỗi từ lúc chạy về lúc biên dịch và xóa bớt việc ép kiểu. Rồi ta gặp đặc điểm khiến Generics Java khác hẳn các ngôn ngữ khác — type erasure, tham số kiểu bị xóa sạch sau khi biên dịch — cùng bốn giới hạn mà nó kéo theo.

Vì sao Generics tồn tại
Câu trả lời gọn: để trình biên dịch bắt lỗi thay cho người dùng. Trước Java 5, collection chứa Object, nên bỏ nhầm một số vào danh sách chuỗi là chuyện hoàn toàn hợp lệ — và bạn chỉ biết khi ứng dụng nổ ClassCastException trên production.
// Trước Java 5, collection chứa Object — và mọi thứ đều phải ép kiểu:
List ds = new ArrayList();
ds.add("Xin chào");
ds.add(42); // trình biên dịch KHÔNG phàn nàn
String s = (String) ds.get(1); // ClassCastException LÚC CHẠY 💥
// Generics đẩy lỗi đó từ LÚC CHẠY về LÚC BIÊN DỊCH:
List<String> ds2 = new ArrayList<>();
ds2.add("Xin chào");
ds2.add(42); // ❌ không biên dịch nổi
String s2 = ds2.get(0); // không cần ép kiểu
// Đó là toàn bộ giá trị của Generics, gói trong một câu:
// BẮT LỖI SỚM HƠN, và BỚT ÉP KIỂU.
// Lỗi lúc biên dịch rẻ hơn lỗi lúc chạy hàng trăm lần.
// Toán tử kim cương <> — vế phải suy ra từ vế trái, khỏi lặp lại:
Map<String, List<Nhanvien>> theoPhong = new HashMap<>();Generics là công cụ của trình biên dịch: nó siết chặt lúc bạn gõ code, chứ không thêm một phép kiểm tra nào lúc chạy.
Bớt ép kiểu không chỉ để code ngắn — mỗi phép ép kiểu tay là một chỗ có thể nổ mà bạn đang tự hứa là sẽ không sai.
Toán tử kim cương
<>để trình biên dịch tự suy ra vế phải, càng đáng dùng khi kiểu lồng nhau nhiều tầng.
Lớp generic, method generic và bounded type
Viết class Hop<T> nghĩa là để trống một ô cho kiểu, và người dùng điền vào lúc khai báo. Điều hay bị bỏ qua: method cũng generic được mà không cần lớp phải generic — và trong thực tế đây mới là dạng bạn viết nhiều nhất.
// Lớp generic: T là CHỖ TRỐNG cho một kiểu, điền lúc dùng.
class Hop<T> {
private T noiDung;
void dat(T x) { this.noiDung = x; }
T lay() { return noiDung; } // trả đúng kiểu, khỏi ép
}
Hop<String> h = new Hop<>();
String s = h.lay(); // không cần (String)
// Quy ước đặt tên tham số kiểu — chỉ là quy ước, nhưng nên theo:
// T = Type E = Element K = Key V = Value R = Result N = Number
// Method generic: <T> đặt TRƯỚC kiểu trả về, và không cần lớp phải generic.
static <T> List<T> ghep(List<T> a, List<T> b) {
List<T> kq = new ArrayList<>(a);
kq.addAll(b);
return kq;
}
List<String> x = ghep(List.of("a"), List.of("b")); // T suy ra là String
// Bounded type — chặn trên bằng extends, để dùng được method của kiểu đó:
static <T extends Comparable<T>> T lonNhat(List<T> ds) {
T max = ds.get(0);
for (T e : ds) if (e.compareTo(max) > 0) max = e; // gọi được compareTo
return max;
}
// Không có "extends Comparable" thì T chỉ được coi là Object,
// và compareTo không tồn tại — trình biên dịch chặn ngay.Method generic khai
<T>ngay trước kiểu trả về; trình biên dịch tự suyTtừ đối số nên chỗ gọi vẫn gọn.Bounded type (
<T extends Comparable<T>>) mở khóa method của kiểu đó. Không có chặn trên thìTchỉ làObject— đây là lý do phần lớn hàm tiện ích cần một cáiextends.Bám quy ước tên
T,E,K,V,Rđể người đọc nhận ra vai trò ngay, khỏi phải luận.
Type erasure — điều khiến Generics Java khác biệt
Đây là ý quan trọng nhất của bài, và cũng là câu hỏi phỏng vấn quen thuộc. Sau khi biên dịch, List<String> và List<Integer> hoàn toàn giống nhau trong mắt JVM: cả hai chỉ là ArrayList. Tham số kiểu bị xóa để code mới dùng chung được với thư viện cũ.
// Đây là chỗ Generics của Java KHÁC hẳn C# hay C++: TYPE ERASURE.
// Sau khi biên dịch, tham số kiểu bị XÓA SẠCH — JVM chỉ thấy Object.
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
a.getClass() == b.getClass(); // true! Cùng là class java.util.ArrayList
// Vì sao lại thiết kế vậy? Để code Java 5 chạy chung được với thư viện Java 1.4 —
// đánh đổi lấy TƯƠNG THÍCH NGƯỢC. Cái giá là bốn giới hạn phải nhớ:
// 1. Không kiểm tra được kiểu lúc chạy:
if (ds instanceof List<String>) { } // ❌ không biên dịch được
// 2. Không new được tham số kiểu:
class Hop<T> { T tao() { return new T(); } } // ❌ không hợp lệ
// Cách vòng: truyền "nhà máy" hoặc Class<T> vào
class Hop<T> { T tao(Supplier<T> nm) { return nm.get(); } }
// 3. Không tạo được mảng generic:
T[] mang = new T[10]; // ❌
List<String>[] ds = new List[10]; // cảnh báo unchecked — nên dùng List<List<String>>
// 4. Không overload theo tham số kiểu:
void in(List<String> a) { }
void in(List<Integer> a) { } // ❌ sau khi xóa kiểu thì HAI method GIỐNG HỆT nhau
// Kiểu nguyên thủy KHÔNG làm tham số kiểu được — List<int> không tồn tại,
// phải dùng List<Integer>, và autoboxing quay lại (Ngày 22).Nhớ theo một câu: kiểu chỉ tồn tại lúc biên dịch. Mọi giới hạn bên dưới đều suy ra được từ câu đó, không cần học thuộc.
Vì kiểu không còn lúc chạy nên không
instanceofđược, khôngnew T()được, không tạo mảng generic, và không overload theo tham số kiểu.Cách vòng chuẩn cho việc tạo đối tượng là truyền
Supplier<T>hoặcClass<T>vào — tự mình mang thông tin kiểu theo.Không dùng được kiểu nguyên thủy:
List<int>không tồn tại, nên bài học autoboxing của Ngày 22 quay lại đây.
Raw type và chuyện thừa kế của kiểu generic
Còn hai chỗ hay khiến người mới vấp. Thứ nhất là raw type — viết List trần, vứt bỏ mọi an toàn mà vẫn biên dịch được. Thứ hai là quan hệ thừa kế: List<String> không phải con của List<Object>, điều nghe rất phản trực giác cho tới khi bạn thấy vì sao.
// RAW TYPE — dùng List trần, không có <>:
List raw = new ArrayList<String>();
raw.add(42); // biên dịch được! Chỉ CẢNH BÁO unchecked
String s = (String) raw.get(0); // và nổ lúc chạy 💥
// Raw type chỉ tồn tại để code cũ vẫn biên dịch được. Trong code mới:
// ĐỪNG BAO GIỜ dùng. Cảnh báo unchecked là lời cảnh báo THẬT, không phải tiếng ồn.
// Không biết kiểu gì thì dùng List<?> ("list của kiểu nào đó"), an toàn hơn hẳn:
void in(List<?> ds) {
for (Object o : ds) System.out.println(o); // đọc được
ds.add("x"); // ❌ KHÔNG ghi được — đúng như mong đợi
}
// Một điểm quan trọng dễ nhầm: List<String> KHÔNG phải con của List<Object>.
List<Object> ds = new ArrayList<String>(); // ❌ không hợp lệ
// Nếu hợp lệ thì bạn có thể add(42) vào một list vốn chỉ chứa String —
// nên trình biên dịch chặn. Đây chính là lý do sinh ra WILDCARD,
// và là toàn bộ nội dung của Ngày 40: ? extends, ? super và PECS.Raw type chỉ để tương thích code cũ. Trong code mới nó là bug đang chờ ngày nổ — đừng bỏ qua cảnh báo unchecked.
List<?>là cách an toàn để nhận "list của kiểu nào đó": đọc thoải mái, còn ghi thì trình biên dịch chặn.Nếu
List<String>là con củaList<Object>thì ai đó sẽ nhét được số vào danh sách chuỗi của bạn. Chính chỗ nghẽn này sinh ra wildcard — nội dung của Ngày 40.
Bài tập nhỏ
Viết lại đoạn
Listtrần cho nổClassCastException, rồi thêm<String>và xem lỗi chuyển từ lúc chạy về lúc biên dịch.Cài
class Hop<T>vớidat/lay, rồi thử viếtnew T()bên trong để đọc chính xác thông báo lỗi của trình biên dịch.Viết
<T extends Comparable<T>> T lonNhat(List<T>), rồi bỏ phầnextendsđi và giải thích vì saocompareTobiến mất.In
new ArrayList<String>().getClass() == new ArrayList<Integer>().getClass()và tự giải thích kết quả bằng type erasure.Thử khai hai method
in(List<String>)vàin(List<Integer>)trong cùng một lớp — đọc thông báo lỗi, nó nói thẳng về erasure.
Kết lại
Bốn ý gói lại hôm nay: Generics tồn tại để bắt lỗi lúc biên dịch và bớt ép kiểu, chứ không thêm kiểm tra nào lúc chạy; lớp và method generic dùng bounded type để mở khóa method của kiểu, không có extends thì T chỉ là Object; type erasure xóa tham số kiểu sau khi biên dịch, và bốn giới hạn quen thuộc đều suy ra từ đúng một câu "kiểu chỉ tồn tại lúc biên dịch"; còn raw type thì tuyệt đối tránh, và nhớ List<String> không phải con của List<Object>. Ngày 40 ta gỡ đúng nút thắt đó: wildcard và nguyên tắc PECS — khi nào ? extends, khi nào ? super. Hẹn gặp lại!
Site Admin
Engineer and writer. Building things with TypeScript and distributed systems.
Bình luận (0)
Bạn cần đăng nhập bằng Google để bình luận.
Hãy là người bình luận đầu tiên.


