Kỹ thuật Generics trong Java: Từ cơ bản đến nâng cao

Generics trong Java là một tính năng mạnh mẽ cho phép các lớp, giao diện và phương thức hoạt động với các kiểu dữ liệu khác nhau mà vẫn giữ được tính an toàn kiểu. Thay vì viết mã lặp đi lặp lại cho từng kiểu dữ liệu, generics cho phép bạn định nghĩa các cấu trúc dữ liệu và thuật toán theo cách chung chung, sau đó chỉ định kiểu cụ thể khi sử dụng.

Generics là gì?

Generics, hay còn gọi là các kiểu được tham số hóa (parameterized types), cho phép bạn truyền kiểu dữ liệu như một tham số. Trong Java, điều này được thể hiện bằng cách sử dụng dấu ngoặc nhọn <> chứa tên kiểu, ví dụ: <T> hoặc <E>.

Lợi ích của Generics

Trước khi có generics, các lập trình viên thường phải sử dụng kiểu Object để làm việc với các kiểu dữ liệu đa dạng. Điều này dẫn đến nhu cầu ép kiểu xuống (downcasting) và tiềm ẩn rủi ro xảy ra lỗi ClassCastException tại thời điểm chạy (runtime). Với generics, việc kiểm tra kiểu được thực hiện ngay tại thời điểm biên dịch (compile time), giúp phát hiện và ngăn chặn các lỗi kiểu không tương thích sớm hơn, nâng cao độ tin cậy của mã nguồn.

Ứng dụng của Generics: Lớp và Giao diện tổng quát

Nhiều giao diện chuẩn của Java đã sử dụng generics, điển hình là java.lang.Comparablejava.util.Comparator.

Triển khai giao diện tổng quát (Generic Interface)

Khi triển khai một giao diện tổng quát, bạn cần chỉ định kiểu cụ thể cho tham số kiểu.

package dev.example.generics;

import java.util.Objects;

public class Employee implements Comparable<Employee> {
    private int employeeId;
    private String fullName;
    private double salary;

    public Employee() {}

    public Employee(int employeeId, String fullName, double salary) {
        this.employeeId = employeeId;
        this.fullName = fullName;
        this.salary = salary;
    }

    public int getEmployeeId() { return employeeId; }
    public void setEmployeeId(int employeeId) { this.employeeId = employeeId; }
    public String getFullName() { return fullName; }
    public void setFullName(String fullName) { this.fullName = fullName; }
    public double getSalary() { return salary; }
    public void setSalary(double salary) { this.salary = salary; }

    @Override
    public int compareTo(Employee other) {
        // So sánh theo ID nhân viên
        return Integer.compare(this.employeeId, other.employeeId);
    }

    @Override
    public String toString() {
        return "Employee{" +
               "employeeId=" + employeeId +
               ", fullName='" + fullName + '\'' +
               ", salary=" + salary +
               '}';
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Employee employee = (Employee) o;
        return employeeId == employee.employeeId;
    }

    @Override
    public int hashCode() {
        return Objects.hash(employeeId);
    }
}
package dev.example.generics;

import java.util.Arrays;
import java.util.Comparator;
import org.junit.jupiter.api.Test;

public class EmployeeSortingDemo {

    @Test
    void testEmployeeSorting() {
        // Khởi tạo một mảng nhân viên
        Employee[] employees = new Employee[3];
        employees[0] = new Employee(102, "Lê Văn B", 65000.0);
        employees[1] = new Employee(101, "Nguyễn Thị A", 72000.0);
        employees[2] = new Employee(103, "Trần Văn C", 60000.0);

        System.out.println("Danh sách ban đầu:");
        for (Employee emp : employees) {
            System.out.println(emp);
        }

        // Sắp xếp theo ID (sử dụng Comparable<Employee> đã triển khai trong Employee)
        System.out.println("\nSắp xếp theo Employee ID:");
        Arrays.sort(employees);
        for (Employee emp : employees) {
            System.out.println(emp);
        }

        // Định nghĩa một Comparator tùy chỉnh để sắp xếp theo mức lương
        Comparator<Employee> salaryComparator = new Comparator<Employee>() {
            @Override
            public int compare(Employee emp1, Employee emp2) {
                return Double.compare(emp1.getSalary(), emp2.getSalary());
            }
        }; // Lớp nội ẩn danh triển khai Comparator<Employee>

        System.out.println("\nSắp xếp theo mức lương:");
        Arrays.sort(employees, salaryComparator);
        for (Employee emp : employees) {
            System.out.println(emp);
        }
    }
}

