Command Palette

Search for a command to run...

[Advanced Java] Generics trong Java: type parameter, bounded type, wildcard và type erasure

Khóa Java Basics dùng generic type ở gần như mọi bài — List<String>, Map<String, Integer>, ArrayList<Student> — mà chưa bao giờ nói cặp ngoặc nhọn đó ở đâu ra, hay làm sao để gắn một cặp như vậy lên class của chính bạn. Bài này lấp đúng khoảng trống đó.

Generics là một feature hoàn toàn thuộc về compile-time. Nó không thêm field nào, method nào hay instruction bytecode nào; thứ nó thêm vào là một cam kết mà javac kiểm tra rồi cố ý vứt đi. Cả hai vế đều quan trọng — vế kiểm tra là thứ biến một cú crash lúc chạy thành một build lỗi, còn vế vứt đi là thứ giải thích mọi hạn chế kỳ lạ mà ngôn ngữ áp lên type parameter.

Chữ T lớn nằm trong ngoặc nhọn phía trên hai card: một raw List phát hiện lúc runtime và một List có type argument phát hiện lúc compile

Mọi message lỗi, mọi dòng output và mọi bản dump javap bên dưới đều lấy từ việc compile và chạy code thật trên OpenJDK 21.0.6. Bài này không có bất kỳ con số đo thời gian nào, vì generics không có hành vi lúc chạy để mà đo — phần type erasure sẽ chứng minh điều đó theo hướng cấu trúc.

Vì sao cần generics: cái cast chết lúc runtime

Trước Java 5, một collection chứa Object. Cái gì cũng bỏ vào được, và mọi thứ lấy ra đều phải tự tay cast lại.

import java.util.ArrayList;
import java.util.List;

public class RawBox {
    public static void main(String[] args) {
        List names = new ArrayList();   // raw type: holds Object
        names.add("Ann");
        names.add("Ben");
        names.add(42);                  // nothing stops this

        for (Object o : names) {
            String s = (String) o;      // the cast you are forced to write
            System.out.println(s.toUpperCase());
        }
    }
}

Nó compile được. Thậm chí chạy được một lúc:

ANN
BEN
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
	at RawBox.main(RawBox.java:12)

Có hai chỗ sai ở đây. Lỗi nổ ở dòng 12, tại chỗ cast, trong khi sai lầm nằm ở dòng 9 nơi int được thêm vào — nên stack trace chỉ vào nạn nhân chứ không chỉ vào thủ phạm. Và nó nổ trên production chứ không phải trong build. Compiler có lên tiếng, nhưng tất cả những gì nó đưa ra được chỉ là một warning:

RawBox.java:7: warning: [unchecked] unchecked call to add(E) as a member of the raw type List
        names.add("Ann");
                 ^
  where E is a type-variable:
    E extends Object declared in interface List

Vẫn chương trình đó nhưng có type argument thì hỏng đúng dòng, đúng lúc, với đúng message:

import java.util.ArrayList;
import java.util.List;

public class TypedBox {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>();
        names.add("Ann");
        names.add("Ben");
        names.add(42);                  // rejected here, not at run time

        for (String s : names) {        // no cast needed
            System.out.println(s.toUpperCase());
        }
    }
}
TypedBox.java:9: error: incompatible types: int cannot be converted to String
        names.add(42);                  // rejected here, not at run time
                  ^
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
1 error

Cái note đó đáng làm theo. javac -Xdiags:verbose in ra đúng bảng overload mà nó đã cân nhắc:

TypedBox.java:9: error: no suitable method found for add(int)
        names.add(42);
             ^
    method List.add(String) is not applicable
      (argument mismatch; int cannot be converted to String)
    method List.add(int,String) is not applicable
      (actual and formal argument lists differ in length)
1 error

Vòng for (String s : names) cũng không cần cast, và đó không phải tiện lợi — đó là chính cam kết kia nhìn từ đầu bên kia.

Raw ListList<String>
Thêm một Integercompile được, kèm warningerror: incompatible types
Đọc một phần tửObject, phải tự castString, không viết cast
Sai lầm lộ ra ở đâulúc runtime, tại chỗ castlúc compile, tại chỗ gọi
Hình dạng của thất bạiClassCastExceptionmột build không chạy xong

Viết một generic class

Một generic class khai báo một hoặc nhiều type parameter trong ngoặc nhọn ngay sau tên class, rồi dùng chúng y như dùng một type thật.

public class Box<T> {
    private T value;

    public Box(T value) { this.value = value; }

    public T get() { return value; }

    public void set(T value) { this.value = value; }

    // T is the class's parameter; U is this method's own, fixed per call
    public <U> String describe(U label) {
        return label + " -> " + value;
    }

    @Override
    public String toString() { return "Box(" + value + ")"; }
}

T không phải là một type. Nó là một type variable: chỗ trống mà compiler sẽ thay thế khi ai đó viết new Box<String>(...). Bên trong thân class nó hành xử như một type mà supertype duy nhất được biết là Object, nên value.length() sẽ không compile được ở đó, dù sau này Box<String> chắc chắn giữ một String.

import java.util.List;

public class Main {
    // a generic method: <T> is declared by the method itself
    static <T> T firstOf(List<T> items) {
        return items.get(0);
    }

    public static void main(String[] args) {
        Box<String> name = new Box<>("Ann");
        String s = name.get();                 // no cast
        System.out.println(s.length());

        Box<Integer> age = new Box<>(30);
        System.out.println(age.get() + 1);     // arithmetic, not Object

        System.out.println(name.describe(1));
        System.out.println(name.describe("id"));

        String first = firstOf(List.of("a", "b"));          // T inferred as String
        Integer one = Main.<Integer>firstOf(List.of(1, 2)); // T given explicitly
        System.out.println(first + " " + one);
    }
}
3
31
1 -> Ann
id -> Ann
a 1

describe là method đáng chú ý. T thuộc về Box và được chốt một lần, khi object được tạo. U thuộc về describe và được chốt lại ở mỗi lần gọi — đó là lý do hai lần gọi ở trên bind nó thành Integer rồi thành String trên cùng một object.

Hai panel cạnh nhau làm nổi bật scope của type parameter cấp class và scope của type parameter cấp method trong cùng class Box

Compile Box.javaMain.java cùng nhau sinh ra đúng hai class file, Box.classMain.class. Không có Box$String.class nào cả. Một class phục vụ mọi instantiation, và riêng chi tiết đó là gốc rễ của toàn bộ phần type erasure bên dưới.

Đặt tên cho type parameter

Theo quy ước, type parameter là một chữ in hoa, và chữ được chọn không tùy tiện — nó nói cho người đọc biết parameter đó dùng để làm gì.

