Chuyển tới nội dung chính

Lập trình tổng quát (generics)

Hãy hình dung một lớp hộp chứa một giá trị. Nếu khai báo trường kiểu Object để chứa mọi thứ, ta gặp hai rắc rối: khi lấy ra phải ép kiểu thủ công, ép sai thì ném ClassCastException lúc chạy chứ không phải lúc biên dịch; và không gì ngăn ta bỏ một chuỗi vào hộp lẽ ra chỉ chứa số. Sai sót kiểu bị đẩy sang lúc chạy, nơi khó phát hiện.

Lập trình tổng quát (generic programming) cho phép viết lớp, giao tiếp và phương thức làm việc với một tham số kiểu (type parameter) chưa xác định, ký hiệu bằng chữ in hoa như T, E, K, V. Kiểu thực được điền vào khi sử dụng, và trình biên dịch kiểm tra tính đúng kiểu ngay lúc biên dịch.

Thời Java chưa có generic

Trước Java 5, các cấu trúc dữ liệu chỉ biết chứa Object, gọi là kiểu trơ (raw type).

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

public class KhoCu {
public static void chay() {
List danhSach = new ArrayList(); // raw type: no element type at all
danhSach.add("DAU");
danhSach.add(2026); // nothing stops this line
for (Object o : danhSach) {
String s = (String) o; // ClassCastException on the second item
System.out.println(s.length());
}
}
}

Đoạn mã trên biên dịch trót lọt rồi sập lúc chạy: lỗi thật nằm ở dòng add(2026) nhưng chương trình chỉ ngã ở dòng ép kiểu, cách đó rất xa. Chỉ cần đổi khai báo thành List<String> danhSach = new ArrayList<>();add(2026) hết biên dịch được, còn vòng lặp for (String s : danhSach) chạy thẳng không cần ép kiểu. Đó là toàn bộ lời hứa của generic: dời sai sót về phía trình biên dịch, nơi ta sửa rẻ nhất.

Hãy cân chi phí của một lỗi theo thời điểm nó bị bắt. Lỗi kiểu bị trình biên dịch chặn tốn của ta vài giây, vì dòng sai được gạch chân ngay trong trình soạn thảo. Cũng lỗi đó mà lọt tới lúc chạy thì tốn vài phút đọc vết ngăn xếp, còn nếu lọt tới máy chủ đang phục vụ sinh viên đăng ký học phần thì thành một sự cố thật. Tệ hơn, lỗi kiểu thường nổ xa nơi gây ra: dòng add(2026) không hề phản ứng, mãi tới khi ai đó ở lớp khác lấy phần tử ra và ép về String thì ClassCastException mới nổ, và vết ngăn xếp lại chỉ vào đúng chỗ vô tội. Generic chặn đứng kịch bản đó ngay tại dòng ta viết sai.

Đáng chú ý là trình biên dịch không báo lỗi cho kiểu trơ, nó chỉ phát cảnh báo, vì mã viết trước Java 5 phải tiếp tục biên dịch được. Cái giá của quyết định tương thích ngược ấy là kiểu trơ vẫn hợp lệ tới hôm nay và nó lặng lẽ tắt sạch mọi kiểm tra kiểu trên biến đó, nên với generic thì cảnh báo mới là chỗ nguy hiểm nằm.

Vì sao không dùng Object cho xong

Nếu chỉ cần chứa mọi thứ, sao không khai báo kiểu Object rồi ép kiểu lúc lấy ra? Có ba lý do, và cả ba đều phải trả bằng thời gian gỡ lỗi. Thứ nhất là an toàn kiểu: với Object, không gì ngăn ta bỏ nhầm một Integer vào cấu trúc chỉ nên chứa String, vì Integer đúng là một Object. Thứ hai là ép kiểu thủ công: mỗi lời ép là một lời hứa chưa được kiểm chứng kiểu "tin tôi đi, chỗ này chắc chắn là String", và chỉ cần một lời sai là có ClassCastException. Thứ ba là khả năng diễn đạt ý định: List<SinhVien> tự nó đã là tài liệu, còn List trơ thì không nói lên điều gì. Nói gọn, Object cho ta tính tổng quát bằng cách vứt bỏ thông tin kiểu, còn generic giữ tính tổng quát mà vẫn giữ lại thông tin kiểu.

