Trang chủ » Blog » Java Generics: Type Erasure, Invariance, Wildcards và nguyên tắc PECS

Java Generics: Type Erasure, Invariance, Wildcards và nguyên tắc PECS

| Blog

Java Generics không chỉ là cú pháp giúp bạn viết List<String> thay vì List. Đằng sau Generics là cả một hệ thống quy tắc về an toàn kiểu, quan hệ giữa các kiểu dữ liệu, Type Erasure và Wildcards.

Đặc biệt, để thiết kế API hiệu quả với Generics, bạn cần trả lời được một câu hỏi tưởng như đơn giản: 

Nếu String là subtype của Object, tại sao List<String> lại không phải là subtype của List<Object>?

Câu trả lời nằm ở đặc tính cốt lõi: Generics trong Java là invariant (bất biến).

Trong bài viết này, chúng ta sẽ cùng mổ xẻ từ bản chất của Invariance, cơ chế Type Erasure, Bridge Methods đến ứng dụng thực tế của Wildcards & PECS, từ đó rút ra các nguyên tắc thiết kế Generic API an toàn và tối ưu nhất.

1. Vì sao Java cần Generics?

Trước JDK 5, Collections API chủ yếu làm việc với Object. Điều này cho phép một collection chứa nhiều loại đối tượng khác nhau, nhưng đổi lại lập trình viên phải tự chịu trách nhiệm kiểm tra và ép kiểu khi lấy dữ liệu ra.

Ví dụ:

List rawList = new ArrayList();

rawList.add(“Hello World”);

Integer number = (Integer) rawList.get(0);

Đoạn mã trên vẫn biên dịch được. Tuy nhiên, phần tử thực tế trong danh sách là String, trong khi chương trình lại cố gắng ép nó thành Integer. Kết quả là lỗi chỉ xuất hiện khi chương trình chạy:

ClassCastException

Đây chính là vấn đề lớn của cách tiếp cận trước Generics: compiler không có đủ thông tin để kiểm tra tính tương thích kiểu tại compile-time.

Generics giải quyết vấn đề này như thế nào?

Từ JDK 5, Java đã đưa vào Generics nhằm:

  • Tăng type safety: phát hiện nhiều lỗi sai kiểu ngay tại compile-time.
  • Loại bỏ phần lớn explicit cast: compiler tự chèn các phép cast cần thiết khi đọc dữ liệu.
  • Làm rõ contract của API: List<String> thể hiện rõ collection này được thiết kế để chứa String.
  • Tăng khả năng tái sử dụng: một class hoặc method có thể hoạt động với nhiều kiểu dữ liệu mà vẫn duy trì type safety.

Ví dụ:

List<String> names = new ArrayList<>();

names.add(“Alice”);

names.add(“Bob”);

// Compiler biết get() trả về String

String name = names.get(0);

Nếu cố gắng thêm một Integer:

names.add(100);

compiler sẽ từ chối ngay:

The method add(String) in the type List<String> is not applicable for the arguments (int)

Như vậy, Generics chuyển nhiều lỗi từ runtime về compile-time.

2. Invariance: Vì sao List<String> không phải List<Object>?

Đây là điểm quan trọng nhất cần hiểu khi làm việc với Java Generics.

Ta biết:

String <: Object

Tức là mọi String đều là Object.

Tuy nhiên:

List<String> !<: List<Object>

Nói cách khác:

List<String> không phải subtype của List<Object>.

Đây là hệ quả của tính invariant của Java Generics.

2.1. So sánh Arrays và Generics

Để hiểu tại sao Java thiết kế như vậy, hãy so sánh Arrays và Generics.

Arrays là covariant

Với array:

String[] strings = new String[10];

Object[] objects = strings;

Đoạn code này hợp lệ vì Java cho phép:

String[] <: Object[]

Tức là array có tính covariant.

Nhưng cách thiết kế này tạo ra một rủi ro:

objects[0] = 100;

JVM sẽ phát hiện rằng array thực tế là String[], nên không thể chứa Integer.

Kết quả:

ArrayStoreException

Kiểm tra kiểu ở đây được thực hiện tại runtime.

2.2. Generics là invariant

Với Generics:

List<String> strings = new ArrayList<>();

List<Object> objects = strings; // Compile error

Java không cho phép phép gán này.

Nếu được phép, chúng ta có thể làm:

objects.add(100);

Nhưng objects và strings sẽ cùng tham chiếu đến một collection.

Khi đó:

String value = strings.get(0);

sẽ trở thành một thao tác nguy hiểm vì danh sách có thể đã chứa Integer.

Tại sao Java không chờ đến runtime?