Tạo đối tượng từ lớp tổng quát (Generic Class)

Một lớp có thể tự định nghĩa tham số kiểu để trở thành một lớp tổng quát. Các tham số kiểu này sau đó được sử dụng trong định nghĩa các trường hoặc phương thức của lớp.

package dev.example.generics;

public class Point<T> {
    private T coordinateX;
    private T coordinateY;

    public Point() {}

    public Point(T coordinateX, T coordinateY) {
        this.coordinateX = coordinateX;
        this.coordinateY = coordinateY;
    }

    public T getCoordinateX() { return coordinateX; }
    public void setCoordinateX(T coordinateX) { this.coordinateX = coordinateX; }
    public T getCoordinateY() { return coordinateY; }
    public void setCoordinateY(T coordinateY) { this.coordinateY = coordinateY; }

    @Override
    public String toString() {
        return "Point{" +
               "coordinateX=" + coordinateX +
               ", coordinateY=" + coordinateY +
               '}';
    }
}
package dev.example.generics;

import org.junit.jupiter.api.Test;

public class PointDemo {

    @Test
    void testStringCoordinates() {
        // Tọa độ địa lý: kinh độ, vĩ độ
        Point<String> geoPoint = new Point<>("21.0285 N", "105.8542 E");
        // Từ JDK 7 trở đi, có thể sử dụng "diamond operator" <> để Java tự suy luận kiểu
        System.out.println("Tọa độ địa lý: " + geoPoint);
    }

    @Test
    void testDoubleCoordinates() {
        // Tọa độ trên mặt phẳng Descartes
        // Generics chỉ hỗ trợ kiểu đối tượng (reference types), không hỗ trợ kiểu nguyên thủy (primitive types).
        // Cần sử dụng các lớp bao bọc (wrapper classes) cho các kiểu nguyên thủy.
        Point<Double> planePoint = new Point<>(15.7, 8.3);
        System.out.println("Tọa độ mặt phẳng: " + planePoint);
    }
}

Kế thừa lớp tổng quát

Khi một lớp con kế thừa từ một lớp tổng quát, có hai cách chính để xử lý tham số kiểu:

  1. Chỉ định rõ kiểu cho lớp cha tổng quát: Lớp con trở thành một lớp "bình thường", không tổng quát.

    package dev.example.generics;
    
    public class DataContainer<T> {
        private T dataItem;
    
        public DataContainer() {}
    
        public DataContainer(T dataItem) {
            this.dataItem = dataItem;
        }
    
        public T getDataItem() { return dataItem; }
        public void setDataItem(T dataItem) { this.dataItem = dataItem; }
    }
    
    package dev.example.generics;
    
    // Tình huống 1: Lớp con chỉ định rõ kiểu String cho tham số T của lớp cha.
    // Lớp StringContainer không phải là lớp tổng quát.
    public class StringContainer extends DataContainer<String> {
        public StringContainer() {}
    
        public StringContainer(String item) {
            super(item);
        }
    }
    
  2. Giữ lại hoặc định nghĩa tham số kiểu mới cho lớp con: Lớp con vẫn là lớp tổng quát.

    package dev.example.generics;
    
    // Tình huống 2: Lớp con vẫn giữ là tổng quát.
    // Tham số kiểu có thể giữ nguyên (T) hoặc đổi tên (U)
    public class FlexibleContainer<U> extends DataContainer<U> {
        public FlexibleContainer() {}
    
        public FlexibleContainer(U item) {
            super(item);
        }
    }
    

Ứng dụng của Generics: Phương thức tổng quát (Generic Method)

Một phương thức tổng quát là phương thức có tham số kiểu riêng, được khai báo giữa các từ khóa bổ trợ (modifiers) và kiểu trả về (return type). Tham số kiểu này có phạm vi chỉ trong phương thức đó.

