Java 中 Varargs 機制的理解

xuanfengling發表於2016-09-27

J2SE 1.5提供了“Varargs”機制。藉助這一機制,可以定義能和多個實參相匹配的形參。從而,可以用一種更簡單的方式,來傳遞個數可變的實參。本文介紹這一機制的使用方法,以及這一機制與陣列、泛型、過載之間的相互作用時的若干問題。

到J2SE 1.4為止,一直無法在Java程式裡定義實參個數可變的方法——因為Java要求實參(Arguments)和形參(Parameters)的數量和類 型都必須逐一匹配,而形參的數目是在定義方法時就已經固定下來了。儘管可以通過過載機制,為同一個方法提供帶有不同數量的形參的版本,但是這仍然不能達到 讓實引數量任意變化的目的。

然而,有些方法的語義要求它們必須能接受個數可變的實參——例如著名的main方法,就需要能接受所有的命令列引數為實參,而命令列引數的數目,事先根本無法確定下來。

對於這個問題,傳統上一般是採用“利用一個陣列來包裹要傳遞的實參”的做法來應付。

用陣列包裹實參

“用陣列包裹實參”的做法可以分成三步:首先,為這個方法定義一個陣列型的引數;然後在呼叫時,生成一個包含了所有要傳遞的實參的陣列;最後,把這個陣列作為一個實參傳遞過去。

這種做法可以有效的達到“讓方法可以接受個數可變的引數”的目的,只是呼叫時的形式不夠簡單。

J2SE 1.5中提供了Varargs機制,允許直接定義能和多個實參相匹配的形參。從而,可以用一種更簡單的方式,來傳遞個數可變的實參。

Varargs的含義
大體說來,“Varargs”是“variable number of arguments”的意思。有時候也被簡單的稱為“variable arguments”,不過因為這一種叫法沒有說明是什麼東西可變,所以意義稍微有點模糊。

定義實參個數可變的方法

只要在一個形參的“型別”與“引數名”之間加上三個連續的“.”(即“…”,英文裡的句中省略號),就可以讓它和不確定個實參相匹配。而一個帶有這樣的形參的方法,就是一個實參個數可變的方法。

清單1:一個實參個數可變的方法

private static int sumUp(int... values) {    
}

注意,只有最後一個形參才能被定義成“能和不確定個實參相匹配”的。因此,一個方法裡只能有一個這樣的形參。另外,如果這個方法還有其它的形參,要把它們放到前面的位置上。

編譯器會在背地裡把這最後一個形參轉化為一個陣列形參,並在編譯出的class檔案裡作上一個記號,表明這是個實參個數可變的方法。

清單2:實參個數可變的方法的祕密形態

private static int sumUp(int[] values) {    
}

由於存在著這樣的轉化,所以不能再為這個類定義一個和轉化後的方法簽名一致的方法。

清單3:會導致編譯錯誤的組合

private static int sumUp(int... values) {   
}   

private static int sumUp(int[] values) {   
}

空白的存亡問題

根據J2SE 1.5的語法,在“…”前面的空白字元是可有可無的。這樣就有在“…”前面新增空白字元(形如“Object … args”)和在“…”前面不加空白字元(形如“Object… args”)的兩種寫法。因為目前和J2SE 1.5相配合的Java Code Conventions還沒有正式釋出,所以無法知道究竟哪一種寫法比較正統。不過,考慮到陣列引數也有“Object [] args”和“Object[] args”兩種書寫方式,而正統的寫法是不在“[]”前新增空白字元,似乎採取不加空白的“Object… args”的寫法在整體上更協調一些。

呼叫實參個數可變的方法

只要把要傳遞的實參逐一寫到相應的位置上,就可以呼叫一個實參個數可變的方法。不需要其它的步驟。

清單4:可以傳遞若干個實參

sumUp(1, 3, 5, 7);

在背地裡,編譯器會把這種呼叫過程轉化為用“陣列包裹實參”的形式:

清單5:偷偷出現的陣列建立

sumUp(new int[]{1, 2, 3, 4});

另外,這裡說的“不確定個”也包括零個,所以這樣的呼叫也是合乎情理的:

清單6:也可以傳遞零個實參

sumUp(); 

這種呼叫方法被編譯器祕密轉化之後的效果,則等同於這樣:

清單7:零實參對應空陣列

sumUp(new int[]{});

注意這時傳遞過去的是一個空陣列,而不是null。這樣就可以採取統一的形式來處理,而不必檢測到底屬於哪種情況。

4. 處理個數可變的實參

處理個數可變的實參的辦法,和處理陣列實參的辦法基本相同。所有的實參,都被儲存到一個和形參同名的陣列裡。根據實際的需要,把這個陣列裡的元素讀出之後,要蒸要煮,就可以隨意了。

清單8:處理收到的實參們

private static int sumUp(int... values) {   
    int sum = 0;   
    for (int i = 0; i < values.length; i++) {   
        sum += values[i];   
    }   
    return sum;   
}

5. 轉發個數可變的實參

有時候,在接受了一組個數可變的實參之後,還要把它們傳遞給另一個實參個數可變的方法。因為編碼時無法知道接受來的這一組實參的數目,所以“把它們 逐一寫到該出現的位置上去”的做法並不可行。不過,這並不意味著這是個不可完成的任務,因為還有另外一種辦法,可以用來呼叫實參個數可變的方法。

在J2SE 1.5的編譯器的眼中,實參個數可變的方法是最後帶了一個陣列形參的方法的特例。因此,事先把整組要傳遞的實參放到一個陣列裡,然後把這個陣列作為最後一個實參,傳遞給一個實參個數可變的方法,不會造成任何錯誤。藉助這一特性,就可以順利的完成轉發了。

清單9:轉發收到的實參們

public class PrintfSample {   

    public static void main(String[] args) {   
        // 列印出“Pi:3.141593 E:2.718282”   
        printOut("Pi:%f E:%f/n", Math.PI, Math.E);   
    }   

    private static void printOut(String format, Object... args) {   
        // J2SE 1.5裡PrintStream新增的printf(String format, Object... args)方法   
        System.out.printf(format, args);   
    }   
}

Java裡的“printf”和“sprintf”

C語言裡的printf(按一定的格式輸出字串)和sprintf(按一定的格式組合字串)是十分經典的使用Varargs機制的例子。在 J2SE 1.5中,也分別在java.io.PrintStream類和java.lang.String類中提供了類似的功能。

按一定的格式輸出字串的功能,可以通過呼叫PrintStream物件的printf(String format, Object… args)方法來實現。

按一定的格式組合字串的工作,則可以通過呼叫String類的String format(String format, Object… args)靜態方法來進行。

6. 是陣列?不是陣列?

儘管在背地裡,編譯器會把能匹配不確定個實參的形參,轉化為陣列形參;而且也可以用陣列包了實參,再傳遞給實參個數可變的方法;但是,這並不表示“能匹配不確定個實參的形參”和“陣列形參”完全沒有差異。

一個明顯的差異是,如果按照呼叫實參個數可變的方法的形式,來呼叫一個最後一個形參是陣列形參的方法,只會導致一個“cannot be applied to”的編譯錯誤。

清單10:一個“cannot be applied to”的編譯錯誤

private static void testOverloading(int[] i) {   
    System.out.println("A");   
}   

public static void main(String[] args) {   
    testOverloading(1, 2, 3);// 編譯出錯   
}

由於這一原因,不能在呼叫只支援用陣列包裹實參的方法的時候(例如在不是專門為J2SE 1.5設計第三方類庫中遺留的那些),直接採用這種簡明的呼叫方式。

如果不能修改原來的類,為要呼叫的方法增加引數個數可變的版本,而又想採用這種簡明的呼叫方式,那麼可以藉助“引入外加函式(Introduce Foreign Method)”和“引入本地擴充套件(Intoduce Local Extension)”的重構手法來近似的達到目的。

7. 當個數可變的實參遇到泛型

J2SE 1.5中新增了“泛型”的機制,可以在一定條件下把一個型別引數化。例如,可以在編寫一個類的時候,把一個方法的形參的型別用一個識別符號(如T)來代表, 至於這個識別符號到底表示什麼型別,則在生成這個類的例項的時候再行指定。這一機制可以用來提供更充分的程式碼重用和更嚴格的編譯時型別檢查。

不過泛型機制卻不能和個數可變的形參配合使用。如果把一個能和不確定個實參相匹配的形參的型別,用一個識別符號來代表,那麼編譯器會給出一個“generic array creation”的錯誤。

清單11:當Varargs遇上泛型

private static <T> void testVarargs(T... args) {   
    // 編譯出錯   
}

造成這個現象的原因在於J2SE 1.5中的泛型機制的一個內在約束——不能拿用識別符號來代表的型別來建立這一型別的例項。在出現支援沒有了這個約束的Java版本之前,對於這個問題,基本沒有太好的解決辦法。

不過,傳統的“用陣列包裹”的做法,並不受這個約束的限制。

清單12:可以編譯的變通做法

private static <T> void testVarargs(T[] args) {   
    for (int i = 0; i < args.length; i++) {   
        System.out.println(args[i]);   
    }   
}

8. 過載中的選擇問題

Java支援“過載”的機制,允許在同一個類擁有許多隻有形參列表不同的方法。然後,由編譯器根據呼叫時的實參來選擇到底要執行哪一個方法。

傳統上的選擇,基本是依照“特殊者優先”的原則來進行。一個方法的特殊程度,取決於為了讓它順利執行而需要滿足的條件的數目,需要條件越多的越特殊。

在引入Varargs機制之後,這一原則仍然適用,只是要考慮的問題豐富了一些——傳統上,一個過載方法的各個版本之中,只有形引數量與實引數量正 好一致的那些有被進一步考慮的資格。但是Varargs機制引入之後,完全可以出現兩個版本都能匹配,在其它方面也別無二致,只是一個實參個數固定,而一 個實參個數可變的情況。

遇到這種情況時,所用的判定規則是“實參個數固定的版本優先於實參個數可變的版本”。

清單13:實參個數固定的版本優先

public class OverloadingSampleA {   

    public static void main(String[] args) {   
        testOverloading(1);// 列印出A   
        testOverloading(1, 2);// 列印出B   
        testOverloading(1, 2, 3);// 列印出C   
    }   

    private static void testOverloading(int i) {   
        System.out.println("A");   
    }   

    private static void testOverloading(int i, int j) {   
        System.out.println("B");   
    }   

    private static void testOverloading(int i, int... more) {   
        System.out.println("C");   
    }   
}

如果在編譯器看來,同時有多個方法具有相同的優先權,它就會陷入無法就到底呼叫哪個方法作出一個選擇的狀態。在這樣的時候,它就會產生一個 “reference to 被呼叫的方法名 is ambiguous”的編譯錯誤,並耐心的等候作了一些修改,足以免除它的迷惑的新原始碼的到來。

在引入了Varargs機制之後,這種可能導致迷惑的情況,又增加了一些。例如現在可能會有兩個版本都能匹配,在其它方面也如出一轍,而且都是實參個數可變的衝突發生。

清單14:左右都不是,為難了編譯器

public class OverloadingSampleB {   

    public static void main(String[] args) {   
        testOverloading(1, 2, 3);// 編譯出錯   
    }   

    private static void testOverloading(Object... args) {   
    }   

    private static void testOverloading(Object o, Object... args) {   
    }   
}

另外,因為J2SE 1.5中有“Autoboxing/Auto-Unboxing”機制的存在,所以還可能發生兩個版本都能匹配,而且都是實參個數可變,其它方面也一模一樣,只是一個能接受的實參是基本型別,而另一個能接受的實參是包裹類的衝突發生。

清單15:Autoboxing/Auto-Unboxing帶來的新問題

public class OverloadingSampleC {   

    public static void main(String[] args) { /* 編譯出錯 */  
        testOverloading(1, 2); /* 還是編譯出錯 */  
        testOverloading(new Integer(1), new Integer(2));   
    }   

    private static void testOverloading(int... args) {   
    }   

    private static void testOverloading(Integer... args) {   
    }   
}

9. 歸納總結

和“用陣列包裹”的做法相比,真正的實參個數可變的方法,在呼叫時傳遞引數的操作更為簡單,含義也更為清楚。不過,這一機制也有它自身的侷限,並不是一個完美無缺的解決方案。

相關文章