Generics là gì?
Xem thửTình huống mở đầu
Nhà Hàng Lửa lưu tên khách trong một danh sách. Ba tháng sau, một đoạn code cũ nhận danh sách đó và thêm vào số 42 để đánh dấu. Mọi thứ vẫn chạy cho tới sáng thứ Hai, khi báo cáo cuối tuần đọc phần tử đầu tiên và dừng với ClassCastException. Dòng báo lỗi trỏ vào đoạn code chỉ đọc tên khách, chẳng liên quan gì tới số 42.
Vậy lỗi nằm ở dòng nào, và vì sao trình biên dịch không cảnh báo lúc bạn viết? Hãy giữ câu hỏi này, cuối bài bạn sẽ tái hiện đúng lỗi đó và biết cách chặn nó.
Ôn nhanh bài trước: bài trước nói về bộ gom rác. Một danh sách static chỉ thêm mà không xoá có thể gây ra lỗi nào khi chạy lâu: StackOverflowError hay OutOfMemoryError?
Đáp án: OutOfMemoryError, vì danh sách static là gốc nên giữ mọi phần tử sống mãi, làm heap đầy.
Mục tiêu bài học
Sau bài này, bạn có thể:
- Viết được lớp
Box<T>và phương thức<T>đơn giản, rồi dùng chúng với hai kiểu khác nhau. - Giải thích được vì sao generics chuyển lỗi sai kiểu từ lúc chạy sang lúc biên dịch: nhãn được ghi nhớ và kiểm tra ở đâu, và chương trình đã chạy còn gì.
- Tránh được kiểu thô và đọc được cảnh báo
uncheckedcùngClassCastExceptiondo nó gây ra. - Đọc được thông báo biên dịch khi dùng sai kiểu hoặc dùng kiểu nguyên thủy làm tham số kiểu, và sửa được.
Bài này đo được bằng năm câu trong phần Tự kiểm tra, mỗi câu ghi rõ nó kiểm tra mục tiêu nào.
Hãy xem video dưới đây để có cái nhìn đầu tiên, sau đó mình đi chậm hơn từng bước ở các phần sau.
Bức tranh lớn (Big Picture)
Trước khi vào chi tiết, hãy xem bức tranh của generics: một bản thiết kế, nhiều hộp có nhãn, và một người kiểm tra.
Cách đọc hình: đọc từ trái sang phải. Box<T> (ô vàng) là bản thiết kế có nhãn T còn để trống. Hai ô xanh nhạt Box<String> và Box<Integer> là hai hộp được điền nhãn. Ô lục Compiler kiểm tra mọi chỗ dùng nhãn, và chỉ khi mọi thứ khớp mới tới ô trắng Run time. Mỗi ô đều có nhãn chữ, màu chỉ giúp bạn tìm nhanh.
Xuyên suốt bài, mình dùng một hình ảnh: hộp trên kệ hàng có dán nhãn. Hộp có nhãn "cà phê" thì chỉ cà phê được để vào, và ai lấy ra cũng biết trước đó là cà phê. Dự án xuyên suốt là kệ hàng của Nhà Hàng Lửa. Có chỗ hình ảnh này không còn đúng, và mình sẽ chỉ rõ khi tới.
Khái niệm cốt lõi
Bài này có bốn khái niệm. Mỗi khái niệm đi kèm một hình. Code chạy thật nằm ở mục Ví dụ đầu tiên và mục Thử phá nó đi.
Khái niệm 1: hộp không nhãn và cái giá của nó
Trước generics, một hộp muốn chứa mọi thứ phải khai báo nội dung là Object. Lấy ra thì chỉ nhận lại Object, và bạn phải tự ép kiểu (cast) về kiểu mình tin là đúng. Trình biên dịch tin lời bạn, nên nếu bạn tin sai thì lỗi chỉ lộ khi chương trình chạy.
Cách đọc hình: hàng trên là hộp không nhãn: ObjectBox chứa Latte, dòng ép (Integer) box.get() biên dịch được (ô xanh dương), rồi ClassCastException nổ khi chạy (ô đỏ nhạt). Hàng dưới là hộp có nhãn: Box<String>, dòng Integer i = box.get() bị trình biên dịch chặn (ô lục Compile error) trước khi chương trình chạy.
Khái niệm 2: kiểu tham số T, nhãn điền ở nơi dùng
Kiểu tham số (type parameter) T là một cái nhãn còn để trống trong bản thiết kế của lớp. Khi dùng, bạn điền nhãn: Box<String> nghĩa là "hộp mà T là String". Trình biên dịch nhớ nhãn đó và dùng nó để biết get() trả về kiểu gì. Dấu <> (diamond) là chỗ bạn nhờ Java tự điền nhãn phía bên phải.
Cách đọc hình: hàng trên: new Box<>("Latte") (ô trắng) cho nhãn T = String (ô xanh nhạt), nên get() trả String và không cần ép (ô lục). Hàng dưới: new Box<>(3) cho nhãn Integer, và get() trả Integer, được mở thành int khi cần.
Khái niệm 3: phương thức generic
Không chỉ lớp, một phương thức cũng có thể có nhãn riêng. Bạn khai báo nhãn ngay trước kiểu trả về: static <T> T first(List<T> items). Mỗi lời gọi tự quyết định T theo kiểu phần tử của danh sách truyền vào, và kiểu trả về đi theo.
Cách đọc hình: ô vàng ở giữa trên là phương thức static <T> T first(List<T>). Hai ô trắng bên trái là hai lời gọi với danh sách String và danh sách Integer. Hai ô lục bên phải là kết quả: String ở lời gọi đầu, Integer ở lời gọi sau, vì T được suy ra từ đối số.
Khái niệm 4: kiểu thô, nhãn bị tắt
Kiểu thô (raw type) là dùng một lớp generic mà không ghi nhãn, ví dụ List thay vì List<String>. Nó tồn tại để mã viết từ trước generics vẫn biên dịch được. Khi dùng kiểu thô, trình biên dịch tắt kiểm tra nhãn và chỉ đưa cảnh báo unchecked.
Cách đọc hình: hàng trên đọc từ trái sang phải: List<String> names có nhãn, List raw = names ẩn nhãn (ô xanh dương), raw.add(42) biên dịch được với một cảnh báo (ô xám). Mũi tên "later" đi xuống names.get(0), rồi ClassCastException nổ ở ô đỏ nhạt, xa dòng gây lỗi.
Chỗ hình ảnh không còn đúng: hộp có nhãn trên kệ thật không thể bị ai nhét nhầm món vì có người gác. Trong Java, nhãn chỉ có tác dụng khi mọi người cùng dùng hộp đều tôn trọng nhãn, và kiểu thô là cái cửa hậu để đi vòng nhãn. Đây chính là lỗi ở tình huống mở đầu, và mình sẽ tái hiện nó từng bước ở mục Thử phá nó đi.
Ví dụ đầu tiên (Worked Example) và "Xem từng bước"
Đây là chương trình đầu tiên của dự án: kệ hàng Nhà Hàng Lửa có hai hộp, một hộp đồ uống và một hộp số cốc, mỗi hộp mang nhãn riêng.
ShelfDemo.java
public class ShelfDemo {
static class Box<T> {
private T content;
Box(T content) {
this.content = content;
}
T get() {
return content;
}
void set(T content) {
this.content = content;
}
}
public static void main(String[] args) {
// Nhãn String ghi ngay ở khai báo, dấu <> để Java tự suy ra phần còn lại
Box<String> drink = new Box<>("Latte");
Box<Integer> cups = new Box<>(3);
// get() trả đúng String nên không cần ép kiểu
String name = drink.get();
int count = cups.get();
System.out.println(name.length() + " " + (count + 1));
}
}
Predict: hãy dừng lại, lấy giấy nháp và đoán: dòng in cuối cùng cho ra những số nào, và dòng nào trong main sẽ không biên dịch được nếu bạn đổi Box<String> thành Box<Integer> mà giữ nguyên dòng còn lại? Đoán xong rồi hãy đọc tiếp.
Kết quả mong đợi [Đã chạy, JDK 17.0.15]:
5 4
Đọc code từng dòng:
| Dòng code | Máy làm gì | Vì sao viết như vậy |
|---|---|---|
static class Box<T> | Khai báo một lớp có nhãn T còn để trống | Một bản thiết kế dùng được cho mọi kiểu |
private T content; và T get() | Dùng T như một kiểu bình thường | Kiểu của nội dung và kiểu trả về luôn đi cùng nhau |
new Box<>("Latte") với Box<String> | Tạo hộp có T là String | Nhãn khai báo bên trái quyết định kiểu của get() |
String name = drink.get(); | Gán thẳng, không ép kiểu | Trình biên dịch biết get() trả String nên không cần ép |
int count = cups.get(); | Mở hộp Integer thành int | Nhãn phải là kiểu đối tượng, nên số 3 được bọc thành Integer |
Bây giờ xem từng bước. Mỗi bước nêu dòng code đang chạy, nhãn của từng hộp và điều trình biên dịch kiểm tra:
Đang tải Animation…
Bảng trace (bản chữ tương đương của animation, [Suy luận] bằng cách đi từng dòng và khớp với kết quả chạy thật):
| Bước | Dòng chạy | Điều trình biên dịch biết | Điều xảy ra |
|---|---|---|---|
| 1 | Box<String> drink = ... | drink có nhãn String | Hộp chứa Latte được tạo |
| 2 | Box<Integer> cups = ... | cups có nhãn Integer | Số 3 được bọc thành Integer |
| 3 | String name = drink.get(); | get() trả String | Cho qua, không cần ép kiểu |
| 4 | int count = cups.get(); | get() trả Integer | Cho qua, tự mở hộp |
| 5 | Chạy chương trình | Mọi chỗ đã khớp | In ra 5 4 |
Để đối chiếu, đây là hộp không nhãn của khái niệm 1, trích từ ObjectBoxDemo.java (file đầy đủ nằm trong kho GitHub), với ObjectBox giữ nội dung kiểu Object và get() trả về Object:
ObjectBox box = new ObjectBox("Latte");
try {
// Trình biên dịch cho qua vì get() trả về Object
Integer cups = (Integer) box.get();
} catch (ClassCastException e) {
// In phần đầu của thông báo lỗi
}
Kết quả khi chạy cả file [Đã chạy, JDK 17.0.15]:
run time: class java.lang.String cannot be cast to class java.lang.Integer
Cùng một sai lầm, nhưng hộp không nhãn chỉ báo lỗi khi chạy, còn hộp có nhãn ở trên bị chặn trước đó.
Bạn vừa làm được: nói được nhãn T được điền ở đâu và trình biên dịch dùng nó để kiểm tra từng chỗ gọi get().
Concept Check xen kẽ
Chọn đáp án của bạn trước, rồi đọc phần đáp án ngay bên dưới mỗi câu.
Concept Check 1. Có Box<String> b = new Box<>("Tea"); rồi viết Integer i = b.get();. Điều gì xảy ra?
- A. Biên dịch được, và chương trình ném
ClassCastExceptionkhi chạy. - B. Không biên dịch được, vì
get()trảStringmà không gán được choInteger. - C. Biên dịch được, và
inhận giá trịnullvì hai kiểu khác nhau. - D. Biên dịch được, và
inhận số 3 vì Java tự đổi chuỗi thành số.
Đáp án: B. Nhãn của b là String, nên trình biên dịch biết get() trả String và chặn phép gán. A là hiểu lầm hay gặp nhất: nó là hành vi của hộp không nhãn, đúng thứ mà generics sinh ra để thay thế. C và D bịa ra hành vi tự chuyển đổi mà Java không có. [Đã chạy bằng trình biên dịch: incompatible types: java.lang.String cannot be converted to java.lang.Integer.]
Concept Check 2. Với static <T> T first(List<T> items), dòng nào không biên dịch được?
- A.
String s = first(List.of("Latte")); - B.
Integer n = first(List.of(10)); - C.
Integer n = first(List.of("Latte")); - D.
Object o = first(List.of(10));
Đáp án: C. Danh sách chứa String nên T là String, và kết quả không gán được cho Integer. A và B khớp nhãn nên hợp lệ. D hợp lệ vì mọi Integer đều là một Object, và đó là lý do lời gọi vẫn qua dù kết quả được gán vào kiểu rộng hơn.
Concept Check 3. Có List<String> names = new ArrayList<>(); List raw = names; raw.add(42); String first = names.get(0);. Dòng nào làm chương trình thất bại với ClassCastException?
- A.
String first = names.get(0);, dòng đọc phần tử ra. - B.
raw.add(42);, dòng thêm số vào danh sách. - C.
List raw = names;, dòng tạo biến không nhãn. - D.
List<String> names = new ArrayList<>();, dòng tạo danh sách.
Đáp án: A. Lỗi chỉ nổ khi trình biên dịch chèn phép ép sang String và phần tử thật là Integer. B là dòng gây ra sai lầm, nhưng nó chỉ có cảnh báo và chạy bình thường. C chỉ tạo thêm biến trỏ cùng danh sách. D chỉ tạo danh sách rỗng. Khoảng cách giữa B và A là điều làm lỗi này khó truy tìm. [Đã chạy trong RawPollution.java, xem mục Thử phá nó đi.]
Làm cùng mình (Guided Practice)
Bạn sẽ dựng dần class ShelfBoxes qua năm bước nhỏ. Mỗi bước thêm vào chương trình của bước trước, và hỗ trợ giảm dần: bước 1 gần như làm sẵn, bước 5 chỉ còn đề. Gõ vào một file ShelfBoxes.java rồi chạy bằng lệnh java ShelfBoxes.java.
Bước 1: một hộp String.
import java.util.List;
public class ShelfBoxes {
static class Box<T> {
private T content;
Box(T content) {
this.content = content;
}
T get() {
return content;
}
}
public static void main(String[] args) {
// TODO 1: tạo Box<String> tên drink chứa "Latte", rồi in "step 1: " cộng độ dài của drink.get()
}
}
Chạy xong bạn sẽ thấy: step 1: 5.
Bước 2: một hộp Integer. Dưới bước 1, thêm:
// TODO 2: tạo Box<Integer> tên cups chứa 3, rồi in "step 2: " cộng (cups.get() cộng 1)
Chạy xong bạn sẽ thấy: step 2: 4.
Vẽ ra giấy: vẽ hai hộp, ghi nhãn của từng hộp lên nắp, rồi ghi kiểu mà get() của từng hộp trả về. Đối chiếu: hộp thứ nhất trả String, hộp thứ hai trả Integer. Hình ở khái niệm 2 là cùng ý.
Bước 3: thêm khả năng thay nội dung.
// TODO 3: thêm phương thức void set(T content) vào Box, rồi gọi drink.set("Mocha") và in "step 3: " cộng drink.get()
Chạy xong bạn sẽ thấy: step 3: Mocha. Gợi ý: tham số của set có kiểu T, nên drink.set(5) sẽ bị chặn khi biên dịch.
Bước 4: một phương thức generic. Bạn tự quyết cách viết.
// TODO 4: viết phương thức static <T> T last(List<T> items) trả phần tử cuối, rồi in "step 4: " cộng last(List.of("a", "b", "c"))
Chạy xong bạn sẽ thấy: step 4: c. Gợi ý: phần tử cuối nằm ở chỉ số items.size() - 1.
Bước 5: phương thức tạo hộp.
// TODO 5: viết static <T> Box<T> boxOf(T value), rồi in "step 5: " cộng boxOf(42).get() cộng boxOf("x").get()
Chạy xong bạn sẽ thấy: step 5: 42x. Gợi ý: hình ở khái niệm 3 là cùng ý với first. Lời giải đầy đủ nằm ở cuối bài.
Bạn vừa làm được: tự viết một lớp và hai phương thức generic, rồi dùng chúng với hai kiểu mà không ép kiểu lần nào. Toàn bộ mã nguồn của bài nằm trong thư mục 11-generics/01-generics của kho GitHub, nhưng bạn không cần mở nó để học.
Thử phá nó đi (Lỗi thường gặp)
Bốn lỗi dưới đây là những lỗi người mới gặp nhiều nhất với generics. Mỗi thông báo đã được chạy thật bằng trình biên dịch của JDK 17.0.15 [Đã chạy; chữ trong thông báo có thể khác ở phiên bản Java khác]:
| Code lỗi | Thông báo lỗi | Nguyên nhân bằng lời thường | Cách sửa |
|---|---|---|---|
Box<String> b = new Box<>(5); | incompatible types: cannot infer type arguments for Box<> | Nhãn khai báo là String nhưng giá trị là số | Truyền giá trị cùng kiểu với nhãn |
List<int> l = new ArrayList<>(); | unexpected type, required reference, found int | Nhãn phải là kiểu đối tượng, int thì không | Dùng kiểu bọc: List<Integer> |
raw.add(42); trên List thô | Cảnh báo unchecked call to add(E) as a member of the raw type java.util.List | Kiểu thô tắt kiểm tra nhãn | Ghi nhãn: List<String> và bật cảnh báo để sửa |
String first = names.get(0); sau khi raw.add(42) | ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String [Đã chạy] | Phần tử sai kiểu đã lọt vào qua kiểu thô và nổ ở lúc đọc | Bỏ kiểu thô, không ép kiểu bằng tay |
Hai lỗi cuối là lỗi tình huống mở đầu, nên mình tái hiện nó đầy đủ. Đoạn trích từ RawPollution.java:
List<String> names = new ArrayList<>();
// Kiểu thô: tắt nhãn, trình biên dịch chỉ cảnh báo
List raw = names;
raw.add(42);
// Lỗi chỉ nổ ở đây, xa dòng gây ra nó
String first = names.get(0);
Kết quả khi chạy cả file [Đã chạy, JDK 17.0.15; hai dòng Note về unchecked nằm ở luồng lỗi chuẩn của trình biên dịch]:
added without error, size 1
read failed: ClassCastException
| Dòng code | Máy làm gì | Vì sao quan trọng |
|---|---|---|
List raw = names; | Tạo biến thứ hai trỏ tới cùng danh sách, không nhãn | Cùng một danh sách nhưng mất lớp bảo vệ của nhãn |
raw.add(42); | Thêm một Integer vào danh sách String | Chỉ có cảnh báo, không có lỗi biên dịch |
String first = names.get(0); | Trình biên dịch chèn phép ép sang String | Phần tử thật là Integer nên phép ép thất bại ở đây |
Hãy xem từng bước:
Đang tải Animation…
Bản chữ tương đương của animation:
| Bước | Dòng chạy | Danh sách | Điều xảy ra |
|---|---|---|---|
| 1 | List<String> names = new ArrayList<>(); | rỗng | Nhãn String gắn với names |
| 2 | List raw = names; | rỗng | raw trỏ cùng danh sách, không nhãn |
| 3 | raw.add(42); | chứa 42 | Chỉ có cảnh báo, size bằng 1 |
| 4 | String first = names.get(0); | chứa 42 | Phép ép sang String thất bại |
| 5 | catch | chứa 42 | In read failed |
Lỗi thứ tư và lỗi thứ ba cùng một gốc, và hình dưới đây gom mô hình sai và mô hình đúng lại về hộp Object.
Cách đọc hình: hàng trên màu đỏ nhạt là mô hình sai: nhiều người tưởng hộp Object "linh hoạt" mà vẫn an toàn nên dự đoán dùng sai sẽ được báo. Hàng dưới màu lục là mô hình đúng: hộp Object linh hoạt vì không ai kiểm tra gì, nên dùng sai chỉ lộ khi chương trình chạy.
Mô hình đúng này giải thích luôn tình huống mở đầu: danh sách tên bị nhét số vì không có ai kiểm tra nhãn.
Bạn vừa làm được: đọc bốn thông báo lỗi phổ biến của generics và nói được nguyên nhân bằng lời thường.
Tự tay làm (Bài tập)
Làm lần lượt từng mức. Mỗi bài có đề, dữ liệu vào, kết quả mong đợi, tiêu chí nghiệm thu và gợi ý. Lời giải nằm riêng ở cuối bài, hãy thử trước rồi mới so.
Mức Cơ bản
Bài 1. Điền bảng trạng thái. Dữ liệu vào là đoạn code sau, với Box<T> có get() và set(T):
Box<String> a = new Box<>("Latte");
Box<Integer> b = new Box<>(7);
String s = a.get();
int n = b.get();
b.set(n + 1);
Điền bảng gồm bốn cột: biến, kiểu khai báo, nhãn T, giá trị sau dòng cuối cho a, b, s, n. Kết quả mong đợi: một bảng bốn dòng. Tiêu chí nghiệm thu: bạn nêu được b chứa 8 và n vẫn là 7, và giải thích vì sao. Gợi ý: n là một bản sao của số, không nối với hộp.
Bài 2. Lỗi lúc biên dịch hay lúc chạy. Dữ liệu vào: hai đoạn. Đoạn một dùng Box<String> x = new Box<>("Tea"); Integer i = x.get();. Đoạn hai dùng hộp ObjectBox với Integer i = (Integer) box.get(); khi hộp chứa "Tea". Kết quả mong đợi: hai dòng, mỗi dòng ghi lỗi nào và xảy ra lúc nào. Tiêu chí nghiệm thu: đoạn một báo lúc biên dịch với thông báo incompatible types, đoạn hai báo lúc chạy với ClassCastException. Gợi ý: đoạn nào có nhãn thì trình biên dịch biết kiểu.
Bài 3. Phương thức orElse. Viết static <T> T orElse(T value, T fallback) trả fallback nếu value là null, ngược lại trả value. Kết quả mong đợi: orElse(null, "none") in none, và orElse(5, 9) in 5. Tiêu chí nghiệm thu: một phương thức duy nhất chạy được với cả hai kiểu. Gợi ý: cả hai tham số cùng nhãn T.
Mức Vận dụng
Bài 4. Class Shelf cho kệ hàng Nhà Hàng Lửa. Viết class Shelf<T> bọc một ArrayList<T> bên trong:
| Phương thức | Việc phải làm |
|---|---|
void add(T item) | Thêm món vào kệ |
T get(int index) | Lấy món ở vị trí index |
int size() và toString() | Trả số món và in danh sách |
Dữ liệu vào: Shelf<String> drinks thêm Latte, Mocha; Shelf<Integer> prices thêm 10, 25, 40. Kết quả mong đợi:
[Latte, Mocha] size 2
[10, 25, 40] size 3
first drink: Latte
Tiêu chí nghiệm thu: ba dòng in ra khớp từng ký tự, drinks.get(0) gán được vào String mà không ép kiểu, và drinks.add(5) bị trình biên dịch từ chối. Gợi ý: nhãn T của Shelf dùng thẳng cho nhãn của ArrayList bên trong.
Mức Thử thách (tuỳ chọn)
Bài 5. Bắt kiểu thô tận tay. Chép RawPollution.java, đổi List raw thành List<Object> objects = names; và đọc thông báo lỗi biên dịch. Kết quả mong đợi: một lỗi incompatible types giải thích List<String> không gán được cho List<Object>. Tiêu chí nghiệm thu: bạn giải thích bằng chữ vì sao Java chặn việc này, dù String là một Object. Gợi ý: nếu việc này được phép, bạn có thể nhét một Integer vào danh sách String qua biến objects, đúng thứ nhãn sinh ra để chặn. Chi tiết nằm ở bài sau.
Tự kiểm tra (Retrieval Practice)
Trả lời bằng trí nhớ, không xem lại bài. Mỗi câu ghi rõ nó kiểm tra mục tiêu nào trong bốn mục tiêu ở đầu bài.
Câu 1 (nhớ lại, mục tiêu 2). Generics giải quyết điều gì mà hộp Object không giải quyết được? Nêu khác biệt về thời điểm báo lỗi.
Câu 2 (dự đoán kết quả code, mục tiêu 1). Đoạn sau in ra gì?
Box<String> a = new Box<>("Tea");
Box<Integer> b = new Box<>(2);
System.out.println(a.get().length() + b.get());
Câu 3 (dự đoán trạng thái sau nhiều bước, mục tiêu 3). Cho List<String> names = new ArrayList<>(); List raw = names; raw.add(1); raw.add("An");. Sau hai dòng add, names.size() bằng bao nhiêu, và names.get(1) có lỗi không?
Câu 4 (tìm lỗi, mục tiêu 4). Dòng List<int> codes = new ArrayList<>(); không biên dịch được. Chỉ ra lỗi và sửa.
Câu 5 (giải thích cho bạn chưa học, mục tiêu 2). Bạn của bạn hỏi: "Dán nhãn lên hộp thì có giúp gì, vì bên trong vẫn là món đó?" Hãy giải thích bằng hình ảnh hộp trên kệ, nhấn vào thời điểm lỗi bị phát hiện.
Điều cần nhớ
- Dùng
Box<T>và<T>trước kiểu trả về để một bản thiết kế phục vụ nhiều kiểu, và điền nhãn ở nơi dùng (mục tiêu 1). - Nhãn được trình biên dịch ghi nhớ và kiểm tra, nên lỗi sai kiểu bị chặn trước khi chạy thay vì nổ ra
ClassCastException(mục tiêu 2). - Tránh kiểu thô: nó tắt nhãn, cho phần tử sai kiểu lọt vào, và lỗi nổ ở dòng khác với dòng gây ra (mục tiêu 3).
- Nhãn phải là kiểu đối tượng, nên dùng
Integerthayint; đọc kỹ thông báoincompatible typesvàunexpected type(mục tiêu 4).
Cheat sheet một trang.
| Việc cần làm | Cú pháp | Kết quả | Ghi chú |
|---|---|---|---|
| Khai báo lớp generic | class Box<T> | Bản thiết kế có nhãn trống | T dùng như một kiểu trong lớp |
| Dùng lớp generic | Box<String> b = new Box<>("x"); | Hộp có nhãn String | <> để Java tự điền |
| Khai báo phương thức generic | static <T> T first(List<T> l) | Phương thức theo nhãn | <T> đứng trước kiểu trả về |
| Nhãn là số | List<Integer> | Danh sách số | List<int> không hợp lệ |
| Tránh kiểu thô | List<String>, không phải List | Nhãn được kiểm tra | Cảnh báo unchecked là dấu hiệu |
So sánh Integer | a.equals(b) | boolean | Không so bằng == ngoài khoảng nhỏ |
Cách đọc hình: mỗi hàng là một nhu cầu bên trái và điều nên viết bên phải. Hộp cho nhiều kiểu thì dùng class Box<T>, phương thức cho nhiều kiểu thì dùng static <T> T first(...), và cảnh báo unchecked nghĩa là bạn đang dùng kiểu thô nên hãy thêm nhãn.
Câu hỏi để dành cho ngày mai: String là một Object, vậy Box<String> có phải là một Box<Object> không? Hãy nghĩ trước khi sang bài sau.
Đào sâu
Phần này dành cho bạn tò mò. Bỏ qua cũng không thiếu gì ở trên.
Bên trong sau khi biên dịch. Trình biên dịch dùng nhãn để kiểm tra, rồi bỏ nhãn đi: lúc chạy chỉ còn một lớp Box duy nhất, kỹ thuật này gọi là xoá kiểu (type erasure). Một lần chạy của người biên soạn [Đã chạy, JDK 17.0.15] cho Box<String> và Box<Integer> dùng chung lớp, và ArrayList<String> với ArrayList<Integer> cũng vậy:
true
true
true false true
unboxing null: NullPointerException
Hai dòng đầu là true. Điều này cũng giải thích vì sao bạn không viết được new T() (trình biên dịch báo unexpected type, required class, found type parameter T) hay new T[3] (báo generic array creation) [Đã chạy bằng trình biên dịch]: lúc chạy không còn biết T là gì.
So với lựa chọn khác. Vì nhãn phải là kiểu đối tượng, List<Integer> phải bọc từng số thành đối tượng Integer. Dòng thứ ba của kết quả trên cho thấy một cái bẫy: với số nhỏ (ở đây 127) hai Integer cùng giá trị là cùng một đối tượng nên == cho true, còn với 128 thì không. Vì thế hãy so Integer bằng equals. Dòng cuối cho thấy mở hộp một Integer là null thì ném NullPointerException.
Hiểu lầm nâng cao. Nhiều người nghĩ kiểu thô chỉ là cách viết tắt tiện lợi. Thực ra nó tắt hoàn toàn kiểm tra nhãn, và nó còn tồn tại chỉ để tương thích với code viết trước generics. Mã mới không nên dùng nó, và cảnh báo unchecked đáng được sửa chứ không nên tắt.
Giới hạn của bài. Bài này chưa nói về chặn kiểu (T extends ...), ký tự đại diện (?), phương thức cầu và việc Box<String> không phải là Box<Object>; mọi thứ đó nằm ở bài sau. Bài cũng không bàn tới phản chiếu kiểu generic.
Đọc thêm (tài liệu chính thống, Java SE 17):
Bài tiếp theo
Bài sau trả lời câu hỏi để dành: vì sao Box<String> không phải là Box<Object>, và cách nới lỏng có kiểm soát bằng chặn kiểu và ký tự đại diện.
Mini-dự án nối tiếp: ở bài sau, kệ hàng của Nhà Hàng Lửa sẽ có hàm tính tổng giá cho mọi danh sách số. Hãy giữ lại class Shelf<T> bạn vừa viết làm điểm xuất phát.
Lời giải và đáp án
Hãy thử xong rồi mới đọc phần này.
Lời giải Làm cùng mình. Thay các dòng // TODO bằng những dòng dưới đây, ghép lại chạy ra đúng năm dòng step ở trên [Đã chạy, JDK 17.0.15]:
Box<String> drink = new Box<>("Latte");
System.out.println("step 1: " + drink.get().length());
Box<Integer> cups = new Box<>(3);
System.out.println("step 2: " + (cups.get() + 1));
// Thêm vào lớp Box
void set(T content) {
this.content = content;
}
drink.set("Mocha");
System.out.println("step 3: " + drink.get());
// T được suy ra từ phần tử của danh sách
static <T> T last(List<T> items) {
return items.get(items.size() - 1);
}
static <T> Box<T> boxOf(T value) {
return new Box<>(value);
}
System.out.println("step 4: " + last(List.of("a", "b", "c")));
System.out.println("step 5: " + boxOf(42).get() + boxOf("x").get());
| Dòng code | Máy làm gì | Lý do chọn cách này |
|---|---|---|
new Box<>("Latte") với nhãn String | Tạo hộp String | Nhãn bên trái quyết định kiểu của get() |
void set(T content) | Nhận một giá trị đúng kiểu nhãn | Nhãn chặn việc nhét sai kiểu ngay khi biên dịch |
static <T> T last(List<T> items) | Khai báo nhãn riêng của phương thức | <T> đứng trước kiểu trả về để kiểu trả về theo nhãn |
items.get(items.size() - 1) | Lấy phần tử cuối | Chỉ số cuối là size() - 1 |
boxOf(42).get() + boxOf("x").get() | Hai hộp khác nhãn từ cùng một phương thức | Số 42 cộng chuỗi x ra 42x vì có một vế là chuỗi |
Lời giải Bài 1. [Suy luận, khớp với kết quả chạy thật]
| Biến | Kiểu khai báo | Nhãn T | Giá trị sau dòng cuối |
|---|---|---|---|
a | Box<String> | String | chứa "Latte" |
b | Box<Integer> | Integer | chứa 8 sau set(n + 1) |
s | String | không có | "Latte" |
n | int | không có | 7, vì n là bản sao của số |
Lời giải Bài 2. [Đã chạy]
Đoạn một báo lúc biên dịch: incompatible types: java.lang.String cannot be converted to java.lang.Integer. Đoạn hai biên dịch được và nổ lúc chạy với ClassCastException, thông báo nói class java.lang.String cannot be cast to class java.lang.Integer.
Lời giải Bài 3. [Đã chạy, in none rồi 5]
static <T> T orElse(T value, T fallback) {
// Cả hai tham số cùng nhãn T nên phải cùng kiểu
return value == null ? fallback : value;
}
Một phương thức dùng được cho String và Integer, vì T được suy ra ở mỗi lời gọi từ hai đối số.
Lời giải Bài 4. [Đã chạy, ba dòng khớp với kết quả mong đợi]
public class Shelf<T> {
private final List<T> items = new ArrayList<>();
void add(T item) {
items.add(item);
}
T get(int index) {
return items.get(index);
}
}
| Dòng code | Máy làm gì | Lý do chọn cách này |
|---|---|---|
class Shelf<T> | Khai báo kệ có nhãn T | Một lớp cho cả đồ uống và giá |
List<T> items | Dùng lại nhãn T cho danh sách bên trong | Nhãn của kệ và nhãn của danh sách phải khớp |
void add(T item) | Chỉ nhận món đúng nhãn | drinks.add(5) bị chặn khi biên dịch |
T get(int index) | Trả món đúng nhãn | Không cần ép kiểu khi lấy ra |
Gợi ý lời giải Bài 5. Thay dòng thô bằng List<Object> objects = names; rồi biên dịch: trình biên dịch báo incompatible types vì List<String> không phải là List<Object>. Lý do là nếu cho gán, bạn có thể gọi objects.add(42) và nhét số vào danh sách String mà không có cảnh báo nào.
Đáp án Tự kiểm tra.
- Generics kiểm tra nhãn kiểu và chặn lỗi sai kiểu lúc biên dịch; hộp
Objectchỉ lộ lỗi lúc chạy dưới dạngClassCastException. Kiểm tra mục tiêu 2. - In ra
5[Đã chạy]:a.get().length()là 3,b.get()là 2, và 3 cộng 2 bằng 5 vì cả hai đều là số. Kiểm tra mục tiêu 1. names.size()bằng 2, vànames.get(1)là"An"nên không lỗi, trong khinames.get(0)mới némClassCastExceptionvì phần tử đầu là số 1 [Suy luận]. Kiểm tra mục tiêu 3.- Nhãn phải là kiểu đối tượng nên
intkhông hợp lệ. Sửa thànhList<Integer> codes = new ArrayList<>();[Đã chạy bằng trình biên dịch]. Kiểm tra mục tiêu 4. - Dán nhãn không đổi món bên trong, nhưng nó cho người kiểm tra trên đường vào biết ngay món nào sai nhãn, trước khi món được đưa lên kệ. Không dán nhãn thì sai nhãn chỉ lộ khi khách mở hộp. Kiểm tra mục tiêu 2.