Nếu bạn từng làm việc với Java Collection và các phiên bản Java từ 5 trở lên, chắc hẳn bạn đã từng dùng qua Generics. Không khó để sử dụng nó với các lớp collection và Generics cũng mang lại nhiều lợi ích hơn là chỉ định kiểu cho collection. Trong bài viết này, chúng ta sẽ cùng đi sâu hơn về các tính năng của Generics trong Java.
Generics trong Java
Generics được thêm vào Java 5 nhằm mục đích kiểm tra kiểu dữ liệu ngay tại thời điểm biên dịch (compile-time type checking) và loại bỏ rủi ro lỗi ClassCastException
vốn thường xảy ra khi làm việc với các lớp collection. Toàn bộ collection framework cũng đã được viết lại để sử dụng Generics để tăng cường an toàn kiểu dữ liệu (type-safety).
Chúng ta hãy cùng xem cách Generics giúp sử dụng lớp collection an toàn hơn.
List list = new ArrayList();
list.add("abc");
list.add(new Integer(5)); //OK
for(Object obj : list){
//ép kiểu dẫn đến lỗi ClassCastException lúc chạy
String str=(String) obj;
}
Đoạn code ví dụ ở trên tuy biên dịch thành công nhưng sẽ gây ra lỗi ClassCastException
tại thời điểm chạy (runtime). Lý do là vì ta đang cố ép kiểu (cast) một đối tượng Object
trong list sang loại String
, trong khi một trong số các phần tử trong đó lại thuộc kiểu Integer
.
Kể từ Java 5 trở đi, cách sử dụng collection class đã thay đổi như sau.
List<String> list1 = new ArrayList<String>(); // java 7 ? List<String> list1 = new ArrayList<>();
list1.add("abc");
//list1.add(new Integer(5)); //lỗi biên dịch
for(String str : list1){
//không cần ép kiểu, tránh được ClassCastException
}
Hãy chú ý rằng, tại thời điểm khởi tạo list, ta đã chỉ định kiểu phần tử của danh sách là String
. Do đó, nếu ta cố gắng thêm bất kỳ đối tượng thuộc kiểu khác vào list, chương trình sẽ báo lỗi ngay tại thời điểm biên dịch. Ngoài ra, trong vòng lặp for
, ta không cần phải ép kiểu các phần tử lấy ra từ list nữa, nhờ đó tránh được lỗi ClassCastException
tại thời điểm runtime.
Class kiểu generic trong Java
Ta có thể tự định nghĩa các class với kiểu generic. Một kiểu generic (generic type) là một class hoặc interface được tham số hóa (parameterized) bằng các kiểu. Ta sử dụng dấu ngoặc nhọn (<>
) để khai báo tham số kiểu (type parameter).
Để hiểu rõ lợi ích của chúng, hãy phân tích một class đơn giản như sau:
package com.journaldev.generics;
public class GenericsTypeOld {
private Object t;
public Object get() {
return t;
}
public void set(Object t) {
this.t = t;
}
public static void main(String args[]){
GenericsTypeOld type = new GenericsTypeOld();
type.set("Pankaj");
String str = (String) type.get(); //ép kiểu, dễ gây ra lỗi, cụ thể là ClassCastException
}
}
Khi sử dụng class này, ta bắt buộc phải ép kiểu. Điều này có thể dẫn đến ClassCastException
tại thời điểm runtime. Bây giờ, ta sẽ sử dụng generic class trong Java để viết lại class trên như sau.
package com.journaldev.generics;
public class GenericsType<T> {
private T t;
public T get(){
return this.t;
}
public void set(T t1){
this.t=t1;
}
public static void main(String args[]){
GenericsType<String> type = new GenericsType<>();
type.set("Pankaj"); //hợp lệ
GenericsType type1 = new GenericsType(); //kiểu raw
type1.set("Pankaj"); //hợp lệ
type1.set(10); //hợp lệ and hỗ trợ autoboxing
}
}
Hãy để ý cách sử dụng GenericsType<T>
trong phương thức main
. Ta không cần phải ép kiểu như cũ nữa, và từ đó thể tránh được lỗi ClassCastException
tại thời điểm runtime.
Nếu ta không cung cấp kiểu cụ thể khi khởi tạo, trình biên dịch sẽ đưa ra cảnh báo: “GenericsType is a raw type. References to generic type GenericsType<T> should be parameterized (GenericsType là một raw type. Các tham chiếu đến kiểu generic GenericsType<T>
cần được tham số hóa.)
Khi không cung cấp kiểu, kiểu mặc định sẽ là Object
và cho phép cả đối tượng String
lẫn Integer
. Tuy nhiên, ta nên luôn tránh trường hợp này vì khi đó, ta lại phải ép kiểu cho kiểu thô (raw) và có nguy cơ gặp lỗi runtime.
Interface kiểu generic trong Java
Comparable
interface là một ví dụ điển hình về việc áp dụng Generics cho interface. Nó được định nghĩa như sau:
package java.lang;
import java.util.*;
public interface Comparable<T> {
public int compareTo(T o);
}
Tương tự, ta cũng có thể tạo các generic interface trong Java. Một generic interface có thể có nhiều tham số kiểu, tương tự như Map
interface. Hơn nữa, ta cũng có thể cung cấp giá trị được tham số hóa cho một kiểu được tham số hóa. Ví dụ new HashMap<String, List<String>>();
là một khai báo hoàn toàn hợp lệ.
Type kiểu generic trong Java
Quy ước đặt tên cho generic type giúp ta đọc hiểu code dễ dàng hơn. Và việc tuân theo quy ước đặt tên là một trong những nguyên lý tốt nên thực hiện theo trong lập trình Java. Do đó, Generics cũng có những quy ước đặt tên riêng.
Thông thường, tên của tham số kiểu (type parameter) là một ký tự đơn, viết hoa để dễ dàng phân biệt chúng với các biến Java thông thường. Các tên tham số kiểu phổ biến bao gồm:
- E – Element (được Java Collections Framework sử dụng rộng rãi, ví dụ:
ArrayList
,Set
, v.v.) - K – Key (Sử dụng trong
Map
) - N – Number (Số)
- T – Type (Kiểu dữ liệu)
- V – Value (Giá trị, sử dụng trong
Map
) - S, U, V, v.v. – Các kiểu thứ hai, thứ ba, thứ tư
Phương thức kiểu generic trong Java
Đôi khi, ta không muốn tham số hóa toàn bộ class mà chỉ cần một vài phương thức (method) cụ thể. Trong trường hợp này, ta có thể tạo các phương thức generic. Vì constructor cũng là một dạng phương thức đặc biệt, ta cũng có thể áp dụng kiểu generic cho nó.
Dưới đây là một class minh họa cách sử dụng phương thức generic trong Java.
package com.journaldev.generics;
public class GenericsMethods {
//Java Generic Method
public static <T> boolean isEqual(GenericsType<T> g1, GenericsType<T> g2){
return g1.get().equals(g2.get());
}
public static void main(String args[]){
GenericsType<String> g1 = new GenericsType<>();
g1.set("Pankaj");
GenericsType<String> g2 = new GenericsType<>();
g2.set("Pankaj");
boolean isEqual = GenericsMethods.<String>isEqual(g1, g2);
//code ở trên có thể được viết lại thành
isEqual = GenericsMethods.isEqual(g1, g2);
// Tính năng type inference cho phép gọi một phương thức generic như một phương thức thông thường mà không cần chỉ định kiểu dữ liệu trong dấu ngoặc nhọn.
// Compiler sẽ tự suy luận ra kiểu dữ liệu cần thiết }
}
Hãy chú ý đến phần khai báo (signature) của phương thức isEqual
với cú pháp sử dụng kiểu generic trong phương thức. Đồng thời, hãy xem cách các phương thức này được sử dụng trong chương trình Java.
Ta có thể chỉ định kiểu khi gọi các phương thức này hoặc có thể gọi chúng như những phương thức thông thường. Trình biên dịch của Java đủ thông minh để tự xác định kiểu cần thiết phải dùng. Tính năng này được gọi là suy luận kiểu (type inference).
Tham số kiểu có giới hạn của Generics
Giả sử ta muốn giới hạn các kiểu đối tượng có thể được sử dụng với một kiểu tham số hóa (parameterized). Ví dụ có một phương thức so sánh hai đối tượng và ta muốn đảm bảo rằng các đối tượng truyền vào phải là Comparable
. Để khai báo một tham số kiểu có giới hạn (bounded type parameter), ta liệt kê tên của tham số kiểu, theo sau là từ khóa extends
và sau đó là cận trên (upper bound) của nó, tương tự như trong phương thức ví dụ dưới đây.
public static <T extends Comparable<T>> int compare(T t1, T t2){
return t1.compareTo(t2);
}
Cách gọi các phương thức này tương tự như phương thức không có giới hạn (unbounded method), điểm khác biệt là nếu ta cố gắng sử dụng một class không phải là Comparable
, chương trình sẽ báo lỗi ngay tại thời điểm biên dịch.
Tham số kiểu có giới hạn có thể được áp dụng cho cả phương thức, class và interface. Generics cũng hỗ trợ khai báo nhiều giới hạn (multiple bounds).
Ví dụ trong <T extends A & B & C>
, A
có thể là một interface hoặc class. Nếu A
là class, thì B
và C
bắt buộc phải là interface. Ta không thể có nhiều hơn một class trong danh sách các giới hạn.
Generics và tính thừa kế
Ta biết rằng trong Java, tính kế thừa cho phép gán một biến kiểu A
cho một biến kiểu B
nếu A
là subclass của B
. Do đó, nhiều người lầm tưởng rằng một kiểu generic của A
(ví dụ List<A>
) cũng có thể được gán cho một kiểu generic của B
(ví dụ List<B>
).
Tuy nhiên, thực tế không phải như vậy. Hãy cùng xem xét điều này qua một ví dụ đơn giản sau.
package com.journaldev.generics;
public class GenericsInheritance {
public static void main(String[] args) {
String str = "abc";
Object obj = new Object();
obj=str; // hợp lệ vì String là Object theo quy tắc kế thừa trong Java
MyClass<String> myClass1 = new MyClass<String>();
MyClass<Object> myClass2 = new MyClass<Object>();
//myClass2=myClass1; // lỗi biên dịch vì MyClass<String> không phải là MyClass<Object>
obj = myClass1; // cha của MyClass<T> là một Object
}
public static class MyClass<T>{}
}
Ta không được phép gán biến MyClass<String>
cho biến MyClass<Object>
vì chúng không liên quan với nhau (về mặt kế thừa). Thực tế, MyClass<T>
là một Object
.
Lớp generic và subtyping (phân kiểu con)
Ta có thể tạo subtype (kiểu con) cho một generic class hoặc interface bằng cách kế thừa hoặc triển khai nó. Mối quan hệ giữa các tham số kiểu của một class/interface với các tham số kiểu của một class/interface khác được xác định thông qua mệnh đề extends
và implements
. Ví dụ, ArrayList<E> implements List<E>
, và List<E>
lại extends Collection<E>
. Do đó, ArrayList<String>
là một subtype của List<String>
, và List<String>
là một subtype của Collection<String>
.
Mối quan hệ subtyping này được duy trì miễn là ta không thay đổi đối số kiểu (type argument). Dưới đây là một ví dụ với nhiều tham số kiểu.
interface MyList<E,T> extends List<E>{
}
Các subtype của List<String>
có thể là ArrayList<String>
, LinkedList<String>
, v.v.
Ký tự đại diện trong Generics
Dấu chấm hỏi (?
) là ký tự đại diện (wildcard) trong Generics. Nó biểu thị một kiểu không xác định (unknown type). Wildcard này có thể được dùng làm kiểu cho tham số phương thức, trường (field), biến cục bộ, và đôi khi cả kiểu trả về của phương thức.
Ta không thể sử dụng wildcard khi gọi một phương thức generic hoặc khi khởi tạo một instance của một generic class.
Wildcard có giới hạn trên
Wildcard có giới hạn trên được dùng để nới lỏng các ràng buộc về kiểu của tham số trong một phương thức. Khi muốn viết một phương thức trả về tổng của các số trong một danh sách, ta có thể làm như sau.
public static double sum(List<Number> list){
double sum = 0;
for(Number n : list){
sum += n.doubleValue();
}
return sum;
}
Vấn đề với cách triển khai trên là nó sẽ không chạy được với list của integer và double vì như ta đã biết, List<Integer>
và List<Double>
không có mối quan hệ kế thừa trực tiếp. Đây chính là lúc wildcard có giới hạn trên phát huy tác dụng.
Ta sử dụng wildcard (?
) cùng với từ khóa extends
và một class hoặc interface làm cận trên (upper bound). Điều này cho phép phương thức chấp nhận các đối số là kiểu cận trên đó hoặc bất kỳ subtype nào của nó. Code ở trên có thể được sửa đổi lại như sau.
package com.journaldev.generics;
import java.util.ArrayList;
import java.util.List;
public class GenericsWildcards {
public static void main(String[] args) {
List<Integer> ints = new ArrayList<>();
ints.add(3); ints.add(5); ints.add(10);
double sum = sum(ints);
System.out.println("Sum of ints="+sum);
}
public static double sum(List<? extends Number> list){
double sum = 0;
for(Number n : list){
sum += n.doubleValue();
}
return sum;
}
}
Điều này tương tự như việc lập trình dựa trên interface. Trong phương thức trên, ta có thể sử dụng tất cả các phương thức của class Number
(là upper bound).
Cần lưu ý rằng, đối với một danh sách sử dụng wildcard có giới hạn trên, ta không được phép thêm bất kỳ đối tượng nào vào danh sách đó (ngoại trừ null
). Nếu ta cố gắng thêm một phần tử vào danh sách bên trong phương thức sum
, chương trình sẽ không biên dịch được.
Wildcard không giới hạn
Đôi khi ta cần một phương thức generic hoạt động với tất cả các kiểu. Trong trường hợp này, ta có thể dùng một wildcard không giới hạn (unbounded). Nó được sử dụng giống như <? extends Object>
.
public static void printData(List<?> list){
for(Object obj : list){
System.out.print(obj + "::");
}
}
Ta có thể truyền một List<String>
, List<Integer>
, hoặc danh sách của bất kỳ kiểu đối tượng nào khác làm đối số cho phương thức printData
. Tương tự như với list có giới hạn trên, ta không được phép thêm bất kỳ phần tử nào vào danh sách này.
Wildcard có giới hạn dưới
Giả sử ta muốn viết một phương thức để thêm các Integer
vào một list. Ta có thể khai báo tham số là List<Integer>
. Nhưng như vậy phương thức sẽ chỉ chấp nhận List<Integer>
, trong khi List<Number>
hay List<Object>
(vốn cũng có thể chứa các Integer
) lại không dùng được.
Để giải quyết vấn đề này, ta có thể sử dụng wildcard (?
) với từ khóa super
và một cận dưới (lower bound). Phương thức lúc này có thể chấp nhận đối số có kiểu là chính cận dưới đó hoặc bất kỳ supertype nào của nó. Trong trường hợp này, trình biên dịch Java cho phép thêm các đối tượng thuộc kiểu cận dưới vào list.
public static void addIntegers(List<? super Integer> list){
list.add(new Integer(50));
}
Phân kiểu con dùng wildcard của Generics
List<? extends Integer> intList = new ArrayList<>();
List<? extends Number> numList = intList; // OK. List<? extends Integer> is a subtype of List<? extends Number>
Xóa bỏ kiểu với Generics
Generics trong Java được thêm vào chủ yếu để kiểm tra kiểu tại thời điểm biên dịch. Chúng không có vai trò gì ở thời điểm runtime. Do đó, trình biên dịch Java sử dụng một kỹ thuật gọi là type erasure (xóa bỏ kiểu) để loại bỏ toàn bộ thông tin về kiểu generic trong bytecode, đồng thời chèn các lệnh ép kiểu (type-casting) cần thiết.
Type erasure đảm bảo rằng không có class mới nào được tạo ra cho các kiểu tham số hóa (parameterized). Kết quả là Generics không làm phát sinh thêm gánh nặng xử lý.
Ví dụ ta có một generic class như sau:
public class Test<T extends Comparable<T>> {
private T data;
private Test<T> next;
public Test(T d, Test<T> n) {
this.data = d;
this.next = n;
}
public T getData() { return this.data; }
}
Trình biên dịch Java thay thế tham số kiểu có giới hạn T
bằng interface giới hạn đầu tiên của nó, ví dụ như Comparable
, như trong đoạn code dưới đây:
public class Test {
private Comparable data;
private Test next;
public Node(Comparable d, Test n) {
this.data = d;
this.next = n;
}
public Comparable getData() { return data; }}
Những lỗi thường gặp khi làm việc với Generics
Ngay cả những lập trình viên giàu kinh nghiệm đôi khi cũng mắc phải sai lầm dưới đây khi sử dụng Generics trong code Java của họ.
1. Sử dụng raw type
Sử dụng raw type (class hoặc interface generic không có đối số kiểu) sẽ làm mất đi lợi ích chính mà Generics mang lại.
List list = new ArrayList(); // Tránh viết như thế này
list.add("Hello");
list.add(123); // Mất an toàn kiểu
Thay vào đó, hãy sử dụng kiểu được tham số hóa (parameterized):
List<String> list = new ArrayList<>();
2. Trộn lẫn code Generics mới với code cũ
Nên tránh việc trộn lẫn code sử dụng và code không sử dụng Generics vì điều này có thể dẫn đến các vấn đề không lường trước.
3. Sử dụng wildcard không chính xác
Ví dụ:
public void addItem(List<? extends Number> list) {
// list.add(10); // Lỗi biên dịch
}
Thay vào đó, hãy sử dụng ? super T
nếu cần sửa đổi:
public void addItem(List<? super Integer> list) {
list.add(10);
}
4. Lạm dụng wildcard
Mặc dù wildcard giúp tăng tính linh hoạt cho Generics, việc dùng chúng một cách không cần thiết có thể khiến code trở nên khó hiểu hơn.
Các câu hỏi thường gặp
1. Generics trong Java là gì?
Generics cho phép ta định nghĩa một class hoặc phương thức với một tham số kiểu (type parameter). Ví dụ:
class Box<T> {
private T value;
public void setValue(T value) {
this.value = value;
}
public T getValue() {
return value;
}
}
Ở đây, T
là một tham số kiểu. Nó sẽ được thay thế bằng một kiểu cụ thể khi class hoặc phương thức đó được sử dụng.
2. Tại sao ta nên sử dụng Generics trong Java?
- An toàn kiểu: Ngăn chặn lỗi
ClassCastException
tại thời điểm runtime. - Tái sử dụng code: Cho phép một class hoặc phương thức làm việc với nhiều kiểu dữ liệu khác nhau.
- Cải thiện khả năng đọc hiểu code: Tránh việc phải ép kiểu không cần thiết, giúp code trở nên mạch lạc và dễ bảo trì hơn.
3. Hãy lấy một ví dụ về phương thức generic
Một phương thức generic có thể khai báo một tham số kiểu riêng:
public static <T> void printArray(T[] elements) {
for (T element : elements) {
System.out.println(element);
}
}
Phương thức này dùng để in ra các phần tử của một mảng với kiểu bất kỳ.
4. List<?>
trong Java có nghĩa là gì?
List<?>
có nghĩa là một List
của một kiểu không xác định. Ở đây, ?
là một wildcard (ký tự đại diện) trong Generics. Nó biểu thị một kiểu chưa biết. Ví dụ:
List<?> list = new ArrayList<String>();
Ý nghĩa của dòng là list đó có thể chứa các phần tử thuộc bất kỳ kiểu nào, nhưng ta không biết được kiểu chính xác của các phần tử đó tại thời điểm biên dịch.
5. Giải thích về wildcard trong Generics
Có hai loại wildcard chính:
<? extends T>
: Biểu thị một kiểu không xác định, nhưng phải làT
hoặc một subtype (lớp con) củaT
.
public void processElements(List<? extends Number> numbers) {
for (Number num : numbers) {
System.out.println(num);
}
}
<? super T>
: Biểu thị một kiểu không xác định, nhưng phải làT
hoặc một supertype (lớp cha) củaT
.
public void addNumbers(List<? super Integer> numbers) {
numbers.add(42);
}
Các wildcard này mang lại sự linh hoạt cho khai bao kiểu đồng thời vẫn đảm bảo tính an toàn kiểu.
6. Có thể tạo một mảng của kiểu generic không?
Vì cơ chế type erasure, Java không cho phép tạo mảng của kiểu generic. Nếu điều này được phép diễn ra, nó có thể dẫn đến lỗi ArrayStoreException
tại thời điểm runtime. Thay vào đó, bạn nên sử dụng List<T>
:
List<String> list = new ArrayList<>();
7. Tại sao Generics lại an toàn để sử dụng trong Java?
Generics nâng cao tính an toàn kiểu bằng cách thực hiện kiểm tra kiểu tại thời điểm biên dịch, nhờ đó giảm thiểu đáng kể nguy cơ xảy ra lỗi tại thời điểm runtime. Chúng cũng loại bỏ nhu cầu ép kiểu tường minh (explicit casting) trong nhiều trường hợp, giúp code trở nên dễ đọc và dễ bảo trì hơn.
8. Nêu một số quy tắc và /hạn chế khi sử dụng Generics trong Java
Ta không thể tạo instance của kiểu generic:
class Box<T> {
// T obj = new T(); // Không cho phép
}
Ta không thể khai báo thành viên static
của kiểu generic:
class Box<T> {
// static T instance; // Không cho phép
}
Ta không thể sử dụng các kiểu dữ liệu nguyên thủy (primitive type) làm đối số kiểu. Thay vào đó, phải sử dụng các wrapper class tương ứng.
// List<int> list = new ArrayList<>(); // Không cho phép
List<Integer> list = new ArrayList<>();
Kết luận
Generics trong Java cung cấp một cơ chế mạnh mẽ giúp đảm bảo an toàn kiểu dữ liệu và tăng cường khả năng tái sử dụng code. Khi đã hiểu rõ các khía cạnh của wildcard, tránh các lỗi thường gặp và nguyên tắc tối ưu khi sử dụng Generics, bạn sẽ xây dựng được các ứng dụng Java dễ bảo trì, hoạt động hiệu quả và ít lỗi hơn.