Parameter của một method là các variable được đặt tên trong phần khai báo. Argument là các giá trị bạn đưa vào tại call site. Phần nằm giữa hai thứ đó — cái gì được copy, caller còn nhìn thấy được gì sau khi method chạy xong, và method nào thật sự được gọi khi nhiều method trùng tên — chính là nơi tập trung gần hết những bất ngờ của Java.
Bài này đi hết chuyện truyền parameter, bao gồm cả điều mà phần lớn tài liệu nói sai: Java luôn luôn là pass-by-value, kể cả với object. Sau đó là giá trị trả về, final parameter, quy tắc overloading, thuật toán bốn phase mà compiler dùng để chọn overload, và varargs. Mọi dòng output và mọi error message bên dưới đều lấy từ lần compile và chạy thật trên OpenJDK 21.0.6.
![]()
Hai quy tắc gánh cả bài: argument luôn được copy, và overload được chọn ngay lúc compile.
Parameter và argument
Hai từ này không thay thế cho nhau được, và dùng chúng chính xác sẽ làm phần còn lại của bài dễ diễn đạt hơn nhiều.
public class ParamVsArg {
// vvvvvvvvvvvvvvvvvvvvvv parameters: names in the declaration
static int discount(int price, int percent) {
return price - price * percent / 100;
}
public static void main(String[] args) {
int total = 200;
// vvvvvvvvv arguments: values at the call site
System.out.println(discount(total, 15));
System.out.println(discount(99, 10));
}
}
170
90
| Từ | Nằm ở đâu | Là cái gì |
|---|---|---|
| Parameter | phần khai báo method | một tên variable kèm type, được tạo mới ở mỗi lần gọi |
| Argument | call site | một expression, được tính trước khi gọi, giá trị của nó được copy vào parameter |
price và percent là parameter. total, 15, 99 và 10 là argument. Để ý rằng total không được truyền vào discount — thứ được truyền vào là giá trị của total. Khác biệt đó chính là toàn bộ phần tiếp theo.
Java luôn luôn là pass-by-value