Bởi vì mục tiêu của Generics là đảm bảo type safety ngay từ compile-time.

Nếu Java cho phép:

List<String> strings = new ArrayList<>();

List<Object> objects = strings;

thì contract của List<String> sẽ bị phá vỡ thông qua một reference có kiểu List<Object>.

Do đó, Java áp dụng quy tắc:

String <: Object

        nhưng

List<String> không phải subtype của List<Object>

Đây chính là Invariance.

3. Type Erasure: Generics biến mất ở đâu?

Một đặc điểm quan trọng của Java Generics là Type Erasure.

Không giống một số cơ chế generic được reify đầy đủ ở runtime, Java chủ yếu thực hiện kiểm tra Generic ở compile-time. Khi biên dịch, compiler thực hiện quá trình xóa thông tin type parameter theo các quy tắc của Java Language Specification.

Ví dụ:

class Node<T extends Number> {

    private T data;

    public void setData(T data) {

        this.data = data;

    }

}

Sau quá trình erasure, về mặt khái niệm, T được thay bằng bound của nó:

class Node {

    private Number data;

    public void setData(Number data) {

        this.data = data;

    }

}

Nếu T không có bound:

class Box<T> {

    private T value;

}

thì T thường được erasure thành:

Object

Nếu có bound:

<T extends Number>

thì compiler sử dụng bound đầu tiên:

Number làm erasure của T.

3.1. Compiler tự chèn cast

Type Erasure không có nghĩa là compiler bỏ qua type information.

Compiler sử dụng thông tin Generic ở compile-time để kiểm tra mã nguồn và có thể chèn các phép cast cần thiết vào bytecode.

Ví dụ:

List<String> names = new ArrayList<>();

String name = names.get(0);

Về mặt khái niệm, sau erasure, List.get() trả về Object.

Compiler có thể tạo bytecode tương đương với:

String name = (String) names.get(0);

Điều này giải thích tại sao một số lỗi ClassCastException vẫn có thể xuất hiện trong các tình huống liên quan đến raw types hoặc unchecked casts.

4. Bridge Method: Làm thế nào Generics vẫn giữ được tính đa hình?

Type Erasure tạo ra một vấn đề khác khi Generic class được kế thừa.

Xét class cha:

class Node<T> {

    public void setData(T data) {

        // …

    }

}

Và class con:

class MyNode extends Node<Integer> {

    @Override

    public void setData(Integer data) {

        // …

    }

}

Sau erasure, phương thức của Node<T> có dạng gần như:

void setData(Object data)

Trong khi MyNode có:

void setData(Integer data)

Hai method này có parameter type khác nhau.

Nếu compiler chỉ đơn giản xóa Generic information, tính đa hình có thể bị phá vỡ.

Java giải quyết vấn đề này bằng cách compiler tự động sinh Bridge Method.

Về mặt khái niệm, MyNode có thể được compiler bổ sung một method tương đương:

public void setData(Object data) {

    setData((Integer) data);

}

Nhờ đó, đoạn code:

Node<Integer> node = new MyNode();

node.setData(100);

vẫn có thể hoạt động đúng sau quá trình erasure.

Bridge Method là một chi tiết implementation quan trọng giúp Java duy trì polymorphism trong khi vẫn sử dụng Type Erasure.

5. Wildcards: Giải quyết sự cứng nhắc của Invariance

Invariance giúp Generics an toàn, nhưng đôi khi nó khiến API trở nên quá cứng.

Ví dụ:

void printNumbers(List<Number> numbers) {

    // …

}

Ta không thể truyền:

List<Integer>

vào method này:

List<Integer> integers = new ArrayList<>();

printNumbers(integers); // Compile error

Mặc dù:

Integer <: Number

nhưng:

List<Integer> !<: List<Number>

Đây là lúc Wildcard (?) phát huy tác dụng.

6. Ba loại Wildcard quan trọng

6.1. Unbounded Wildcard: <?>

<?> có nghĩa là:

Một collection của một kiểu nào đó, nhưng chúng ta không biết chính xác kiểu đó là gì.

Ví dụ:

List<?> list;

Có thể tham chiếu đến:

List<String>

List<Integer>

List<User>

Tuy nhiên, vì compiler không biết chính xác kiểu phần tử, ta không thể tùy ý thêm dữ liệu:

list.add(“Hello”); // Compile error

list.add(100);     // Compile error

Ngoại lệ là:

list.add(null);

Khi đọc dữ liệu:

Object value = list.get(0);

ta chỉ có thể chắc chắn rằng giá trị đọc ra là một Object.

List<?> khác Raw Type

Đây là điểm rất quan trọng.

