Backend

99 Ngày Java — Ngày 25: Varargs & đệ quy

SSite Admin
21 tháng 08, 2026 8 phút đọc 0 lượt xem
99 Ngày Java — Ngày 25: Varargs & đệ quy

Ngày 24 khép lại phần contract của Object — hôm nay ta đổi nhịp, quay về kỹ thuật viết method với hai công cụ linh hoạt: varargs cho phép method nhận số lượng tham số biến thiên, và đệ quy — nghệ thuật để method tự gọi chính mình. Nghe không liên quan, nhưng cả hai chung một chủ đề: cách một lời gọi method thực sự diễn ra — compiler chọn bản overload nào, và mỗi lời gọi tốn gì trên stack. Bài hôm nay đi qua quy tắc varargs, thứ tự overload resolution, cấu trúc chuẩn của đệ quy và ranh giới StackOverflowError.

Sketchnote Ngày 25: Varargs và đệ quy — quy tắc tham số biến thiên, thứ tự overload resolution, cấu trúc base case + bước đệ quy, StackOverflowError và khi nào chọn vòng lặp

Varargs — tham số biến thiên

// Varargs — dấu ... biến tham số cuối thành "0 hoặc nhiều":
static int tong(int... so) {           // bên trong method, "so" là int[]
    int ketQua = 0;
    for (int s : so) ketQua += s;
    return ketQua;
}

tong();                 // hợp lệ — "so" là mảng RỖNG, không phải null
tong(1, 2, 3);          // compiler tự gói thành new int[]{1, 2, 3}
tong(new int[]{4, 5});  // truyền thẳng mảng cũng được — không gói lại

// Hai quy tắc cứng:
//   1. varargs phải là tham số CUỐI CÙNG của danh sách
//   2. mỗi method chỉ được MỘT varargs
static String noi(String phanCach, String... tu) { /* ... */ }  // ✓ chuẩn
// static void sai(int... a, String b) {}   // ✗ không biên dịch
  • Bản chất varargs là cú pháp đường trên mảng (Ngày 8): bên trong method, tham số int... so chính là int[] — gọi với 0 phần tử cho mảng rỗng chứ không phải null, nên vòng for chạy an toàn không cần kiểm tra.

  • Bạn đã dùng varargs từ lâu mà có thể chưa để ý: List.of(1, 2, 3), String.format, Objects.hash (vừa gặp Ngày 24) — API chuẩn dùng varargs ở mọi nơi cần "bao nhiêu cũng được".

  • Vị trí cuối cùng không phải quy ước tùy hứng: compiler cần các tham số đứng trước để neo — phần còn lại bao nhiêu cũng gói vào mảng; hai varargs hoặc varargs đứng giữa sẽ làm phép chia đó nhập nhằng.

Overload resolution — compiler chọn bản nào?

static void in(int x)     { System.out.println("int"); }
static void in(long x)    { System.out.println("long"); }
static void in(Integer x) { System.out.println("Integer"); }
static void in(int... x)  { System.out.println("varargs"); }

in(7);   // in ra "int"

// Compiler chọn bản overload theo THỨ TỰ ưu tiên — varargs luôn xếp CUỐI:
//   1. khớp chính xác          → in(int)       ← thắng
//   2. widening (int → long)   → in(long)
//   3. boxing (int → Integer)  → in(Integer)
//   4. varargs                 → in(int...)
// Xóa dần từng bản từ trên xuống để thấy từng tầng nhường chỗ.

