Java OO & Functional/Generics cơ bản — class và method nhận kiểu làm tham số
17/38
Bài 17 / 38~18 phútGenerics & CollectionsMiễn phí lượt xem

Generics cơ bản — class và method nhận kiểu làm tham số

Vì sao cần generics, cú pháp class/method generic, type parameter T, bounded type parameter (T extends Comparable), và ví dụ tự viết generic collection đơn giản.

Bạn đã dùng List<String>, Map<String, Integer> trong nhiều bài trước. Dấu <...>generics — cơ chế Java cho phép class/method nhận kiểu làm tham số. Thay vì viết StringList, IntegerList, UserList riêng, bạn viết một List<E> duy nhất, compiler thay E bằng kiểu cụ thể ở call site.

Bài này giải thích tại sao generics cần thiết (pre-Java 5 không có), cú pháp class/method generic, bounded type parameter, và viết một Box<T> đơn giản để nắm cơ chế.

1. Trước Java 5 — thế giới không generics

// Java 4 style — khong generics
List list = new ArrayList();
list.add("hello");
list.add(42);          // cast vo tu — compiler khong can
String s = (String) list.get(0);   // phai cast moi lan
String broken = (String) list.get(1);   // ClassCastException RUNTIME

Vấn đề:

  • Compiler không biết list chứa gì → không check type at compile time.
  • Add nhầm kiểu → ra runtime mới biết → bug tinh vi.
  • Caller cast mọi lần → code nặng nề, dễ sai.

Sơ đồ so sánh cơ chế kiểm tra kiểu (Type Casting vs Generics):

Cơ chế cũ (Pre-Java 5: Ép kiểu thủ công - Type Casting)
======================================================
[ Client Code ] --(add Object)--> [ ArrayList (raw) ]
      |                                   |
      | (Lấy dữ liệu ra)                  | (Chứa Object)
      v                                   v
[ Ép kiểu thủ công (String) ] <----(trả Object)---
      |
      +---> Thành công (nếu đúng kiểu)
      +---> LỖI RUNTIME ClassCastException (nếu sai kiểu)

Cơ chế mới (Java 5+: Tự động kiểm tra an toàn - Generics)
======================================================
[ Client Code ]
      |
      |--(Truyền String)--> [ Kiểm tra kiểu (Compile-time) ] --(Hợp lệ)--> [ ArrayList<String> ]
      |                           |                                                |
      |                           v (Báo lỗi nếu sai kiểu)                         | (Lưu trữ & Xóa kiểu)
      |                    [ COMPILE ERROR! ]                                      |
      |                                                                            |
      |<----(Tự động chèn cast ẩn)-------------------------------------------------+

Java 5 (2004) giới thiệu generics để fix:

List<String> list = new ArrayList<>();
list.add("hello");
list.add(42);          // COMPILE ERROR — int khong phai String
String s = list.get(0);   // khong can cast

Compiler bắt kiểu tại compile time, bỏ cast runtime, type-safe từ đầu đến cuối.

2. Cú pháp generic class

public class Box<T> {
    private T value;

    public void set(T value) { this.value = value; }
    public T get() { return value; }
}

// Su dung:
Box<String> s = new Box<>();
s.set("hello");
String v = s.get();    // tu dong la String, khong cast

Box<Integer> i = new Box<>();
i.set(42);
int n = i.get();       // auto-unbox Integer -> int
  • <T> khai báo type parameter — chữ viết hoa đơn, convention T, E, K, V, R.
  • Trong class body, T dùng như kiểu bình thường — compiler thay bằng kiểu cụ thể ở call site.
  • new Box<>()diamond operator (Java 7+), compiler suy String/Integer từ biến khai báo.

2.1 Convention tên type parameter

Để giữ cho mã nguồn dễ đọc và nhất quán, Java quy ước sử dụng các ký tự đơn, viết hoa để đại diện cho tham số kiểu:

