一次學會 Java Varargs:為什麼它比你想的還重要?【Thinking in Java筆記(4-6)】

一次學會 Java Varargs:為什麼它比你想的還重要?【Thinking in Java筆記(4-6)】
Photo by orbtal media / Unsplash

你是不是也曾經在寫 Java 的時候,被方法參數搞得頭昏眼花?明明就是要傳幾個值,卻搞得像是在寫保單申請書一樣繁瑣。好消息來了:Java 在 SE5 引入的 可變參數列表(varargs) 功能,讓你不再需要手動創造陣列來包裝一堆參數。這篇文章是寫給你這種(迷路中的)初學者的,目的是幫你理解這個特性是怎麼設計的、要解決什麼問題,以及怎麼在實務上使用它而不踩雷。重點是讓你不只「會用」,還「知道為什麼要這樣設計」

可變參數列表(Varargs)是什麼鬼?

在 Java 引入 varargs 之前,如果你想讓一個方法接收多個參數,只能用萬能的 Object 陣列。這就像你去自助餐拿菜,每一樣都要自己包起來裝袋,麻煩又容易灑出來:

class A {}

public class VarArgs {
  static void printArray(Object[] args) {
    for(Object obj : args)
      System.out.print(obj + " ");
    System.out.println();
  }
  public static void main(String[] args) {
    printArray(new Object[]{
      new Integer(47), new Float(3.14), new Double(11.11)
    });
    printArray(new Object[]{"one", "two", "three" });
    printArray(new Object[]{new A(), new A(), new A()});
  }
} /* Output: (Sample)
47 3.14 11.11
one two three
A@1a46e30 A@3e25a5 A@19821f
*/

為什麼 varargs 讓人生更簡單?

在 Java SE5 之前,如果你要讓一個方法接受多個參數,就必須手動建立陣列並傳進去,語法繁瑣又不直覺。以上面的程式為例,你必須這樣呼叫方法:

printArray(new Object[]{"one", "two", "three"});

這樣的語法不僅冗長,而且對初學者來說相當不友善,很容易就搞混到底是要傳什麼型態、什麼格式。

Java SE5 引入 varargs 後,你可以改寫成:

printArray("one", "two", "three");

是不是一秒變清爽?varargs 的設計讓方法呼叫更像自然語言,也更接近其他語言如 Python 的使用習慣

從實務上來說,這個改變大大簡化了開發流程,尤其在撰寫工具類別或是需要支援彈性參數的 API 時格外有用

總結來說,varargs 就是讓 Java 更接地氣的一個設計,你不需要為了靈活性犧牲語法簡潔性

Java SE5 的改進:省事省心的 varargs

到了 Java SE5,終於有良心地加入了 varargs 語法糖,讓你直接把參數丟進方法裡就好了,連陣列都不用你造:

public class NewVarArgs {
  static void printArray(Object... args) {
    for(Object obj : args)
      System.out.print(obj + " ");
    System.out.println();
  }
  public static void main(String[] args) {
    // Can take individual elements:
    printArray(new Integer(47), new Float(3.14),
      new Double(11.11));
    printArray(47, 3.14F, 11.11);
    printArray("one", "two", "three");
    printArray(new A(), new A(), new A());
    // Or an array:
    printArray((Object[])new Integer[]{ 1, 2, 3, 4 });
    printArray(); // Empty list is OK
  }
} /* Output: (75% match)
47 3.14 11.11
47 3.14 11.11
one two three
A@1bab50a A@c3c749 A@150bd4d
1 2 3 4
*/
  • 使用 Object... args 表示這個方法可以收 N 個 Object,無論你傳幾個參數,Java 都會自動幫你打包成陣列
  • 呼叫者可以直接寫出 printArray("one", "two", "three"),不用自己手動 new 陣列,語法更簡潔,也更容易看懂。這對初學者來說是友善的:你只要像平常講話一樣列出幾個值就好,背後的結構 Java 幫你處理
  • 編譯器會在背後偷偷幫你把這些參數收集成陣列,就像你列出旅行用品,然後有人幫你自動打包進行李箱一樣。你省事,它幫你整理得好好的
  • 上面範例中的 printArray("one", "two", "three"),就是典型的 varargs 用法。如果沒有 varargs,你就得自己寫 new Object[]{"one", "two", "three"},初學者常常搞錯陣列語法,這個特性直接幫你少寫一半的程式碼