Cú pháp khai báo phương thức tổng quát

【modifiers】 <TypeParameter> ReturnType methodName(【parameterList】) 【throws ExceptionList】 {
    // Thân phương thức
}

Tham số kiểu của một phương thức tổng quát <TypeParameter> thường được suy luận tự động từ các đối số thực tế khi phương thức được gọi, không cần chỉ định rõ ràng.

Ví dụ, lớp java.util.Arrays có nhiều phương thức tổng quát, như:

public static <T> T[] copyOf(T[] original, int newLength)
package dev.example.generics;

import java.lang.reflect.Array; // Cần import để dùng Array.newInstance

public class ArrayOperations {
    // Phương thức copy không tổng quát, trả về Object[]
    public static Object[] copyNonGeneric(Object[] sourceArray, int newSize) {
        Object[] newArray = new Object[newSize];
        for (int i = 0; i < Math.min(sourceArray.length, newSize); i++) {
            newArray[i] = sourceArray[i];
        }
        return newArray;
    }

    // Phương thức tổng quát, trả về mảng đúng kiểu T[]
    public static <T> T[] copyGeneric(T[] sourceArray, int newSize) {
        // Do cơ chế Type Erasure, không thể tạo mảng kiểu T trực tiếp (ví dụ: new T[newSize])
        // Cần sử dụng Java Reflection API để tạo mảng với kiểu thành phần đã biết.
        @SuppressWarnings("unchecked")
        T[] newArray = (T[]) Array.newInstance(sourceArray.getClass().getComponentType(), newSize);

        for (int i = 0; i < Math.min(sourceArray.length, newSize); i++) {
            newArray[i] = sourceArray[i];
        }
        return newArray;
    }
}
package dev.example.generics;

import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.util.Arrays;

public class ArrayOperationsDemo {

    @Test
    void testNonGenericCopy() {
        String[] originalStrings = {"Apple", "Banana", "Cherry"};
        Object[] copiedObjects = ArrayOperations.copyNonGeneric(originalStrings, 5);

        System.out.println("Mảng được sao chép không tổng quát (Object[]):");
        // Cần ép kiểu thủ công và có rủi ro ClassCastException nếu sai kiểu
        // String[] castedStrings = (String[]) copiedObjects; // Sẽ gây lỗi nếu kích thước mới lớn hơn và có null
        for (Object obj : copiedObjects) {
            System.out.println(obj);
        }
    }

    @Test
    void testSystemGenericCopy() {
        String[] originalStrings = {"Apple", "Banana", "Cherry"};
        // Sử dụng phương thức copyOf của java.util.Arrays (là một phương thức tổng quát)
        String[] newStrings = Arrays.copyOf(originalStrings, 5);

        System.out.println("\nMảng String được sao chép bằng Arrays.copyOf:");
        for (String s : newStrings) {
            System.out.println(s);
        }
    }

    @Test
    void testCustomGenericCopy() {
        LocalDate[] dates = {LocalDate.now(), LocalDate.of(2023, 1, 1), LocalDate.of(2024, 6, 15)};
        // Sử dụng phương thức copyGeneric tự định nghĩa
        LocalDate[] newDates = ArrayOperations.copyGeneric(dates, 5);

        System.out.println("\nMảng LocalDate được sao chép bằng ArrayOperations.copyGeneric:");
        for (LocalDate d : newDates) {
            System.out.println(d);
        }
    }
}

So sánh: Tham số kiểu ở cấp độ lớp và cấp độ phương thức

Sự khác biệt chính giữa việc định nghĩa tham số kiểu trên khai báo lớp và trên khai báo phương thức là phạm vi của tham số kiểu:

  • Tham số kiểu ở cấp độ lớp: Được định nghĩa sau tên lớp (ví dụ: class MyClass<T>). Phạm vi của T là toàn bộ lớp, bao gồm tất cả các trường không tĩnh, các phương thức không tĩnh, và các phương thức khởi tạo. Mỗi đối tượng của lớp tổng quát này sẽ có một kiểu cụ thể cho T.
  • Tham số kiểu ở cấp độ phương thức: Được định nghĩa ngay trước kiểu trả về của phương thức (ví dụ: public <T> T myMethod(T param)). Phạm vi của T chỉ giới hạn trong phương thức đó. Một phương thức tổng quát có thể thuộc về một lớp không tổng quát, hoặc thuộc về một lớp tổng quát nhưng sử dụng tham số kiểu độc lập với tham số kiểu của lớp.
