Command Palette

Search for a command to run...

[Java Basics] Tham số, giá trị trả về và overloading trong Java

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.

Một tên method, bốn parameter list, một cái được chạy

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 ở đâuLà cái gì
Parameterphần khai báo methodmột tên variable kèm type, được tạo mới ở mỗi lần gọi
Argumentcall sitemột expression, được tính trước khi gọi, giá trị của nó được copy vào parameter

pricepercent là parameter. total, 15, 9910 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

Primitive được copy vào parameter slot, và reference được copy vào parameter slot

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. ab là bản copy của 12, đổ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àmDùng khi nàoVì sao
Trả về một object hoặc recordgần như luôn luôncá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 arraycác giá trị cùng type và thứ tự là hiển nhiênrẻ, nhưng result[0] chẳng nói gì cho người đọc
Sửa một argument do caller đưa vàogầ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ệtCó tính là overload không?
Type của parameter
Số lượng parameter
Thứ tự các type
Chỉ khác return typekhông
Chỉ khác tên parameterkhông
final trên parameterkhô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)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

Bốn phase của overload resolution như một cái thang compiler đi xuống

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 == otrue 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à intInteger 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 longlong 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

Các argument rời được gói vào một array, zero argument cho array rỗng, array có sẵn đi thẳng qua

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

f(int[]) với f(int...)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 intObject. 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 intremove(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 intObject, 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 ở đâuChuyện gì xảy raMessage
Hai method chỉ khác return typecompile errormethod f(int) is already defined in class ...
Hai method chỉ khác tên parameter, hoặc khác ở finalcompile errormethod f(String) is already defined in class ...
Varargs không đứng cuối, hoặc có hai varargscompile errorvarargs parameter must be the last parameter
Khai báo cả f(int[]) lẫn f(int...)compile errorcannot declare both f(int...) and f(int[])
f(null) với hai overload reference không họ hàngcompile errorreference to f is ambiguous
Truyền int khi chỉ có f(Long)compile errorincompatible types: int cannot be converted to Long
Quên return ở một nhánhcompile errormissing return statement
Trông chờ swap(a, b) đổi được variable của callercompile đượ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 intcompile được, chạy nhầm methodkhông có
Overload trên intObject với ý nghĩa khác nhaucompile được, chạy nhầm methodkhô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ử?

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.

Bài viết liên quan

[Java Basics] Vòng lặp lồng nhau, break và continue trong Java

Vòng lặp lồng nhau trong Java và hai keyword cắt ngang vòng lặp: số lần thân vòng lặp trong chạy, break chỉ thoát vòng lặp gần nhất, continue bỏ qua update trong while, labeled break và continue, cùng bẫy switch nằm trong loop.

[Java Basics] Từ khóa this, static và final trong Java

Ba từ khóa this, static và final trong Java trên JDK 21 — this trỏ vào đâu, vì sao static field dùng chung cho mọi object, thứ tự static initialiser, constant inlining soi bằng javap, và vì sao final không hề làm object immutable.

[Java Basics] Field, method và constructor trong Java

Field, instance method và constructor trong Java: giá trị default của field, field initialiser, overloading constructor và chaining bằng this(...), thứ tự khởi tạo chứng minh bằng lệnh in, cùng mọi error message thật của javac từ việc viết void trước constructor tới việc đặt this(...) không phải đầu tiên.

[Java Basics] Mảng nhiều chiều trong Java

Mảng nhiều chiều trong Java thực chất là array của array chứ không phải hình chữ nhật - cách tạo và duyệt grid, jagged array, deepToString, deepEquals, shallow vs deep copy, mảng 3 chiều và ví dụ matrix, mọi output đều compile và chạy thật trên JDK 21.