這讓方法設計可以同時兼顧靈活性與可讀性,也減少了不必要的樣板程式碼

可變參數可以幹嘛?(比你想的多)

傳 0 個也 OK,沒人會怪你

public class OptionalTrailingArguments {
  static void f(int required, String... trailing) {
    System.out.print("required: " + required + " ");
    for(String s : trailing)
      System.out.print(s + " ");
    System.out.println();
  }
  public static void main(String[] args) {
    f(1, "one");
    f(2, "two", "three");
    f(0);
  }
} /* Output:
required: 1 one
required: 2 two three
required: 0
*/

這邊的 trailing 參數是一個可變參數列表,也就是說它可以接收 0 個、1 個或多個 String。舉例來說,f(1, "one") 中,required 參數是 1,而 trailing 則是接收到一個字串 "one";而 f(2, "two", "three") 中,trailing 則是接收到兩個字串。當你寫 f(0) 時,只傳入必要參數,trailing 自然就成為空陣列,也就是沒有額外的字串

你可以把 trailing 想成是點餐時的附加備註:「我要一份牛排,加點黑胡椒、不要蔥、醬汁另外放」這些附加項目就是 trailing,主餐一定要有(對應到 required),但附加項目可以完全省略、加一樣、或加好幾樣。這就是可變參數的彈性所在

不只是 Object,任何型別都能上桌

public class VarargType {
  static void f(Character... args) {
    System.out.print(args.getClass());
    System.out.println(" length " + args.length);
  }
  static void g(int... args) {
    System.out.print(args.getClass());
    System.out.println(" length " + args.length);
  }
  public static void main(String[] args) {
    f('a');
    f();
    g(1);
    g();
    System.out.println("int[]: " + new int[0].getClass());
  }
} /* Output:
class [Ljava.lang.Character; length 1
class [Ljava.lang.Character; length 0
class [I length 1
class [I length 0
int[]: class [I
*/

你可以用任何型別作為參數,包括基本型別如 int,甚至可以混搭使用。這是 varargs 相較於傳統 Object[] 最大的優勢之一,因為 varargs 在語法上和語意上都更加彈性與清晰

首先,傳統的 Object[] 只能裝物件類型,像 IntegerStringDouble 等。如果你要傳基本型別如 intchar,就必須手動裝箱(boxing),而 varargs 配合自動裝箱機制,讓這些轉換可以在背後自動完成,程式碼更簡潔,少了許多雜訊

再來是方法簽章(method signature)層級的差異。使用 Object[] 作為參數時,方法表面上看起來是接受陣列,但實際使用時,讀者不見得一眼就知道這個方法接受可變參數。而用 varargs 則是明確地在簽章上標示「這個方法可以接受不定數量的參數」,不需要再去看呼叫端的實作就能推斷用途,對程式可讀性有很大幫助

此外,在處理方法多載(overloading)或 API 設計時,使用 varargs 也更能避免歧義。例如 doSomething(String label, Object... args) 明確表示除了第一個參數是標籤,其餘是可以彈性傳入的內容,遠比 Object[] 來得直觀

你可以把這比喻成:以前用 Object[] 像是要自己把所有菜夾好裝進便當盒再遞給廚房,現在用 varargs,只要報菜名,廚房自動幫你打包好送上桌

自動裝箱(Autoboxing)+ varargs = 一段微妙的戀情

public class AutoboxingVarargs {
  public static void f(Integer... args) {
    for(Integer i : args)
      System.out.print(i + " ");
    System.out.println();
  }
  public static void main(String[] args) {
    f(new Integer(1), new Integer(2));
    f(4, 5, 6, 7, 8, 9);
    f(10, new Integer(11), 12);
  }
} /* Output:
1 2
4 5 6 7 8 9
10 11 12
*/

你在開派對,有人穿正式西裝(包裝類別 Integer),有人穿便服(基本型別 int),Java 就像是貼心的門口人員,會自動幫你換上正裝入場。不用你親手操作每個人的穿著

換句話說,在這段程式碼中:

f(4, 5, 6, 7, 8, 9);

你其實是傳入了六個基本型別的 int 數值,但這個方法定義的是 f(Integer... args),也就是說它期望的是 Integer 類型。這時 Java 編譯器就會自動幫你把 int 轉成 Integer,這個過程就叫做「自動裝箱」(autoboxing)。你不需要自己去寫 new Integer(4) 這種多餘又冗長的程式碼,它自動幫你處理好,讓你的程式碼更乾淨,也更容易閱讀

這個範例不只是告訴你可以省事,還展示了 Java 如何讓語言變得更貼近開發者的日常邏輯。實際開發中,我們很常會有這種「一堆數值參數」的需求,比如記錄一批使用者輸入、處理一系列 ID、批量計算結果等等。這時候用 Integer... args 加上 autoboxing,能夠兼顧彈性和可讀性,不會被類型轉換拖累流程

可變參數列表與方法多載

可變參數列表的引入也對方法的多載(overloading)帶來了影響,需要注意可能的歧義問題:

public class OverloadingVarargs {
  static void f(Character... args) {
    System.out.print("first");
    for(Character c : args)
      System.out.print(" " + c);
    System.out.println();
  }
  static void f(Integer... args) {
    System.out.print("second");
    for(Integer i : args)
      System.out.print(" " + i);
    System.out.println();
  }
  static void f(Long... args) {
    System.out.println("third");
  }
  public static void main(String[] args) {
    f('a', 'b', 'c');
    f(1);
    f(2, 1);
    f(0);
    f(0L);
    //! f(); // Won't compile -- ambiguous
  }
} /* Output:
first a b c
second 1
second 2 1
second 0
third
*/

當一個類別中有多個方法名稱相同,但參數型別不同(也就是所謂的方法多載),而且這些方法都使用了可變參數時,Java 在編譯時會根據你提供的參數型別去推測該呼叫哪一個版本。這通常沒問題,但如果你傳的是空參數清單,也就是完全沒有提供任何參數,那麼編譯器就陷入困境了

因為它會看到多個版本的方法,像是 f(Character... args)f(Integer... args)f(Long... args),每個都能接受「零個參數」,所以它不知道哪一個才是你真正想呼叫的。這就像你走進飲料店對店員說「我要一杯」,卻沒說你要茶、咖啡、還是果汁。對人類來說這可能還能靠猜,但對編譯器來說,這樣是編譯錯誤

這種情況下,除非你明確提供至少一個參數,或是用不同的參數型別明確告訴編譯器你想要哪個方法,否則它會直接報錯,因為它無法做出「最適合的」判斷。這就是在方法多載時使用 varargs 需要特別小心的地方

怎麼解?幫方法穿制服!

// {CompileTimeError} (Won't compile)

public class OverloadingVarargs2 {
  static void f(float i, Character... args) {
    System.out.println("first");
  }
  static void f(Character... args) {
    System.out.print("second");
  }
  public static void main(String[] args) {
    f(1, 'a');
    f('a', 'b');
  }
}

加上一個不同型別的固定參數,可以讓每個方法簽名變得獨特且可辨識,讓編譯器在解析時不再困惑

以這段程式碼為例:

static void f(float i, Character... args) {...}
static void f(Character... args) {...}

雖然兩個方法的名稱都叫 f,但因為第一個方法多了一個 float 參數,兩者的簽章就不一樣。這表示當你寫 f(1, 'a') 時,Java 編譯器可以根據第一個參數的型別(float)明確判斷你要呼叫哪一個方法

這樣的設計解決了前面提到的歧義問題。如果你只是寫 f() 而沒有任何參數,而類別裡剛好有多個 varargs 方法,那麼 Java 就不知道該選哪一個。但一旦每個方法都帶上一個不同型別的固定參數,哪怕你只傳一個值,編譯器就可以根據那個值的型別來做出正確選擇。

這就像在超市結帳時,每條櫃台除了號碼還寫上用途,例如「快速結帳(10件以下)」、「大宗採購」、「會員專用」。即使你站在最前面,收銀員也能根據你的購物車內容馬上知道你該去哪條線,效率大幅提升

小結

varargs 是 Java 語言中一個實用又容易忽略的功能。它讓你寫方法時不再被固定參數數量綁死,呼叫上更自然,程式碼也更乾淨

搭配自動裝箱,你可以傳入基本型別也沒問題,還能寫出更靈活的 API。同時它也比傳統的 Object[] 更明確、可讀性更高,不容易搞混

只要記住:當你想讓方法接收彈性數量的輸入,就可以考慮用 varargs。但也別忘了遇上多載時要小心設計,避免讓 Java 編譯器猜不透你的心

簡單、彈性、好維護──這就是 varargs 的價值