package dev.example.generics;

// Lớp với tham số kiểu ở cấp độ lớp
public class DataStore<T> {
    private T item1;
    private T item2;

    public DataStore(T val1, T val2) {
        this.item1 = val1;
        this.item2 = val2;
    }

    public T getItem1() { return item1; }
    public T getItem2() { return item2; }

    public void displayItems() {
        System.out.println("Item 1: " + item1 + ", Item 2: " + item2);
    }
}
package dev.example.generics;

import org.junit.jupiter.api.Test;

public class DataStoreDemo {
    @Test
    void testClassLevelGenerics() {
        // Mỗi instance của DataStore có một kiểu T cụ thể
        DataStore<String> stringStore = new DataStore<>("Hello", "World");
        stringStore.displayItems(); // Output: Item 1: Hello, Item 2: World

        DataStore<Integer> intStore = new DataStore<>(100, 200);
        intStore.displayItems(); // Output: Item 1: 100, Item 2: 200
    }
}
package dev.example.generics;

// Lớp chứa các phương thức tổng quát tĩnh
public class UtilityProcessor {
    // Phương thức tổng quát tĩnh
    public static <T> void printValue(T value) {
        System.out.println("Processing value: " + value);
    }

    // Một phương thức tổng quát khác
    public static <K, V> void printPair(K key, V value) {
        System.out.println("Key: " + key + ", Value: " + value);
    }
}
package dev.example.generics;

import org.junit.jupiter.api.Test;

public class UtilityProcessorDemo {
    @Test
    void testMethodLevelGenerics() {
        // Kiểu được suy luận tự động khi gọi phương thức
        UtilityProcessor.printValue("Java is cool"); // T là String
        UtilityProcessor.printValue(123);           // T là Integer
        UtilityProcessor.printValue(true);          // T là Boolean

        UtilityProcessor.printPair("Name", "Alice"); // K là String, V là String
        UtilityProcessor.printPair(1, 3.14);          // K là Integer, V là Double
    }
}

Một điểm quan trọng là các phương thức tĩnh trong một lớp tổng quát không thể sử dụng trực tiếp tham số kiểu của lớp (ví dụ: DataStore<T>). Nếu một phương thức tĩnh cần sử dụng generics, nó phải tự định nghĩa tham số kiểu riêng để trở thành một phương thức tổng quát.

Yêu cầu khi khai báo và sử dụng Generics

  1. Tên tham số kiểu: Nên sử dụng một chữ cái in hoa duy nhất.
    • T: Type (kiểu chung)
    • E: Element (phần tử, thường dùng trong collection)
    • K, V: Key, Value (thường dùng trong Map)
  2. Chỉ dùng kiểu tham chiếu: Các tham số kiểu phải là kiểu đối tượng (reference types), không được là kiểu nguyên thủy (primitive types) như int, double, boolean. Với kiểu nguyên thủy, cần sử dụng các lớp bao bọc (wrapper classes) tương ứng (Integer, Double, Boolean...).
  3. Nhiều tham số kiểu: Có thể định nghĩa nhiều tham số kiểu, phân tách bằng dấu phẩy, ví dụ: <T, U, V>.
  4. Ràng buộc kiểu (Bounded Type Parameters): Có thể giới hạn các kiểu được phép sử dụng cho tham số kiểu bằng từ khóa extends (tương đương với "nhỏ hơn hoặc bằng"). Một tham số kiểu có thể có nhiều ràng buộc (multi-bounds), nhưng chỉ có thể có tối đa một lớp cụ thể và phải đặt nó ở vị trí đầu tiên. Các ràng buộc còn lại phải là giao diện. Cú pháp: <T extends ClassA & I1 & I2>.
package dev.example.generics;

import java.io.Serializable;