Thử ngay: xem lớp tổng quát chạy từng bước

Chưa cần đọc hết lý thuyết, bấm ▶ Chạy (hoặc kéo thanh trượt) để theo dõi một lớp tổng quát Stack<T> đẩy và lấy phần tử. Hãy chú ý: cùng một mã push/pop, nhưng T được điền là String. Đối tượng Stack nằm trên bộ nhớ động, mảng data bên trong nó hiện ra qua các trường, và biến top cho biết đỉnh ngăn xếp đang ở đâu. Phần dưới sẽ giải thích cách khai báo tham số kiểu và chuyện gì xảy ra với T lúc biên dịch.

Lớp tổng quát Stack<T> với push và pop java
1class Stack<T> { // one public type per file, so not public
2 private Object[] data = new Object[4]; // backing array, erased to Object[]
3 private int top = 0; // index of next free slot
4
5 public void push(T x) {
6 data[top] = x; // store the element
7 top++; // move the top up
8 }
9
10 @SuppressWarnings("unchecked")
11 public T pop() {
12 top--; // step back to last element
13 return (T) data[top]; // erasure turns this into (Object): a no-op
14 }
15}
16
17public class Demo {
18 public static void main(String[] args) {
19 Stack<String> st = new Stack<>(); // T is fixed to String
20 st.push("A");
21 st.push("B");
22 String x = st.pop(); // the real (String) check is inserted HERE
23 }
24}
Ngăn xếp stack
main()
Bộ nhớ động heap
(trống)
Bắt đầu main(). Chưa có biến nào, bộ nhớ động còn trống. Ta sắp tạo một Stack với T = String.
1/6
Ghi nhớ

Lớp tổng quát viết một lần, dùng cho nhiều kiểu: cùng mã Stack<T> chạy được với String, Integer, hay bất kỳ kiểu nào. Trình biên dịch chèn các lời ép kiểu cần thiết và bắt lỗi kiểu sai ngay lúc biên dịch, nên ta vừa có an toàn kiểu vừa không phải ép tay.

Lớp và phương thức tổng quát

Để biến một lớp thành lớp tổng quát, ta khai báo tham số kiểu trong cặp ngoặc nhọn ngay sau tên lớp, rồi dùng tham số đó như một kiểu bình thường bên trong.

public class Box<T> { // T is a type parameter
private T value;

public Box(T value) { this.value = value; }
public T get() { return value; } // returns the exact type T
public void set(T v) { this.value = v; }
}

Box<String> sb = new Box<>("DAU"); // T is fixed to String here
String s = sb.get(); // no cast needed, type-safe
Box<Integer> ib = new Box<>(2026); // T is fixed to Integer here
// sb.set(99); // compile error: 99 is not a String

Một phương thức tổng quát (generic method) khai báo tham số kiểu riêng của nó, đặt trong ngoặc nhọn ngay trước kiểu trả về. Trình biên dịch tự suy ra kiểu từ đối số truyền vào, gọi là suy luận kiểu (type inference).

public class Utils {
// <T> declares the method's own type parameter before the return type
public static <T> T firstOf(List<T> items) {
return items.isEmpty() ? null : items.get(0);
}
}

List<String> names = List.of("An", "Binh");
String first = Utils.firstOf(names); // compiler infers T = String

Kiểu giới hạn với extends

Tham số kiểu mặc định có thể là bất kỳ kiểu nào, nên bên trong lớp tổng quát ta chỉ gọi được các phương thức của Object. Nhiều khi ta muốn ràng buộc thêm: một phương thức tìm phần tử lớn nhất chỉ có nghĩa khi các phần tử so sánh được với nhau. Kiểu giới hạn (bounded type parameter) dùng từ khoá extends để nói rằng T phải là kiểu con của một giới hạn trên (upper bound).

// T must be a subtype of Comparable<T>, so compareTo is available
public static <T extends Comparable<T>> T max(List<T> items) {
T best = items.get(0);
for (T x : items) {
if (x.compareTo(best) > 0) best = x; // legal thanks to the bound
}
return best;
}

Integer m = max(List.of(3, 9, 1, 7)); // m = 9

Từ khoá ở đây luôn là extends, kể cả khi giới hạn là một giao tiếp như Comparable. Trong ngữ cảnh kiểu giới hạn, extends mang nghĩa rộng là "là kiểu con của", bao gồm cả kế thừa lớp lẫn cài đặt giao tiếp.

Giới hạn cũng dùng được cho cả lớp: class BoCong<T extends Number> gọi được doubleValue() trên mọi phần tử mà không cần ép kiểu. Một tham số kiểu còn có thể chịu nhiều giới hạn nối bằng dấu và &, ví dụ <T extends Comparable<T> & CharSequence>.

Có một cách nghĩ giúp nhớ lâu: giới hạn là hợp đồng hai chiều. Với người viết phương thức, nó mở khoá các phương thức của giới hạn trên ngay trong thân hàm, nhờ đó compareTo gọi được. Với người gọi, nó thu hẹp tập kiểu được phép truyền vào, nên max(List.of(new Object())) bị từ chối ngay tại chỗ gọi. Giới hạn cũng quyết định kiểu mà T biến thành sau khi bị xoá: T trơ xoá thành Object, còn T extends Comparable<T> xoá thành Comparable, và đó chính là lý do kỹ thuật khiến lời gọi compareTo hợp lệ ở mức bytecode.

Ký tự đại diện wildcard

Có một bẫy kiểu: List<Integer> không phải kiểu con của List<Number>, dù Integer là kiểu con của Number. Nếu cho phép như vậy, ta có thể bỏ một Double vào danh sách vốn chỉ chứa Integer, phá vỡ an toàn kiểu. Vì thế kiểu tổng quát trong Java là bất biến (invariant) theo tham số kiểu.

Để viết một phương thức nhận "danh sách của một kiểu con bất kỳ của Number", ta dùng ký tự đại diện (wildcard), ký hiệu dấu hỏi ?. Có hai dạng giới hạn:

  • <? extends T>giới hạn trên: chấp nhận T hoặc bất kỳ kiểu con nào của T. Dùng khi ta chỉ đọc dữ liệu ra khỏi cấu trúc.
  • <? super T>giới hạn dưới: chấp nhận T hoặc bất kỳ kiểu cha nào của T. Dùng khi ta chỉ ghi dữ liệu vào cấu trúc.

Một câu nhắc dễ nhớ là PECS: producer extends, consumer super. Cấu trúc đóng vai nguồn cấp dữ liệu để đọc thì dùng extends; đóng vai đích để ghi vào thì dùng super.

// accepts List<Integer>, List<Double>, List<Number>, ...
public static double sum(List<? extends Number> nums) {
double total = 0;
for (Number x : nums) total += x.doubleValue(); // safe to read
return total;
}

List<Integer> ints = List.of(1, 2, 3);
List<Double> dbls = List.of(1.5, 2.5);
System.out.println(sum(ints)); // prints: 6.0
System.out.println(sum(dbls)); // prints: 4.0

Cái giá của <? extends T> là ta không ghi vào được: từ bên trong phương thức, kiểu phần tử chỉ được biết là "một kiểu con nào đó của Number" mà không rõ là con nào, nên trình biên dịch từ chối mọi lời add, ngoại lệ duy nhất là add(null)null hợp với mọi kiểu tham chiếu. Ngược lại <? super T> cho phép ghi, nhưng khi đọc ra thì thứ duy nhất ta dám khẳng định là phần tử thuộc Object.

Hiểu vì sao hai lệnh cấm ấy tồn tại quan trọng hơn học thuộc chúng. Với tham số List<? extends Number>, lời gọi thực tế có thể truyền vào List<Integer> hoặc List<Double>, và bên trong thân phương thức ta không biết là cái nào. Nếu trình biên dịch cho phép nums.add(1) mà người gọi lại truyền vào một List<Double>, ta vừa nhét một Integer vào danh sách chỉ chứa Double; vì không loại trừ được kịch bản đó, nó cấm luôn mọi add. Riêng add(null) được cho qua vì null là giá trị hợp lệ của mọi kiểu tham chiếu, nên dù danh sách thật là gì thì nhét null vào cũng không phá vỡ cam kết kiểu của nó. Ngoại lệ ấy gần như vô dụng trong thực tế, nhưng nó cho thấy lệnh cấm không tuỳ tiện: cái gì chứng minh được là an toàn thì vẫn được cho qua.

Chiều ngược lại đối xứng hoàn toàn. Với List<? super Integer>, danh sách thật có thể là List<Integer>, List<Number> hay List<Object>; bỏ một Integer vào cái nào cũng an toàn nên add được phép, nhưng khi đọc ra thì trong trường hợp xấu nhất danh sách là List<Object>, nên thứ duy nhất ta dám khẳng định là phần tử thuộc Object. Wildcard vì thế giống một cái van một chiều. Khi phân vân, đừng nhìn vào kiểu mà hãy tự hỏi: trong thân phương thức, tôi lấy ra hay bỏ vào?

Minh hoạ kinh điển của PECS là một phương thức sao chép: nguồn là producer nên dùng extends, đích là consumer nên dùng super.

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

public class SaoChep {
// src produces elements -> ? extends T
// dst consumes elements -> ? super T
public static <T> void copy(List<? extends T> src, List<? super T> dst) {
for (T x : src) { // reading from src yields at least a T
dst.add(x); // writing a T into dst is always allowed
}
}

public static void chay() {
List<Integer> nguon = List.of(1, 2, 3);
List<Number> dich = new ArrayList<>();
copy(nguon, dich); // Integer into a list of Number: fine
System.out.println(dich); // prints: [1, 2, 3]
}
}

Nếu viết chữ ký copy(List<T> src, List<T> dst) thì lời gọi trên không biên dịch, vì List<Integer>List<Number> là hai kiểu khác nhau. Cách chọn nhanh: tham số là nơi ta lấy ra thì dùng ? extends T, là nơi ta bỏ vào thì dùng ? super T, vừa đọc vừa ghi thì khai báo thẳng List<T>.

Lỗi thường gặp với wildcard
  • Cố add một phần tử vào List<? extends T>. Nguyên nhân gốc: trong thân phương thức ta không biết kiểu phần tử thật là kiểu con nào của T, nên không phần tử nào chứng minh được là an toàn; chỉ add(null) lọt qua vì null thuộc mọi kiểu tham chiếu.
  • Cố gán phần tử đọc từ List<? super T> vào biến kiểu T. Nguyên nhân gốc: danh sách thật có thể là List<Object>, nên điều duy nhất chắc chắn là phần tử thuộc Object.
  • Dùng List<Object> khi ý thật là "danh sách của kiểu bất kỳ". Nguyên nhân gốc: generic bất biến, nên List<String> không truyền vào tham số List<Object> được; muốn nhận kiểu bất kỳ thì viết List<?>.

Type erasure

Thông tin về tham số kiểu không tồn tại lúc chạy. Trình biên dịch dùng nó để kiểm tra an toàn kiểu, rồi xoá kiểu (type erasure) khi sinh bytecode: T không có giới hạn bị thay bằng Object, còn T extends Number bị thay bằng Number; các lời ép kiểu cần thiết được chèn tự động. Hệ quả là lúc chạy, List<String>List<Integer>cùng một kiểu List.

Vì sao Java chọn cách này? Lại là tương thích ngược. Khi generic ra đời ở Java 5 đã có vô số thư viện biên dịch sẵn chạy trên máy ảo cũ; nếu List<String> là một kiểu khác với List ở mức bytecode thì máy ảo phải đổi và mọi thư viện cũ phải dựng lại. Xoá kiểu tránh được điều đó, và generic xét cho cùng gần như là một tính năng thuần tuý của trình biên dịch.

Cái giá phải trả là danh sách những thứ ta không làm được, và mọi hạn chế đều truy về đúng một nguyên nhân gốc: lúc chạy, T không còn tồn tại. Không viết được new T() vì máy ảo không biết gọi hàm dựng của lớp nào; không viết được T.class vì không có đối tượng lớp nào ứng với T; không viết được obj instanceof List<String> vì lúc chạy chỉ còn List. Cũng không nạp chồng được xuLy(List<String>) với xuLy(List<Integer>), vì sau khi xoá kiểu chúng thành hai chữ ký giống hệt nhau. Khi thật sự cần kiểu lúc chạy, cách chuẩn là mang thông tin kiểu theo bằng tay dưới dạng một thẻ kiểu (type token): truyền thêm một Class<T> rồi dùng kieu.isInstance(o)kieu.cast(o).

Hệ quả của type erasure

Vì type erasure, các biểu thức sau không biên dịch hoặc vô nghĩa lúc chạy: if (obj instanceof List<String>), new T[n], và T.class. Khi cần thông tin kiểu lúc chạy, ta truyền thêm một đối tượng Class<T> làm tham số.

Không tạo được mảng generic

Java không cho phép new T[n], cũng không cho phép new List<String>[10]. Mảng là hiệp biến (covariant): String[] được coi là kiểu con của Object[], nên máy ảo phải lưu kiểu phần tử thật trong đối tượng mảng và kiểm tra mỗi lần ghi, ném ArrayStoreException nếu sai. Generic thì bất biến và bị xoá kiểu, nên mảng generic lúc chạy không còn kiểu phần tử thật để kiểm tra, cơ chế bảo vệ ấy sụp đổ. Cách làm chuẩn là để trường là Object[], chỉ ghi qua phương thức nhận T, rồi ép kiểu tại nơi đọc kèm @SuppressWarnings("unchecked"), đúng như lớp Stack<T> trong bản trace ở đầu bài.

Lỗi thường gặp với generic
  • Dùng kiểu trơ List thay vì List<String>: mất sạch kiểm tra kiểu, mà trình biên dịch chỉ cảnh báo chứ không báo lỗi nên rất dễ lọt.
  • Tưởng List<Integer> là kiểu con của List<Number>: generic bất biến.
  • Cố ghi vào List<? extends T> hoặc cố đọc ra một T từ List<? super T>.
  • Viết new T[n] hay instanceof List<String>: kiểu đã bị xoá nên không biên dịch được.
  • Rắc @SuppressWarnings("unchecked") khắp nơi, thay vì chỉ đặt ở đúng dòng đã tự chứng minh là an toàn.

Bài tập thực hành

Bài tập 1: lớp tổng quát Box có map

Viết lớp tổng quát Box<T> có phương thức map nhận một Function<T, R> và trả về Box<R>. Dùng Lambda để biến một Box<Integer> chứa số 5 thành Box<String> chứa chuỗi "5".

Lời giải chi tiết

Bước 1: khai báo Box<T> với một trường kiểu T. Bước 2: phương thức map khai báo tham số kiểu riêng <R> ngay trước kiểu trả về Box<R>; nó áp dụng Function lên giá trị bên trong rồi bọc kết quả vào một Box mới. Vì map tự khai báo R, một Box<Integer> có thể map sang Box<String>.

import java.util.function.Function;

public class Box<T> {
private final T value;
public Box(T value) { this.value = value; }
public T get() { return value; }

// <R> is the method's own type parameter, declared before the return type
public <R> Box<R> map(Function<T, R> f) {
return new Box<>(f.apply(value)); // apply, then wrap again
}
}

Box<Integer> bi = new Box<>(5);
Box<String> bs = bi.map(n -> Integer.toString(n)); // or map(String::valueOf)
System.out.println(bs.get()); // prints: 5

Ý chính: phương thức tổng quát cho phép kiểu kết quả R khác kiểu nguồn T, đúng tinh thần của Function<T, R>.

Bài tập 2: phương thức max với kiểu giới hạn