// Hai bản varargs cùng "vừa" là lỗi biên dịch — mơ hồ, không đoán:
static void m(int... a)  {}
static void m(long... a) {}
// m(1);   // ✗ ambiguous method call
  • Overloading đã gặp từ Ngày 10 — hôm nay thêm mảnh cuối: khi nhiều bản cùng "vừa", compiler ưu tiên khớp chính xác, rồi widening (Ngày 5), rồi boxing (Ngày 22), và varargs luôn đứng cuối hàng — nó là phương án vét, chỉ dùng khi không còn bản nào khớp.

  • Thứ tự này giữ tương thích ngược: code viết trước khi Java 5 sinh ra boxing và varargs vẫn gọi đúng bản method cũ — một quyết định thiết kế ngôn ngữ, không phải chi tiết ngẫu nhiên.

  • Trường hợp mơ hồ compiler từ chối biên dịch thay vì đoán — cùng triết lý fail-fast đã gặp nhiều lần: lỗi lộ càng sớm càng rẻ; gặp lỗi ambiguous, cách xử lý gọn nhất là ép kiểu tường minh tại chỗ gọi.

Đệ quy — điều kiện dừng là mạng sống

// Đệ quy = method tự gọi chính mình — luôn phải đủ HAI phần:
static long giaiThua(int n) {
    if (n <= 1) return 1;            // 1. điều kiện dừng (base case)
    return n * giaiThua(n - 1);      // 2. bước đệ quy — bài toán NHỎ HƠN
}

giaiThua(5);
// 5 * giaiThua(4)
//     4 * giaiThua(3)
//         3 * giaiThua(2)
//             2 * giaiThua(1)   ← chạm đáy — bắt đầu "gấp" ngược lên
// = 120

// Mỗi lời gọi là một STACK FRAME nằm chờ kết quả của lời gọi bên trong —
// chuỗi lời gọi càng sâu, stack càng cao (Ngày 11 đã gặp stack và heap)
  • Mọi hàm đệ quy đúng đều đủ hai phần: base case trả kết quả ngay không gọi tiếp, và bước đệ quy thu nhỏ bài toán tiến về base case — thiếu một trong hai, chuỗi lời gọi không bao giờ chạm đáy.

  • Hình dung đúng nhất là chồng thư chờ: giaiThua(5) chưa thể trả lời khi chưa có giaiThua(4) — mỗi tầng nằm nguyên trên stack giữ chỗ, đến khi base case trả 1 thì cả chồng "gấp" ngược lên từng tầng một.

  • Tư duy đệ quy: tin lời gọi nhỏ hơn đã đúng — chỉ cần lo hai việc là base case chính xác và bước thu nhỏ hợp lệ, không cần lần theo cả chuỗi trong đầu; đây chính là quy nạp toán học mặc áo code.

Giếng cầu thang nhìn dọc trục — các tầng lặp lại cùng một hình dạng và nhỏ dần về tâm: mỗi tầng là một stack frame của đệ quy, đáy giếng là base case, và đường quay lên đi qua đúng từng tầng đã xuống

StackOverflowError — và khi nào nên dùng vòng lặp

// Thiếu điều kiện dừng — hoặc bước đệ quy không NHỎ dần:
static long treo(int n) { return n * treo(n - 1); }   // không có đáy
treo(5);   // StackOverflowError — stack đầy sau ~10.000 frame

// Đệ quy "ngây thơ" đúng nhưng đắt: fib gọi lại CÙNG bài toán hàng triệu lần
static long fib(int n) {
    if (n <= 1) return n;
    return fib(n - 1) + fib(n - 2);   // fib(50) → hơn 40 TỶ lời gọi
}