// Lớp tổng quát với nhiều tham số kiểu và ràng buộc
// Z phải là một lớp con của Number, đồng thời triển khai Comparable và Serializable
public class Triple<T, U, Z extends Number & Comparable<Z> & Serializable> {
    private T firstValue;
    private U secondValue;
    private Z thirdValue;

    public Triple() {}

    public Triple(T firstValue, U secondValue, Z thirdValue) {
        this.firstValue = firstValue;
        this.secondValue = secondValue;
        this.thirdValue = thirdValue;
    }

    public T getFirstValue() { return firstValue; }
    public void setFirstValue(T firstValue) { this.firstValue = firstValue; }
    public U getSecondValue() { return secondValue; }
    public void setSecondValue(U secondValue) { this.secondValue = secondValue; }
    public Z getThirdValue() { return thirdValue; }
    public void setThirdValue(Z thirdValue) { this.thirdValue = thirdValue; }

    @Override
    public String toString() {
        return "Triple{" +
               "firstValue=" + firstValue +
               ", secondValue=" + secondValue +
               ", thirdValue=" + thirdValue +
               '}';
    }
}
package dev.example.generics;

import org.junit.jupiter.api.Test;

public class TripleDemo {
    @Test
    void testBoundedGenerics() {
        // Hợp lệ: Double là lớp con của Number, triển khai Comparable và Serializable
        Triple<String, Integer, Double> data1 = new Triple<>("Item A", 10, 5.5);
        System.out.println(data1);

        // Không hợp lệ: String không phải là lớp con của Number
        // Triple<String, Integer, String> data2 = new Triple<>("Item B", 20, "invalid"); // Lỗi biên dịch
    }
}

Xóa kiểu (Type Erasure)

Generics trong Java được triển khai bằng cơ chế xóa kiểu. Điều này có nghĩa là thông tin về kiểu tổng quát (như <String> trong List<String>) chỉ tồn tại trong thời gian biên dịch. Sau khi biên dịch, các tham số kiểu sẽ bị "xóa" và thay thế bằng kiểu Object (hoặc kiểu ràng buộc trên cùng nếu có). Điều này đảm bảo khả năng tương thích ngược với các phiên bản Java cũ hơn không có generics. Tuy nhiên, nó cũng có nghĩa là bạn không thể kiểm tra kiểu tổng quát tại runtime (ví dụ: if (obj instanceof List<String>) là không hợp lệ).

Wildcards (Ký tự đại diện) trong Generics

Wildcards được sử dụng trong tham số kiểu của generics, cho phép bạn tạo ra các phương thức linh hoạt hơn khi làm việc với các kiểu tổng quát khác nhau. Wildcards luôn phải đi kèm với tên lớp hoặc giao diện và nằm trong dấu <>.

  • <?>: Đại diện cho bất kỳ kiểu tham chiếu nào (unbounded wildcard).
  • <? extends A>: Đại diện cho kiểu A hoặc bất kỳ kiểu con nào của A (upper bounded wildcard).
  • <? super A>: Đại diện cho kiểu A hoặc bất kỳ kiểu cha nào của A (lower bounded wildcard).
package dev.example.generics;

// Lớp đại diện cho một cặp giá trị
public class Pair<F, S> { // F: First, S: Second
    private F firstElement;
    private S secondElement;

    public Pair() {}

    public Pair(F firstElement, S secondElement) {
        this.firstElement = firstElement;
        this.secondElement = secondElement;
    }

    public F getFirstElement() { return firstElement; }
    public void setFirstElement(F firstElement) { this.firstElement = firstElement; }
    public S getSecondElement() { return secondElement; }
    public void setSecondElement(S secondElement) { this.secondElement = secondElement; }

    @Override
    public String toString() {
        return "Pair{" +
               "firstElement=" + firstElement +
               ", secondElement=" + secondElement +
               '}';
    }
}
package dev.example.generics;

// Một số lớp trong hệ thống phân cấp
class Person {
    private String name;
    public Person(String name) { this.name = name; }
    public String getName() { return name; }
    @Override public String toString() { return "Person(" + name + ")"; }
}

class Student extends Person {
    public Student(String name) { super(name); }
    @Override public String toString() { return "Student(" + getName() + ")"; }
}

class Professor extends Person {
    public Professor(String name) { super(name); }
    @Override public String toString() { return "Professor(" + getName() + ")"; }
}

