99 Ngày Java — Ngày 40: Wildcard & PECS
Ngày 39 khép lại bằng một nút thắt: List<String> không phải con của List<Object>, nên một hàm nhận List<Number> lại từ chối thẳng List<Integer>. Hôm nay ta gỡ nút đó bằng wildcard — dấu hỏi trong ngoặc nhọn — và học một câu thần chú ngắn quyết định nên dùng ? extends hay ? super: PECS. Đây cũng là bài khép lại giai đoạn Collections và Generics của series.

Bài toán: vì sao List<Integer> bị từ chối
Nghe rất vô lý khi gặp lần đầu: Integer rõ ràng là một Number, vậy mà danh sách Integer lại không dùng được ở chỗ cần danh sách Number. Nhưng đó chính là cái giá của an toàn kiểu — và wildcard là cách Java trả lại sự linh hoạt mà không đánh mất an toàn.
// Ngày 39 kết lại bằng một nút thắt: List<String> KHÔNG phải con của List<Object>.
// Hệ quả rất khó chịu — hàm này chỉ nhận được đúng List<Number>:
static double tong(List<Number> ds) {
double s = 0;
for (Number n : ds) s += n.doubleValue();
return s;
}
tong(List.of(1, 2, 3)); // ❌ List<Integer> KHÔNG truyền được!
tong(List.of(1.5, 2.5)); // ❌ List<Double> cũng không!
// Vô lý: Integer LÀ Number, mà List<Integer> lại không dùng được ở chỗ cần List<Number>.
// WILDCARD sinh ra để mở đúng cánh cửa này:
static double tong(List<? extends Number> ds) { // "list của MỘT loại Number nào đó"
double s = 0;
for (Number n : ds) s += n.doubleValue(); // ĐỌC ra Number: an toàn
return s;
}
tong(List.of(1, 2, 3)); // ✅
tong(List.of(1.5, 2.5)); // ✅Nếu generic hiệp biến (tức
List<Integer>là con củaList<Number>) thì ai đó sẽ thêm được mộtDoublevào danh sách vốn chỉ chứaInteger.? extends Numberđọc là "list của một loại Number nào đó" — đủ để đọc raNumber, và đó là tất cả những gì hàm cần.Đây không phải mẹo vặt: gần như mọi API nhận collection trong JDK đều viết theo kiểu này.
? extends chỉ đọc, ? super chỉ ghi
Hai dạng wildcard đối xứng nhau một cách rất đẹp, và lý do của cả hai đều quy về một câu: trình biên dịch chỉ cấm những gì nó không thể chắc chắn.
// ? extends T — "kiểu nào đó LÀ CON của T". Chỉ ĐỌC được.
List<? extends Number> ds = List.of(1, 2, 3);
Number n = ds.get(0); // ✅ đọc ra Number — chắc chắn đúng
ds.add(42); // ❌ KHÔNG ghi được. Vì sao?
// Vì trình biên dịch chỉ biết "list của MỘT loại Number", chứ không biết loại nào.
// Nếu đó thật ra là List<Double>, thì thêm Integer 42 vào là phá vỡ nó.
// Không biết chắc thì cấm — nguyên tắc an toàn của Java.
// (Ngoại lệ duy nhất được phép ghi: null, vì null hợp với mọi kiểu.)
// ? super T — "kiểu nào đó LÀ CHA của T". Chỉ GHI được (an toàn).
List<? super Integer> ds2 = new ArrayList<Number>();
ds2.add(42); // ✅ Integer chắc chắn bỏ vừa vào cha của Integer
Integer x = ds2.get(0); // ❌ đọc ra không biết kiểu gì — chỉ chắc là Object
Object o = ds2.get(0); // ✅ chỉ đảm bảo được tới Object
// Một câu để nhớ cả hai:
// extends → mở cửa ĐỌC, khóa cửa GHI
// super → mở cửa GHI, khóa cửa ĐỌC (ngoài Object)? extends T: biết mọi phần tử ít nhất làTnên đọc an toàn; nhưng không biết chính xác kiểu gì nên cấm ghi.? super T: biết cái hộp chứa đượcTnên ghi an toàn; nhưng lấy ra thì chỉ chắc chắn tớiObject.Còn
List<?>(Ngày 39) là dạng cực đoan: đọc raObject, và không ghi được gì ngoàinull.
PECS — câu thần chú để không phải suy nghĩ lại
Joshua Bloch gói toàn bộ chuyện này thành bốn chữ: Producer Extends, Consumer Super. Mỗi lần phân vân, chỉ cần hỏi một câu: tham số này sản xuất dữ liệu cho tôi đọc, hay tiêu thụ dữ liệu tôi đưa vào?
// PECS — Producer Extends, Consumer Super. Câu thần chú của Joshua Bloch.
// Hỏi đúng MỘT câu: tham số này SẢN XUẤT dữ liệu cho tôi, hay TIÊU THỤ dữ liệu của tôi?
// nguon SẢN XUẤT (tôi đọc từ nó) → extends
// dich TIÊU THỤ (tôi ghi vào nó) → super
static <T> void chepTatCa(List<? extends T> nguon, List<? super T> dich) {
for (T x : nguon) dich.add(x);
}
List<Integer> a = List.of(1, 2, 3);
List<Number> b = new ArrayList<>();
chepTatCa(a, b); // ✅ chạy được nhờ PECS — không có wildcard thì không
// Đây chính là chữ ký thật của Collections.copy trong JDK, và của cả họ hàng:
// Collections.max(Collection<? extends T>)
// list.addAll(Collection<? extends E>)
// list.sort(Comparator<? super E>) ← comparator TIÊU THỤ phần tử → super
// Vì sao sort nhận Comparator<? super E>? Vì Comparator<Object> so sánh được
// mọi thứ, nên nó dùng được cho List<String>. Nếu ép đúng Comparator<String>
// thì bạn không tái dùng được comparator tổng quát.
// Quy tắc nhỏ đi kèm: kiểu TRẢ VỀ thì ĐỪNG dùng wildcard.
List<? extends Number> layDs() { ... } // ❌ ép người gọi cũng phải viết wildcard
List<Number> layDs() { ... } // ✅ trả kiểu cụ thểHàm chép hai tham số là ví dụ chuẩn: nguồn sản xuất nên
extends, đích tiêu thụ nênsuper.Nhìn lại JDK sẽ thấy PECS ở khắp nơi — và giờ bạn đọc hiểu được chữ ký của
addAll,sort,Collections.maxthay vì lướt qua.Đừng dùng wildcard ở kiểu trả về: nó đẩy sự phức tạp sang cho mọi người gọi hàm.
Giới hạn — và khi nào nên dùng
Wildcard mở rộng đáng kể những gì generic làm được, nhưng nó không gỡ được các giới hạn của type erasure ở Ngày 39. Và như mọi công cụ mạnh, dùng quá tay thì code khó đọc hơn là dễ.
// Wildcard KHÔNG BAO GIỜ giải được mọi thứ — vẫn còn giới hạn của erasure (Ngày 39):
// 1. Không có thông tin kiểu lúc chạy → không viết được hàm kiểu này:
static <T> boolean laKieu(Object o) { return o instanceof T; } // ❌
// 2. Mảng và generic không hợp nhau. Mảng thì "hiệp biến" (covariant) NHƯNG
// kiểm tra lúc CHẠY, còn generic kiểm tra lúc BIÊN DỊCH:
Object[] mang = new String[3];
mang[0] = 42; // biên dịch được → ArrayStoreException lúc chạy 💥
List<Object> ds = new ArrayList<String>(); // ❌ chặn ngay lúc biên dịch — TỐT HƠN
// Đây chính là lý do generic KHÔNG hiệp biến: bắt lỗi sớm thay vì để nổ lúc chạy.
// 3. Wildcard lồng nhau đọc rất khó — dấu hiệu thiết kế nên đơn giản lại:
Map<String, List<? extends Map<?, ? super Integer>>> x; // đừng làm thế này
// KHI NÀO DÙNG WILDCARD: khi viết API cho người khác dùng, để họ truyền vào
// linh hoạt hơn. Code nội bộ, dùng một chỗ thì kiểu cụ thể vẫn dễ đọc hơn.
// Đừng rắc wildcard khắp nơi chỉ vì nó "tổng quát hơn".So sánh mảng với generic là cách hiểu nhanh nhất: mảng cho bạn viết sai rồi nổ lúc chạy, generic chặn ngay lúc biên dịch — đắt hơn một chút lúc gõ code, rẻ hơn rất nhiều lúc vận hành.
Wildcard đáng dùng nhất khi bạn viết API cho người khác; code nội bộ dùng một chỗ thì kiểu cụ thể vẫn dễ đọc hơn.
Wildcard lồng nhiều tầng là dấu hiệu nên đơn giản hóa thiết kế, không phải dấu hiệu bạn cần thêm dấu hỏi.
Bài tập nhỏ
Viết
tong(List<Number>)rồi thử truyềnList.of(1, 2, 3)— đọc thông báo lỗi, sau đó sửa thành? extends Number.Với một
List<? extends Number>, thử gọiadd(42)và tự giải thích vì sao trình biên dịch từ chối.Cài
chepTatCa(List<? extends T>, List<? super T>)rồi chépList<Integer>sangList<Number>; bỏ wildcard đi và xem nó vỡ ở đâu.Chạy
Object[] mang = new String[3]; mang[0] = 42;để thấyArrayStoreException— rồi đối chiếu với generic bị chặn ngay lúc biên dịch.Mở mã nguồn JDK của
Collections.maxvàList.sort, giải thích từng chỗextendsvàsuperbằng PECS.
Kết lại
Bốn ý gói lại hôm nay: generic không hiệp biến để bắt lỗi sớm, và wildcard là cách trả lại sự linh hoạt; ? extends mở cửa đọc còn ? super mở cửa ghi, cả hai đều vì trình biên dịch chỉ cấm thứ nó không chắc chắn; PECS — Producer Extends, Consumer Super — là câu hỏi duy nhất bạn cần đặt ra, và nó giải thích luôn chữ ký của nửa bộ Collections; còn wildcard thì dành cho API công khai, đừng rắc vào kiểu trả về hay lồng nhiều tầng. Vậy là khép lại giai đoạn Collections và Generics. Ngày 41 ta mở giai đoạn mới với thứ mà lập trình viên nào cũng gặp mỗi ngày mà ít ai xử lý tử tế: exception — hierarchy Throwable, checked so với unchecked, và cách đọc stack trace cho đúng. Hẹn gặp lại!
Site Admin
Engineer and writer. Building things with TypeScript and distributed systems.
Bình luận (0)
Bạn cần đăng nhập bằng Google để bình luận.
Hãy là người bình luận đầu tiên.


