Hình dung wildcard như hai cái cửa một chiều. Một bên là máy bán nước: nó chỉ nhả lon ra, bạn lấy được lon nào cũng chắc chắn là đồ uống, nhưng không có cách nào nhét lon của mình vào. Bên kia là hòm thư: nó chỉ nuốt thư vào, bạn bỏ lá thư của mình vào thoải mái, nhưng thò tay lấy ra thì không. ? extends là cái máy bán — nguồn cung, chỉ đọc. ? super là cái hòm thư — nơi nhận, chỉ ghi. Giữ hai hình ảnh đó trong đầu thì cả bài hôm nay chỉ là đi chứng minh chúng bằng lỗi biên dịch.

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 và ? extends đó không phải để cho phức tạp. Chúng theo một quy tắc có tên là PECS — Producer 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 — đúng cái máy bán nước, lấy ra thì được, nhét vào thì không:

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. Đây là cái hòm thư: bỏ vào thì được.

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.

Nếu muốn tự tay chạm vào hai lỗi này một lần cho nhớ, thử đúng ba mươi giây: viết static void them(List<? extends Number> ds) { ds.add(1); } rồi biên dịch, đọc thông báo lỗi có chữ CAP#1. Xong đổi extends thành super — lần này biên dịch được, nhưng 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ự gây ra chúng một lần thì nhớ lâu hơn đọc mười lần: cái máy bán không cho bỏ vào, cái hòm thư không cho lấy ra.

Mẫu số chung

PECS nghe như một mẹo riêng của Java, nhưng thứ nằm dưới nó — biến thiên (variance) của kiểu generic — là bài toán mọi ngôn ngữ có generic đều phải trả lời. Câu hỏi luôn giống nhau: nếu Cho là một loại DongVat, thì List<Cho> có phải một loại List<DongVat> không? Khác biệt lớn giữa các ngôn ngữ không nằm ở câu trả lời, mà ở chỗ bạn phải khai nó ở đâu.

  • Java khai ở nơi dùng (use-site): bạn rắc ? extends / ? super vào từng tham số phương thức. Linh hoạt, nhưng lặp lại — cùng một danh sách có thể xuất hiện vừa là producer vừa là consumer ở các hàm khác nhau, và bạn phải tự nhớ bỏ dấu nào ở đâu.
  • Kotlin và C# cho khai ở nơi định nghĩa (declaration-site): bạn đánh dấu tham số kiểu một lần lúc khai báo — Kotlin viết out T (chỉ sản xuất, ~ extends) và in T (chỉ tiêu thụ, ~ super); C# dùng đúng hai từ khoá out/in trên interface generic (IEnumerable<out T>, IComparer<in T>). Khai một chỗ, mọi nơi dùng tự đúng. Đó là lý do trong C# một IEnumerable<Cho> tự động là IEnumerable<DongVat> mà không cần wildcard nào.
  • Scala đẩy nó vào ký hiệu kiểu: class Box[+T] là hiệp biến (covariant, ~ producer), class Box[-T] là nghịch biến (contravariant, ~ consumer). Cùng một triết lý declaration-site, chỉ khác cú pháp.
  • Rust thì không có từ khoá cho chuyện này — biến thiên được suy ra tự động từ cách kiểu dùng tham số của nó (chủ yếu liên quan tới lifetime và &T/&mut T), nên lập trình viên hiếm khi phải nghĩ tới nó một cách tường minh.

Sợi chỉ chung đáng mang theo: "producer thì đọc, consumer thì ghi" là một định luật về biến thiên, không phải đặc sản của Java — extends/super của Java, out/in của Kotlin và C#, +/- của Scala đều đang mã hoá đúng một ý. Java chỉ chọn cách phiền hơn: bắt bạn khai ở mỗi chỗ dùng thay vì khai một lần lúc định nghĩa. Hiểu được cái lõi đó thì wildcard của Java thôi là bộ quy tắc phải học thuộc, mà trở thành một phiên bản của thứ bạn sẽ gặp lại ở mọi ngôn ngữ có kiểu tĩnh.

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> và List<Integer>, và generics thực sự biến mất tới mức nào.