Java Generics không chỉ là cú pháp giúp bạn viết List<String> thay vì List. Đằng sau Generics là cả một hệ thống quy tắc về an toàn kiểu, quan hệ giữa các kiểu dữ liệu, Type Erasure và Wildcards.
Đặc biệt, để thiết kế API hiệu quả với Generics, bạn cần trả lời được một câu hỏi tưởng như đơn giản:
Nếu String là subtype của Object, tại sao List<String> lại không phải là subtype của List<Object>?
Câu trả lời nằm ở đặc tính cốt lõi: Generics trong Java là invariant (bất biến).
Trong bài viết này, chúng ta sẽ cùng mổ xẻ từ bản chất của Invariance, cơ chế Type Erasure, Bridge Methods đến ứng dụng thực tế của Wildcards & PECS, từ đó rút ra các nguyên tắc thiết kế Generic API an toàn và tối ưu nhất.
1. Vì sao Java cần Generics?
Trước JDK 5, Collections API chủ yếu làm việc với Object. Điều này cho phép một collection chứa nhiều loại đối tượng khác nhau, nhưng đổi lại lập trình viên phải tự chịu trách nhiệm kiểm tra và ép kiểu khi lấy dữ liệu ra.
Ví dụ:
List rawList = new ArrayList();
rawList.add(“Hello World”);
Integer number = (Integer) rawList.get(0);
Đoạn mã trên vẫn biên dịch được. Tuy nhiên, phần tử thực tế trong danh sách là String, trong khi chương trình lại cố gắng ép nó thành Integer. Kết quả là lỗi chỉ xuất hiện khi chương trình chạy:
ClassCastException
Đây chính là vấn đề lớn của cách tiếp cận trước Generics: compiler không có đủ thông tin để kiểm tra tính tương thích kiểu tại compile-time.
Generics giải quyết vấn đề này như thế nào?
Từ JDK 5, Java đã đưa vào Generics nhằm:
- Tăng type safety: phát hiện nhiều lỗi sai kiểu ngay tại compile-time.
- Loại bỏ phần lớn explicit cast: compiler tự chèn các phép cast cần thiết khi đọc dữ liệu.
- Làm rõ contract của API: List<String> thể hiện rõ collection này được thiết kế để chứa String.
- Tăng khả năng tái sử dụng: một class hoặc method có thể hoạt động với nhiều kiểu dữ liệu mà vẫn duy trì type safety.
Ví dụ:
List<String> names = new ArrayList<>();
names.add(“Alice”);
names.add(“Bob”);
// Compiler biết get() trả về String
String name = names.get(0);
Nếu cố gắng thêm một Integer:
names.add(100);
compiler sẽ từ chối ngay:
The method add(String) in the type List<String> is not applicable for the arguments (int)
Như vậy, Generics chuyển nhiều lỗi từ runtime về compile-time.
2. Invariance: Vì sao List<String> không phải List<Object>?
Đây là điểm quan trọng nhất cần hiểu khi làm việc với Java Generics.
Ta biết:
String <: Object
Tức là mọi String đều là Object.
Tuy nhiên:
List<String> !<: List<Object>
Nói cách khác:
List<String> không phải subtype của List<Object>.
Đây là hệ quả của tính invariant của Java Generics.
2.1. So sánh Arrays và Generics
Để hiểu tại sao Java thiết kế như vậy, hãy so sánh Arrays và Generics.
Arrays là covariant
Với array:
String[] strings = new String[10];
Object[] objects = strings;
Đoạn code này hợp lệ vì Java cho phép:
String[] <: Object[]
Tức là array có tính covariant.
Nhưng cách thiết kế này tạo ra một rủi ro:
objects[0] = 100;
JVM sẽ phát hiện rằng array thực tế là String[], nên không thể chứa Integer.
Kết quả:
ArrayStoreException
Kiểm tra kiểu ở đây được thực hiện tại runtime.
2.2. Generics là invariant
Với Generics:
List<String> strings = new ArrayList<>();
List<Object> objects = strings; // Compile error
Java không cho phép phép gán này.
Nếu được phép, chúng ta có thể làm:
objects.add(100);
Nhưng objects và strings sẽ cùng tham chiếu đến một collection.
Khi đó:
String value = strings.get(0);
sẽ trở thành một thao tác nguy hiểm vì danh sách có thể đã chứa Integer.
Tại sao Java không chờ đến runtime?
Bởi vì mục tiêu của Generics là đảm bảo type safety ngay từ compile-time.
Nếu Java cho phép:
List<String> strings = new ArrayList<>();
List<Object> objects = strings;
thì contract của List<String> sẽ bị phá vỡ thông qua một reference có kiểu List<Object>.
Do đó, Java áp dụng quy tắc:
String <: Object
nhưng
List<String> không phải subtype của List<Object>
Đây chính là Invariance.
3. Type Erasure: Generics biến mất ở đâu?
Một đặc điểm quan trọng của Java Generics là Type Erasure.
Không giống một số cơ chế generic được reify đầy đủ ở runtime, Java chủ yếu thực hiện kiểm tra Generic ở compile-time. Khi biên dịch, compiler thực hiện quá trình xóa thông tin type parameter theo các quy tắc của Java Language Specification.
Ví dụ:
class Node<T extends Number> {
private T data;
public void setData(T data) {
this.data = data;
}
}
Sau quá trình erasure, về mặt khái niệm, T được thay bằng bound của nó:
class Node {
private Number data;
public void setData(Number data) {
this.data = data;
}
}
Nếu T không có bound:
class Box<T> {
private T value;
}
thì T thường được erasure thành:
Object
Nếu có bound:
<T extends Number>
thì compiler sử dụng bound đầu tiên:
Number làm erasure của T.
3.1. Compiler tự chèn cast
Type Erasure không có nghĩa là compiler bỏ qua type information.
Compiler sử dụng thông tin Generic ở compile-time để kiểm tra mã nguồn và có thể chèn các phép cast cần thiết vào bytecode.
Ví dụ:
List<String> names = new ArrayList<>();
String name = names.get(0);
Về mặt khái niệm, sau erasure, List.get() trả về Object.
Compiler có thể tạo bytecode tương đương với:
String name = (String) names.get(0);
Điều này giải thích tại sao một số lỗi ClassCastException vẫn có thể xuất hiện trong các tình huống liên quan đến raw types hoặc unchecked casts.
4. Bridge Method: Làm thế nào Generics vẫn giữ được tính đa hình?
Type Erasure tạo ra một vấn đề khác khi Generic class được kế thừa.
Xét class cha:
class Node<T> {
public void setData(T data) {
// …
}
}
Và class con:
class MyNode extends Node<Integer> {
@Override
public void setData(Integer data) {
// …
}
}
Sau erasure, phương thức của Node<T> có dạng gần như:
void setData(Object data)
Trong khi MyNode có:
void setData(Integer data)
Hai method này có parameter type khác nhau.
Nếu compiler chỉ đơn giản xóa Generic information, tính đa hình có thể bị phá vỡ.
Java giải quyết vấn đề này bằng cách compiler tự động sinh Bridge Method.
Về mặt khái niệm, MyNode có thể được compiler bổ sung một method tương đương:
public void setData(Object data) {
setData((Integer) data);
}
Nhờ đó, đoạn code:
Node<Integer> node = new MyNode();
node.setData(100);
vẫn có thể hoạt động đúng sau quá trình erasure.
Bridge Method là một chi tiết implementation quan trọng giúp Java duy trì polymorphism trong khi vẫn sử dụng Type Erasure.
5. Wildcards: Giải quyết sự cứng nhắc của Invariance
Invariance giúp Generics an toàn, nhưng đôi khi nó khiến API trở nên quá cứng.
Ví dụ:
void printNumbers(List<Number> numbers) {
// …
}
Ta không thể truyền:
List<Integer>
vào method này:
List<Integer> integers = new ArrayList<>();
printNumbers(integers); // Compile error
Mặc dù:
Integer <: Number
nhưng:
List<Integer> !<: List<Number>
Đây là lúc Wildcard (?) phát huy tác dụng.
6. Ba loại Wildcard quan trọng
6.1. Unbounded Wildcard: <?>
<?> có nghĩa là:
Một collection của một kiểu nào đó, nhưng chúng ta không biết chính xác kiểu đó là gì.
Ví dụ:
List<?> list;
Có thể tham chiếu đến:
List<String>
List<Integer>
List<User>
Tuy nhiên, vì compiler không biết chính xác kiểu phần tử, ta không thể tùy ý thêm dữ liệu:
list.add(“Hello”); // Compile error
list.add(100); // Compile error
Ngoại lệ là:
list.add(null);
Khi đọc dữ liệu:
Object value = list.get(0);
ta chỉ có thể chắc chắn rằng giá trị đọc ra là một Object.
List<?> khác Raw Type
Đây là điểm rất quan trọng.
List list;
là Raw Type và làm mất type safety.
Trong khi:
List<?> list;
vẫn được compiler kiểm soát.
Do đó, List<?> là cách an toàn để biểu diễn:
“Tôi không quan tâm collection chứa kiểu cụ thể nào.”
7. Upper-Bounded Wildcard: <? extends T>
<? extends T> có nghĩa:
Collection chứa T hoặc một subtype của T.
Ví dụ:
List<? extends Number> numbers;
Có thể tham chiếu đến:
List<Integer>
List<Double>
List<Float>
Điểm quan trọng là collection này phù hợp với vai trò Producer.
Ta có thể đọc:
Number number = numbers.get(0);
Nhưng không thể tùy ý thêm một Number:
numbers.add(100); // Compile error
Tại sao?
Bởi vì compiler không biết collection thực tế là:
List<Integer>
hay:
List<Double>
Nếu cho phép:
numbers.add(3.14);
thì thao tác này sẽ phá vỡ List<Integer> nếu collection thực tế là danh sách số nguyên.
Vì vậy:
? extends T
→ đọc được dưới dạng T
→ không thể thêm T một cách an toàn
8. Lower-Bounded Wildcard: <? super T>
Ngược lại:
List<? super Integer>
có thể tham chiếu đến:
List<Integer>
List<Number>
List<Object>
Với kiểu này, ta có thể an toàn thêm Integer:
list.add(100);
Bởi vì dù collection thực tế là:
List<Integer>
hay:
List<Number>
hay:
List<Object>
thì Integer vẫn có thể được lưu trữ.
Tuy nhiên, khi đọc ra:
Object value = list.get(0);
ta chỉ có thể chắc chắn giá trị là Object.
Do đó:
? super T
→ ghi được T
→ đọc ra an toàn nhất dưới dạng Object
9. PECS: Producer Extends, Consumer Super
Ba quy tắc trên dẫn đến một nguyên tắc thiết kế API nổi tiếng trong Java:
PECS — Producer Extends, Consumer Super
Nguyên tắc này được sử dụng để lựa chọn wildcard dựa trên vai trò của parameter, thay vì dựa đơn thuần vào kiểu dữ liệu.
| Vai trò | Cách khai báo | Ý nghĩa |
| Producer | <? extends T> | Collection cung cấp dữ liệu |
| Consumer | <? super T> | Collection nhận dữ liệu |
| Read + Write chính xác T | T | Cần thao tác với đúng kiểu T |
Producer → extends
Nếu method chỉ đọc dữ liệu:
double sum(List<? extends Number> numbers) {
// …
}
Ta không quan tâm danh sách cụ thể là:
List<Integer>
List<Double>
List<Float>
Miễn là phần tử của nó có thể được xem như Number.
Consumer → super
Nếu method cần ghi Integer:
void addNumbers(List<? super Integer> numbers) {
numbers.add(10);
numbers.add(20);
}
Method có thể làm việc với:
List<Integer>
List<Number>
List<Object>
10. PECS trong chính JDK
Một ví dụ kinh điển là:
public static <T> void copy(
List<? super T> dest,
List<? extends T> src) {
for (int i = 0; i < src.size(); i++) {
dest.set(i, src.get(i));
}
}
Ở đây:
src → Producer → ? extends T
dest → Consumer → ? super T
Ta đọc dữ liệu từ src và ghi dữ liệu vào dest.
Đây chính là cách PECS được áp dụng trong một API thực tế của JDK.
Có thể ghi nhớ bằng một quy tắc đơn giản:
Dữ liệu đi RA khỏi collection
→ extends
Dữ liệu đi VÀO collection
→ super
11. Type Erasure và Non-Reifiable Types
Type Erasure dẫn đến một khái niệm quan trọng khác: Reifiable Type.
Một kiểu được gọi là reifiable khi JVM có thể biết đủ thông tin cần thiết về kiểu đó tại runtime.
Ngược lại, những kiểu như:
List<String>
List<Integer>
là non-reifiable types vì thông tin type argument không còn đầy đủ sau erasure.
Về runtime, JVM không thể dựa vào Generic metadata để phân biệt một cách trực tiếp:
List<String>
với:
List<Integer>
Đây là nguyên nhân của nhiều giới hạn kỹ thuật trong Java Generics.
12. Heap Pollution và Unchecked Cast
Heap Pollution xảy ra khi một reference có kiểu parameterized type trỏ đến một object mà kiểu thực tế không phù hợp với parameterized type đó.
Ví dụ:
Vector<Integer> integers = new Vector<>();
integers.add(42);
Object rawVector = integers;
Vector<String> strings =
(Vector<String>) rawVector;
Compiler sẽ đưa ra cảnh báo unchecked cast.
Về mặt logic, strings đang được xem như:
Vector<String>
nhưng object thực tế vẫn là:
Vector<Integer>
Khi thực hiện:
String value = strings.get(0);
compiler có thể chèn cast sang String, và cast đó thất bại tại runtime.
Đây là một trong những lý do cần tránh Raw Type và unchecked cast trong mã nguồn mới.
13. Generic Varargs và @SafeVarargs
Một tình huống đặc biệt khác xuất hiện khi kết hợp Generics với Varargs.
Ví dụ:
public static <T> void addToList(
List<T> list,
T… elements) {
for (T element : elements) {
list.add(element);
}
}
Vấn đề nằm ở chỗ Varargs được triển khai dựa trên array, trong khi generic type lại chịu ảnh hưởng của Type Erasure.
Điều này có thể dẫn đến cảnh báo:
Possible heap pollution from parameterized vararg type
Nếu method thực sự đảm bảo an toàn — chẳng hạn không làm thay đổi sai nội dung array và không để lộ reference của array ra bên ngoài — có thể sử dụng:
@SafeVarargs
cho những method phù hợp với điều kiện của Java.
@SafeVarargs không làm cho một method nguy hiểm trở thành an toàn. Annotation này là một cam kết của lập trình viên rằng cách sử dụng generic varargs trong method đó không gây heap pollution.
14. Những giới hạn quan trọng của Java Generics
Type Erasure mang lại khả năng tương thích với hệ sinh thái Java cũ, nhưng cũng tạo ra một số giới hạn.
14.1. Không thể new T()
Không thể viết:
T object = new T();
bởi tại runtime JVM không biết T cụ thể là class nào.
Thay vào đó, có thể truyền Class<T> vào method hoặc sử dụng một factory phù hợp.
14.2. Không thể tạo Generic Array trực tiếp
Không thể viết:
T[] array = new T[10];
vì T không phải reifiable type.
Tương tự, không thể trực tiếp tạo:
List<String>[] lists = new List<String>[10];
14.3. Không thể kiểm tra instanceof với Generic Type cụ thể
Không thể:
if (obj instanceof List<String>) {
// …
}
bởi runtime không có đầy đủ thông tin để thực hiện kiểm tra này.
Có thể kiểm tra:
if (obj instanceof List<?>) {
// …
}
14.4. Không sử dụng Primitive Type làm Type Argument
Không thể viết:
List<int>
mà phải dùng:
List<Integer>
Tương tự:
List<double>
phải trở thành:
List<Double>
Điều này dẫn đến cơ chế boxing/unboxing khi làm việc giữa primitive và wrapper type.
14.5. Không thể overload chỉ dựa trên Generic Type Argument
Không thể khai báo đồng thời:
void print(List<String> list) {
}
void print(List<Integer> list) {
}
Bởi sau Type Erasure, cả hai về cơ bản đều có signature:
void print(List list)
Do đó chúng xung đột với nhau.
14.6. Static Context không thể sử dụng Type Parameter của Instance
Ví dụ:
class Box<T> {
private T value;
public static T getValue() {
// Compile error
}
}
Điều này không hợp lệ vì T thuộc về instance parameterization, trong khi thành phần static thuộc về class và không phụ thuộc vào một instance cụ thể.
Nếu cần Generic trong static method, method phải tự khai báo type parameter:
public static <T> T identity(T value) {
return value;
}
15. Thiết kế Generic API an toàn
Hiểu Generics không chỉ để đọc code. Quan trọng hơn là biết cách sử dụng chúng khi thiết kế API.
Quy tắc 1: Dùng PECS cho Collection Parameter
Nếu method nhận một collection chỉ để đọc:
void process(List<? extends Number> numbers) {
}
Nếu method nhận collection để ghi:
void addNumbers(List<? super Integer> numbers) {
}
Không nên mặc định sử dụng:
List<Number>
nếu API thực tế không yêu cầu chính xác Number. Wildcard có thể làm API linh hoạt hơn mà vẫn duy trì type safety.
Quy tắc 2: Tránh Raw Types
Không nên viết:
List list = new ArrayList();
Trong code mới, hãy sử dụng:
List<String> list = new ArrayList<>();
hoặc khi thực sự không quan tâm đến kiểu cụ thể:
List<?> list;
Điểm khác biệt rất quan trọng:
List
→ bỏ qua kiểm tra Generic
List<?>
→ vẫn được compiler kiểm soát
16. Type Token: Khi cần biết kiểu ở Runtime
Do Type Erasure, đôi khi method cần biết class cụ thể tại runtime.
Một cách phổ biến là truyền Class<T> vào method:
public <T> T createInstance(Class<T> clazz)
throws Exception {
return clazz.getDeclaredConstructor()
.newInstance();
}
Sử dụng:
User user = createInstance(User.class);
Ở đây:
Class<T>
đóng vai trò như một Type Token — cung cấp thông tin kiểu mà Generic type parameter không còn giữ đầy đủ sau erasure.
17. Advanced Pattern: Recursive Type Bounds
Một kỹ thuật nâng cao của Java Generics là Recursive Type Bound.
Ví dụ:
public static <T extends Comparable<T>> T max(T a, T b) {
return a.compareTo(b) >= 0 ? a : b;
}
Ràng buộc:
T extends Comparable<T>
đảm bảo rằng T phải có khả năng so sánh với chính T.
Điều này hữu ích khi xây dựng các API yêu cầu một kiểu phải đáp ứng một capability cụ thể.
18. Advanced Pattern: Self-Type và Generic Builder
Generics cũng có thể được sử dụng để duy trì kiểu của subclass trong Fluent API.
Ví dụ:
abstract class Builder<T, B extends Builder<T, B>> {
protected String name;
@SuppressWarnings(“unchecked”)
public B withName(String name) {
this.name = name;
return (B) this;
}
public abstract T build();
}
Subclass:
class UserBuilder
extends Builder<User, UserBuilder> {
private int age;
public UserBuilder withAge(int age) {
this.age = age;
return this;
}
@Override
public User build() {
return new User(name, age);
}
}
Khi sử dụng:
User user = new UserBuilder()
.withName(“Alice”)
.withAge(30)
.build();
Ta vẫn giữ được kiểu UserBuilder xuyên suốt Fluent API mà không cần explicit cast ở phía client.
19. Bức tranh tổng thể: Generics hoạt động như thế nào?
Có thể nhìn toàn bộ cơ chế sau:
Mỗi khái niệm giải quyết một vấn đề khác nhau:
- Invariance bảo vệ type safety.
- Wildcards cung cấp tính linh hoạt mà không phá vỡ type safety.
- PECS giúp lựa chọn wildcard khi thiết kế API.
- Type Erasure giúp Java duy trì khả năng tương thích với mã nguồn và bytecode cũ.
- Bridge Methods duy trì polymorphism sau erasure.
- Type Token cung cấp thông tin kiểu khi runtime cần biết class cụ thể.
20. Kết luận
Java Generics là một hệ thống được thiết kế với sự đánh đổi rõ ràng: đưa phần lớn việc kiểm tra kiểu về compile-time, trong khi vẫn duy trì khả năng tương thích với hệ sinh thái Java được xây dựng trước JDK 5.
Ba ý tưởng quan trọng nhất cần ghi nhớ là:
1. List<String> không phải List<Object>
Mặc dù:
String <: Object
nhưng:
List<String> !<: List<Object>
vì Java Generics là invariant.
2. Wildcard giúp mở rộng khả năng sử dụng Generic API
? extends T → Producer → đọc
? super T → Consumer → ghi
Từ đó hình thành nguyên tắc:
Producer Extends, Consumer Super — PECS.
3. Type Erasure giải thích nhiều giới hạn của Generics
Vì Generic type information không được reify đầy đủ tại runtime, Java có những giới hạn như:
new T()
new T[10]
instanceof List<String>
List<int>
overload(List<String>) và overload(List<Integer>)
Hiểu được Type Erasure sẽ giúp giải thích được tại sao những giới hạn này tồn tại, thay vì chỉ ghi nhớ chúng như những quy tắc rời rạc.
Cuối cùng, khi thiết kế Generic API, câu hỏi quan trọng không phải là:
“Tôi có thể viết wildcard ở đây không?”
mà là:
“Collection này đang đóng vai trò Producer hay Consumer?”
Nếu trả lời được câu hỏi đó, việc lựa chọn giữa T, ? extends T và ? super T sẽ trở nên tự nhiên hơn rất nhiều.






0 Lời bình