ChữÝ nghĩa quy ướcGặp ở
Ttype, không hàm ý gì thêmBox<T>, Comparable<T>
Eelement của một collectionList<E>, Set<E>
K, Vkey và valueMap<K, V>
Nnumbercác class tiện ích số học
Rresult của một phép biến đổicác method mapping
S, U, Vtype thứ hai, ba, tưPair<K, V>, method nhiều parameter

Tên dài vẫn hợp lệ — class Box<Item> compile được — nhưng ở chỗ dùng nó trông y như tên một class, còn người đọc Java nào cũng đã quen với các chữ cái đơn. Một class có thể nhận nhiều parameter, và mỗi cái độc lập với nhau:

public class Pair<K, V> {
    private final K key;
    private final V value;

    public Pair(K key, V value) { this.key = key; this.value = value; }

    public K key() { return key; }

    public V value() { return value; }

    public Pair<V, K> swapped() { return new Pair<>(value, key); }

    @Override
    public String toString() { return key + "=" + value; }

    public static void main(String[] args) {
        Pair<String, Integer> p = new Pair<>("age", 30);
        int age = p.value();          // unboxes directly, no cast
        System.out.println(p + " / " + p.swapped() + " / " + age);
    }
}
age=30 / 30=age / 30

Viết một generic method

Một method có thể khai báo type parameter riêng, bất kể class chứa nó có generic hay không. Phần khai báo nằm giữa các modifier và return type — static <T> T firstOf(...) — và scope của nó là chính method đó.

Phần lớn thời gian type argument được suy ra từ lời gọi, nên firstOf(List.of("a", "b")) không cần chú thích gì. Khi inference không quyết định được, hoặc khi bạn muốn ép một lựa chọn, type argument được đưa vào trước tên method: Main.<Integer>firstOf(...). Cú pháp đó bắt buộc phải có receiver, nên một lời gọi ngay trong cùng class vẫn phải viết Main.<Integer>firstOf(...) chứ không phải <Integer>firstOf(...).

Static method không dùng được type parameter của class

T của class tồn tại theo từng object. Một member static tồn tại theo class, nên không có object nào để lấy T ra:

public class StaticT<T> {
    static void helper(T x) { }          // uses the class's T

    static <T> void ok(T x) { }          // declares its own T — legal
}
StaticT.java:2: error: non-static type variable T cannot be referenced from a static context
    static void helper(T x) { }          // uses the class's T
                       ^
1 error

Method thứ hai compile được vì nó khai báo một T của riêng nó. T này che mất parameter của class và không liên quan gì tới nó, đó là lý do tốt để đặt cho parameter của method một cái tên khác.

Bounded type parameter

Một T không bound thì compiler chỉ biết nó là Object, nên chỉ các method của Object mới resolve được:

import java.util.List;

public class Bounds {
    // unbounded: T is only known to be an Object
    static <T> double sumBroken(List<T> nums) {
        double total = 0;
        for (T n : nums) total += n.doubleValue();
        return total;
    }

    public static void main(String[] args) { }
}
Bounds.java:7: error: cannot find symbol
        for (T n : nums) total += n.doubleValue();
                                   ^
  symbol:   method doubleValue()
  location: variable n of type T
  where T is a type-variable:
    T extends Object declared in method <T>sumBroken(List<T>)
1 error

extends trên một type variable sửa được chuyện đó bằng cách cho compiler một upper bound. Từ khóa luôn là extends, dù bound là class hay interface:

import java.util.List;

public class Bounded {
    // T is at least a Number, so Number's methods are available
    static <T extends Number> double sum(List<T> nums) {
        double total = 0;
        for (T n : nums) total += n.doubleValue();
        return total;
    }

    // multiple bounds: class first, then interfaces, joined by &
    static <T extends Number & Comparable<T>> T max(List<T> items) {
        T best = items.get(0);
        for (T x : items) if (x.compareTo(best) > 0) best = x;
        return best;
    }

    public static void main(String[] args) {
        System.out.println(sum(List.of(1, 2, 3)));
        System.out.println(sum(List.of(1.5, 2.5)));
        System.out.println(max(List.of(3, 9, 4)));
        System.out.println(max(List.of(3.5, 9.25, 4.0)));
    }
}
6.0
4.0
9
9.25

Bound cũng được kiểm tra ở chỗ dùng. Counter<T extends Number> từ chối một String:

BoundedClass.java:6: error: type argument String is not within bounds of type-variable T
    Counter<String> bad;
            ^
  where T is a type-variable:
    T extends Number declared in class Counter
1 error

Bốn dòng khai báo cho thấy mỗi bound mở ra những method nào, và dòng mà Java từ chối

Nhiều bound cùng lúc

Một type variable có thể có nhiều bound nối bằng &. Nhiều nhất một trong số đó được là class, và nếu có thì nó phải đứng đầu — compiler nói rất rõ về thứ tự này:

BoundOrder.java:2: error: interface expected here
    static <T extends Comparable<T> & Number> T pick(T a) { return a; }
                                      ^
1 error

Bound đầu tiên cũng chính là thứ mà type variable erase về, chi tiết này sẽ quan trọng ở phần sau: T extends Number & Comparable<T> thành Number trong bytecode, không phải Comparable.

Vì sao khai báo không được dùng super

super hợp lệ trong wildcard nhưng không hợp lệ trong phần khai báo type parameter. Java thậm chí không đi tới bước kiểm tra ngữ nghĩa — parser bỏ cuộc ngay tại từ khóa:

public class SuperBound {
    static <T super Integer> void store(T x) { }

    public static void main(String[] args) { }
}
SuperBound.java:2: error: > expected
    static <T super Integer> void store(T x) { }
             ^
SuperBound.java:2: error: illegal start of type
    static <T super Integer> void store(T x) { }
              ^
SuperBound.java:2: error: <identifier> expected
    static <T super Integer> void store(T x) { }
                           ^
3 errors

Lý do là một lower bound chẳng nói cho compiler điều gì hữu ích. T extends Number nghĩa là mọi T đều có doubleValue(); T super Integer chỉ nói T nằm đâu đó giữa IntegerObject, và tập method duy nhất chắc chắn có trên toàn khoảng đó là của Object — đúng bằng thứ mà một T không bound đã cho bạn. Lower bound chỉ có ý nghĩa khi bạn đưa giá trị vào chứ không phải lấy ra, và đó chính là việc của wildcard.

Vì sao List<String> không phải là List<Object>

Mọi String đều là Object, nên rất dễ nghĩ rằng List<String> dùng được ở chỗ cần List<Object>. Không phải vậy:

import java.util.ArrayList;
import java.util.List;

public class Invariant {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>();
        List<Object> objects = names;   // looks harmless, is not
        objects.add(42);
        String s = names.get(0);
    }
}
Invariant.java:7: error: incompatible types: List<String> cannot be converted to List<Object>
        List<Object> objects = names;   // looks harmless, is not
                               ^
