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.

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ịchBản chất varargs là cú pháp đường trên mảng (Ngày 8): bên trong method, tham số
int... sochính làint[]— gọi với 0 phần tử cho mảng rỗng chứ không phảinull, nên vòngforchạ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 callOverloading đã 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.
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
fibngâ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ọiin(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ớiStringBuilder.reverse(Ngày 21) và so độ dài code.Chạy
giaiThua(100_000)để tự gặpStackOverflowError— đọ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.time — LocalDate, 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!
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.


