Có một câu hỏi mà mỗi lần xuất hiện trên diễn đàn Java là kéo theo ba trăm bình luận: Java truyền tham số theo giá trị hay theo tham chiếu?
Câu trả lời ngắn: luôn luôn theo giá trị. Không có ngoại lệ.
Câu trả lời dài cần bốn phép thử, vì lý do khiến người ta cãi nhau là ai cũng nhìn thấy một nửa sự thật.
Bốn phép thử
static void doiSo(int x) { x = 99; }
static void doiMang(int[] a) { a[0] = 99; }
static void ganLaiMang(int[] a) { a = new int[]{7, 7}; }
static void doiDanhSach(List<String> ds) { ds.add("thêm"); }
static void ganLaiDanhSach(List<String> ds) { ds = new ArrayList<>(List.of("mới")); }
Chạy thật:
số nguyên sau doiSo() : 1
mảng sau doiMang() : [99, 2]
mảng sau ganLaiMang() : [1, 2]
list sau doiDanhSach() : [gốc, thêm]
list sau ganLaiDanhSach() : [gốc]
Nhìn hai dòng giữa là hiểu ra vấn đề. Cùng một mảng, cùng truyền vào phương thức:
Sửa nội dung thì bên ngoài thấy. a[0] = 99 có tác dụng.
Gán lại biến thì bên ngoài không thấy. a = new int[]{7,7} chẳng ảnh hưởng gì.
Nếu Java truyền theo tham chiếu thật, phép gán lại cũng phải có tác dụng — như ref trong C# hay & trong C++. Nó không có tác dụng, nên đây không phải truyền theo tham chiếu.
Vậy chuyện gì thực sự xảy ra
Biến kiểu tham chiếu giữ một địa chỉ, không giữ đối tượng. Khi gọi phương thức, Java sao chép cái địa chỉ đó vào tham số.
Kết quả là bạn có hai biến — một ở ngoài, một ở trong — cùng trỏ tới một đối tượng:
biến ngoài ──┐
├──► [ đối tượng thật trên heap ]
tham số ──┘
Đi theo địa chỉ rồi sửa nội dung (a[0] = 99, ds.add(...)) thì cả hai cùng thấy, vì chỉ có một đối tượng.
Gán lại tham số (a = new int[]{7,7}) chỉ đổi chỗ mũi tên bên trong trỏ đi đâu. Mũi tên bên ngoài vẫn nguyên.
Nói gọn: cái được sao chép là địa chỉ, không phải đối tượng. Nhiều người gọi đây là "truyền tham chiếu theo giá trị" — mô tả đúng, chỉ là tên gọi dễ gây nhầm.
Hệ quả thực tế đáng nhớ hơn cả thuật ngữ: truyền một List vào phương thức là bạn đang cho phương thức đó quyền sửa danh sách của mình. Nếu không muốn thế thì truyền bản sao, hoặc truyền List.copyOf(ds) để bên nhận không sửa được.
Nạp chồng: trình biên dịch chọn phương thức nào
Khi có nhiều phương thức cùng tên, việc chọn diễn ra lúc biên dịch, dựa trên kiểu khai báo của đối số. Cho năm phiên bản:
static String nhan(int x) { return "int"; }
static String nhan(long x) { return "long"; }
static String nhan(Integer x) { return "Integer"; }
static String nhan(Object x) { return "Object"; }
static String nhan(int... x) { return "varargs"; }
Kết quả:
nhan(1) -> int
nhan(bt) -> int // bt là byte
nhan(1L) -> long
nhan((Integer)1) -> Integer
nhan("x") -> Object
Chú ý dòng thứ hai: truyền một byte, và nó chọn nhan(int).
Trình biên dịch làm ba lượt, dừng ngay khi tìm được ứng viên:
Lượt một — chỉ xét nới rộng kiểu nguyên thuỷ, không đóng hộp, không varargs. byte nới rộng được thành int nên nhan(int) thắng ở lượt này. nhan(Integer) và nhan(int...) còn chẳng được xét tới.
Lượt hai — cho phép đóng hộp. Nếu không có nhan(int), nhan(byte) sẽ phải đóng hộp và nhan(Integer) mới vào cuộc.
Lượt ba — cuối cùng mới xét varargs.
Quy tắc rút ra: varargs luôn là lựa chọn cuối cùng, và nới rộng luôn được ưu tiên hơn đóng hộp. Đây là lý do rất nhiều lỗi nạp chồng khó hiểu — mã gọi đúng cái bạn không nghĩ tới.
Một ví dụ kinh điển trong chính thư viện chuẩn:
List<Integer> ds = new ArrayList<>(List.of(10, 20, 30));
ds.remove(1); // xoá PHẦN TỬ THỨ 1, còn lại [10, 30]
List có remove(int index) và remove(Object o). Truyền 1 là số nguyên nên lượt một chọn remove(int) — xoá theo chỉ số. Muốn xoá giá trị 1 thì phải viết ds.remove(Integer.valueOf(1)).
Nạp chồng không xét kiểu trả về
static int lay() { ... }
static long lay() { ... } // lỗi biên dịch
Hai phương thức chỉ khác kiểu trả về thì không phải nạp chồng — chúng là hai định nghĩa trùng nhau. Lý do dễ thấy: khi bạn viết lay(); mà bỏ kết quả đi, trình biên dịch không có căn cứ nào để chọn.
Tên tham số cũng không tính. Chỉ kiểu và thứ tự của danh sách tham số mới tạo nên chữ ký.
Varargs
static int tong(int... so) {
int t = 0;
for (int s : so) t += s;
return t;
}
tong() = 0
tong(1,2,3) = 6
tong(mảng) = 9
Bên trong, so chính là một mảng. Gọi không tham số nào thì nhận mảng rỗng — không phải null, nên duyệt luôn an toàn. Và vì nó là mảng, truyền thẳng một mảng vào cũng được.
Ba luật khi dùng varargs:
Tham số varargs phải là tham số cuối cùng, và mỗi phương thức chỉ có một.
Tránh nạp chồng giữa varargs và phiên bản thường nếu không muốn đau đầu — quy tắc ba lượt ở trên khiến kết quả rất khó đoán với người đọc mã.
Cảnh giác khi trộn varargs với generics. static <T> void them(List<T> ds, T... phanTu) sẽ khiến javac cảnh báo "unchecked generic array creation", vì Java không tạo được mảng của kiểu tham số. Chú thích @SafeVarargs tắt cảnh báo đó, nhưng chỉ nên dùng khi bạn chắc chắn phương thức không ghi gì vào mảng ấy.
Vài thói quen về chữ ký phương thức
Đừng quá bốn tham số. Nhiều hơn thì người gọi phải đếm vị trí, và hoán đổi nhầm hai tham số cùng kiểu là lỗi trình biên dịch không bắt được. Gom lại thành một record vừa rõ nghĩa vừa an toàn:
// khó đọc ở chỗ gọi: taoDon("A", "B", true, false, 3)
void taoDon(String khach, String diaChi, boolean gapGap, boolean xuatHoaDon, int soLuong)
// rõ ràng hơn hẳn
record YeuCauDatHang(String khach, String diaChi, boolean gapGap,
boolean xuatHoaDon, int soLuong) {}
void taoDon(YeuCauDatHang yeuCau)
Tham số boolean ở chỗ gọi là mã khó đọc. xuLy(dulieu, true) — true nghĩa là gì? Dùng enum thì chỗ gọi tự giải thích: xuLy(dulieu, CheDo.NGHIEM_NGAT).
Đừng trả về null cho danh sách. Trả danh sách rỗng, để bên gọi không phải kiểm tra null trước mỗi vòng lặp. Với một giá trị đơn có thể vắng mặt thì Optional là lựa chọn đúng — chuyện của phần sau.
Thử lại cho chắc
Viết một phương thức nhận StringBuilder, gọi .append("x") bên trong, rồi in ra ở ngoài. Sau đó đổi thành gán lại sb = new StringBuilder("y") và in lại.
Hai kết quả khác nhau, và giờ bạn giải thích được vì sao chỉ bằng một câu: cái được sao chép là địa chỉ, không phải đối tượng.
Ngày mai ta khép lại chặng nền tảng bằng chuyện tổ chức mã — package, import, classpath — và vì sao ClassNotFoundException với NoClassDefFoundError là hai lỗi hoàn toàn khác nhau.