Mỗi argument được tính ra giá trị, rồi giá trị đó được copy vào parameter — một variable hoàn toàn mới thuộc về method. Vì vậy gán vào parameter chỉ đổi được bản copy. Java không có ngoại lệ nào cho quy tắc này: không có keyword ref, không có &, không có cách nào đưa cho method chính bản thân variable của caller.
Argument primitive: vì sao swap không hoạt động
Với primitive, giá trị là một con số, nên con số được copy.
public class SwapPrimitive {
static void swap(int a, int b) {
int tmp = a;
a = b;
b = tmp;
System.out.println("inside swap: a = " + a + ", b = " + b);
}
public static void main(String[] args) {
int x = 1;
int y = 2;
System.out.println("before swap: x = " + x + ", y = " + y);
swap(x, y);
System.out.println("after swap: x = " + x + ", y = " + y);
}
}
before swap: x = 1, y = 2
inside swap: a = 2, b = 1
after swap: x = 1, y = 2
Việc hoán đổi có xảy ra thật — nhưng chỉ bên trong swap. a và b là bản copy của 1 và 2, đổi chỗ hai bản copy thì bản gốc không hề nhúc nhích. Trong Java bạn không thể viết được một method swap cho primitive; hoặc là trả về cặp giá trị, hoặc là hoán đổi ngay tại call site.
Argument object: mutate thì thấy, reassign thì không
Với reference type, thứ nằm trong variable là một reference, nên reference đó được copy. Variable của caller và parameter cùng trỏ tới một object trên heap, y hệt như String alias = name; tạo ra hai cái tên cho một object trong bài về variable và data type. Từ đó sinh ra hai kết cục khác nhau, tuỳ vào việc method làm gì.
import java.util.Arrays;
public class ArrayParam {
static void mutate(int[] data) {
data[0] = 99;
System.out.println("inside mutate: data = " + Arrays.toString(data));
}
static void reassign(int[] data) {
data = new int[] {7, 8, 9};
System.out.println("inside reassign: data = " + Arrays.toString(data));
}
public static void main(String[] args) {
int[] nums = {1, 2, 3};
System.out.println("before mutate: nums = " + Arrays.toString(nums));
mutate(nums);
System.out.println("after mutate: nums = " + Arrays.toString(nums));
System.out.println();
System.out.println("before reassign: nums = " + Arrays.toString(nums));
reassign(nums);
System.out.println("after reassign: nums = " + Arrays.toString(nums));
}
}
before mutate: nums = [1, 2, 3]
inside mutate: data = [99, 2, 3]
after mutate: nums = [99, 2, 3]
before reassign: nums = [99, 2, 3]
inside reassign: data = [7, 8, 9]
after reassign: nums = [99, 2, 3]
mutate không hề đụng vào parameter của nó. Nó đi theo reference đã được copy tới object rồi ghi vào chính object đó, mà object thì chỉ có một, nên caller nhìn thấy [99, 2, 3]. Còn reassign ghi thẳng vào parameter, tức là trỏ bản copy sang một array mới, và nums vẫn nằm nguyên chỗ cũ.
Gói lại trong một câu: bạn đổi được chỗ mà reference trỏ tới từ bên trong method, nhưng chỉ đổi được cho bản copy của riêng mình.
Phép thử một dòng chốt lại vấn đề
Nếu Java là pass-by-reference thì swap phải chạy được với object. Nó không chạy được.
public class SwapObjects {
static void swap(StringBuilder a, StringBuilder b) {
StringBuilder tmp = a;
a = b;
b = tmp;
System.out.println("inside swap: a = " + a + ", b = " + b);
}
public static void main(String[] args) {
StringBuilder x = new StringBuilder("first");
StringBuilder y = new StringBuilder("second");
swap(x, y);
System.out.println("after swap: x = " + x + ", y = " + y);
}
}
inside swap: a = second, b = first
after swap: x = first, y = second
Giống hệt trường hợp primitive. Ở một ngôn ngữ pass-by-reference thật sự, chẳng hạn C# với ref, hai variable của caller sẽ đổi chỗ cho nhau. Trong Java thì không, vì thứ được copy là reference chứ không phải variable. Cách phát biểu chính xác là: Java truyền reference theo kiểu by value. Nó không phải pass-by-reference.
Vì sao String trông giống một phản ví dụ
Một parameter kiểu String trông như đang chứng minh object được truyền by reference, rồi lại bí ẩn không chịu thay đổi.
public class StringParam {
static void shout(String text) {
text = text.toUpperCase();
System.out.println("inside shout: text = " + text);
}
static void append(StringBuilder sb) {
sb.append(" world");
System.out.println("inside append: sb = " + sb);
}
public static void main(String[] args) {
String s = "hello";
shout(s);
System.out.println("after shout: s = " + s);
StringBuilder b = new StringBuilder("hello");
append(b);
System.out.println("after append: b = " + b);
}
}
inside shout: text = HELLO
after shout: s = hello
inside append: sb = hello world
after append: b = hello world
String là immutable, đúng như bài về String đã nói, nên toUpperCase() không sửa được gì cả — nó trả về một object mới, và phép gán trỏ parameter sang object mới đó, tức là đúng trường hợp reassign ở trên. StringBuilder thì mutable, nên append sửa trực tiếp object dùng chung và thay đổi hiện ra bên ngoài. Quy tắc không hề đổi; chỉ có việc method đó có sửa được object hay không là đổi.
Giá trị trả về: một giá trị, hoặc không có
Một method trong Java trả về đúng một giá trị, hoặc không trả về gì. return expr; đưa giá trị ra ngoài và kết thúc method; void khai báo rằng không có giá trị nào, và bạn vẫn dùng được return; trơ trọi để thoát method sớm. Mọi thứ khác đều là cách đi vòng.
Trả về nhiều hơn một giá trị
Có ba cách lấy hai giá trị ra khỏi một method, và chỉ hai trong số đó là tốt.
import java.util.Arrays;
public class ReturnValues {
// two values, as an array: works, but both must share a type
static int[] minMaxArray(int[] data) {
int min = data[0], max = data[0];
for (int n : data) {
if (n < min) min = n;
if (n > max) max = n;
}
return new int[] {min, max};
}
// two values, as an object: named, typed, self-documenting
record MinMax(int min, int max) {}
static MinMax minMax(int[] data) {
int[] pair = minMaxArray(data);
return new MinMax(pair[0], pair[1]);
}
// two values, by mutating a caller-supplied array: works, reads badly
static void minMaxInto(int[] data, int[] out) {
int[] pair = minMaxArray(data);
out[0] = pair[0];
out[1] = pair[1];
}
public static void main(String[] args) {
int[] data = {4, 9, 1, 7};
System.out.println("array -> " + Arrays.toString(minMaxArray(data)));
MinMax m = minMax(data);
System.out.println("record -> " + m + " min=" + m.min() + " max=" + m.max());
int[] out = new int[2];
minMaxInto(data, out);
System.out.println("out-param -> " + Arrays.toString(out));
}
}
array -> [1, 9]
record -> MinMax[min=1, max=9] min=1 max=9
out-param -> [1, 9]
| Cách làm | Dùng khi nào | Vì sao |
|---|---|---|
Trả về một object hoặc record | gần như luôn luôn | các giá trị có tên riêng và type riêng, bản thân return type đã tự mô tả |
| Trả về một array | các giá trị cùng type và thứ tự là hiển nhiên | rẻ, nhưng result[0] chẳng nói gì cho người đọc |
| Sửa một argument do caller đưa vào | gần như không bao giờ | nó chỉ chạy được nhờ reference được copy, và signature giấu mất việc method ghi vào đó |
Cách thứ ba là kiểu "out parameter" mượn từ C. Nó chạy được — đó chính xác là điều phần trước vừa chứng minh — nhưng người đọc minMaxInto(data, out) không thể biết argument nào là input nếu không mở method ra xem. Class và record có bài riêng ở phần sau của series; từ giờ tới đó, một record nhỏ là câu trả lời đúng mỗi khi hai giá trị đi liền với nhau.
Return sớm và return từ trong loop
return kết thúc method ngay lập tức, từ bất cứ chỗ nào, kể cả từ bên trong một loop.
public class EarlyReturn {
static String classify(int n) {
if (n < 0) {
return "negative";
}
if (n == 0) {
return "zero";
}
return "positive";
}
static int indexOf(int[] data, int target) {
for (int i = 0; i < data.length; i++) {
if (data[i] == target) {
return i; // leaves the loop AND the method
}
}
return -1; // only reached if the loop finished
}
static void warn(int n) {
if (n >= 0) {
return; // a bare return in a void method
}
System.out.println("negative input: " + n);
}
public static void main(String[] args) {
System.out.println(classify(-5) + " " + classify(0) + " " + classify(5));
int[] data = {4, 9, 1, 7};
System.out.println("indexOf(9) = " + indexOf(data, 9));
System.out.println("indexOf(42) = " + indexOf(data, 42));
warn(3);
warn(-3);
}
}
negative zero positive
indexOf(9) = 1
indexOf(42) = -1
negative input: -3
Return sớm thay thế cho cái kim tự tháp if/else lồng nhau mà cùng logic đó sẽ phải viết, còn return từ trong loop là cách viết tìm kiếm bình thường — không cần biến cờ, không cần break rồi kiểm tra thêm lần nữa.
Compiler bắt buộc mọi đường đi ra khỏi một method khác void đều phải trả về thứ gì đó. Quên một nhánh thì message chỉ vào dấu ngoặc đóng chứ không chỉ vào câu lệnh còn thiếu:
static int abs(int n) {
if (n < 0) {
return -n;
}
}
MissingReturn.java:6: error: missing return statement
}
^
1 error
final parameter ngăn được điều gì
Đánh dấu final cho một parameter là cấm gán lại vào nó. Nó hoàn toàn không nói gì về object mà nó đang trỏ tới.
import java.util.Arrays;
public class FinalParam {
static void bump(final int[] data) {
data[0] = 42; // mutation: allowed
System.out.println("inside bump: " + Arrays.toString(data));
}
static void reassign(final int[] data) {
data = new int[] {0}; // reassignment: rejected
}
...
}
FinalParam.java:10: error: final parameter data may not be assigned
data = new int[] {0}; // reassignment: rejected
^
1 error
data[0] = 42 compile và chạy bình thường; data = ... thì không. Đây đúng là khác biệt của final List<String> names trong bài về variable: cái bị đóng băng là liên kết tên–object, không phải object.
final cũng không thuộc signature, nên không dùng nó để phân biệt hai overload được:
public class FinalOverload {
static void f(int n) { }
static void f(final int n) { }
}
FinalOverload.java:3: error: method f(int) is already defined in class FinalOverload
static void f(final int n) { }
^
1 error
Hãy dùng final cho parameter khi một method dài có thể khiến người đọc phân vân không biết giá trị còn đúng như signature nói hay không. Đừng dùng nó như một lời khẳng định về tính immutable, vì nó không phải vậy.
Overloading: cùng tên, khác parameter list
Hai method trong cùng một class được phép trùng tên miễn là parameter list của chúng khác nhau. Khác type, khác số lượng, hoặc cùng type nhưng khác thứ tự đều tính.
public class OverloadShapes {
// different count
static String join(String a) { return a; }
static String join(String a, String b) { return a + "-" + b; }
// different types
static String describe(int n) { return "int " + n; }
static String describe(double d) { return "double " + d; }
static String describe(String s) { return "String " + s; }
// different ORDER of the same types
static String pair(int n, String s) { return "(int, String)"; }
static String pair(String s, int n) { return "(String, int)"; }
public static void main(String[] args) {
System.out.println(join("a"));
System.out.println(join("a", "b"));
System.out.println(describe(7));
System.out.println(describe(7.0));
System.out.println(describe("7"));
System.out.println(pair(1, "x"));
System.out.println(pair("x", 1));
}
}
a
a-b
int 7
double 7.0
String 7
(int, String)
(String, int)
| Khác biệt | Có tính là overload không? |
|---|---|
| Type của parameter | có |
| Số lượng parameter | có |
| Thứ tự các type | có |
| Chỉ khác return type | không |
| Chỉ khác tên parameter | không |
Có final trên parameter | không |
Return type không nằm trong những thứ phân biệt hai method, vì compiler phải chọn method trước khi biết bạn định làm gì với kết quả. Hai method chỉ khác nhau ở return type sẽ đụng nhau:
public class ReturnTypeOnly {
static int f(int n) {
return n;
}
static String f(int n) {
return String.valueOf(n);
}
...
}
ReturnTypeOnly.java:6: error: method f(int) is already defined in class ReturnTypeOnly
static String f(int n) {
^
1 error
Đổi tên parameter cũng không giải quyết được gì — log(String message) và log(String text) cho ra đúng lỗi already defined đó. Thứ compiler đem ra so sánh là danh sách có thứ tự các type của parameter.
⚠️ Overloading không phải overriding. Overriding thay thế một method của superclass bằng method cùng signature ở subclass và được quyết định lúc runtime; overloading chọn giữa các signature khác nhau và được quyết định lúc compile. Overriding cần tới inheritance nên nó có bài riêng ở phần sau của series.
Compiler chọn overload như thế nào

Hai điều quyết định tất cả ở đây. Lựa chọn được thực hiện lúc compile, và nó dựa trên static type của các argument — type viết trong source, không phải class thật của object lúc chạy.
public class StaticType {
static void f(Object o) { System.out.println("f(Object) ran"); }
static void f(String s) { System.out.println("f(String) ran"); }
public static void main(String[] args) {
String s = "hello";
Object o = s; // same object, wider static type
f(s);
f(o);
System.out.println("s == o -> " + (s == o));
}
}
f(String) ran
f(Object) ran
s == o -> true
Một object, hai lần gọi, hai method khác nhau. s == o là true nên giá trị lúc runtime giống hệt nhau; chỉ có type khai báo của variable là khác, và compiler chỉ nhìn vào type khai báo.
Bản thân việc chọn thì chạy theo từng phase. Compiler gom các method áp dụng được theo quy tắc của một phase; nếu có method nào áp dụng được thì một trong số đó thắng và việc tìm kiếm dừng lại. Chỉ khi không có method nào áp dụng được nó mới đi tiếp. Nó không bao giờ quay lại phase trước, và đó là lý do một phase sau không bao giờ thắng được phase trước, dù trông có vẻ khớp hơn đến đâu.
Bốn bậc thang phía trên là dạng thực dụng của quy tắc đó, và chúng cho ra kết quả đúng với mọi lời gọi trong bài này. Bản specification phát biểu hơi khác, và khác biệt đó đáng biết. JLS định nghĩa ba phase: strict invocation, cho phép identity conversion, widening primitive conversion và widening reference conversion; loose invocation, thêm boxing và unboxing; và variable arity invocation, tới đây mới nhận varargs. Khi nhiều method cùng áp dụng được trong một phase thì cái cụ thể nhất thắng — cái mà parameter type của nó truyền được vào tất cả những cái còn lại. Tách phase đầu thành "exact match" và "widening" chính là làm hiện ra quy tắc cụ thể nhất đó, nên hai cách mô tả không bao giờ cho kết quả khác nhau.
Phase 1: exact match thắng
Một exact match không cần conversion nào, nên không gì thắng được nó.
public class Phase1 {
static void f(int n) { System.out.println("f(int) ran with " + n); }
static void f(long n) { System.out.println("f(long) ran with " + n); }
public static void main(String[] args) {
int value = 5;
f(value);
}
}
f(int) ran with 5
f(long) cũng áp dụng được — int sang long là widening conversion mà phase đầu cho phép — nhưng f(int) không cần conversion nào nên nó cụ thể hơn và nhận lời gọi. Quy tắc đó cũng giải quyết reference type: khai báo f(String) cạnh f(Object) thì f("hi") in ra f(String) ran.
Phase 2: widening thắng boxing
Đây là chỗ bẫy tất cả mọi người. Bỏ f(int) đi và đặt một widening conversion cạnh một boxing conversion.
public class Phase2 {
static void f(long n) { System.out.println("f(long) ran with " + n); }
static void f(Integer n) { System.out.println("f(Integer) ran with " + n); }
public static void main(String[] args) {
int value = 5;
f(value);
}
}
f(long) ran with 5
f(Integer) trông sát hơn hẳn — giá trị là int mà Integer chính là wrapper của int — nhưng widening primitive conversion được dùng trước khi boxing kịp được xét tới. f(long) áp dụng được mà không cần box gì cả, nên việc tìm kiếm dừng lại và f(Integer) không bao giờ trở thành ứng viên. Thứ tự này tồn tại vì lý do tương thích ngược: autoboxing xuất hiện từ Java 5, và code compile trước đó phải tiếp tục gọi đúng những method cũ. Cũng chính lý lẽ đó khiến f(long) thắng f(Object) với một argument int, điều rất dễ đoán nhầm theo chiều ngược lại.
Khi cả hai đều là widening thì parameter type cụ thể hơn sẽ thắng:
public class TwoWidenings {
static void f(long n) { System.out.println("f(long) ran"); }
static void f(float n) { System.out.println("f(float) ran"); }
...
}
f(long) ran
long widen được sang float, nên f(long) là cái cụ thể hơn trong hai cái.
Phase 2 còn giải thích một lỗi trông rất vô lý. Widening và boxing không ghép được với nhau theo bất kỳ thứ tự nào:
public class NoWidenThenBox {
static void f(Long n) { System.out.println("f(Long) ran"); }
public static void main(String[] args) {
int value = 5;
f(value);
}
}
NoWidenThenBox.java:6: error: incompatible types: int cannot be converted to Long
f(value);
^
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
1 error
int widen được sang long và long box được thành Long, nhưng không phase nào làm cả hai việc, nên không có method nào áp dụng được. Đây cũng chính là quy tắc khiến Long x = 5; bị từ chối trong một phép gán bình thường.
Phase 3: boxing thắng varargs
Phase 3 cho phép autoboxing và unboxing. Varargs vẫn chưa được đưa lên bàn.
public class Phase3 {
static void f(Integer n) { System.out.println("f(Integer) ran with " + n); }
static void f(int... n) { System.out.println("f(int...) ran with " + n.length + " arg(s)"); }
public static void main(String[] args) {
int value = 5;
f(value);
}
}
f(Integer) ran with 5
Chỉ khi không method cố định số argument nào áp dụng được ở bất kỳ phase trước đó thì compiler mới xét tới số argument thay đổi:
public class Phase4 {
static void f(int... n) { System.out.println("f(int...) ran with " + n.length + " arg(s)"); }
public static void main(String[] args) {
int value = 5;
f(value);
}
}
f(int...) ran with 1 arg(s)
Đặt cả bốn vào một class thì cái thang hiện ra chỉ trong một lần chạy — cùng lời gọi f(5) đó rơi vào f(int), còn ba overload kia nằm im.
null, type cụ thể nhất và trường hợp ambiguous
null gán được cho mọi reference type, nên mọi overload nhận reference đều áp dụng được. Java xử lý bằng cách chọn cái cụ thể nhất: type mà có thể truyền vào tất cả những cái còn lại.
public class NullMostSpecific {
static void f(Object o) { System.out.println("f(Object) ran"); }
static void f(String s) { System.out.println("f(String) ran"); }
public static void main(String[] args) {
f(null);
}
}
f(String) ran
Mọi String đều là Object, nên String cụ thể hơn và thắng. Thêm một overload thứ ba có type không họ hàng gì với String thì không còn ứng viên nào cụ thể nhất nữa:
public class Amb {
static void f(Object o) { System.out.println("f(Object) ran"); }
static void f(String s) { System.out.println("f(String) ran"); }
static void f(Integer i) { System.out.println("f(Integer) ran"); }
public static void main(String[] args) {
f(null);
}
}
Amb.java:7: error: reference to f is ambiguous
f(null);
^
both method f(String) in Amb and method f(Integer) in Amb match
1 error
String không phải subtype của Integer và ngược lại, nên không cái nào cụ thể hơn, và compiler từ chối đoán. Cách sửa là ép kiểu ngay tại call site: f((String) null) compile được và in ra f(String) ran.
Ambiguous không chỉ xảy ra với null. Hai overload mà mỗi cái cần boxing ở một vị trí khác nhau cũng cho ra đúng lỗi đó:
public class BoxAmbiguous {
static void f(int a, Integer b) { System.out.println("f(int, Integer)"); }
static void f(Integer a, int b) { System.out.println("f(Integer, int)"); }
public static void main(String[] args) {
f(1, 2);
}
}
BoxAmbiguous.java:6: error: reference to f is ambiguous
f(1, 2);
^
both method f(int,Integer) in BoxAmbiguous and method f(Integer,int) in BoxAmbiguous match
1 error
Varargs: int... nums là một array

Một parameter khai báo int... nums nhận số lượng argument int tuỳ ý, kể cả không có cái nào. Bên trong method nó là một int[] bình thường — compiler dựng array đó ngay tại call site rồi truyền vào.
import java.util.Arrays;
public class Varargs {
static int sum(int... nums) {
System.out.println(" received " + nums.getClass().getSimpleName()
+ ", length " + nums.length + ", " + Arrays.toString(nums));
int total = 0;
for (int n : nums) {
total += n;
}
return total;
}
public static void main(String[] args) {
System.out.println("sum(1, 2, 3) = " + sum(1, 2, 3));
System.out.println("sum() = " + sum());
int[] data = {10, 20};
System.out.println("sum(data) = " + sum(data));
}
}
received int[], length 3, [1, 2, 3]
sum(1, 2, 3) = 6
received int[], length 0, []
sum() = 0
received int[], length 2, [10, 20]
sum(data) = 30
Ba điều mà output này chốt lại. getSimpleName() trả về int[], nên parameter đúng là một array chứ không phải object đặc biệt nào của ngôn ngữ. Gọi mà không truyền argument nào thì được array có length 0, không phải null, nên for (int n : nums) an toàn mà không cần kiểm tra null. Và một int[] có sẵn truyền thẳng vào được — compiler nhận ra argument đã đúng type array rồi nên đưa qua luôn thay vì bọc thêm một lớp.
"Đưa qua luôn" là nghĩa đen: method nhận chính array của caller chứ không phải bản copy, nên sửa nums là sửa array của caller.
static int[] capture(int... nums) { return nums; }
...
int[] data = {1, 2};
System.out.println("existing array reused? " + (capture(data) == data));
System.out.println("two calls, same array? " + (capture(1, 2) == capture(1, 2)));
System.out.println("zero args, same empty? " + (capture() == capture()));
existing array reused? true
two calls, same array? false
zero args, same empty? false
Mỗi lần gọi mà thật sự phải gói argument đều cấp phát một array mới, kể cả trường hợp không có argument nào thì vẫn cấp phát một array rỗng.
Ba quy tắc khai báo, tất cả đều do javac canh. Parameter varargs phải đứng cuối:
public class VarargsNotLast {
static void log(String... parts, String level) {
}
}
VarargsNotLast.java:2: error: varargs parameter must be the last parameter
static void log(String... parts, String level) {
^
1 error
Một method chỉ được có một parameter varargs, mà thật ra vẫn là quy tắc trên — cái đầu tiên trong hai varargs thì đương nhiên không đứng cuối, và message nói đúng như vậy:
static void log(String... parts, int... codes) { }
VarargsTwice.java:2: error: varargs parameter must be the last parameter
static void log(String... parts, int... codes) {
^
1 error
Và f(int[]) với f(int...) là cùng một method, không phải hai overload, vì varargs được compile thành một parameter array:
public class ArrayVsVarargs {
static void f(int[] a) { System.out.println("f(int[])"); }
static void f(int... a) { System.out.println("f(int...)"); }
...
}
ArrayVsVarargs.java:3: error: cannot declare both f(int...) and f(int[]) in ArrayVsVarargs
static void f(int... a) { System.out.println("f(int...)"); }
^
1 error
Còn hai hành vi nữa nên biết. Overload có số argument cố định luôn thắng overload varargs, đúng theo phase 4:
static void f(int a, int b) { System.out.println("f(int, int) ran"); }
static void f(int... nums) { System.out.println("f(int...) ran with " + nums.length); }
...
f(1, 2);
f(1, 2, 3);
f();
f(int, int) ran
f(int...) ran with 3
f(int...) ran with 0
Và truyền null trơ trọi vào một parameter varargs kiểu reference thì không cho ra array rỗng — nó truyền null vào làm cả cái array, kèm một warning:
static void g(String... parts) {
System.out.println(parts == null ? "parts is null" : "length " + parts.length);
}
...
g(null);
VarargsNull.java:13: warning: non-varargs call of varargs method with inexact argument type for last parameter;
g(null);
^
cast to String for a varargs call
cast to String[] for a non-varargs call and to suppress this warning
1 warning
parts is null
Warning nói thẳng ra bạn cần viết cast nào. g((String) null) cho một array một phần tử chứa null; g((String[]) null) là cố ý truyền null.
Cái bẫy kinh điển: remove(int) và remove(Object)
Overload resolution hết là chuyện lý thuyết ngay khi một API thật overload trên int và Object. java.util.List làm đúng như thế: remove(int index) xoá theo vị trí còn remove(Object o) xoá theo giá trị.
import java.util.ArrayList;
import java.util.List;
...
List<Integer> a = new ArrayList<>(List.of(10, 20, 30));
List<Integer> b = new ArrayList<>(List.of(10, 20, 30));
a.remove(1); // remove(int index)
b.remove(Integer.valueOf(1)); // remove(Object o)
System.out.println("a.remove(1) -> " + a);
System.out.println("b.remove(Integer.valueOf(1)) -> " + b);
a.remove(1) -> [10, 30]
b.remove(Integer.valueOf(1)) -> [10, 20, 30]
a.remove(1) xoá mất 20 — phần tử ở index 1 — vì 1 là một int và remove(int) khớp chính xác ngay ở phase 1, nên remove(Object) không bao giờ được đụng tới. Còn b.remove(Integer.valueOf(1)) đi tìm giá trị 1, không thấy, nên không đổi gì cả.
Bài học ở đây là về API của chính bạn chứ không phải về collection: đừng overload trên int và Object, hay trên một primitive và wrapper của nó, khi hai overload mang ý nghĩa khác hẳn nhau. Compiler sẽ âm thầm chọn một cái, còn người đọc call site không có một dấu hiệu nào cho biết đã có một lựa chọn xảy ra.
Khi nào nên overload và pattern forwarding
Overloading đáng dùng khi các overload làm cùng một việc trên những input có hình dạng khác nhau. System.out.println là mẫu chuẩn: overload nào cũng in argument của nó ra. Nó là ý tồi khi các overload làm những việc khác nhau, đúng như remove vừa cho thấy.
Java không có giá trị mặc định cho parameter, nên cách thay thế đúng chuẩn là một bộ overload ngắn cùng forward về một implementation đầy đủ.
import java.nio.charset.StandardCharsets;
public class Forwarding {
// the one real implementation
static String connect(String host, int port, int timeoutMs, boolean tls) {
return "connect host=" + host + " port=" + port
+ " timeoutMs=" + timeoutMs + " tls=" + tls;
}
// defaults, expressed as overloads that forward
static String connect(String host, int port, int timeoutMs) {
return connect(host, port, timeoutMs, true);
}
static String connect(String host, int port) {
return connect(host, port, 5000, true);
}
static String connect(String host) {
return connect(host, 443, 5000, true);
}
public static void main(String[] args) {
System.out.println(connect("api.example.com"));
System.out.println(connect("api.example.com", 8080));
System.out.println(connect("api.example.com", 8080, 250));
System.out.println(connect("api.example.com", 8080, 250, false));
// the JDK does exactly this: new String(byte[]) forwards to new String(byte[], Charset)
byte[] bytes = {74, 97, 118, 97};
System.out.println(new String(bytes));
System.out.println(new String(bytes, StandardCharsets.UTF_8));
}
}
connect host=api.example.com port=443 timeoutMs=5000 tls=true
connect host=api.example.com port=8080 timeoutMs=5000 tls=true
connect host=api.example.com port=8080 timeoutMs=250 tls=true
connect host=api.example.com port=8080 timeoutMs=250 tls=false
Java
Java
Đúng một method chứa logic; những cái còn lại chỉ cấp giá trị mặc định rồi gọi tiếp. Sửa implementation một lần là mọi overload đổi theo. Cả JDK được xây theo kiểu này, và hình dạng đó đáng để bắt chước.
Lỗi thường gặp và error message tương ứng
| Sai ở đâu | Chuyện gì xảy ra | Message |
|---|---|---|
| Hai method chỉ khác return type | compile error | method f(int) is already defined in class ... |
Hai method chỉ khác tên parameter, hoặc khác ở final | compile error | method f(String) is already defined in class ... |
| Varargs không đứng cuối, hoặc có hai varargs | compile error | varargs parameter must be the last parameter |
Khai báo cả f(int[]) lẫn f(int...) | compile error | cannot declare both f(int...) and f(int[]) |
f(null) với hai overload reference không họ hàng | compile error | reference to f is ambiguous |
Truyền int khi chỉ có f(Long) | compile error | incompatible types: int cannot be converted to Long |
Quên return ở một nhánh | compile error | missing return statement |
Trông chờ swap(a, b) đổi được variable của caller | compile được, âm thầm không làm gì | không có |
Trông chờ f(Integer) thắng f(long) với một int | compile được, chạy nhầm method | không có |
Overload trên int và Object với ý nghĩa khác nhau | compile được, chạy nhầm method | không có |
Ba dòng cuối mới là ba dòng nguy hiểm: chúng không sinh ra bất kỳ cảnh báo nào. Cả ba đều bị chặn bởi cùng một thói quen — xác định rõ bạn muốn overload nào, rồi kiểm tra xem static type của các argument có thật sự chọn đúng cái đó không.
FAQ
Java là pass-by-value hay pass-by-reference?
Pass-by-value, luôn luôn. Với primitive thì thứ được copy là con số; với object thì thứ được copy là reference. Vì reference được copy chứ không phải variable, method sửa được object mà caller nhìn thấy, nhưng gán vào parameter thì chỉ trỏ lại bản copy của riêng method. Phép thử là swap: ở một ngôn ngữ pass-by-reference nó chạy được với object, còn trong Java thì không.
Hai method chỉ khác return type có được không?
Không. javac báo method f(int) is already defined in class .... Compiler phải chọn overload chỉ dựa vào argument, trước khi kết quả được dùng, nên return type không nằm trong những thứ phân biệt method. Tên parameter và final trên parameter cũng vậy.
Vì sao f(long) thắng f(Integer) khi truyền vào một int?
Vì việc chọn chạy theo từng phase và không quay lại, mà widening primitive conversion thuộc một phase sớm hơn boxing. int sang long là widening conversion nên f(long) áp dụng được mà không phải box gì, và việc tìm kiếm dừng lại trước khi f(Integer) được xét tới. Thứ tự này được chọn để việc thêm autoboxing ở Java 5 không làm đổi method mà code cũ đang gọi.
Varargs có chậm hơn truyền array không?
Mỗi lần gọi mà phải gói các argument rời đều cấp phát một array mới, và lần gọi không có argument nào cũng cấp phát một array rỗng, nên một method varargs nằm trong vòng lặp nóng có cấp phát trong khi parameter array thì không. Truyền một array có sẵn thì bỏ qua hoàn toàn việc cấp phát, và JIT thường loại bỏ được một array ngắn hạn không thoát ra khỏi method. Hãy coi đây là câu hỏi thiết kế hơn là câu hỏi hiệu năng, và đo trên chính workload của bạn trước khi sửa lại API vì nó.
Vì sao list.remove(1) lại xoá nhầm phần tử?
Vì List overload cả remove(int index) lẫn remove(Object o), mà 1 là một int nên khớp chính xác với remove(int) ngay ở phase 1. Phần tử ở index 1 bị xoá chứ không phải phần tử bằng 1. Viết list.remove(Integer.valueOf(1)) để xoá theo giá trị.
Làm sao trả về hai giá trị từ một method Java?
Trả về một object chứa cả hai. record là dạng gọn nhất — record MinMax(int min, int max) {} — vì hai giá trị giữ được tên riêng và type riêng. Array cũng được khi hai giá trị cùng type và thứ tự là hiển nhiên. Ghi vào một array do caller đưa vào thì cũng chạy, nhưng nó giấu mất trong signature việc method làm gì, nên hãy ưu tiên hai cách đầu.
Kết luận
Hai ý gánh gần hết bài. Thứ nhất, argument luôn được copy vào parameter — đó là lý do swap không chạy được, lý do arr[0] = 99 bên trong method lại nhìn thấy được từ bên ngoài, và lý do gán lại parameter không bao giờ chạm tới caller. Java truyền reference theo kiểu by value; nó không phải pass-by-reference. Thứ hai, overload được chọn lúc compile dựa trên static type, theo các phase lần lượt là exact match, widening, boxing, varargs, và không bao giờ lùi lại — đó là lý do f(long) thắng f(Integer) với một int và lý do list.remove(1) xoá theo index.
Phần còn lại là một danh sách ngắn: một giá trị trả về hoặc không có, dùng record khi cần hai, final đóng băng parameter chứ không đóng băng object, parameter list mới là thứ phân biệt các overload, varargs phải đứng cuối và trả cho bạn array rỗng chứ không phải null, và một bộ overload mang hai ý nghĩa khác nhau là một bug đang chờ được viết ra.
Bài tiếp theo trong series: local variable, scope và variable sống ở đâu — một cái tên nhìn thấy được trong những block nào, nó sinh ra và mất đi lúc nào, và vì sao che tên (shadowing) là hợp lệ nhưng hiếm khi là ý hay.