List list;

là Raw Type và làm mất type safety.

Trong khi:

List<?> list;

vẫn được compiler kiểm soát.

Do đó, List<?> là cách an toàn để biểu diễn:

“Tôi không quan tâm collection chứa kiểu cụ thể nào.”

7. Upper-Bounded Wildcard: <? extends T>

<? extends T> có nghĩa:

Collection chứa T hoặc một subtype của T.

Ví dụ:

List<? extends Number> numbers;

Có thể tham chiếu đến:

List<Integer>

List<Double>

List<Float>

Điểm quan trọng là collection này phù hợp với vai trò Producer.

Ta có thể đọc:

Number number = numbers.get(0);

Nhưng không thể tùy ý thêm một Number:

numbers.add(100); // Compile error

Tại sao?

Bởi vì compiler không biết collection thực tế là:

List<Integer>

hay:

List<Double>

Nếu cho phép:

numbers.add(3.14);

thì thao tác này sẽ phá vỡ List<Integer> nếu collection thực tế là danh sách số nguyên.

Vì vậy:

? extends T

→ đọc được dưới dạng T

→ không thể thêm T một cách an toàn

8. Lower-Bounded Wildcard: <? super T>

Ngược lại:

List<? super Integer>

có thể tham chiếu đến:

List<Integer>

List<Number>

List<Object>

Với kiểu này, ta có thể an toàn thêm Integer:

list.add(100);

Bởi vì dù collection thực tế là:

List<Integer>

hay:

List<Number>

hay:

List<Object>

thì Integer vẫn có thể được lưu trữ.

Tuy nhiên, khi đọc ra:

Object value = list.get(0);

ta chỉ có thể chắc chắn giá trị là Object.

Do đó:

? super T

→ ghi được T

→ đọc ra an toàn nhất dưới dạng Object

9. PECS: Producer Extends, Consumer Super

Ba quy tắc trên dẫn đến một nguyên tắc thiết kế API nổi tiếng trong Java:

PECS — Producer Extends, Consumer Super

Nguyên tắc này được sử dụng để lựa chọn wildcard dựa trên vai trò của parameter, thay vì dựa đơn thuần vào kiểu dữ liệu.

Vai trò Cách khai báo Ý nghĩa
Producer <? extends T> Collection cung cấp dữ liệu
Consumer <? super T> Collection nhận dữ liệu
Read + Write chính xác T T Cần thao tác với đúng kiểu T

Producer → extends

Nếu method chỉ đọc dữ liệu:

double sum(List<? extends Number> numbers) {

    // …

}

Ta không quan tâm danh sách cụ thể là:

List<Integer>

List<Double>

List<Float>

Miễn là phần tử của nó có thể được xem như Number.

Consumer → super

Nếu method cần ghi Integer:

void addNumbers(List<? super Integer> numbers) {

    numbers.add(10);

    numbers.add(20);

}

Method có thể làm việc với:

List<Integer>

List<Number>

List<Object>

10. PECS trong chính JDK

Một ví dụ kinh điển là:

public static <T> void copy(

        List<? super T> dest,

        List<? extends T> src) {

 

    for (int i = 0; i < src.size(); i++) {

        dest.set(i, src.get(i));

    }

}

Ở đây:

src  → Producer → ? extends T

dest → Consumer → ? super T

Ta đọc dữ liệu từ src và ghi dữ liệu vào dest.

Đây chính là cách PECS được áp dụng trong một API thực tế của JDK.

Có thể ghi nhớ bằng một quy tắc đơn giản:

Dữ liệu đi RA khỏi collection

→ extends

 

Dữ liệu đi VÀO collection

→ super

11. Type Erasure và Non-Reifiable Types

Type Erasure dẫn đến một khái niệm quan trọng khác: Reifiable Type.

Một kiểu được gọi là reifiable khi JVM có thể biết đủ thông tin cần thiết về kiểu đó tại runtime.

Ngược lại, những kiểu như:

List<String>

List<Integer>

là non-reifiable types vì thông tin type argument không còn đầy đủ sau erasure.

Về runtime, JVM không thể dựa vào Generic metadata để phân biệt một cách trực tiếp:

List<String>

với:

List<Integer>

Đây là nguyên nhân của nhiều giới hạn kỹ thuật trong Java Generics.

12. Heap Pollution và Unchecked Cast

Heap Pollution xảy ra khi một reference có kiểu parameterized type trỏ đến một object mà kiểu thực tế không phù hợp với parameterized type đó.

Ví dụ:

Vector<Integer> integers = new Vector<>();

integers.add(42);

Object rawVector = integers;