1 error

Hai dòng ngay sau đó cho thấy vì sao compiler buộc phải từ chối. Nếu phép gán được phép, objects.add(42) sẽ nhét một Integer vào cái list mà names vẫn tin rằng chỉ chứa string, và names.get(0) sẽ nổ ở một chỗ hoàn toàn khác. Generics là invariant: List<A>List<B> là hai type không liên quan trừ khi AB giống hệt nhau.

Array chọn hướng ngược lại, và rất đáng nhìn xem cái giá của lựa chọn đó là gì:

public class ArrayCovariance {
    public static void main(String[] args) {
        String[] names = new String[2];
        Object[] objects = names;   // arrays ARE covariant, and this compiles
        objects[0] = 42;            // fails at run time instead
    }
}
Exception in thread "main" java.lang.ArrayStoreException: java.lang.Integer
	at ArrayCovariance.main(ArrayCovariance.java:5)

Array mang theo component type của nó tới lúc chạy và kiểm tra mọi lần ghi, nên sai lầm vẫn bị bắt — chỉ là muộn hơn, và bằng một phép kiểm tra tốn kém ở mỗi lần ghi. Generics dời phép kiểm tra đó về compile-time và không trả gì lúc chạy, còn invariance là cái giá phải trả. Wildcard là cách mua lại sự linh hoạt mà không phải bỏ cam kết.

Wildcard và PECS

Wildcard ? nghĩa là một type nào đó mà tôi không gọi tên. Thêm bound vào nó là nói cái type chưa biết đó được phép biến thiên theo hướng nào, và hướng bạn chọn quyết định thao tác nào còn hợp lệ.

Chuỗi Object tới Number tới Integer với hai thanh phạm vi, và hai card cho thấy mỗi wildcard từ chối thao tác nào

Quy tắc có một câu thần chú: PECS — Producer Extends, Consumer Super. Nếu parameter sản xuất ra giá trị để bạn đọc, dùng extends. Nếu nó tiêu thụ giá trị bạn ghi vào, dùng super. Hai phần tiếp theo cho thấy những lỗi compile khiến quy tắc này không thể thương lượng.

Producer extends: đọc thì được, add thì không

import java.util.List;

public class ProducerExtends {
    // producer: we only read out of it
    static double sum(List<? extends Number> nums) {
        double total = 0;
        for (Number n : nums) total += n.doubleValue();   // reading is fine
        return total;
    }

    public static void main(String[] args) {
        System.out.println(sum(List.of(1, 2, 3)));            // List<Integer>
        System.out.println(sum(List.of(1.5, 2.5)));           // List<Double>
    }
}
6.0
4.0

Giờ một method nhận được cả List<Integer>, List<Double>, List<Long> lẫn List<Number>. Đọc thì an toàn vì dù element type hóa ra là gì thì nó cũng là một Number. Ghi thì không:

import java.util.List;

public class AddToExtends {
    static void addOne(List<? extends Number> nums) {
        nums.add(1);
    }

    public static void main(String[] args) { }
}
AddToExtends.java:5: error: incompatible types: int cannot be converted to CAP#1
        nums.add(1);
                 ^
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Number from capture of ? extends Number
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
1 error

CAP#1 chính là toàn bộ lời giải thích. Tại chỗ dùng, compiler capture wildcard thành một type variable mới toanh, không tên — một subtype cụ thể nào đó của Number mà nó không gọi tên được. List đó có thể là List<Double>; thêm một Integer vào sẽ làm hỏng nó. Vì không giá trị nào được biết chắc là instance của CAP#1, không gì thêm vào được.

Trừ null, giá trị gán được cho mọi reference type:

import java.util.ArrayList;
import java.util.List;

public class AddNull {
    static void tryIt(List<? extends Number> nums) {
        nums.add(null);   // the only value assignable to every possible T
    }

    public static void main(String[] args) {
        List<Integer> xs = new ArrayList<>(List.of(1, 2));
        tryIt(xs);
        System.out.println(xs);
    }
}
[1, 2, null]

Consumer super: add thì được, đọc thì không

? super Integer nói element type là Integer hoặc một thứ nằm trên nó. Mọi type trong khoảng đó đều nhận được một Integer:

import java.util.ArrayList;
import java.util.List;

public class ConsumerSuper {
    // consumer: we only write into it
    static void fillWithInts(List<? super Integer> sink) {
        sink.add(1);
        sink.add(2);
    }

    public static void main(String[] args) {
        List<Integer> ints = new ArrayList<>();
        List<Number> nums = new ArrayList<>();
        List<Object> objs = new ArrayList<>();
        fillWithInts(ints);
        fillWithInts(nums);
        fillWithInts(objs);
        System.out.println(ints + " " + nums + " " + objs);
    }
}
[1, 2] [1, 2] [1, 2]

Đọc ngược lại ra một type cụ thể mới là chỗ hỏng:

import java.util.List;

public class ReadFromSuper {
    static int firstInt(List<? super Integer> sink) {
        Integer first = sink.get(0);
        return first;
    }

    public static void main(String[] args) { }
}
ReadFromSuper.java:5: error: incompatible types: CAP#1 cannot be converted to Integer
        Integer first = sink.get(0);
                                ^
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Object super: Integer from capture of ? super Integer
1 error

Hãy đọc kỹ mô tả capture: CAP#1 extends Object super: Integer. List đó có thể là List<Object> đang chứa string, nên một phần tử không chắc chắn là Integer. Nó chắc chắn là Object, và phép gán đó compile được:

import java.util.List;

public class ReadObject {
    static void show(List<? super Integer> sink) {
        Object first = sink.get(0);   // Object is the only guaranteed supertype
        System.out.println(first);
    }

    public static void main(String[] args) {
        show(List.of(7, 8));
    }
}
7

Chính lập luận đó giải thích một bound bạn sẽ gặp liên tục trong JDK. Một hàm tìm max viết ngây thơ sẽ dùng <T extends Comparable<T>>, và nó lặng lẽ loại bỏ mọi type thừa kế compareTo từ supertype:

import java.util.List;

class Animal implements Comparable<Animal> {
    final int age;
    Animal(int age) { this.age = age; }
    public int compareTo(Animal other) { return Integer.compare(age, other.age); }
    public String toString() { return getClass().getSimpleName() + "(" + age + ")"; }
}

class Dog extends Animal {
    Dog(int age) { super(age); }
}

public class RecursiveBound {
    static <T extends Comparable<T>> T maxStrict(List<T> items) {
        T best = items.get(0);
        for (T x : items) if (x.compareTo(best) > 0) best = x;
        return best;
    }

