Backend

99 Ngày Java — Ngày 40: Wildcard & PECS

SSite Admin
5 tháng 09, 2026 8 phút đọc 5 lượt xem
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.

Sketchnote Ngày 40: Wildcard và PECS — vì sao List<Integer> không truyền được vào List<Number>, ? extends chỉ đọc và ? super chỉ ghi, quy tắc Producer Extends Consumer Super, và giới hạn của wildcard

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 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ủa List<Number>) thì ai đó sẽ thêm được một Double vào danh sách vốn chỉ chứa Integer.

  • ? extends Number đọc là "list của một loại Number nào đó" — đủ để đọc ra Number, 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à T nê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 được T nên ghi an toàn; nhưng lấy ra thì chỉ chắc chắn tới Object.

  • Còn List<?> (Ngày 39) là dạng cực đoan: đọc ra Object, và không ghi được gì ngoài null.

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ên super.

  • 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.max thay 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ền List.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ọi add(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ép List<Integer> sang List<Number>; bỏ wildcard đi và xem nó vỡ ở đâu.

  • Chạy Object[] mang = new String[3]; mang[0] = 42; để thấy ArrayStoreException — 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.maxList.sort, giải thích từng chỗ extendssuper bằ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!

S

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.

Bài viết liên quan

99 Ngày Spring — Ngày 41: Kiến trúc Spring Security

Security là chuỗi filter đứng trước controller chứ không nằm trong nó, SecurityContext giữ Authentication trong ThreadLocal, phân biệt authentication 401 với authorization 403 — và bẫy hasRole so với hasAuthority.

6 thg 9, 20266 phút1
99 Ngày Java — Ngày 41: Exception tổng quan

Cây thừa kế Throwable và vì sao nó quyết định cách hành xử, ranh giới checked với unchecked cùng câu hỏi duy nhất để phân loại, cách đọc stack trace từ Caused by — và bốn kiểu bắt exception khiến người khác khổ.

6 thg 9, 20267 phút0
99 Ngày Spring — Ngày 40: Tổng kết tối ưu JPA

Checklist bảy vấn đề theo thứ tự đáng sửa trước: N+1 query và ba cách chữa, projection, keyset pagination, index khóa ngoại, transaction gọn, batch ghi — và nguyên tắc đo trước, cache cuối cùng.

5 thg 9, 20267 phút5