Vector<String> strings =

        (Vector<String>) rawVector;

Compiler sẽ đưa ra cảnh báo unchecked cast.

Về mặt logic, strings đang được xem như:

Vector<String>

nhưng object thực tế vẫn là:

Vector<Integer>

Khi thực hiện:

String value = strings.get(0);

compiler có thể chèn cast sang String, và cast đó thất bại tại runtime.

Đây là một trong những lý do cần tránh Raw Type và unchecked cast trong mã nguồn mới.

13. Generic Varargs và @SafeVarargs

Một tình huống đặc biệt khác xuất hiện khi kết hợp Generics với Varargs.

Ví dụ:

public static <T> void addToList(

        List<T> list,

        T… elements) {

    for (T element : elements) {

        list.add(element);

    }

}

Vấn đề nằm ở chỗ Varargs được triển khai dựa trên array, trong khi generic type lại chịu ảnh hưởng của Type Erasure.

Điều này có thể dẫn đến cảnh báo:

Possible heap pollution from parameterized vararg type

Nếu method thực sự đảm bảo an toàn — chẳng hạn không làm thay đổi sai nội dung array và không để lộ reference của array ra bên ngoài — có thể sử dụng:

@SafeVarargs

cho những method phù hợp với điều kiện của Java.

@SafeVarargs không làm cho một method nguy hiểm trở thành an toàn. Annotation này là một cam kết của lập trình viên rằng cách sử dụng generic varargs trong method đó không gây heap pollution.

14. Những giới hạn quan trọng của Java Generics

Type Erasure mang lại khả năng tương thích với hệ sinh thái Java cũ, nhưng cũng tạo ra một số giới hạn.

14.1. Không thể new T()

Không thể viết:

T object = new T();

bởi tại runtime JVM không biết T cụ thể là class nào.

Thay vào đó, có thể truyền Class<T> vào method hoặc sử dụng một factory phù hợp.

14.2. Không thể tạo Generic Array trực tiếp

Không thể viết:

T[] array = new T[10];

vì T không phải reifiable type.

Tương tự, không thể trực tiếp tạo:

List<String>[] lists = new List<String>[10];

14.3. Không thể kiểm tra instanceof với Generic Type cụ thể

Không thể:

if (obj instanceof List<String>) {

    // …

}

bởi runtime không có đầy đủ thông tin để thực hiện kiểm tra này.

Có thể kiểm tra:

if (obj instanceof List<?>) {

    // …

}

14.4. Không sử dụng Primitive Type làm Type Argument

Không thể viết:

List<int>

mà phải dùng:

List<Integer>

Tương tự:

List<double>

phải trở thành:

List<Double>

Điều này dẫn đến cơ chế boxing/unboxing khi làm việc giữa primitive và wrapper type.

14.5. Không thể overload chỉ dựa trên Generic Type Argument

Không thể khai báo đồng thời:

void print(List<String> list) {

}

void print(List<Integer> list) {

}

Bởi sau Type Erasure, cả hai về cơ bản đều có signature:

void print(List list)

Do đó chúng xung đột với nhau.

14.6. Static Context không thể sử dụng Type Parameter của Instance

Ví dụ:

class Box<T> {

    private T value;

    public static T getValue() {

        // Compile error

    }

}

Điều này không hợp lệ vì T thuộc về instance parameterization, trong khi thành phần static thuộc về class và không phụ thuộc vào một instance cụ thể.

Nếu cần Generic trong static method, method phải tự khai báo type parameter:

public static <T> T identity(T value) {

    return value;

}

15. Thiết kế Generic API an toàn

Hiểu Generics không chỉ để đọc code. Quan trọng hơn là biết cách sử dụng chúng khi thiết kế API.

Quy tắc 1: Dùng PECS cho Collection Parameter

Nếu method nhận một collection chỉ để đọc:

void process(List<? extends Number> numbers) {

}

Nếu method nhận collection để ghi:

void addNumbers(List<? super Integer> numbers) {

}

Không nên mặc định sử dụng:

List<Number>

nếu API thực tế không yêu cầu chính xác Number. Wildcard có thể làm API linh hoạt hơn mà vẫn duy trì type safety.

Quy tắc 2: Tránh Raw Types

Không nên viết:

List list = new ArrayList();

Trong code mới, hãy sử dụng:

List<String> list = new ArrayList<>();

hoặc khi thực sự không quan tâm đến kiểu cụ thể:

List<?> list;

Điểm khác biệt rất quan trọng:

List

→ bỏ qua kiểm tra Generic

List<?>

→ vẫn được compiler kiểm soát

16. Type Token: Khi cần biết kiểu ở Runtime

