1. Giới thiệu
Hãy tưởng tượng bạn có một chiếc hộp đa dụng, có thể bỏ vào bất cứ thứ gì: một quả táo, một cuốn sách hay thậm chí một món đồ chơi. Trong Java, “chiếc hộp đa dụng” như vậy là một lớp lưu trữ dữ liệu ở kiểu tổng quát nhất, Object. Kiểu này là cha của mọi lớp khác trong Java, vì thế bạn có thể đặt bất kỳ đối tượng nào vào biến kiểu Object.
Bây giờ hãy hình dung một nhà kho với những chiếc hộp như vậy. Nếu trên hộp không có nhãn, bạn có thể bỏ vào đó bất cứ thứ gì, nhưng khi đến lúc cần lấy ra — bạn sẽ phải mở hộp và đoán xem bên trong là gì. Với generics, tình huống giống như một kho với nhãn rõ ràng: “Chỉ táo”, “Chỉ sách”, “Chỉ dụng cụ”. Bây giờ bạn luôn biết mỗi hộp chứa gì và sẽ không thể vô tình đặt một cuốn sách vào hộp dành cho táo.
Thoạt nhìn, lưu trữ trong Object có vẻ tiện: không cần tạo các lớp riêng cho từng kiểu dữ liệu khác nhau. Nhưng trên thực tế, sự “đa dụng” này dẫn đến vấn đề:
- Dễ mắc lỗi. Bạn có thể vô tình đặt nhầm đối tượng vào hộp.
- Trình biên dịch không thấy vấn đề. Nó sẽ đơn giản cho phép bạn đặt bất cứ thứ gì, vì kiểu Object cho phép điều đó.
- Phải “mở hộp” thủ công. Khi bạn lấy đối tượng ra khỏi hộp như vậy, nó lại có kiểu Object, và bạn phải tự ép nó về kiểu cần thiết (việc này gọi là ép kiểu hay cast). Nếu bạn ép về sai kiểu, chương trình sẽ dừng lại với lỗi!
Hãy xem điều này qua một ví dụ đơn giản.
class Box {
private Object value;
public void set(Object value) {
this.value = value;
}
public Object get() {
return value;
}
}
Bây giờ hãy sử dụng chiếc hộp này:
Box box = new Box();
box.set("Xin chào"); // Đã đặt một chuỗi vào
String s = (String) box.get(); // Lấy ra chuỗi, mọi thứ đều ổn
box.set(123); // Đã đặt một số vào
// Trình biên dịch không thấy vấn đề...
String t = (String) box.get(); // Lỗi khi chạy chương trình!
Như bạn thấy, trình biên dịch đã yên lặng cho qua đoạn mã cuối cùng dẫn đến lỗi. Chúng ta chỉ biết về vấn đề khi chương trình chạy và “sập”.
2. Giải pháp — generics
Generics (kiểu tổng quát) là cách giải quyết vấn đề này. Đây là cú pháp đặc biệt cho phép gắn một lớp hoặc một phương thức với kiểu dữ liệu cụ thể ngay ở giai đoạn biên dịch. Nói đơn giản, đó như một nhãn dán trên hộp, nói rằng: “Trong hộp này chỉ được chứa chuỗi” hoặc “Trong hộp này chỉ được chứa số”.
Nhờ đó, chúng ta có tính an toàn kiểu (type safety): trình biên dịch sẽ không cho phép bạn đặt vào “hộp” một đối tượng sai kiểu. Nó kiểm tra mã của bạn và chỉ ra lỗi trước khi bạn chạy chương trình.
Cùng một lớp Box, nhưng giờ có generics:
class Box<Type> {
private Type value;
public void set(Type value) {
this.value = value;
}
public Type get() {
return value;
}
}
Ở đây Type là tham số kiểu. Đây là một tên giả định do chúng ta tự chọn (thường dùng T, E, K, V), và nó có nghĩa: “Khi tạo Box, chúng ta sẽ chỉ định nó làm việc với kiểu nào, và tôi sẽ dùng kiểu đó ở mọi nơi trong mã nơi có Type”.
3. Sử dụng Generics
Khi tạo một đối tượng của lớp có generics, ta chỉ định kiểu cụ thể trong dấu ngoặc nhọn <...>.
// Tạo một hộp chỉ làm việc với chuỗi
Box<String> stringBox = new Box<>();
stringBox.set("Xin chào, thế giới!"); // OK, đã đặt một chuỗi vào
String s = stringBox.get(); // Lấy chuỗi ra mà không cần ép kiểu
stringBox.set(123); // Lỗi biên dịch! Trình biên dịch sẽ không cho phép điều này.
Nếu chúng ta tạo Box<Integer>, trình biên dịch sẽ đảm bảo rằng chỉ số mới được đặt vào đó:
// Tạo một hộp chỉ làm việc với số
Box<Integer> intBox = new Box<>();
intBox.set(42); // OK, đã đặt một số vào
Integer number = intBox.get(); // Lấy số ra mà không cần ép kiểu
intBox.set("Xin chào"); // Lỗi biên dịch!
Giờ thì trình biên dịch biết chính xác kiểu dữ liệu nào phải có trong mỗi hộp và bảo vệ chúng ta khỏi lỗi một cách đáng tin cậy.
4. Cách nó hoạt động qua ví dụ
Generics có thể dùng không chỉ trong các lớp mà còn trong các phương thức. Điều này cho phép viết mã rất linh hoạt và tổng quát.
Ví dụ 1 — lớp với generics
Giả sử chúng ta cần một lớp Pair lưu hai đối tượng cùng một kiểu. Với generics, nó trông như sau:
class Pair<T> {
private T first;
private T second;
public Pair(T first, T second) {
this.first = first;
this.second = second;
}
public T getFirst() {
return first;
}
public T getSecond() {
return second;
}
}
Cách dùng:
Pair<String> greetings = new Pair<String>("Xin chào", "Thế giới");
System.out.println(greetings.getFirst() + " " + greetings.getSecond());
Pair<Integer> numbers = new Pair<Integer>(10, 20);
System.out.println(numbers.getFirst() + numbers.getSecond());
Trong trường hợp đầu tiên, trình biên dịch chắc chắn rằng greetings chứa các chuỗi, còn trong trường hợp thứ hai — các số.
Ví dụ 2 — phương thức tổng quát
Chúng ta có thể viết một phương thức làm việc với bất kỳ kiểu dữ liệu nào.
class Utils {
// <T> trước void cho biết phương thức sẽ làm việc với tham số kiểu T
public static <T> void printTwice(T value) {
System.out.println(value);
System.out.println(value);
}
}
Giờ chúng ta có thể gọi phương thức này với bất kỳ kiểu dữ liệu nào và nó sẽ hoạt động giống nhau:
Utils.printTwice("Java");
Utils.printTwice(123);
Utils.printTwice(3.14);
5. Ưu điểm của generics
Generics không chỉ là “đường cú pháp” mà là một công cụ mạnh mẽ giải quyết các vấn đề thực tế trong phát triển. Hãy xem xét ba ưu điểm then chốt.
Tính an toàn kiểu là ưu điểm lớn nhất của generics. Không có chúng, trình biên dịch không thể kiểm tra xem bạn có dùng đúng kiểu dữ liệu trong các lớp và phương thức “đa dụng” của mình hay không. Những lỗi như cố đặt một chuỗi vào “hộp dành cho số” chỉ được phát hiện khi chạy chương trình, khi nó đột ngột “sập” với ngoại lệ ClassCastException. Với generics, trình biên dịch kiểm soát kiểu một cách nghiêm ngặt. Nó đảm bảo bạn chỉ đặt chuỗi vào Box<String>, và chỉ đặt số vào Box<Integer>. Nếu bạn làm điều gì đó sai, nó sẽ chỉ ra ngay lập tức, để bạn sửa trước khi chạy chương trình. Điều này khiến mã của bạn đáng tin cậy và dễ dự đoán hơn nhiều.
Mã sạch hơn (không cần ép kiểu thừa). Hãy nhớ lại mã với “hộp” không có generics: Box box = new Box(); box.set("Xin chào"); String s = (String) box.get(); Mỗi lần lấy đối tượng ra khỏi hộp, bạn phải viết (String), (Integer) v.v. Với generics, nhu cầu này biến mất. Trình biên dịch đã biết bên trong là kiểu gì và tự động chuyển cho bạn: Box<String> stringBox = new Box<>(); stringBox.set("Xin chào"); String s = stringBox.get(); Điều này không chỉ làm mã ngắn hơn mà còn cải thiện đáng kể tính dễ đọc và tiện dụng.
Linh hoạt và tái sử dụng mã. Generics cho phép tạo các lớp và phương thức thực sự tổng quát, hoạt động với nhiều kiểu dữ liệu khác nhau mà vẫn giữ được tính an toàn kiểu. Ví dụ, lớp Box<T> có thể dùng để lưu chuỗi, số, các lớp do bạn định nghĩa (Student, Car) — bất cứ thứ gì! Bạn không cần viết riêng StringBox, IntegerBox hay StudentBox. Bạn viết một lớp tổng quát Box<T> và chỉ cần chỉ định kiểu khi tạo đối tượng. Điều này giúp giảm số lượng mã, tránh trùng lặp và khiến chương trình của bạn mô-đun và linh hoạt hơn.
6. Hạn chế của generics
Mặc dù có nhiều ưu điểm, generics cũng có một vài hạn chế quan trọng mà bạn cần biết.
Kiểu nguyên thủy (primitives). Bạn không thể dùng các kiểu nguyên thủy (như int, double, boolean v.v.) làm tham số kiểu. Ví dụ, mã Box<int> intBox = new Box<>(); sẽ gây lỗi biên dịch. Thay vào đó, hãy dùng các “lớp bao” của chúng (Integer, Double, Boolean). Box<Integer> intBox = new Box<>();. Trình biên dịch Java có thể tự động chuyển đổi giữa kiểu nguyên thủy và lớp bao (int ↔ Integer) — cơ chế này gọi là tự đóng gói/mở gói (autoboxing/unboxing).
Xóa kiểu (Type Erasure). Đây là đặc điểm then chốt của cách generics được hiện thực trong Java. Ý tưởng là trình biên dịch Java chỉ sử dụng thông tin về generics trong thời gian biên dịch. Sau khi kiểm tra mã và đảm bảo tính an toàn kiểu, nó sẽ xóa toàn bộ thông tin về tham số kiểu. Ví dụ, Box<String> và Box<Integer> trong mã đã biên dịch (bytecode) sẽ chỉ còn là Box. Điều đó có nghĩa là đối với máy ảo Java (JVM), chúng trở thành cùng một kiểu.
Điều này có ý nghĩa gì với bạn? Bạn không thể tạo mảng generics, chẳng hạn new Box<String>[10], và bạn không thể dùng instanceof với generics để kiểm tra xem một đối tượng có phải là instanceof Box<String> hay không. Đây là chủ đề phức tạp hơn, nhưng việc hiểu nó quan trọng cho nghiên cứu sâu về Java. Ở giai đoạn này, chỉ cần nhớ rằng generics về bản chất là “nhãn dán” cho trình biên dịch nhằm giúp kiểm tra mã, và nhãn này bị gỡ bỏ khi chương trình sẵn sàng chạy.
GO TO FULL VERSION