Ký tựTên viết tắt đầy đủÝ nghĩa thực tế
TTypeKiểu dữ liệu chung (General Type parameter).
EElementPhần tử trong một Collection (ví dụ: List<E>, Set<E>).
KKeyKhóa của bản đồ (thường đi cặp trong Map<K, V>).
VValueGiá trị đi kèm với khóa trong Map<K, V>.
RReturn / ResultKiểu dữ liệu trả về của một hàm (Functional Interface).
NNumberGiới hạn kiểu số (thường dùng làm bound T extends Number).
?WildcardKiểu không xác định (sẽ được tìm hiểu sâu ở Bài 3).

Không bắt buộc theo — nhưng đọc quen mắt với JDK.

2.2 Nhiều type parameter

public class Pair<A, B> {
    private final A first;
    private final B second;

    public Pair(A first, B second) {
        this.first = first;
        this.second = second;
    }

    public A getFirst() { return first; }
    public B getSecond() { return second; }
}

Pair<String, Integer> p = new Pair<>("Alice", 30);

3. Generic method

Không chỉ class — method cũng có thể generic riêng:

public class Utils {
    public static <T> T firstOrDefault(List<T> list, T defaultValue) {
        return list.isEmpty() ? defaultValue : list.get(0);
    }
}

String s = Utils.firstOrDefault(List.of("a", "b"), "default");   // "a"
Integer n = Utils.firstOrDefault(List.<Integer>of(), 0);          // 0

Cú pháp: <T> khai trước return type. Compiler suy T từ argument.

// Compiler infer T = String
firstOrDefault(List.of("a"), "default");

// Explicit:
Utils.<String>firstOrDefault(List.of("a"), "default");

3.1 Generic method trong generic class

Class có type T, method có type riêng R:

public class Container<T> {
    private T value;

    public <R> R transform(Function<T, R> mapper) {
        return mapper.apply(value);
    }
}

Container<Integer> c = new Container<>();
// c.value = ...
String s = c.transform(n -> "value=" + n);

T là class-level, R là method-level.

3.2 Target Type Inference (Java 8+, JEP 101)

Trong các phiên bản Java ban đầu (Java 5 đến 7), khả năng tự động suy luận kiểu (Type Inference) của compiler còn rất sơ khai. Đối với các phương thức generic, dev thường phải khai báo Type Witness tường minh ở call site khi compiler gặp khó khăn trong việc đoán kiểu:

// Cu phap nap kieu tuong minh (Explicit Type Witness) truoc Java 8:
String s = Utils.<String>firstOrDefault(Arrays.asList("a", "b"), "default");

Từ Java 8 (JEP 101 - Generalized Target-Type Inference), JDK đã cải tiến vượt bậc công cụ suy luận kiểu của trình biên dịch. Compiler giờ đây không chỉ nhìn vào các đối số truyền vào phương thức, mà còn phân tích Target Type (Kiểu đích) từ ngữ cảnh xung quanh, bao gồm:

  1. Kiểu biến được gán (Variable Assignment Target):
    // Compiler tu luan T = Integer dua vao kieu nhan cua bien n (Integer)
    Integer n = Utils.firstOrDefault(Collections.emptyList(), 0);
    
  2. Kiểu đối số truyền vào phương thức lồng nhau (Nested Method Calls Context):
    // Compiler tự biết Collections.emptyList() cần trả về List<String>
    // để tương thích với signature nhận vào của firstOrDefault
    String s = Utils.firstOrDefault(Collections.emptyList(), "default");
    

Nhờ JEP 101, cú pháp <Kiểu> (Type Witness) hầu như không còn cần thiết trong code hàng ngày, giúp code trở nên tinh gọn, tự nhiên mà vẫn đảm bảo tính an toàn kiểu ở mức tối đa tại thời điểm compile.

4. Bounded type parameter — T extends X

Mặc định T có thể là bất kỳ kiểu nào. Nếu cần gọi method của kiểu cụ thể (vd compareTo), giới hạn T bằng extends:

public class SortedBox<T extends Comparable<T>> {
    private final List<T> items = new ArrayList<>();

    public void add(T item) {
        items.add(item);
        Collections.sort(items);   // can T co compareTo
    }
}

SortedBox<Integer> ints = new SortedBox<>();   // OK — Integer implement Comparable
SortedBox<Book> books = new SortedBox<>();      // compile error neu Book khong Comparable