Do Type Erasure, đôi khi method cần biết class cụ thể tại runtime.

Một cách phổ biến là truyền Class<T> vào method:

public <T> T createInstance(Class<T> clazz)

        throws Exception {

    return clazz.getDeclaredConstructor()

                .newInstance();

}

Sử dụng:

User user = createInstance(User.class);

Ở đây:

Class<T>

đóng vai trò như một Type Token — cung cấp thông tin kiểu mà Generic type parameter không còn giữ đầy đủ sau erasure.

17. Advanced Pattern: Recursive Type Bounds

Một kỹ thuật nâng cao của Java Generics là Recursive Type Bound.

Ví dụ:

public static <T extends Comparable<T>> T max(T a, T b) {

    return a.compareTo(b) >= 0 ? a : b;

}

Ràng buộc:

T extends Comparable<T>

đảm bảo rằng T phải có khả năng so sánh với chính T.

Điều này hữu ích khi xây dựng các API yêu cầu một kiểu phải đáp ứng một capability cụ thể.

18. Advanced Pattern: Self-Type và Generic Builder

Generics cũng có thể được sử dụng để duy trì kiểu của subclass trong Fluent API.

Ví dụ:

abstract class Builder<T, B extends Builder<T, B>> {

    protected String name;

    @SuppressWarnings(“unchecked”)

    public B withName(String name) {

        this.name = name;

        return (B) this;

    }

    public abstract T build();

}

Subclass:

class UserBuilder

        extends Builder<User, UserBuilder> {

    private int age;

    public UserBuilder withAge(int age) {

        this.age = age;

        return this;

    }

    @Override

    public User build() {

        return new User(name, age);

    }

}

Khi sử dụng:

User user = new UserBuilder()

        .withName(“Alice”)

        .withAge(30)

        .build();

Ta vẫn giữ được kiểu UserBuilder xuyên suốt Fluent API mà không cần explicit cast ở phía client.

19. Bức tranh tổng thể: Generics hoạt động như thế nào?

Có thể nhìn toàn bộ cơ chế sau:

Generics hoạt động như thế nào?

Mỗi khái niệm giải quyết một vấn đề khác nhau:

  • Invariance bảo vệ type safety.
  • Wildcards cung cấp tính linh hoạt mà không phá vỡ type safety.
  • PECS giúp lựa chọn wildcard khi thiết kế API.
  • Type Erasure giúp Java duy trì khả năng tương thích với mã nguồn và bytecode cũ.
  • Bridge Methods duy trì polymorphism sau erasure.
  • Type Token cung cấp thông tin kiểu khi runtime cần biết class cụ thể.

20. Kết luận

Java Generics là một hệ thống được thiết kế với sự đánh đổi rõ ràng: đưa phần lớn việc kiểm tra kiểu về compile-time, trong khi vẫn duy trì khả năng tương thích với hệ sinh thái Java được xây dựng trước JDK 5.

Ba ý tưởng quan trọng nhất cần ghi nhớ là:

1. List<String> không phải List<Object>

Mặc dù:

String <: Object

nhưng:

List<String> !<: List<Object>

vì Java Generics là invariant.

2. Wildcard giúp mở rộng khả năng sử dụng Generic API

? extends T → Producer → đọc

? super T   → Consumer → ghi

Từ đó hình thành nguyên tắc:

Producer Extends, Consumer Super — PECS.

3. Type Erasure giải thích nhiều giới hạn của Generics

Vì Generic type information không được reify đầy đủ tại runtime, Java có những giới hạn như:

new T()

new T[10]

instanceof List<String>

List<int>

overload(List<String>) và overload(List<Integer>)

Hiểu được Type Erasure sẽ giúp giải thích được tại sao những giới hạn này tồn tại, thay vì chỉ ghi nhớ chúng như những quy tắc rời rạc.

Cuối cùng, khi thiết kế Generic API, câu hỏi quan trọng không phải là:

“Tôi có thể viết wildcard ở đây không?”

mà là:

“Collection này đang đóng vai trò Producer hay Consumer?”

Nếu trả lời được câu hỏi đó, việc lựa chọn giữa T, ? extends T và ? super T sẽ trở nên tự nhiên hơn rất nhiều.

Tags:

0 Lời bình

Gửi Lời bình

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *

BÀI VIẾT LIÊN QUAN

BẠN MUỐN HỌC LẬP TRÌNH?

GỌI NGAY

098 953 44 58

Đăng ký tư vấn lộ trình học lập trình

Đăng ký tư vấn, định hướng lộ trình học và giải đáp các thắc mắc về ngành nghề – Miễn phí – Online.

4 + 5 =