    public static void main(String[] args) {
        List<Dog> dogs = List.of(new Dog(3), new Dog(7));
        System.out.println(maxStrict(dogs));
    }
}
RecursiveBound.java:23: error: method maxStrict in class RecursiveBound cannot be applied to given types;
        System.out.println(maxStrict(dogs));
                           ^
  required: List<T>
  found:    List<Dog>
  reason: inference variable T has incompatible equality constraints Animal,Dog
  where T is a type-variable:
    T extends Comparable<T> declared in method <T>maxStrict(List<T>)
1 error

Dog implement Comparable<Animal> chứ không phải Comparable<Dog>, nên không T nào thỏa mãn cả hai ràng buộc. Đổi một từ là xong — Comparable là consumer của thứ được đem ra so sánh, nên nó nhận super:

    static <T extends Comparable<? super T>> T max(List<T> items) {
        T best = items.get(0);
        for (T x : items) if (x.compareTo(best) > 0) best = x;
        return best;
    }
Dog(7)

Đó là lý do JDK được viết theo cách nó đang viết. Đây là signature thật, lấy thẳng từ javap java.util.Collections:

  public static <T extends java.lang.Comparable<? super T>> void sort(java.util.List<T>);
  public static <T> void sort(java.util.List<T>, java.util.Comparator<? super T>);
  public static <T> void copy(java.util.List<? super T>, java.util.List<? extends T>);
  public static <T extends java.lang.Comparable<? super T>> T max(java.util.Collection<? extends T>);
  public static <T> boolean addAll(java.util.Collection<? super T>, T...);

copy là PECS gói gọn trong một dòng: đích tiêu thụ nên nhận ? super T; nguồn sản xuất nên nhận ? extends T. Tự viết lại chỉ tốn bốn dòng:

import java.util.ArrayList;
import java.util.List;

public class Copy {
    // src produces T  -> extends; dest consumes T -> super
    static <T> void copy(List<? super T> dest, List<? extends T> src) {
        for (T item : src) dest.add(item);
    }

    public static void main(String[] args) {
        List<Integer> src = List.of(1, 2, 3);
        List<Number> dest = new ArrayList<>();
        Copy.<Number>copy(dest, src);
        System.out.println(dest);

        List<Object> anything = new ArrayList<>();
        copy(anything, src);
        System.out.println(anything);
    }
}
[1, 2, 3]
[1, 2, 3]

Unbounded wildcard

List<?> là cách viết tắt của List<? extends Object>. Nó nói element type vừa chưa biết vừa không bị ràng buộc, nên chỉ dùng được cho những thao tác hoàn toàn không nhắc tới element type:

import java.util.List;

public class Unbounded {
    static int size(List<?> any) { return any.size(); }

    static void poke(List<?> any) { any.add("x"); }

    public static void main(String[] args) { }
}
Unbounded.java:6: error: incompatible types: String cannot be converted to CAP#1
    static void poke(List<?> any) { any.add("x"); }
                                            ^
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Object from capture of ?
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
1 error

size compile được; poke thì không. List<?> không giống raw List và cũng không giống List<Object>: raw List tắt hẳn type checking và cho mọi thứ chui vào kèm warning, List<Object> chỉ nhận đúng list được khai báo là chứa Object, còn List<?> nhận mọi list nhưng không cho bạn ghi gì vào.

TypeNhận được argument nàoadd("x")get(0) trả về
List (raw)mọi listcompile được, unchecked warningObject
List<Object>chỉ List<Object>compile đượcObject
List<?>mọi listerror: ... cannot be converted to CAP#1Object
List<? extends Number>mọi list của một subtype của Numbercùng lỗi đóNumber
List<? super Integer>mọi list của một supertype của Integercompile đượcObject

Type erasure

Toàn bộ những gì ở trên được javac kiểm tra rồi xóa đi. Sau khi compile, type argument không để lại dấu vết nào mà JVM nhìn thấy được. Ba thí nghiệm làm điều đó thành cụ thể.

Thứ nhất, hai list có element type khác nhau lại có cùng một class lúc chạy:

import java.util.ArrayList;
import java.util.List;

public class SameClass {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>();
        List<Integer> ids = new ArrayList<>();
        System.out.println(names.getClass());
        System.out.println(ids.getClass());
        System.out.println(names.getClass() == ids.getClass());
    }
}
class java.util.ArrayList
class java.util.ArrayList
true

Thứ hai, hai method chỉ khác nhau ở type argument thì không phải là hai method:

import java.util.List;

public class SameErasure {
    void handle(List<String> names) { }
    void handle(List<Integer> ids) { }
}
SameErasure.java:5: error: name clash: handle(List<Integer>) and handle(List<String>) have the same erasure
    void handle(List<Integer> ids) { }
         ^
1 error

Thứ ba, instanceof không hỏi được về type argument, vì chẳng còn gì để mà hỏi:

import java.util.List;

public class InstanceOf {
    static boolean isStringList(Object o) {
        return o instanceof List<String>;
    }

    public static void main(String[] args) { }
}
InstanceOf.java:5: error: Object cannot be safely cast to List<String>
        return o instanceof List<String>;
               ^
1 error

o instanceof List<?>o instanceof List đều compile được và đều trả true với mọi list, vì cả hai đều hỏi một câu mà lúc chạy trả lời được.

Holder.java đi vào một class file lưu cả descriptor đã erase lẫn Signature attribute được giữ lại

Class file thực sự lưu những gì

Compile class này rồi soi bằng javap:

import java.util.List;

public class Holder<T extends Number> {
    private T value;
    private List<T> history;

    public T get() { return value; }

    public void set(T value) { this.value = value; }

    public <U> U passThrough(U x) { return x; }
}
javac Holder.java
javap -s -p Holder
Compiled from "Holder.java"
public class Holder<T extends java.lang.Number> {
  private T value;
    descriptor: Ljava/lang/Number;
  private java.util.List<T> history;
    descriptor: Ljava/util/List;
  public Holder();
    descriptor: ()V

  public T get();
    descriptor: ()Ljava/lang/Number;

  public void set(T);
    descriptor: (Ljava/lang/Number;)V

  public <U> U passThrough(U);
    descriptor: (Ljava/lang/Object;)Ljava/lang/Object;
}

Descriptor là thứ JVM link vào, và nó cho thấy trọn vẹn quy tắc erasure. Một type variable có bound erase về bound đầu tiên của nó, nên T extends Number thành Ljava/lang/Number;. Một type variable không bound erase về Object, nên U thành Ljava/lang/Object;. Một parameterized type erase về dạng raw, nên List<T> thành Ljava/util/List;.

Vì return type bị erase, phía gọi phải khôi phục lại. javac chèn vào đúng cái cast mà bạn sẽ tự viết:

import java.util.List;

public class G {
    static int len(List<String> names) {
        return names.get(0).length();
    }
}
import java.util.List;

public class R {
    static int len(List names) {
        return ((String) names.get(0)).length();
    }
}

javap -c cho hai method thân giống hệt nhau, từng instruction một:

  static int len(java.util.List<java.lang.String>);
    Code:
       0: aload_0
       1: iconst_0
       2: invokeinterface #7,  2            // InterfaceMethod java/util/List.get:(I)Ljava/lang/Object;
       7: checkcast     #13                 // class java/lang/String
      10: invokevirtual #15                 // Method java/lang/String.length:()I
      13: ireturn
  static int len(java.util.List);
    Code:
       0: aload_0
       1: iconst_0
       2: invokeinterface #7,  2            // InterfaceMethod java/util/List.get:(I)Ljava/lang/Object;
       7: checkcast     #13                 // class java/lang/String
      10: invokevirtual #15                 // Method java/lang/String.length:()I
      13: ireturn

Đó là câu trả lời trung thực cho câu hỏi "generics có tốn gì lúc chạy không". Bản generic và bản tự cast bằng tay compile ra bytecode giống hệt nhau, nên chẳng có gì để đo — khác biệt giữa chúng chỉ tồn tại trong lúc javac đang chạy.

Signature attribute là lý do generic sống sót qua separate compilation

Nếu erasure là toàn bộ câu chuyện thì compiler đã không thể type-check code viết dựa trên một library mà nó không compile cùng lượt. Holder là một file .class nằm trên classpath; phía gọi holder.get() vẫn cần biết return type là T. Thông tin đó có được lưu, chỉ là không nằm trong descriptor. javap -v cho thấy cả hai:

javap -v -p Holder
  public T get();
    descriptor: ()Ljava/lang/Number;
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: getfield      #7                  // Field value:Ljava/lang/Number;
         4: areturn
      LineNumberTable:
        line 7: 0
    Signature: #22                          // ()TT;

Signature là một attribute của class file, giữ dạng generic dưới dạng chuỗi, và nó tồn tại ở ba cấp. Bản thân class có một cái:

Signature: #29                          // <T:Ljava/lang/Number;>Ljava/lang/Object;

Mỗi generic field có một cái, và mỗi generic method cũng vậy. Các chuỗi đó nằm trong constant pool ngay cạnh các descriptor:

  #21 = Utf8               ()Ljava/lang/Number;
  #22 = Utf8               ()TT;
  #27 = Utf8               (Ljava/lang/Object;)Ljava/lang/Object;
  #28 = Utf8               <U:Ljava/lang/Object;>(TU;)TU;
  #29 = Utf8               <T:Ljava/lang/Number;>Ljava/lang/Object;

Vậy nên câu cửa miệng "thông tin generic bị xóa" là không chính xác ở một chỗ rất quan trọng. Nó bị xóa khỏi bytecode — khỏi descriptor, khỏi instruction, khỏi phần linking mà JVM thực hiện. Nó được giữ lại trong class file, dưới dạng metadata mà chỉ javac và reflection đọc. Đó là lý do Box<String> vẫn type-check được với một Box.class compile từ năm ngoái, và là lý do Method.getGenericReturnType() báo T cho Holder.get trong khi Method.getReturnType() báo erasure của nó, class java.lang.Number.

Bridge method

Erasure tạo ra một vấn đề mà compiler phải tự âm thầm xử lý. Hãy nhìn một class chốt cứng parameter của supertype:

class Node<T> {
    T value;
    void set(T value) { this.value = value; }
}

public class Bridge extends Node<String> {
    @Override
    void set(String value) { super.set(value); }
}

Node.set erase thành set(Object). Bridge.set được khai báo là set(String). Đó là hai descriptor khác nhau, nên dưới góc nhìn của JVM thì Bridge.set không override gì cả — và một lời gọi virtual qua reference kiểu Node sẽ chạy nhầm thân method. javac bịt lỗ hổng đó bằng cách sinh thêm một method:

  void set(java.lang.String);
    Code:
       0: aload_0
       1: aload_1
       2: invokespecial #7                  // Method Node.set:(Ljava/lang/Object;)V
       5: return

  void set(java.lang.Object);
    Code:
       0: aload_0
       1: aload_1
       2: checkcast     #11                 // class java/lang/String
       5: invokevirtual #13                 // Method set:(Ljava/lang/String;)V
       8: return

Method thứ hai là synthetic, và javap -v gắn nhãn cho nó:

  void set(java.lang.Object);
    descriptor: (Ljava/lang/Object;)V
    flags: (0x1040) ACC_BRIDGE, ACC_SYNTHETIC

Nó cast rồi ủy quyền. Cái cast đó bình thường vô hình, vì compiler đã chứng minh được chỉ có string tới được đây. Phá vỡ chứng minh đó bằng một raw type và bridge method chính là chỗ chương trình chết:

public class RawCall {
    public static void main(String[] args) {
        Node raw = new Bridge();
        raw.set(42);            // dispatches to the bridge method
    }
}
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
	at Bridge.set(Bridge.java:6)
	at RawCall.main(RawCall.java:4)

Dòng 6 của Bridge.java là dòng khai báo class. Trên dòng đó không có set nào — frame này thuộc về một method bạn chưa bao giờ viết.

Những gì erasure làm cho không thể viết được

Bốn hạn chế trông có vẻ tùy tiện đều đến từ cùng một chỗ, và một lần compile báo đủ cả bốn:

public class Impossible<T> {
    private static T shared;              // 1. static field of type T

    T[] makeArray(int n) {
        return new T[n];                  // 2. array creation
    }

    T makeOne() {
        return new T();                   // 3. instantiation
    }

    boolean isT(Object o) {
        return o instanceof T;            // 4. instanceof
    }
}
Impossible.java:2: error: non-static type variable T cannot be referenced from a static context
    private static T shared;              // 1. static field of type T
                   ^
Impossible.java:5: error: generic array creation
        return new T[n];                  // 2. array creation
               ^
Impossible.java:9: error: unexpected type
        return new T();                   // 3. instantiation
                   ^
  required: class
  found:    type parameter T
  where T is a type-variable:
    T extends Object declared in class Impossible
Impossible.java:13: error: Object cannot be safely cast to T
        return o instanceof T;            // 4. instanceof
               ^
4 errors

Một field static thuộc về class, mà class chỉ tồn tại một bản cho mọi instantiation, nên static T sẽ phải vừa là String vừa là Integer cùng lúc. new T[n] không chạy được vì array ghi lại component type của nó lúc chạy, mà không có T nào tồn tại lúc chạy để ghi. new T() không có constructor nào để gọi — T có thể là một interface, hoặc một class không có constructor không tham số. Còn instanceof T hỏi một câu mà không giá trị lúc chạy nào trả lời được.

Hai hạn chế nữa cũng từ nguồn đó mà ra. Type argument bắt buộc phải là reference type, vì cái slot sau khi erase giữ một reference:

