Mở JDK ra đọc chữ ký của những phương thức bạn dùng hằng ngày:

void forEach(Consumer<? super T> action);
void sort(Comparator<? super E> c);
boolean addAll(Collection<? extends E> c);
static <T> void copy(List<? super T> dest, List<? extends T> src);

Những dấu ? super? extends đó không phải để cho phức tạp. Chúng theo một quy tắc có tên là PECSProducer Extends, Consumer Super — và một khi hiểu, bạn sẽ thấy nó ở khắp nơi.

Vấn đề: generics không hiệp biến

Bài hôm qua đã gặp:

List<Object> o = new ArrayList<String>();   // lỗi biên dịch

List<String> không phải một loại của List<Object>. Điều này tránh được lỗ hổng ArrayStoreException của mảng, nhưng nó tạo ra một bất tiện:

static double tongCan(List<Number> ds) { ... }

tongCan(List.of(1, 2, 3));    // List<Integer> -> KHÔNG truyền được!

Hàm chỉ đọc số ra rồi cộng, chẳng làm gì nguy hiểm, mà không nhận được List<Integer>.

Ký tự đại diện giải quyết chuyện này.

extends: nguồn cung, chỉ để đọc

static double tongCan(List<? extends Number> ds) {
    double t = 0;
    for (Number n : ds) t += n.doubleValue();
    return t;
}
  List<Integer> : 6.0
  List<Double>  : 4.0
  List<Long>    : 30.0

List<? extends Number> nghĩa là "một danh sách của một loại nào đó thuộc Number, nhưng tôi không biết chính xác loại gì".

Vì không biết chính xác, bạn đọc ra được — mọi phần tử chắc chắn là Number. Nhưng không ghi vào được:

PecsLoi.java:6: error: incompatible types: Cho cannot be converted to CAP#1
        ds.add(new Cho());
               ^
  where CAP#1 is a fresh type-variable:
    CAP#1 extends DongVat from capture of ? extends DongVat

CAP#1 là cách javac gọi "cái kiểu chưa biết đó". Danh sách có thể thực sự là List<Meo> — thêm một Cho vào sẽ phá vỡ nó. Trình biên dịch không có cách nào biết, nên nó cấm hẳn.

super: nơi nhận, chỉ để ghi

static void themCho(List<? super Cho> ds) {
    ds.add(new Cho());
    ds.add(new ChoCon());
}
  List<DongVat> : [Cho, ChoCon]
  List<Cho>     : [Cho, ChoCon]
  List<Object>  : [Cho, ChoCon]

List<? super Cho> nghĩa là "danh sách của Cho hoặc một lớp cha nào đó của nó". Bất kể là lớp cha nào, một Cho cũng bỏ vào được — nên ghi thoải mái.

Nhưng đọc ra thì không biết được kiểu gì:

PecsLoi.java:9: error: incompatible types: CAP#1 cannot be converted to Cho
        Cho c = ds.get(0);

Danh sách có thể là List<Object>, và phần tử đầu có thể là một String ai đó đã thêm vào từ trước. Thứ duy nhất chắc chắn là Object:

  get(0) trả kiểu Object: Cho

Giá trị thật vẫn là Cho, nhưng kiểu tĩnh chỉ là Object.

Quy tắc gói trong một câu

Bạn định làm gì với tham số? Dùng
Chỉ đọc ra (nó cung cấp dữ liệu) ? extends T
Chỉ ghi vào (nó tiêu thụ dữ liệu) ? super T
Cả hai T trần, không wildcard

Cách nhớ: PECS — Producer Extends, Consumer Super. Nhìn từ phía tham số: nó sản xuất dữ liệu cho bạn thì extends, nó tiêu thụ dữ liệu của bạn thì super.

Và một mẹo tôi thấy dễ nhớ hơn: extends là "trần" — mọi thứ bên dưới trần đều đọc được thành T. super là "sàn" — mọi thứ trên sàn đều nhận được một T.

Áp vào một hàm thật

Hàm chép dữ liệu cần cả hai vế:

static <T> void chep(List<? super T> dich, List<? extends T> nguon) {
    for (T x : nguon) dich.add(x);
}
  chép List<Cho> vào List<Object> : [ChoCon, Cho]

nguon chỉ được đọc → extends. dich chỉ được ghi → super. Đây chính là chữ ký của Collections.copy trong JDK, và giờ nó không còn trông kỳ quặc nữa.

Vì sao forEach nhận Consumer<? super T>

List<Cho> danhSach = ...;
danhSach.forEach(c -> ten.add(c.toString()));

Nếu chữ ký là forEach(Consumer<T>) thì bạn chỉ truyền được đúng Consumer<Cho>. Nhưng một Consumer<DongVat> — hoặc Consumer<Object> — hoàn toàn xử lý được một Cho.

? super T cho phép điều đó:

  forEach với Consumer<Object>: [Cho, ChoCon]
  sort với Comparator<Object> : [Cho, ChoCon]

Cùng lý do với Comparator<? super E> trong sort: một comparator so sánh Object dùng được để sắp xếp danh sách Cho.

Đây là điểm khiến PECS đáng học: nó không phải chuyện học thuật. Nó là lý do các API trong JDK linh hoạt như bạn vẫn thấy, và là thứ bạn cần khi tự viết API cho người khác dùng.

Wildcard không tên

Đôi khi bạn chỉ cần "một danh sách của cái gì đó":

static int dem(Collection<?> c) { return c.size(); }

Collection<?> là viết tắt của Collection<? extends Object>. Bạn đọc ra được Object, và không ghi được gì ngoài null.

Dùng khi hàm không quan tâm kiểu phần tử — size(), isEmpty(), clear(). Nó rõ ràng hơn hẳn dùng kiểu thô Collection, vì kiểu thô tắt hết kiểm tra kiểu còn <?> thì không.

Khi nào KHÔNG dùng wildcard

Kiểu trả về. Đừng viết List<? extends Number> layDanhSach(). Người gọi nhận về một danh sách mà họ không ghi vào được, và phải mang cái wildcard đó đi khắp nơi. Trả kiểu cụ thể.

Khi cần cả đọc lẫn ghi. Lúc đó dùng tham số kiểu T trần.

Trong lớp generic của chính bạn — wildcard là chuyện của tham số phương thức, không phải của khai báo trường.

Đọc CAP#1 trong thông báo lỗi

Khi javac gặp wildcard, nó tạo một tên tạm gọi là capture:

where CAP#1 is a fresh type-variable:
    CAP#1 extends DongVat from capture of ? extends DongVat

Dịch ra: "có một kiểu cụ thể nào đó, tôi gọi tạm là CAP#1, tôi chỉ biết nó là một loại của DongVat".

Thấy CAP# trong lỗi thì gần như luôn là bạn đang ghi vào một ? extends hoặc đọc kiểu cụ thể từ một ? super. Hai câu đó bắt được gần hết trường hợp.

Thử ba mươi giây

Viết static void them(List<? extends Number> ds) { ds.add(1); } và biên dịch.

Đọc thông báo lỗi có chữ CAP#1. Rồi đổi extends thành super — lần này biên dịch được, nhưng thử thêm Number n = ds.get(0); và bạn lại có lỗi ở chiều ngược lại.

Hai lỗi đó là toàn bộ nội dung của PECS, và tự tay gây ra chúng một lần thì nhớ lâu hơn đọc mười lần.

Ngày mai: xoá kiểu lúc biên dịch — vì sao new T[] không hợp lệ, vì sao không nạp chồng được theo List<String>List<Integer>, và generics thực sự biến mất tới mức nào.