Generic trong Java là một tính năng mạnh mẽ giúp lập trình viên tận dụng an toàn kiểu dữ liệu và tăng tính tái sử dụng của mã nguồn. Vậy Generics là gì? Hãy cùng Rikkei Academy tìm hiểu chi tiết từ các khái niệm cơ bản, thành phần và một số kiến thức quan trọng về Generic qua bài viết dưới đây nhé!
Generic trong Java là gì?
Trước khi có Generic, Java chỉ hỗ trợ đa hình bằng cách sử dụng lớp Object làm lớp cơ sở cho tất cả các lớp khác. Tuy nhiên, việc sử dụng lớp Object đã gây ra mất kiểm soát về kiểu dữ liệu và ép kiểu, dẫn đến lỗi trong mã nguồn. Để giải quyết vấn đề này, Generic đã được giới thiệu trong phiên bản Java 5.0.
Generic trong Java là một khái niệm trong lập trình Java liên quan đến việc sử dụng các tham số kiểu dữ liệu (type parameters) để định nghĩa các lớp, giao diện, và phương thức. Mục đích của generic type là để tạo ra các cấu trúc dữ liệu và hàm linh hoạt, có thể hoạt động với nhiều kiểu dữ liệu khác nhau mà không cần ép kiểu (casting) thủ công.
Quy ước về Generic trong Java
Generic trong Java thường được biểu diễn bằng cách sử dụng các ký tự đặc biệt trong dấu ngoặc nhọn (<>). Các ký tự thông dụng nhất là:
- E – Đại diện cho một phần tử (Element) trong một bộ sưu tập.
- K – Đại diện cho một khóa (Key) trong một cấu trúc dữ liệu dựa trên khóa-giá trị.
- V – Đại diện cho một giá trị (Value) trong một cấu trúc dữ liệu dựa trên khóa-giá trị.
- T – Đại diện cho một kiểu dữ liệu (Type) tổng quát.
Thành phần Generic trong Java
Chúng ta sẽ đi sâu vào tìm hiểu các thành phần của Generic trong các lớp, interface và phuong thức
Generic Class trong Java
Một class trong generics là một lớp tổng quát, được định nghĩa với một hoặc nhiều tham số kiểu dữ liệu (type parameter). Tham số kiểu dữ liệu này được sử dụng để chỉ định kiểu dữ liệu cho các thuộc tính và phương thức của lớp.
Thực chất, cách triển khai lớp generic không khác gì một class trong Java, điều khác biệt duy nhất chính lớp generic có tham số kiểu dữ liệu
Thay vì sử dụng một kiểu dữ liệu cụ thể, chúng ta sử dụng các placeholder (kí hiệu đại diện) để chỉ định kiểu dữ liệu. Thông thường ta sẽ dùng ký hiệu đại diện là T. Để tạo một generic class trong Java, chúng ta sử dụng cú pháp sau:
public class ClassName<T> {
// Khai báo thuộc tính và phương thức sử dụng T để chỉ định kiểu dữ liệu } |
Khi muốn tạo đối tượng trong Generic Class, ta dùng cú pháp sau:
ClassName<Type> objectName = new ClassName<Type>(); |
Ví dụ:
public class MyGenericClass<T> {
private T value; public MyGenericClass(T value) { this.value = value; } public T getValue() { return value; } public static void main(String[] args) { MyGenericClass<Integer> objInt = new MyGenericClass<Integer>(10); System.out.println(objInt.getValue()); // Kết quả: 10 } } |
Trong ví dụ này, chúng ta tạo một generic class MyGenericClass với một tham số kiểu T và một đối tượng với kiểu dữ liệu là Integer.
Generic Interface Trong Java
Interface trong generic là một interface chứa một hay nhiều tham số kiểu dữ liệu (type parameters), đại diện cho các kiểu dữ liệu khác nhau được sử dụng trong các phương thức hoặc lớp generic.
Điểm khác biệt giữa generic interface và các interface trong java khác ở kiểu dữ liệu. Nếu Interface thông thường trong Java chỉ định các phương thức trừu tượng mà các lớp phải triển khai và định nghĩa hành vi cụ thể thì generic interface sử dụng kiểu đối tượng để định nghĩa các phương thức.
Interface generic sử dụng các kí hiệu đại diện để chỉ định kiểu dữ liệu. Để tạo một generic interface trong Java, chúng ta sử dụng cú pháp sau:
public interface InterfaceName<T> {
// Khai báo các phương thức sử dụng T để chỉ định kiểu dữ liệu } |
Tương tự các interface khác không thể trực tiếp tạo đối tượng. Để tạo đối tượng trong generic interface, ta cần triển khai (implement) interface đó và chỉ định kiểu dữ liệu cụ thể cho tham số kiểu dữ liệu trong interface.
public class ClassName implements InterfaceName<DataType> {
// Các phương thức của interface được triển khai sử dụng kiểu dữ liệu } InterfaceName<Type> objectName = new ClassName<Type>(); |
Ví dụ:
public interface MyGenericInterface<T> {
void print(T obj); } public class MyClass implements MyGenericInterface<String> { public void print(String obj) { System.out.println(obj); } } public class Main { public static void main(String[] args) { MyGenericInterface<String> obj = new MyClass(); obj.print(“Hello, World!”); } } |
Phương thức Generic trong Java
Trong Java, phương thức generic (generic method) là một phương thức có khả năng làm việc với nhiều kiểu dữ liệu khác nhau mà không cần phải định nghĩa lại phương thức cho từng kiểu dữ liệu cụ thể.
public <T> void methodName(T parameter) {
// code here } |
Lưu ý: Tính chất này cũng được áp dụng cho constructor trong Java. Constructor là một phương thức đặc biệt được sử dụng để khởi tạo đối tượng trong lớp. Thông thường khi muốn tạo đối tượng của một lớp với nhiều kiểu dữ liệu khác nhau, ta phải định nghĩa lại constructor cho từng kiểu dữ liệu. Tuy nhiên, bằng việc sử dụng generic trong constructor, ta có thể tạo ra một constructor duy nhất cho mọi kiểu dữ liệu.
Bounded Type và Multiple Bounds trong Generic Java
Trong Java, khi làm việc với generics, bạn có thể muốn đặt một giới hạn hay nhiều giới hạn cho loại tham số mà bạn sử dụng. Để làm được điều này, ta sẽ cần sử dụng bounded type hay multiple bounds. Trong đó:
Bounded type
Bounded type parameters cho phép bạn đưa ra giới hạn cho loại tham số bạn sử dụng trong một class hoặc interface. Để định nghĩa một bounded type parameter, bạn sử dụng từ khóa extends để chỉ định loại giới hạn. Lưu ý rằng extends ở đây được sử dụng cho cả class và interface.
Ví dụ, giả sử bạn muốn tạo một generic class Container mà chỉ chấp nhận các đối tượng thuộc loại Number hoặc các subclass của Number:
public class Container<T extends Number> {
private T value; public T getValue() { return value; } public void setValue(T value) { this.value = value; } |
Bây giờ, bạn chỉ có thể sử dụng các đối tượng có kiểu là Number hoặc các subclass của Number:
Container<Integer> intContainer = new Container<>(); |
Container<Double> doubleContainer = new Container<>(); |
Giới hạn kiểu tham số trong phương thức generic
Bạn có thể giới hạn các kiểu tham số trong phương thức generic bằng cách 2 cách sau.
- Giới hạn kiểu tham số có giới hạn trên (Upper Bounded Type Parameters) – sử dụng từ khóa “extends”. Trong đó, kiểu tham số là lớp/kế thừa từ lớp hoặc implement từ interface.
Ví dụ: Ta có phương thức print có một tham số kiểu T được giới hạn bởi lớp Number bằng cách sử dụng từ khóa extends.
public <T extends Number> void print(T[] arr) {
for (T element : arr) { System.out.println(element); } } |
- Giới hạn kiểu tham số có giới hạn dưới (Lower Bounded Type Parameters) – sử dụng từ khóa “super”. Trong đó, kiểu tham số là lớp cha của lớp được chỉ định.
Ví dụ: Ta có phương thức print có một tham số kiểu T được giới hạn bởi lớp cha của Integer (tức là Number hoặc Object) bằng cách sử dụng từ khóa super
public <T super Integer> void print(List<T> list) {
for (T element : list) { System.out.println(element); } } |
Multiple Bounds
Multiple Bounds được sử dụng khi chúng ta cần giới hạn kiểu dữ liệu của một tham số generic trong Java phải thỏa mãn nhiều yêu cầu. Cú pháp Multiple Bounds:
<T extends A & B & C> |
Ví dụ, giả sử bạn muốn tạo một generic class MultiBoundContainer mà chỉ chấp nhận các đối tượng thuộc loại Number và cũng implement interface Comparable:
public class MultiBoundContainer<T extends Number & Comparable<T>> {
private T value; public T getValue() { return value; } public void setValue(T value) { this.value = value; } } |
Bây giờ, bạn chỉ có thể sử dụng các đối tượng có kiểu là Number (hoặc các subclass của Number) và cũng implement interface Comparable:
MultiBoundContainer<Integer> intContainer = new MultiBoundContainer<>(); |
MultiBoundContainer<Double> doubleContainer = new MultiBoundContainer<>(); |
Lưu ý: khi sử dụng multiple bounds, class nên được đặt đầu tiên, sau đó là các interface. Ngoài ra, không thể có nhiều hơn một class trong danh sách giới hạn.
Wildcard trong Generics Java
Khi dùng generic trong Java, đôi khi ta không biết chính xác kiểu dữ liệu nào sẽ được sử dụng cho tham số kiểu hoặc muốn cho phép sử dụng bất kỳ kiểu dữ liệu nào. Lúc này ta sử dụng Wildcard.
Trong Java, ký hiệu wildcard (?) được sử dụng trong các khai báo kiểu tham số (generic parameter) của một lớp, interface, phương thức, hoặc biến. Mục đích để chỉ ra rằng ta không biết hoặc không quan tâm đến kiểu dữ liệu đang được sử dụng. Wildcard giúp tạo ra sự linh hoạt hơn trong việc sử dụng các đối tượng generic.
Có ba loại wildcard trong Java:
- Unbounded Wildcard (?): Đại diện cho bất kỳ kiểu dữ liệu nào. Nó được sử dụng khi ta không quan tâm đến kiểu dữ liệu của đối tượng.
- Upper Bounded Wildcard (? extends T): Đại diện cho kiểu dữ liệu T hoặc lớp kế thừa từ T (T có thể là một lớp hoặc một interface). Ta sử dụng khi muốn giới hạn các kiểu dữ liệu trong một danh sách hoặc cấu trúc dữ liệu khác.
- Lower Bounded Wildcard (? super T): Đại diện cho kiểu dữ liệu T hoặc bất kỳ lớp nào là lớp cha của T. Ta sử dụng khi muốn chấp nhận các kiểu dữ liệu là lớp cha của một lớp nhất định.
Ví dụ:
import java.util.ArrayList;
import java.util.Arrays; import java.util.List; public class WildcardExample { public static void main(String[] args) { List<Integer> integers = Arrays.asList(1, 2, 3, 4, 5); List<Double> doubles = Arrays.asList(1.1, 2.2, 3.3, 4.4, 5.5); // Unbounded wildcard printList(integers); printList(doubles); // Upper-bounded wildcard double intSum = sumOfList(integers); double doubleSum = sumOfList(doubles); System.out.println(“Tổng của số nguyên: ” + intSum); System.out.println(“Tổng của số thực: ” + doubleSum); // Lower-bounded wildcard List<Number> numbers = new ArrayList<>(); addAll(integers, numbers); addAll(doubles, numbers); System.out.println(“Danh sách số: ” + numbers); } // Unbounded wildcard public static void printList(List<?> list) { for (Object elem : list) { System.out.print(elem + ” “); } System.out.println(); } // Upper-bounded wildcard public static double sumOfList(List<? extends Number> list) { double sum = 0.0; for (Number n : list) { sum += n.doubleValue(); } return sum; } // Lower-bounded wildcard public static void addAll(List<? extends Number> src, List<? super Number> dest) { for (Number n : src) { dest.add(n); } } } |
Type Erasure trong Generic Java
Generics được thêm vào Java để đảm bảo an toàn kiểu (type safety) khi làm việc với các đối tượng và cấu trúc dữ liệu. Và để đảm bảo rằng Generics không ảnh hưởng đến hiệu năng thời gian chạy của ứng dụng Java, trình biên dịch áp dụng quá trình gọi là Type Erasure (xóa kiểu) trong quá trình biên dịch.
Type Erasure giúp đảm bảo rằng mã Java sử dụng Generics không phí tài nguyên trong thời gian chạy bằng cách loại bỏ thông tin về các tham số kiểu (type parameters) trong mã biên dịch. Khi Type Erasure hoạt động, trình biên dịch sẽ xóa các tham số kiểu và thay thế chúng bằng giới hạn của chúng (nếu có) hoặc với Object nếu tham số kiểu không giới hạn.
Do đó, mã bytecode sau khi biên dịch chỉ chứa các lớp, giao diện và phương thức thông thường, đảm bảo không có kiểu mới nào được tạo ra. Trình biên dịch cũng áp dụng ép kiểu (casting) thích hợp với kiểu Object trong quá trình biên dịch.
Ví dụ:
Mã nguồn trước Type Erasure:
public class GenericBox<T>
private T data; public void setData(T data) { this.data = data; } public T getData() { return data; } } public class Main { public static void main(String[] args) { GenericBox<Integer> integerBox = new GenericBox<>(); integerBox.setData(42); Integer value = integerBox.getData(); System.out.println(“Value: ” + value); } } |
Mã nguồn sau Type Erasure:
public class GenericBox { // 1. Loại bỏ tham số kiểu T và thay thế bằng Object
private Object data; public void setData(Object data) { this.data = data; } public Object getData() { return data; } } public class Main { public static void main(String[] args) { GenericBox integerBox = new GenericBox(); // 2. Khởi tạo đối tượng GenericBox mà không cần chỉ định kiểu (Type Erasure đã loại bỏ kiểu Integer) integerBox.setData(Integer.valueOf(42)); // 3. Gán giá trị cho đối tượng (sử dụng kiểu Object thay vì Integer) Integer value = (Integer) integerBox.getData(); // 4. Lấy giá trị từ đối tượng (cần ép kiểu về Integer) System.out.println(“Value: ” + value); } } |
Generic trong Java và kiểu dữ liệu nguyên thủy
Điều kiện giới hạn của generics trong Java là tham số kiểu không thể là một kiểu nguyên thủy (primitive). Nghĩa là, các kiểu dữ liệu nguyên thủy (primitive types) như int, double, char, boolean,… không thể được sử dụng trực tiếp với các lớp và phương thức thông qua Generic.
Lý do chủ yếu do generics là một tính năng tại thời điểm biên dịch (compile-time), có nghĩa là thông tin về kiểu sẽ được xóa bỏ (type erasure) và tất cả các kiểu generics được thực hiện dưới dạng kiểu đối tượng. Trong khi đó, kiểu nguyên thủy là các kiểu dữ liệu cơ sở và không phải các đối tượng. Chúng không được thừa kế hoặc mở rộng từ lớp Object mà được xử lý bởi JVM một cách riêng biệt. Do đó, ta không thể dùng kiểu dữ liệu nguyên thủy làm tham số kiểu trong Generics.
Để sử dụng các kiểu dữ liệu nguyên thủy, ta cần sử dụng các lớp đóng gói (wrapper class) tương ứng với mỗi kiểu dữ liệu. Các lớp đóng gói này cho phép sử dụng các kiểu dữ liệu nguyên thủy dưới dạng các đối tượng. Ví dụ về kiểu dữ liệu nguyên thủy:
public class Main {
public static void main(String[] args) { // Khởi tạo một biến kiểu nguyên thủy int int myInt = 5; // Chuyển đổi giá trị nguyên thủy int sang lớp đóng gói Integer Integer myInteger = Integer.valueOf(myInt); // Hiển thị giá trị của biến kiểu nguyên thủy và lớp đóng gói tương ứng System.out.println(“Giá trị của biến kiểu nguyên thủy int là: ” + myInt); System.out.println(“Giá trị của biến kiểu đóng gói Integer là: ” + myInteger); } } |
Tại sao nên dùng Generic trong Java?
Sử dụng Generics trong Java mang lại nhiều lợi ích cho lập trình viên, bao gồm:
- An toàn kiểu: Generic trong Java giúp đảm bảo an toàn kiểu dữ liệu khi làm việc với các đối tượng và cấu trúc dữ liệu. Khi sử dụng Generics, bạn chỉ có thể thêm các đối tượng thuộc kiểu đã chỉ định vào cấu trúc dữ liệu đó. Điều này giúp giảm thiểu lỗi do việc sử dụng đối tượng không phù hợp và tránh được việc ép kiểu (casting) không an toàn.
- Tái sử dụng mã: Generics giúp tạo ra các lớp và phương thức có thể hoạt động với nhiều kiểu dữ liệu khác nhau mà không cần phải viết mã riêng biệt cho từng kiểu dữ liệu. Điều này giúp giảm bớt việc lặp lại mã nguồn và tăng tính tái sử dụng của mã.
- Kiểm tra kiểu dữ liệu tại thời điểm biên dịch: Generic trong Java giúp kiểm tra kiểu dữ liệu tại thời điểm biên dịch, giúp lập trình viên phát hiện và sửa lỗi về kiểu dữ liệu sớm hơn. Khi sử dụng Generics, bạn không phải đợi đến khi chương trình chạy mới phát hiện lỗi liên quan đến kiểu dữ liệu, giúp giảm thiểu các vấn đề trong thời gian chạy.
- Loại bỏ ép kiểu: Khi sử dụng Generic, trình biên dịch sẽ tự động chèn các ép kiểu cần thiết vào mã biên dịch. Điều này giúp loại bỏ việc phải thực hiện ép kiểu thủ công và giảm thiểu nguy cơ gây ra lỗi vì ép kiểu sai.
- Tính tương thích ngược: Generics được thiết kế để tương thích ngược với các phiên bản Java cũ hơn. Mã biên dịch sau khi áp dụng Type Erasure không chứa thông tin về Generics, cho phép bạn sử dụng mã Generics với các phiên bản Java cũ hơn mà không gặp vấn đề tương thích.
Kết luận
Như vậy, Rikkei Academy đã cung cấp cho bạn các thông tin chi tiết về Generic Trong Java trên nhiều phương diện bao gồm khái niệm, thành phần và một số điểm quan trọng cần lưu ý khi sử dụng generics. Bằng cách nắm vững kiến thức về Generics sẽ giúp bạn tiết kiệm được thời gian và công sức trong quá trình phát triển, nâng cao chất lượng mã nguồn và hiệu năng của ứng dụng!
Nếu bạn đang muốn tìm hiểu khóa học lập trình tham khảo ngay Rikkei Academy! Chương trình được thiết kế tinh gọn với các kiến thức, kỹ năng bám sát thực tế cùng giảng viên luôn hỗ trợ 24/7 sẽ giúp bạn nhanh chóng trở thành lập trình viên trong 6 tháng! Đăng ký để nhận tư vấn miễn phí ngay tại đây!