Primitive.java:4: error: unexpected type
    List<int> numbers;
         ^
  required: reference
  found:    int
1 error

List<Integer> mới là cách viết đúng, và mỗi int bị box lại trên đường vào. Và một class không thể implement hai parameterization của cùng một interface, vì sau erasure chúng là một:

TwoParam.java:1: error: repeated interface
public class TwoParam implements Comparable<String>, Comparable<Integer> {
                                                               ^
TwoParam.java:1: error: Comparable cannot be inherited with different arguments: <java.lang.String> and <java.lang.Integer>
public class TwoParam implements Comparable<String>, Comparable<Integer> {
       ^
2 errors

catch (T e) bị từ chối vì cùng lý do với instanceof: JVM khớp handler theo class ghi trong exception table, mà T không sinh ra class nào như vậy.

CatchT.java:5: error: unexpected type
        } catch (T e) {
                 ^
  required: class
  found:    type parameter T
  where T is a type-variable:
    T extends Exception declared in class CatchT
1 error

Workaround bằng Class token

Mọi hạn chế kể trên thực chất đều là "không có T nào lúc chạy", nên cách sửa luôn giống nhau: truyền một cái vào. Class<T> là một object bình thường sống được tới lúc chạy, và nó mang type argument ngay trong type của chính nó.

public class Factory<T> {
    private final Class<T> type;   // the token that survives erasure

    public Factory(Class<T> type) { this.type = type; }

    public T create() throws ReflectiveOperationException {
        return type.getDeclaredConstructor().newInstance();
    }

    public boolean isInstance(Object o) {
        return type.isInstance(o);   // stands in for `o instanceof T`
    }

    @SuppressWarnings("unchecked")
    public T[] newArray(int n) {
        return (T[]) java.lang.reflect.Array.newInstance(type, n);
    }

    public static void main(String[] args) throws Exception {
        Factory<StringBuilder> f = new Factory<>(StringBuilder.class);
        StringBuilder sb = f.create();
        sb.append("built");
        System.out.println(sb);
        System.out.println(f.isInstance("a string"));
        System.out.println(f.newArray(3).getClass().getName());
    }
}
built
false
[Ljava.lang.StringBuilder;

newArray trả về một StringBuilder[] thật, không phải một Object[] đội lốt, vì Array.newInstance được đưa cho một class thật. Đây chính là pattern đứng sau Collection.toArray(T[]) và sau mọi framework bắt bạn đưa cho nó một literal .class.

Unchecked warning và lời hứa thật sự của @SuppressWarnings

Unchecked warning nghĩa là compiler bị yêu cầu tin vào một điều nó không kiểm chứng được. Nguồn phổ biến nhất là cái workaround cho array:

public class NoSuppress<T> {
    private final T[] items;

    public NoSuppress(int capacity) {
        items = (T[]) new Object[capacity];
    }
}
NoSuppress.java:5: warning: [unchecked] unchecked cast
        items = (T[]) new Object[capacity];
                      ^
  required: T[]
  found:    Object[]
  where T is a type-variable:
    T extends Object declared in class NoSuppress
1 warning

@SuppressWarnings("unchecked") xóa message đi. Nó không thêm phép kiểm tra nào, không làm cái cast an toàn hơn, và không ràng buộc gì lúc chạy — nó là một lời nhắn cho compiler rằng tôi đã tự chứng minh chỗ này rồi, đừng hỏi nữa. Nếu chứng minh đó sai, thất bại chỉ đơn giản là dời sang chỗ khác:

public class Stack<T> {
    private final T[] items;
    private int size;

    @SuppressWarnings("unchecked")
    public Stack(int capacity) {
        // safe: items is private, never leaks, and only T ever goes in
        items = (T[]) new Object[capacity];
    }

    public void push(T item) { items[size++] = item; }

    public T pop() { return items[--size]; }

    public T[] leak() { return items; }   // this is the unsafe part
}
public class Main {
    public static void main(String[] args) {
        Stack<String> s = new Stack<>(4);
        s.push("a");
        System.out.println(s.pop());
        String[] bad = s.leak();
    }
}
a
Exception in thread "main" java.lang.ClassCastException: class [Ljava.lang.Object; cannot be cast to class [Ljava.lang.String; ([Ljava.lang.Object; and [Ljava.lang.String; are in module java.base of loader 'bootstrap')
	at Main.main(Main.java:6)

Cái comment trong constructor đúng vào lúc nó được viết ra và trở thành sai ngay khoảnh khắc leak() được thêm vào. Để ý chỗ exception rơi xuống: Main.java:6, ở phía gọi. Cast bị suppress nằm trong Stack, chỗ crash lại ở nơi khác, và không dòng nào trong stack trace nhắc tới annotation đó.

Cast một parameterized type sang một cái khác còn tệ hơn, vì sự hỏng hóc diễn ra âm thầm:

import java.util.ArrayList;
import java.util.List;

public class Pollute {
    @SuppressWarnings("unchecked")
    static <T> List<T> pretend(List<?> any) {
        return (List<T>) any;   // the compiler is told to stop asking
    }

    public static void main(String[] args) {
        List<Integer> ints = new ArrayList<>(List.of(1, 2));
        List<String> lie = pretend(ints);
        lie.add("three");                 // no complaint at all
        System.out.println(ints);         // the Integer list now holds a String
        String s = lie.get(2);            // fine
        System.out.println(s);
        Integer boom = ints.get(2);       // the cast the compiler inserted here fails
    }
}
[1, 2, three]
three
Exception in thread "main" java.lang.ClassCastException: class java.lang.String cannot be cast to class java.lang.Integer (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
	at Pollute.main(Pollute.java:17)

Một List<Integer> in ra [1, 2, three] và không ai kêu ca gì cho tới khi một dòng chẳng liên quan đọc nó ra. Đây là heap pollution: một biến parameterized trỏ tới một object không thỏa mãn parameterization của nó.

⚠️ Đặt @SuppressWarnings("unchecked") lên phạm vi nhỏ nhất có thể — một biến local hoặc một method đơn lẻ, đừng bao giờ lên cả class hay cả file — và viết comment nói rõ vì sao cast đó chắc chắn an toàn. Một chỗ suppress không kèm lý do là một bug chưa xảy ra.

Generic varargs sinh ra một warning họ hàng, vì parameter T... thực chất là một T[] và Java vừa nói với bạn rằng nó không tạo cái đó một cách an toàn được:

import java.util.Arrays;
import java.util.List;

public class Varargs {
    static <T> List<T> listOf(T... items) {
        return Arrays.asList(items);
    }

    public static void main(String[] args) {
        System.out.println(listOf("a", "b"));
    }
}
Varargs.java:5: warning: [unchecked] Possible heap pollution from parameterized vararg type T
    static <T> List<T> listOf(T... items) {
                                   ^
  where T is a type-variable:
    T extends Object declared in method <T>listOf(T...)
1 warning

@SafeVarargs mới là annotation đúng ở đây, và nó chặt hơn @SuppressWarnings: nó chỉ được đặt lên method không thể bị override — static, final hoặc private — còn đặt ở chỗ khác là lỗi chứ không phải warning:

Safe.java:9: error: Invalid SafeVarargs annotation. Instance method <T>notFinal(T...) is neither final nor private.
    <T> List<T> notFinal(T... items) { return Arrays.asList(items); }
                ^
  where T is a type-variable:
    T extends Object declared in method <T>notFinal(T...)
1 error

Nó cũng tắt warning ở call site chứ không chỉ ở chỗ khai báo. Truyền hai giá trị List<String> vào một generic varargs method không có annotation sẽ báo unchecked generic array creation for varargs parameter of type List<String>[]; bản có annotation thì không báo gì.

Warning cuối cùng đáng nhận mặt đến từ raw type, và nó phá hoại nhiều hơn vẻ ngoài của nó. Dùng một class ở dạng raw không chỉ xóa cái parameter bạn bỏ qua; nó xóa mọi generic signature trên class đó, kể cả những cái chẳng liên quan gì tới T:

import java.util.List;

class Store<T> {
    T item;
    List<String> tags() { return List.of("a"); }
}

public class RawWipes2 {
    public static void main(String[] args) {
        Store raw = new Store<String>();
        for (String tag : raw.tags()) System.out.println(tag);
    }
}
RawWipes2.java:11: error: incompatible types: Object cannot be converted to String
        for (String tag : raw.tags()) System.out.println(tag);
                                  ^
1 error

tags() trả về List<String> và không hề nhắc tới T, nhưng qua một Store raw thì nó trả về một List raw. Raw type chỉ tồn tại để tương thích với code viết trước năm 2004. Compile với -Xlint:all là chúng tự khai:

Diamond.java:8: warning: [rawtypes] found raw type: ArrayList
        List<String> c = new ArrayList();
                             ^
  missing type arguments for generic class ArrayList<E>

Các quy tắc thực dụng cho generic API của bạn

Ưu tiên generic method hơn wildcard khi một type xuất hiện nhiều hơn một lần. Wildcard là một ẩn số đơn lẻ; nếu hai vị trí trong signature bắt buộc phải khớp nhau thì wildcard không nói được điều đó. Ví dụ kinh điển là swap:

import java.util.List;

public class SwapBad {
    static void swap(List<?> list, int i, int j) {
        list.set(i, list.set(j, list.get(i)));
    }

    public static void main(String[] args) { }
}
SwapBad.java:5: error: incompatible types: Object cannot be converted to CAP#1
        list.set(i, list.set(j, list.get(i)));
                                        ^
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Object from capture of ?
1 error

Giá trị đọc ra và giá trị ghi ngược lại là cùng một phần tử, nhưng mỗi ? capture độc lập, nên compiler không nối chúng lại với nhau. Gọi tên type ra thì nối được:

import java.util.ArrayList;
import java.util.List;

public class SwapGood {
    static <T> void swap(List<T> list, int i, int j) {
        list.set(i, list.set(j, list.get(i)));
    }

    public static void main(String[] args) {
        List<String> xs = new ArrayList<>(List.of("a", "b", "c"));
        swap(xs, 0, 2);
        System.out.println(xs);
    }
}
[c, b, a]

Dùng bounded wildcard cho parameter, và đừng bao giờ dùng cho return type. Wildcard ở vị trí parameter mở rộng những gì phía gọi truyền vào được. Wildcard ở vị trí return thu hẹp những gì phía gọi làm được với kết quả, và nó còn lây — phía gọi buộc phải khai báo một biến wildcard theo, rồi dính đúng những hạn chế đó:

import java.util.ArrayList;
import java.util.List;

public class BadReturn {
    static List<? extends Number> load() {
        return new ArrayList<Integer>();
    }

    public static void main(String[] args) {
        List<? extends Number> nums = load();
        nums.add(1);
    }
}
BadReturn.java:11: error: incompatible types: int cannot be converted to CAP#1
        nums.add(1);
                 ^
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Number from capture of ? extends Number
1 error

Cứ trả về List<Integer> rồi để phía gọi tự quyết định có nới rộng ra hay không.

Dùng diamond, và đừng bao giờ dùng raw type. new ArrayList<>() suy ra type argument từ vế trái; new ArrayList() là raw type, và javac -Xlint:all báo nó hai lần — một lần là [rawtypes] và một lần là [unchecked] unchecked conversion.

Quy tắcVì sao
Parameterize mọi type bạn khai báoraw type xóa mọi generic signature trên class đó
extends cho thứ bạn đọc raelement type là một subtype chưa biết
super cho thứ bạn ghi vàoelement type là một supertype chưa biết
Không wildcard trên return typenó ép hạn chế đó lên mọi phía gọi
Generic method khi một type lặp lạimột T có tên nối các vị trí lại với nhau
Chỉ bound đúng mức cần thiếtT extends Number erase về Number, T erase về Object
Thu hẹp mọi @SuppressWarningschỗ crash rơi rất xa chỗ suppress

FAQ

Generics trong Java là gì và vì sao cần tới nó?

Generics cho phép một class, interface hay method nhận một type làm parameter, nên List<String> là một list mà compiler biết chắc các phần tử là string. Trước Java 5 một collection chứa Object, mỗi lần đọc phải cast tường minh, và một cú cast sai sinh ra ClassCastException lúc chạy — ở dòng đọc giá trị chứ không phải dòng cất giá trị vào. Có type argument thì cùng sai lầm đó thành error: incompatible types: int cannot be converted to String ngay tại dòng cất vào, còn lúc đọc thì không cần cast gì cả.

List<?>, List<Object> và raw List khác nhau chỗ nào?

Raw List tắt hẳn generic checking: nó nhận mọi list, cho bạn add bất cứ thứ gì kèm unchecked warning, và trả về Object. List<Object> được kiểm tra đầy đủ nhưng chỉ nhận đúng list được khai báo là List<Object>List<String> không convert sang nó được. List<?> nhận mọi list, trả về Object, và từ chối mọi add trừ add(null), vì element type là ẩn số. Dùng List<?> khi bạn chỉ cần size(), isEmpty() hay duyệt qua dưới dạng Object; đừng bao giờ dùng raw type.

? extends nghĩa là gì và vì sao không add được vào list đó?

List<? extends Number> nghĩa là một list của một subtype chưa biết nào đó của Number — có thể là List<Double>, có thể là List<Integer>. Đọc thì an toàn vì mọi phần tử đều là Number. Ghi thì không, vì compiler không chứng minh được giá trị của bạn thuộc về đúng cái type thật sự đó; nó báo incompatible types: int cannot be converted to CAP#1, trong đó CAP#1 là type variable mới mà nó tự đặt ra cho capture. Ngoại lệ duy nhất là add(null), vì null thuộc về mọi reference type.

Khi nào nên dùng super trong generic type?

Khi parameter tiêu thụ những giá trị bạn đưa vào. List<? super Integer> nhận List<Integer>, List<Number>List<Object>, và cả ba đều giữ được một Integer, nên add compile được. Đọc ngược ra thì được Object — đòi một Integer sẽ nhận incompatible types: CAP#1 cannot be converted to Integer. super cũng xuất hiện trong bound kiểu Comparable<? super T>, và đó là thứ cho phép Collections.sort nhận một List<Dog>compareTo thừa kế từ Animal.

Type erasure trong Java là gì?

Erasure là việc compiler thay mọi type variable bằng erasure của nó và bỏ các type argument đi sau khi đã kiểm tra xong. T không bound thành Object, T extends Number thành Number, List<T> thành List, và javac chèn một checkcast ở mọi chỗ giá trị được lấy ra. javap -s cho thấy kết quả: public T get() có descriptor ()Ljava/lang/Number;. Lúc chạy, new ArrayList<String>()new ArrayList<Integer>() tạo ra những object mà getClass() đều là cùng một java.util.ArrayList.

Vì sao không tạo được array của một generic type trong Java?

new T[n] báo error: generic array creation. Array lưu component type của nó trong object header và kiểm tra ở mọi lần ghi — đó là thứ ném ra ArrayStoreException — nhưng erasure không để lại T nào lúc chạy để mà lưu, nên array sẽ không tự kiểm tra được. Hai workaround là (T[]) new Object[n] kèm một @SuppressWarnings("unchecked") đặt thật hẹp và một field private không bao giờ lọt ra ngoài, hoặc Array.newInstance(type, n) với một Class<T> token, cách này tạo ra array có type thật.

Dùng generics trong Java có tốn hiệu năng không?

Không. Một generic method và cũng method đó viết với một cast tay compile ra chuỗi instruction giống hệt nhau tới từng byte — cả hai đều kết thúc bằng invokeinterface, checkcast, invokevirtual. Một class file phục vụ mọi instantiation, nên cũng không tốn thêm dung lượng code. Chi phí thật duy nhất không đến từ generics với tư cách một feature ngôn ngữ: type argument bắt buộc là reference type, nên List<Integer> box mỗi int thành một object Integer, còn int[] thì không.

Unchecked warning nghĩa là gì và suppress nó có an toàn không?

Nó nghĩa là compiler không kiểm chứng được một cast hay một lời gọi và đang đi tiếp bằng niềm tin — thường là (T[]) new Object[n], một cast giữa hai parameterized type, hoặc bất kỳ lời gọi nào qua raw type. Suppress chỉ an toàn khi bạn phát biểu được cái chứng minh mà nó không dựng lên nổi. @SuppressWarnings("unchecked") không đổi gì lúc chạy; nếu chứng minh sai thì bạn có heap pollution, và ClassCastException sẽ rơi xuống một đoạn code chẳng liên quan, chỉ vì nó đọc phải object đã hỏng.

Kết luận

Generics là một ý tưởng duy nhất được áp dụng nhất quán: dời một type từ giá trị lên phần khai báo, để compiler kiểm tra nó, rồi vứt nó đi. Vế kiểm tra là thứ thay ClassCastException bằng một build lỗi và bỏ hết cast khỏi code của bạn. Vế vứt đi là thứ sinh ra mọi hạn chế — không new T[], không new T(), không static T, không instanceof T, không hai overload chỉ khác nhau ở type argument — và cũng là thứ làm cho generics miễn phí, vì bytecode đúng bằng thứ bạn sẽ tự viết ra.

Nằm giữa hai vế đó là những phần cần luyện tập. Invariance không phải một hạn chế cần lách qua mà chính là cam kết: nếu List<String> gán được cho List<Object> thì mọi thứ ở trên sụp đổ. Wildcard trả lại sự linh hoạt theo từng hướng một, và PECS chỉ là nhận xét rằng producer thì chỉ đọc được còn consumer thì chỉ ghi được. Bound là cùng một cuộc trao đổi ở quy mô nhỏ — mỗi khả năng bạn thêm cho một type variable lại thu hẹp tập type được phép điền vào đó. Còn Signature attribute là chi tiết làm cho cả cơ chế này chạy được xuyên qua các đơn vị compile: bytecode thì quên, class file thì nhớ.

Bài tiếp theo trong series: các nguyên lý SOLID — năm quy tắc thiết kế quyết định trách nhiệm được chia ra sao giữa các class, và một codebase hành xử thế nào khi yêu cầu thay đổi.

Bài viết liên quan

[Advanced Java] Set trong Java: HashSet, LinkedHashSet và TreeSet

HashSet, LinkedHashSet và TreeSet trên OpenJDK 21: Set thực sự đảm bảo điều gì, HashMap ẩn bên trong HashSet, các lookup của NavigableSet, hai quy tắc khác nhau để quyết định duplicate, compareTo mâu thuẫn với equals, ClassCastException và null trong TreeSet, EnumSet, và vì sao removeAll có thể thành quadratic.

[Advanced Java] Stream API trong Java: map, filter, reduce và collect

Stream API của Java trên OpenJDK 21: pipeline gồm source, intermediate và terminal, tính lazy được chứng minh bằng trace println xen kẽ, map, filter, cả ba overload của reduce, collect cùng bộ Collectors, primitive stream và chi phí allocation của boxing, và các bẫy quanh peek, findAny, lambda có state cùng parallelStream.

[Advanced Java] Enum nâng cao trong Java: constructor, constant body, EnumMap và enum singleton

Enum nâng cao trong Java trên OpenJDK 21: javap cho thấy enum thực sự compile ra cái gì, field và constructor private ngầm định, constant-specific class body cùng những file class sinh thêm, abstract method, enum implement interface, EnumMap và EnumSet, switch exhaustive, enum singleton mà reflection không phá được, state machine bằng enum, và các bẫy quanh ordinal với values().

[Advanced Java] Map nâng cao trong Java: TreeMap, LinkedHashMap, Hashtable và ConcurrentHashMap

Các Map implementation ngoài HashMap trên OpenJDK 21 - TreeMap cùng các method NavigableMap floorEntry, ceilingKey, headMap và subMap, access order của LinkedHashMap và LRU cache năm dòng với removeEldestEntry, vì sao Hashtable là legacy, và ConcurrentHashMap thực sự cam kết gì về lock, weakly consistent iterator và các compound operation atomic.