// Vòng lặp làm cùng việc: O(n) thời gian, O(1) bộ nhớ — không đụng trần stack
static long fibLap(int n) {
    long truoc = 0, hienTai = 1;
    for (int i = 0; i < n; i++) {
        long tiep = truoc + hienTai;
        truoc = hienTai;
        hienTai = tiep;
    }
    return truoc;
}
// → đệ quy tỏa sáng với cấu trúc LỒNG NHAU: cây thư mục, JSON, chia để trị
  • Stack của mỗi thread có hạn (thường ~10.000 frame): đệ quy không đáy chắc chắn tràn, nhưng đệ quy đúng mà dữ liệu quá sâu cũng tràn — JVM không có tail-call optimization như vài ngôn ngữ hàm, nên độ sâu luôn là chi phí thật.

  • Bài toán tuyến tính (tính tổng, duyệt danh sách, Fibonacci) hợp với vòng lặp: nhanh hơn, không giới hạn độ sâu; đệ quy dành cho cấu trúc tự lồng chính nó — cây thư mục, JSON, cây phân loại — nơi vòng lặp phải tự quản lý stack thủ công còn đệ quy diễn đạt tự nhiên.

  • Bản fib ngây thơ chậm không phải vì đệ quy mà vì tính lặp lại: cùng bài toán con bị giải hàng triệu lần — ghi nhớ kết quả (memoization) đưa về O(n); ý tưởng "đổi bộ nhớ lấy thời gian" này sẽ quay lại khi học Map và cache.

Bài tập nhỏ

  • Viết int max(int dauTien, int... conLai) trả về số lớn nhất — vì sao tham số đầu tách riêng thay vì max(int... tatCa)? (Gợi ý: gọi với 0 tham số thì trả về gì?)

  • Chép 4 bản overload in(...) ở trên, gọi in(7) rồi xóa dần từng bản từ trên xuống — ghi lại thứ tự compiler nhường chỗ và đối chiếu với 4 tầng ưu tiên.

  • Viết đệ quy daoNguoc(String s) đảo ngược chuỗi — chỉ rõ base case và bước thu nhỏ; sau đó viết bản vòng lặp với StringBuilder.reverse (Ngày 21) và so độ dài code.

  • Chạy giaiThua(100_000) để tự gặp StackOverflowError — đọc stack trace và đếm xem bao nhiêu dòng giống hệt nhau; rồi viết bản vòng lặp chạy êm.

Kết luận

Hai công cụ hôm nay gói trong bốn ý: varargs là mảng mặc cú pháp gọn với hai quy tắc cứng — đứng cuối và chỉ một; overload resolution đi theo bậc thang chính xác → widening → boxing → varargs và từ chối biên dịch khi mơ hồ; đệ quy sống được nhờ base case + bước thu nhỏ, mỗi lời gọi là một stack frame có giá; và lựa chọn giữa đệ quy với vòng lặp đi theo hình dạng dữ liệu — tuyến tính thì lặp, lồng nhau thì đệ quy. Ngày 26 ta bước vào một mảng API thực dụng bậc nhất: Date/Time API của java.timeLocalDate, Instant, múi giờ và những bài học xương máu về thời gian. 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 25: Quan hệ 1-1 & 1-N

Ngày 25 của 99 Ngày Spring: ánh xạ quan hệ với @ManyToOne giữ khóa ngoại ở phía nhiều (và vì sao phải LAZY từ đầu), @OneToMany với mappedBy làm tấm gương đọc, owning side — phía duy nhất JPA lắng nghe khi ghi, cùng cascade và orphanRemoval cho quan hệ sở hữu trọn vẹn.

21 thg 8, 20267 phút0
99 Ngày Java — Ngày 24: equals, hashCode & toString

Ngày 24 của 99 Ngày Java: bộ ba contract của Object — equals chuyển "bằng nhau" từ tham chiếu sang định danh nghiệp vụ với contract 5 điều, quy tắc vàng equals đi cùng hashCode, hai bước tra cứu hash-rồi-equals của HashMap cùng bẫy key bị sửa, và Objects utility + record giúp cài chuẩn ít dòng nhất.

20 thg 8, 20267 phút10
99 Ngày Spring — Ngày 24: Query methods & @Query

Ngày 24 của 99 Ngày Spring: vượt ranh giới derived query với @Query — JPQL truy vấn trên entity với lưới an toàn fail-fast lúc khởi động, bind tham số theo tên làm hàng rào chống SQL injection, native query cho tính năng riêng của DB kèm đánh đổi, và @Modifying cho bulk update đi thẳng xuống DB.

20 thg 8, 20266 phút8