T extends Comparable<T> đọc là "T là một kiểu có thể compare với T". Trong body, compiler biết TcompareTo(T) → cho phép gọi.

4.1 Multi-bound

public class Task<T extends Runnable & AutoCloseable> {
    private final T task;
    public Task(T task) { this.task = task; }

    public void runAndClose() {
        task.run();           // co tu Runnable
        task.close();         // co tu AutoCloseable
    }
}

Cú pháp: T extends A & B & C. Class chỉ cho 1 (nếu có); interface nhiều. Throwable không được.

5. Ví dụ — tự viết Stack<E> đơn giản

public class Stack<E> {
    private final List<E> items = new ArrayList<>();

    public void push(E item) {
        items.add(item);
    }

    public E pop() {
        if (items.isEmpty()) throw new IllegalStateException("Stack is empty");
        return items.remove(items.size() - 1);
    }

    public E peek() {
        if (items.isEmpty()) throw new IllegalStateException("Stack is empty");
        return items.get(items.size() - 1);
    }

    public int size() { return items.size(); }
    public boolean isEmpty() { return items.isEmpty(); }
}

// Su dung:
Stack<Integer> s = new Stack<>();
s.push(1);
s.push(2);
int top = s.pop();   // 2

Viết 1 lần, dùng được với mọi kiểu E — không lặp IntStack, StringStack.

⚠️ CẢNH BÁO THIẾT KẾ: Stack custom của chúng ta vs java.util.Stack của JDK

Trong ví dụ trên, lớp Stack<E> được thiết kế bằng cơ chế Composition (Ủy thác - Delegation) bằng cách bao bọc một List<E> bên trong. Đây là một tư duy thiết kế cấu trúc dữ liệu mới chuẩn mực, khắc phục hoàn toàn những sai lầm nghiêm trọng của lớp legacy java.util.Stack trong bộ JDK gốc:

  1. Lỗi kế thừa sai lầm (Inheritance Anti-Pattern): java.util.Stack của JDK kế thừa trực tiếp từ java.util.Vector (một cấu trúc mảng động cổ xưa). Do kế thừa (extends), java.util.Stack kế thừa luôn tất cả các phương thức của Vector như insertElementAt(obj, index), removeElementAt(index), clear()... Điều này vi phạm nghiêm trọng nguyên lý đóng gói (Encapsulation) và nguyên lý thay thế Liskov (Liskov Substitution Principle - LSP), cho phép client can thiệp trực tiếp vào giữa Stack vốn chỉ được phép truy xuất dạng LIFO (push / pop).
  2. Hiệu năng kém do lạm dụng đồng bộ (Synchronized Overhead): Vector được đồng bộ hóa mọi phương thức để chạy an toàn đa luồng. Do đó, java.util.Stack cũng bị khóa (synchronized) ở mọi thao tác. Điều này dẫn đến sự sụt giảm hiệu năng nặng nề trong các ứng dụng đơn luồng hoặc khi sử dụng các giải pháp concurrency hiện đại.

Định hình tư duy thiết kế: Khi cần cấu trúc Stack trong thực tế:

  • Hãy ưu tiên sử dụng Deque<E> stack = new ArrayDeque<>() của JDK (không bị synchronized và kế thừa đúng đặc tả).
  • Hoặc tự viết một lớp bọc (Composition) như Stack<E> ở trên để bảo vệ hoàn toàn tính chất LIFO của cấu trúc dữ liệu.

6. Generic interface

public interface Converter<FROM, TO> {
    TO convert(FROM input);
}

public class StringToIntConverter implements Converter<String, Integer> {
    @Override
    public Integer convert(String input) {
        return Integer.parseInt(input);
    }
}

Interface generic dùng khắp JDK: Comparator<T>, Predicate<T>, Function<T, R>, Supplier<T>, Iterable<T>...

7. Kiểu raw — legacy, tránh

List list = new ArrayList();   // RAW TYPE — khong khai kieu
list.add("hello");
list.add(42);                   // compile ok, type-unsafe