Viết một phương thức tổng quát max nhận một List<T> với T extends Comparable<T> và trả về phần tử lớn nhất. Giải thích vì sao ràng buộc extends Comparable<T> là bắt buộc để gọi được compareTo.

Lời giải chi tiết

Nếu không có giới hạn, T bị xoá thành Object nên ta chỉ gọi được các phương thức của Object, mà Object không có compareTo. Khai báo <T extends Comparable<T>> cho trình biên dịch biết mọi T đều cài Comparable, nhờ đó gọi compareTo là hợp lệ.

import java.util.List;

public static <T extends Comparable<T>> T max(List<T> items) {
T best = items.get(0);
for (T x : items) {
if (x.compareTo(best) > 0) best = x; // legal thanks to the bound
}
return best;
}

System.out.println(max(List.of(3, 9, 1, 7))); // 9
System.out.println(max(List.of("an", "binh", "cu"))); // cu (natural order)

Ý chính: kiểu giới hạn mở khoá các phương thức của giới hạn trên ngay bên trong thân phương thức tổng quát.

Bài tập 3: phương thức tĩnh swap cho Pair

Cho lớp Pair<K, V> giữ một cặp khoá và giá trị. Viết phương thức tĩnh swap nhận Pair<K, V> và trả về Pair<V, K>. Vì sao swap phải tự khai báo tham số kiểu của nó?

Lời giải chi tiết

Tham số kiểu của lớp chỉ tồn tại cho từng thể hiện, nên một phương thức tĩnh không nhìn thấy KV của lớp bao quanh. Vì vậy swap phải khai báo bộ tham số kiểu riêng <K, V> ngay trước kiểu trả về; trình biên dịch suy ra chúng từ đối số truyền vào.

public class Pair<K, V> {
private final K khoa;
private final V giaTri;

public Pair(K khoa, V giaTri) { this.khoa = khoa; this.giaTri = giaTri; }
public K getKhoa() { return khoa; }
public V getGiaTri() { return giaTri; }

// a static method must declare its OWN type parameters
public static <K, V> Pair<V, K> swap(Pair<K, V> p) {
return new Pair<>(p.getGiaTri(), p.getKhoa());
}

public static void chay() {
Pair<String, Integer> p = new Pair<>("SV01", 9); // K = String, V = Integer
Pair<Integer, String> q = Pair.swap(p); // swapped: Pair<V, K>
System.out.println(q.getKhoa()); // prints: 9
}
}

Ý chính: tham số kiểu của lớp gắn với thể hiện, còn phương thức tĩnh phải tự khai báo tham số kiểu của mình.

Bài tập 4: đọc lỗi wildcard cho đúng

Cho chữ ký void nap(List<? extends SinhVien> nguon, List<? super SinhVien> dich). Bốn dòng dưới đây, dòng nào biên dịch được và dòng nào không? Hãy giải thích bằng nguyên nhân gốc chứ đừng chỉ đọc thuộc PECS.

// nguon.add(new SinhVien("SV01")); // line 1
// nguon.add(null); // line 2
// SinhVien sv = nguon.get(0); // line 3
// SinhVien x = dich.get(0); // line 4
Lời giải chi tiết

Dòng 1 không biên dịch được: nguon có thể thật sự là một danh sách kiểu con nào đó của SinhVien, nên nhét một SinhVien chung chung vào sẽ phá vỡ cam kết kiểu của danh sách gốc; vì không loại trừ được kịch bản ấy, trình biên dịch cấm mọi add.

Dòng 2 biên dịch được: null là giá trị hợp lệ của mọi kiểu tham chiếu, nên dù danh sách thật là kiểu con nào đi nữa, nhét null vào cũng không phá vỡ gì. Đây là ngoại lệ duy nhất của lệnh cấm ghi trên ? extends.

Dòng 3 biên dịch được: đọc ra từ ? extends SinhVien thì phần tử ít nhất cũng là một SinhVien.

Dòng 4 không biên dịch được: dich có thể thật sự là List<Object>, nên phần tử lấy ra chỉ dám khẳng định là Object; viết Object x = dich.get(0); thì hợp lệ.

Ý chính: cả hai lệnh cấm đều suy ra từ một câu hỏi duy nhất, đó là kịch bản xấu nhất mà người gọi có thể truyền vào.

Bài tập 5: vì sao không tạo được mảng generic

Giải thích vì sao T[] mang = new T[10]; không biên dịch được, rồi viết lớp DanhSach<T> tự quản lý mảng nền và chỉ ra đúng một dòng cần @SuppressWarnings("unchecked") cùng lý do dòng đó an toàn.

Lời giải chi tiết

Nguyên nhân gốc là type erasure: lúc chạy T đã biến mất nên máy ảo không biết cấp phát mảng có kiểu phần tử nào, trong khi mảng lại lưu kiểu phần tử thật và kiểm tra mỗi lần ghi. Cơ chế bảo vệ ấy sụp đổ với mảng generic, nên Java cấm thẳng.

public class DanhSach<T> {
private Object[] data = new Object[8]; // backing array is Object[]
private int size = 0;

public void them(T x) {
if (size == data.length) {
Object[] moi = new Object[data.length * 2];
System.arraycopy(data, 0, moi, 0, size);
data = moi;
}
data[size++] = x; // only a T is ever written here
}

@SuppressWarnings("unchecked") // safe: see the argument below
public T lay(int i) {
if (i < 0 || i >= size) {
throw new IndexOutOfBoundsException("chi so khong hop le: " + i);
}
return (T) data[i];
}
}

Lời ép trong lay an toàn vì data là trường private và lối duy nhất ghi vào nó là them, mà them chỉ nhận T. Chú giải đặt hẹp nhất có thể, tại đúng phương thức đã tự chứng minh xong, chứ không rắc lên cả lớp, vì đặt rộng sẽ nuốt mất một cảnh báo nguy hiểm thật ở chỗ khác.

Câu hỏi tự kiểm

Kiểm tra nhanh: lập trình tổng quát0/4 đúngchưa trả lời
  1. 1Sau type erasure, một tham số kiểu khai báo là T extends Number bị thay bằng kiểu nào ở mức bytecode?
  2. 2Với tham số List<? super Integer> ds, thao tác nào hợp lệ bên trong phương thức?
  3. 3Vì sao biểu thức new T[10] không biên dịch được trong lớp tổng quát?
  4. 4List<Integer> có phải là kiểu con của List<Number> không?

Tóm tắt

  • Generic đẩy kiểm tra kiểu từ lúc chạy về lúc biên dịch: một ClassCastException khó dò trở thành lỗi biên dịch nằm đúng dòng sai.
  • Lớp khai báo tham số kiểu sau tên lớp; phương thức tổng quát khai báo tham số kiểu riêng ngay trước kiểu trả về và thường được suy luận từ đối số.
  • Kiểu giới hạn T extends X mở khoá các phương thức của X trong thân phương thức.
  • Nhớ PECS: nguồn để đọc dùng ? extends T, đích để ghi dùng ? super T.
  • Type erasure xoá tham số kiểu khi sinh bytecode, nên không có new T[n], T.class hay instanceof List<String>; cần kiểu lúc chạy thì truyền thêm Class<T>.

Nếu chỉ giữ lại một ý từ bài này, hãy giữ ý sau. Generic không thêm sức mạnh tính toán nào: mọi thứ viết được bằng generic đều viết được bằng Object cộng ép kiểu, và sau khi xoá kiểu thì bytecode sinh ra gần như y hệt. Cái nó thêm vào là một cuộc đối thoại với trình biên dịch: ta nói ý định của mình bằng tham số kiểu, bằng giới hạn extends, bằng wildcard, đổi lại nó kiểm tra giúp và bắt sai lệch ngay khi ta vừa gõ ra. Mọi hạn chế khó chịu của generic, từ tính bất biến, lệnh cấm ghi vào ? extends, cho tới chuyện không tạo được mảng generic, đều là cái giá để cuộc đối thoại ấy không bao giờ nói dối.