class Robot {
    private String model;
    public Robot(String model) { this.model = model; }
    @Override public String toString() { return "Robot(" + model + ")"; }
}
package dev.example.generics;

import org.junit.jupiter.api.Test;

public class WildcardDemo {

    // Phương thức chấp nhận bất kỳ Pair nào
    public void displayAnyPair(Pair<?, ?> p) {
        System.out.println("Pair Value: " + p.getFirstElement() + ", " + p.getSecondElement());
        // Không thể thêm vào hoặc sửa đổi với <?> vì kiểu không xác định
        // p.setFirstElement(new Object()); // Lỗi biên dịch
        // p.setSecondElement("Test");      // Lỗi biên dịch
    }

    // Phương thức chấp nhận Pair với các phần tử là Person hoặc lớp con của Person
    // (Upper Bounded Wildcard)
    public void processPersonPair(Pair<? extends Person, ? extends Person> p) {
        System.out.println("Processing person pair: " + p.getFirstElement().getName() + " & " + p.getSecondElement().getName());
        // Không thể thêm vào hoặc sửa đổi với <? extends T> (trừ null)
        // p.setFirstElement(new Student("Jane")); // Lỗi biên dịch
    }

    // Phương thức chấp nhận Pair với các phần tử là Person hoặc lớp cha của Person
    // (Lower Bounded Wildcard)
    public void addPeopleToPair(Pair<? super Person, ? super Person> p) {
        System.out.println("Adding people to pair (Lower Bound): " + p.getFirstElement() + ", " + p.getSecondElement());
        // Có thể thêm vào các kiểu là Person hoặc lớp con của Person
        p.setFirstElement(new Student("Alice")); // Hợp lệ
        p.setSecondElement(new Professor("Dr. Bob")); // Hợp lệ
        // p.setFirstElement(new Object()); // Không hợp lệ (Object không phải Person hoặc con của Person)
    }

    @Test
    void testUnboundedWildcard() {
        Pair<Robot, Person> mixedPair = new Pair<>(new Robot("RX-1"), new Person("John"));
        displayAnyPair(mixedPair); // Hợp lệ

        Pair<Integer, Double> numberPair = new Pair<>(100, 3.14);
        displayAnyPair(numberPair); // Hợp lệ
    }

    @Test
    void testUpperBoundedWildcard() {
        Pair<Student, Professor> academicPair = new Pair<>(new Student("Emily"), new Professor("Dr. Smith"));
        processPersonPair(academicPair); // Hợp lệ: Student và Professor đều là con của Person

        Pair<Person, Person> humanPair = new Pair<>(new Person("David"), new Person("Sarah"));
        processPersonPair(humanPair); // Hợp lệ: Person là chính nó

        // Pair<Robot, Person> mixedPair = new Pair<>(new Robot("Optimus"), new Person("Laura"));
        // processPersonPair(mixedPair); // Lỗi biên dịch: Robot không phải con của Person
    }

    @Test
    void testLowerBoundedWildcard() {
        Pair<Person, Person> regularPair = new Pair<>(new Person("Mike"), new Person("Anna"));
        addPeopleToPair(regularPair); // Hợp lệ: Person là chính nó

        Pair<Object, Object> objectPair = new Pair<>(new Object(), new Object());
        addPeopleToPair(objectPair); // Hợp lệ: Object là cha của Person

        // Pair<Student, Student> studentPair = new Pair<>(new Student("Chris"), new Student("Diana"));
        // addPeopleToPair(studentPair); // Lỗi biên dịch: Student không phải là kiểu cha của Person (hoặc Person)
    }
}

Khi sử dụng wildcards, đặc biệt là <? extends T>, bạn thường chỉ có thể đọc dữ liệu từ cấu trúc tổng quát đó (chúng là "producer"). Ngược lại, với <? super T>, bạn có thể ghi dữ liệu vào cấu trúc (chúng là "consumer"). Quy tắc này thường được tóm tắt bằng cụm từ "PECS" (Producer-Extends, Consumer-Super).

Thẻ: Java Generics Type Safety Bounded Wildcards type erasure

Đăng vào ngày 24 tháng 7 lúc 15:39