Java cho phép dùng raw type để backward compat với code pre-Java 5. Compiler warning "unchecked" — đừng bỏ qua. Raw type bypass type checking.

Luôn khai rõ: List<String>, Map<String, Integer>. Nếu không biết kiểu chính xác, dùng wildcard List<?> (bài 3) — type-safe, rõ ý.

8. Generic không dùng được cho primitive

List<int> list = ...;          // COMPILE ERROR
List<Integer> list = ...;       // OK — wrapper

Generic chỉ chấp nhận reference type. Primitive (int, double...) phải dùng wrapper (Integer, Double). Auto-boxing tự động chuyển:

List<Integer> list = new ArrayList<>();
list.add(42);            // auto-box int -> Integer
int x = list.get(0);     // auto-unbox

Vấn đề perf: mỗi auto-box tạo Integer object trên heap — tốn memory + GC. Trong hot loop tính toán, dùng primitive stream (IntStream, LongStream, DoubleStream) hoặc mảng int[] thay vì List<Integer>.

Java 21 đang bàn Valhalla — generic trên primitive (như C#). Chưa stable.

9. Pitfall tổng hợp

Nhầm 1: Dùng raw type trong code mới.

List list = new ArrayList();   // warning "unchecked"

List<String> list = new ArrayList<>(); với diamond <>.

Nhầm 2: Generic trên primitive.

Box<int> b = new Box<>();   // COMPILE ERROR

Box<Integer>. Nhớ cost auto-box.

Nhầm 3: new T() trong class generic — không được.

class Container<T> {
    T createDefault() { return new T(); }   // COMPILE ERROR
}

✅ Pass factory: Container<T> constructor nhận Supplier<T>.

Nhầm 4: Static field type-generic — không được.

class Container<T> {
    static T shared;   // COMPILE ERROR
}

✅ Static thuộc class, không instance → không có type parameter. Dùng Object hoặc tách ra class non-generic.

Nhầm 5: Exception không generic.

class MyException<T> extends Exception { }   // COMPILE ERROR

✅ Exception không được generic (liên quan type erasure + catch clauses).

10. 📚 Deep Dive Oracle

📚 Deep Dive Oracle (optional)

Spec / reference chính thức:

Ghi chú: Generics được thêm vào Java năm 2004 với JDK 5 — một trong những thay đổi lớn nhất. Thiết kế dùng type erasure (bài 2) để backward compat với code pre-Java 5 — đây vừa là điểm mạnh (không phá code cũ) vừa là điểm yếu (không có runtime type info cho generic). Project Valhalla đang thử sửa: generics specialization cho primitive và value type.

11. Tóm tắt

  • Generics = class/method nhận kiểu làm tham số.
  • Trước Java 5: không generics → cast runtime, type-unsafe. Sau: compiler bắt kiểu tại compile time.
  • Cú pháp class: class Box<T> { T value; }. Cú pháp method: <T> T foo(T x).
  • Diamond <> (Java 7+): new Box<>() — compiler suy kiểu từ context.
  • Convention: T generic, E element, K key, V value, R return.
  • Bounded: T extends X — giới hạn kiểu, dùng method của X trong body.
  • Generic interface dùng khắp JDK (Comparator, Predicate, Function).
  • Raw type (List không khai kiểu) — legacy, compiler warning, tránh.
  • Generic chỉ cho reference type. Primitive phải dùng wrapper (auto-box cost).
  • Không được new T(), không được static field generic, không được generic exception.

12. Tự kiểm tra

Tự kiểm tra
Q1
Viết class Pair<A, B> có 2 field final + method swap() trả Pair<B, A>.
public class Pair<A, B> {
    private final A first;
    private final B second;

    public Pair(A first, B second) {
        this.first = first;
        this.second = second;
    }

    public A getFirst() { return first; }
    public B getSecond() { return second; }

    public Pair<B, A> swap() {
        return new Pair<>(second, first);
    }
}

// Su dung:
Pair<String, Integer> p = new Pair<>("alice", 30);
Pair<Integer, String> swapped = p.swap();

Điểm chú ý:

  • Type parameter order trong swap(): return Pair<B, A> — A và B đổi chỗ. Compiler kiểm tra type safety.
  • Diamond <>: new Pair<>(second, first) — compiler suy <B, A> từ return type.
  • Với Java 16+, có thể viết gọn hơn bằng record:
    public record Pair<A, B>(A first, B second) {
        public Pair<B, A> swap() { return new Pair<>(second, first); }
    }
Q2
Đoạn sau compile không? Vì sao?
public class Container<T> {
    public T createDefault() {
        return new T();
    }
}

Không compile. new T() không hợp lệ — Java không biết T có constructor nào (do type erasure, runtime T thành Object).

Workaround — pass factory qua argument:

public class Container<T> {
    private final Supplier<T> factory;
    public Container(Supplier<T> factory) { this.factory = factory; }
    public T createDefault() { return factory.get(); }
}

// Su dung:
Container<User> c = new Container<>(User::new);   // method reference
User u = c.createDefault();

Hoặc nhận Class<T> rồi dùng reflection — phức tạp hơn, dễ fail runtime, chỉ dùng khi bắt buộc.

Q3
Khi nào dùng bounded type parameter <T extends X>? Cho ví dụ.

Khi trong body cần gọi method của kiểu cụ thể mà T không đảm bảo có. Ví dụ:

// Khong bound -> khong goi duoc compareTo
public static <T> T max(List<T> list) {
    // list.stream().sorted(...) -> KHONG biet T co compareTo khong
}

// Bound <T extends Comparable<T>>
public static <T extends Comparable<T>> T max(List<T> list) {
    T maxVal = list.get(0);
    for (T item : list) {
        if (item.compareTo(maxVal) > 0) maxVal = item;
    }
    return maxVal;
}

max(List.of(3, 1, 4, 1, 5));   // OK — Integer implements Comparable<Integer>
max(List.of(new Object()));     // COMPILE ERROR — Object khong Comparable

Bound thu hẹp tập kiểu T xuống những kiểu "có compareTo" → compiler cho phép gọi.

Các case khác:

  • <T extends Number> — T phải là số (Integer, Double, ...). Có intValue(), doubleValue().
  • <T extends Closeable> — T có close().
  • <T extends Runnable & AutoCloseable> — T phải có cả 2 (multi-bound).
Q4
Vì sao List<int> không compile?

Java generics chỉ chấp nhận reference type, không chấp nhận primitive (int, double, boolean, v.v.).

Lý do: type erasure (bài 2) — runtime List<T> thành List (raw) với mọi element là Object. Primitive không phải Object, không wrap được trực tiếp.

Workaround: dùng wrapper class:

List<Integer> list = new ArrayList<>();
list.add(42);    // auto-box: int -> Integer
int x = list.get(0);   // auto-unbox: Integer -> int

Cost: mỗi Integer là object trên heap (~16 bytes overhead mỗi instance). Với 1M phần tử, List<Integer> tốn ~40MB, còn int[] chỉ ~4MB.

Cho hot path số học: dùng int[] / IntStream / LongStream / DoubleStream — primitive specialized, không auto-box.

Project Valhalla (đang phát triển) nhắm cho phép List<int> qua value type — hiện chưa production-ready.

Q5
Đoạn sau compile không?
public class Cache<T> {
    private static List<T> items = new ArrayList<>();
    public static void add(T item) { items.add(item); }
}

Không. Static member không thấy type parameter class.

Lý do: T là parameter của instance — mỗi new Cache() chỉ định T khác nhau (Cache<String>, Cache<Integer>). Static field thuộc class, không instance — không biết T nào để dùng.

Compile error: "non-static type variable T cannot be referenced from a static context".

Fix (chọn theo ý đồ):

  • Không cần generic, đổi static dùng Object: static List<Object> items — bạn mất type safety.
  • Static generic method với type parameter riêng: public static <U> void add(U item, List<U> list).
  • Bỏ static, biến thành instance: private List<T> items.
  • Nếu muốn một cache toàn cục theo kiểu: dùng Map<Class<?>, List<?>> (pattern heterogeneous map).

Bài tiếp theo: Type erasure — sự thật không dễ chịu của generics Java

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên