在 Java 的發展歷程中,程式碼的簡潔性、可讀性和維護性一直是開發者關注的重點。Java 21 的 JEP 443 引入了未命名變數和模式的概念,並在 JDK 22 的 JEP 456 中成為正式功能。它可以簡化模式匹配語法,使其更易於使用與理解,並提升開發效率。
本文將介紹如何使用底線字元 _
去匹配不必要且可完全省略的變數和模式,以及如何在實際開發中運用這項特性來減少冗餘程式碼,使其能夠更明確地表達開發人員的意圖。
目錄
前言
在日常的開發中,我們有時需要宣告一些實際上不會用到的變數,無論是出於程式碼風格的考量,或是因為在某些情況下需要變數宣告。例如,在處理迴圈、捕捉異常或是操作串流時,有些變數純粹是為了符合語法要求而存在,而這些變數的命名往往成為一種負擔。它們不只增加了程式碼的複雜度,還可能導致靜態分析工具發出警告。
雖然我們在編寫程式碼時就已知道並不會使用這些變數,但如果沒有明確地註解說明的話,其他的維護者可能會意外使用該變數,從而違反了原本的意圖。如果我們能夠使這些變數不可能被意外使用,那麼程式碼將更具資訊性、更具可讀性,並且更不容易出錯。
未使用的變數
具體來說,當我們在處理計算集合大小或是移除佇列元素等這些副作用比結果更重要的程式碼時,宣告未使用變數的需求尤其常見。例如,下面的程式碼計算總數 total
作為迴圈的副作用,並不會使用到迴圈變數 order
:
static int count(Iterable<Order> orders) {
int total = 0;
for (Order order : orders) // order 未被使用
total++;
return total;
}
考慮到 order
未被使用,使得 order
的變數宣告有些突兀。宣告可以縮短為 var order
,但無法避免給這個變數一個名稱。我們當然可以將變數名稱縮短為 o
,但這樣寫並不能傳達變數永遠不會被使用的意圖。此外,靜態分析工具通常會抱怨未使用的變數,即使開發人員不打算使用它們,而且可能也無法消除這些警告。
再舉另一個表達式的副作用比其結果更重要的例子。下面的程式碼一次導出三個元素,但每三個元素中只需要使用前兩個:
Queue<Integer> q = ...; // x1, y1, z1, x2, y2, z2 ..
while (q.size() >= 3) {
int x = q.remove();
int y = q.remove();
int z = q.remove(); // z 未被使用
points.add(new Point(x, y));
}
第三次呼叫 remove()
是為了要移除一個元素,但其實我們不會使用到它,因此 z
的宣告可以被省略。然而,為了可維護性,這段程式碼的作者可能希望宣告一個變數來一致地表示 remove()
的結果。他們目前有兩個選項,但都很不理想:
- 不要宣告變數
z
,一來會導致不對稱性,二來可能會產生關於忽略返回值的靜態分析警告,或者 - 宣告一個未使用的變數
z
,但可能會得到關於未使用變數的靜態分析警告
無論何種做法,都可能會產生靜態分析警告。
其他副作用
未使用的變數也經常出現在另外兩種關注副作用的語句中,像是 try-with-resources
語句常常因為資源會自動關閉的副作用而被拿來使用。在某些情況下,try
區塊的程式碼並未使用到資源物件,因此資源變數的名稱變得無關緊要。假設一個 ScopedContext
資源是 AutoCloseable
的,下面的程式碼可以獲取並自動釋放它。然而,名稱 acquiredContext
只是多餘的,所以如果能省略它就好了:
try (var acquiredContext = ScopedContext.acquire()) {
// acquiredContext 未被使用
}
另外,處理異常時可能會產生未使用到的變數。例如,大多數開發人員都寫過這種形式的 catch
區塊,其中的異常參數 ex
未被使用:
String s = getAge();
try {
int i = Integer.parseInt(s);
process(i);
} catch (NumberFormatException ex) {
System.out.println("Bad number: " + s);
}
即使沒有副作用的程式碼,有時也必須宣告未使用的變數。例如在下面的程式碼中會產生一個映射物件,並將其中每個鍵值映射到相同的字串值。由於 lambda 參數 v
未使用,因此其名稱其實不重要:
stream.collect(Collectors.toMap(String::toUpperCase,
v -> "NODATA"));
在上述場景中,當變數未被使用且其名稱無關緊要時,如果我們可以簡單地宣告沒有名稱的變數會更好。這將使維護者不必理解無關的名稱,並且可以避免靜態分析工具對未使用變數的誤報。JEP 456 希望能避免這種困擾。
適合宣告成未命名變數是在方法外部不具可見性的變數,像是區域變數、異常參數和 lambda 參數。這類型的變數可以重新命名或不命名,也不會對產生外部影響。相比之下,即使是 private
欄位,也會橫跨方法去傳遞物件的狀態,因此不適合使用未命名變數的規則。
未使用的模式變數
區域變數可以使用類型模式(type patterns)的語法來宣告,稱之為模式變數(pattern variables)。例如下面的程式碼在 switch
陳述式(針對密封類別 Ball
的物件實例進行 switch
)的 case
標籤中使用了類型模式:
// 密封類別和其子類別宣告
sealed abstract class Ball permits RedBall, BlueBall, GreenBall { }
final class RedBall extends Ball { }
final class BlueBall extends Ball { }
final class GreenBall extends Ball { }
Ball ball = ...;
switch (ball) {
case RedBall red -> process(ball); // RedBall red 稱之為「類型模式」,而 red 則為「模式變數」
case BlueBall blue -> process(ball);
case GreenBall green -> stopProcessing();
}
switch
的 cases
使用類型模式檢查 Ball
的類別,但模式變數 red
、blue
和 green
並未在 case
子句的右側使用。如果我們可以省略這些變數名稱,這段程式碼將會更清晰。
假設我們宣告了一個記錄類別 Box
,它可以容納 Ball
的子類別,但也可能容納 null
值:
record Box<T extends Ball>(T content) { }
Box<? extends Ball> box = ...;
switch (box) {
case Box(RedBall red) -> processBox(box);
case Box(BlueBall blue) -> processBox(box);
case Box(GreenBall green) -> stopProcessing();
case Box(var itsNull) -> pickAnotherBox(); // 捕獲 null 值
}
巢狀的類型模式宣告了未使用的模式變數。由於這個 switch
比前一個更複雜,若能在巢狀類型模式中省略未使用變數名稱的話,可以進一步地提高可讀性。
未使用的巢狀模式
在模式匹配的場景中,特別是處理巢狀結構時,我們可能只需要擷取部分資料,但現行的語法要求我們必須為所有組件提供名稱,這造成了不必要的視覺負擔。例如,我們可以將記錄內嵌為其他記錄的組件,導致資料結構的形狀與其中的數據項一樣重要的情況:
record Point(int x, int y) { } // 記錄 Point
enum Color { RED, GREEN, BLUE }
record ColoredPoint(Point p, Color c) { } // 記錄 Point 內嵌為 ColoredPoint 的組件
var r = new ColoredPoint(new Point(3,4), Color.GREEN);
if (r instanceof ColoredPoint(Point p, Color c)) {
// 僅使用 p.x() 和 p.y(),但未使用 Color c
}
上述程式碼中建立了一個 ColoredPoint
實例,其後使用了 instanceof
模式匹配去測試變數是否為 ColoredPoint
,如果是的話則提取其兩個組成值。
像 ColoredPoint(Point p, Color c)
這樣的模式匹配具有很好的描述性,但程式通常只使用部分的物件進行進一步的處理。例如,上面的程式碼在 if
區塊中只使用了 p
,而沒有使用 c
。每次我們進行這樣的模式匹配時,都要寫出記錄類別中所有組件的類型模式,顯得很費力。此外,從視覺上看,整個 Color
組件是無關的;這也使得 if
區塊中的條件更難閱讀。當記錄類別使用巢狀類別模式以提取組件內的欄位時,這一點尤其明顯,如:
if (r instanceof ColoredPoint(Point(int x, int y), Color c)) {
// 僅使用 x 和 y
}
我們可以使用未命名的模式變數來降低視覺成本,例如 ColoredPoint(Point(int x, int y), Color _)
,但類型模式中 Color
類型的存在仍會讓人分心。我們當然可以使用 var
來移除它,例如 ColoredPoint(Point(int x, int y), var _)
,但巢狀類型模式 var _
仍然過於笨重。最好完全省略不必要的組件來進一步降低視覺成本,消除程式碼中的雜訊來提高可讀性。
JEP 456 概觀
JEP 456 引入了未命名變數和未命名模式,當需要變數宣告但從未使用時,可以用底線字元 _
代替。它減少了冗餘的模式匹配程式碼,也提高了程式碼的可讀性和可維護性。
- 未命名變數(Unnamed variables):在不需要變數名稱的情況下捕獲值。例如使用
case Point(_, y)
來匹配任何 Point 物件,並僅使用其 y 坐標 - 未命名模式(Unnamed patterns):在不需要變數的情況下匹配特定的模式。例如,可以使用
case (int _, int _)
來匹配任何由兩個整數組成的記錄
優點
- 提供簡潔的語法並減少了冗餘程式碼,使模式匹配更易於閱讀和理解
- 改善程式碼的可讀性,使程式碼更專注於匹配的模式,而不是變數名稱
- 未命名變數和模式可以與
switch
表達式和instanceof
模式匹配無縫結合使用 - 明確表達開發者的意圖,即某些變數或模式組件不會被使用
- 減少靜態分析工具的誤報警告
缺點
- 在某些情況下,過度使用未命名變數可能會使程式碼更難理解,尤其是在複雜的模式匹配中
- 在巢狀模式中使用未命名變數時,需要注意可能的命名衝突。
- 不適用於方法參數,這可能會限制其在某些場景下的應用
JEP 456 介紹
JEP 456 使用底線字元 _
(U+005F)來表示未命名變數和未命名模式,它適用於下列各種場景:
- 未命名變數:用於區域變數、
catch
子句中的異常參數、lambda 表達式的參數等場景 - 未命名模式變數:代替類型模式中的未使用到的模式變數
- 未命名模式:代替整個未使用到的類型模式,等效於
var _
,包括嵌套在記錄中的巢狀類型模式,可以省略記錄組件的類型和名稱
單一底線字元是用來表示缺少名稱的最輕量級語法,它在其他語言中也有出現,例如 Scala 和 Python。最初它在 Java 1.0 中是有效識別符號,但後來將其重新用於未命名變數和模式:從 Java 8(2014)開始若使用底線作為變數名稱時會發出編譯時期警告,並且從 Java 9(2017, JEP 213)開始的語言規範中刪除了此類識別符號,並且將警告轉變為錯誤。
我們仍然可以使用長度為二或多個底線字元做為識別符號,因為底線仍然是合法的 Java 字元或數字。因此 _age
、 MAX_AGE
和 __
(兩個底線)之類的識別符號仍然是合法的。將底線用作數字分隔符的能力也沒有改變,例如 123_456_789
和 0b1010_0101
之類的數值文字仍然是合法的。
未命名變數
以下幾種宣告可以引入命名變數(由識別符號表示)或未命名變數(由底線表示):
- 區域中的局部變數宣告語句(JLS §14.4.2)
try-with-resources
語句的資源規範(JLS §14.20.3)- 基本
for
迴圈的標頭(JLS §14.14.1) - 增強
for
迴圈的標頭(JLS §14.14.2) catch
子句的異常參數(JLS §14.20)lambda
表示式的形式參數(JLS §15.27.1)
未命名變數即使在宣告後,其名稱(_
)也不會被放入作用區域中,因此變數在初始化後無法被寫入或讀取。此外,在局部變數宣告語句或 try-with-resources
語句中的未命名變數必須要在等號右方提供初始化方法。
另外,未命名變數永遠不會遮蔽任何其他變數,因為它沒有名稱,所以我們可以在同一個區域中宣告多個未命名變數。以下將上面的範例改寫為使用未命名變數。
一個具有副作用的增強 for
迴圈:
static int count(Iterable<Order> orders) {
int total = 0;
for (Order _ : orders) // 未命名變數
total++;
return total;
}
一個簡單 for
迴圈的初始化也可以宣告未命名的區域變數:
for (int i = 0, _ = sideEffect(); i < 10; i++) { ... i ... }
一個區域變數宣告的賦值語句,其中不需要右邊表示式的結果:
Queue<Integer> q = ...; // x1, y1, z1, x2, y2, z2, ...
while (q.size() >= 3) {
var x = q.remove();
var y = q.remove();
var _ = q.remove(); // 未命名變數
points.add(new Point(x, y));
}
如果程式只需要處理 x1、x2 等座標,則可以在多個賦值語句中使用未命名變數:
while (q.size() >= 3) {
var x = q.remove();
var _ = q.remove(); // 未命名變數
var _ = q.remove(); // 未命名變數
points.add(new Point(x, y));
}
異常處理的 try-with-resources
語法,以及 catch
區域都可以使用未命名變數:
try (var _ = ScopedContext.acquire()) { // 未命名變數
// 變數未被使用 ...
}
String s = getAge();
try {
int i = Integer.parseInt(s);
process(i);
} catch (NumberFormatException _) { // 未命名變數
System.out.println("Bad number: " + s);
}
未命名變數也可以在多個 catch
塊中使用:
try { ... }
catch (Exception _) { ... } // 未命名變數
catch (Throwable _) { ... } // 未命名變數
參數無關緊要的 lambda 表示式:
stream.collect(Collectors.toMap(String::toUpperCase,
_ -> "NODATA")) // 未命名變數
未命名模式變數
未命名模式變數可以出現在類型模式(JLS §14.30.1)中,包括 var
類型模式,無論該類型模式是出現在頂層還是嵌套在記錄的模式匹配中。例如,上面的 Ball
範例現在可以寫成:
switch (ball) {
case RedBall _ -> process(ball); // 未命名模式變數
case BlueBall _ -> process(ball); // 未命名模式變數
case GreenBall _ -> stopProcessing(); // 未命名模式變數
}
而 Box
和 Ball
的範例可以寫成:
switch (box) {
case Box(RedBall _) -> processBox(box); // 未命名模式變數
case Box(BlueBall _) -> processBox(box); // 未命名模式變數
case Box(GreenBall _) -> stopProcessing(); // 未命名模式變數
case Box(var _) -> pickAnotherBox(); // 未命名模式變數,捕獲 null 值
}
未命名模式變數省略了變數名稱,使得基於類型模式的程式碼在視覺上更清晰,無論是在 switch
區塊中還是與 instanceof
運算符一起使用時。
在 case
標籤中使用多個模式
目前,case
標籤最多只能包含一個模式匹配。隨著 JEP 456 未命名模式變數和未命名模式的引入,我們更有可能在一個 switch
區塊中擁有多個 case
子句,它們可以具有不同的模式匹配,但右側相同。例如在 Box
和 Ball
的例子中,前兩個子句具有相同的右側,但模式匹配不同:
switch (box) {
case Box(RedBall _) -> processBox(box); // 相同的右側,但模式匹配不同
case Box(BlueBall _) -> processBox(box); // 相同的右側,但模式匹配不同
case Box(GreenBall _) -> stopProcessing();
case Box(var _) -> pickAnotherBox();
}
我們可以允許前兩個模式匹配出現在同一個 case
標籤中來簡化問題:
switch (box) {
case Box(RedBall _), Box(BlueBall _) -> processBox(box); // 整合不同模式匹配
case Box(GreenBall _) -> stopProcessing();
case Box(var _) -> pickAnotherBox();
}
因此,switch
標籤的語法(JLS §14.11.1)將修改為:
SwitchLabel:
case CaseConstant {, CaseConstant}
case null [, default]
case CasePattern {, CasePattern } [Guard]
default
並將具有多個模式匹配的 case
標籤的語義定義為:如果值匹配其中任何一個模式,則該值與 case
標籤匹配。例如上例中若傳入的 box
為 RedBall
或 BlueBall
類型的話,則匹配該 case
標籤。
如果一個 case
標籤有多個模式匹配,且其中任何一個模式匹配中有明確宣告任何變數的話,則會發生編譯時期錯誤。也就是說,多個模式匹配的 case
標籤只能允許使用未命名模式變數。
另外,具有多個模式匹配的 case
標籤也可以使用 guard,不過它控制的是整個 case
標籤,而不是只控制單一個模式匹配。例如,假設有一個 int
變數 x
,套用到前面的例子:
case Box(RedBall _), Box(BlueBall _) when x == 42 -> processBox(b);
// Guard 的 when 子句會影響到 RedBall 和 BlueBall 模式匹配
因為 guard 是 case
標籤的一部分,並不是模式匹配的一部分,因此禁止在多個模式匹配中編寫多個 guard:
case Box(RedBall _) when x == 0, Box(BlueBall _) when x == 42 -> processBox(b);
// 產生編譯時錯誤
未命名模式
未命名模式是一種無條件模式,它能匹配任何東西,但不宣告和初始化任何東西。與未命名類型模式 var _
一樣,未命名模式可以嵌套在記錄模式中。但是它不能用作頂級模式,例如,使用在 instanceof
表達式或 case
標籤中。
前面的例子可以完全省略 Color
組件的類型模式:
if (r instanceof ColoredPoint(Point(int x, int y), _)) {
// 僅使用 x 和 y
}
同樣地,我們可以在提取 Color
組件值時省略 Point
組件的記錄模式:
if (r instanceof ColoredPoint(_, Color c)) {
// 僅使用 c
}
在深度嵌套的位置,使用未命名模式可以提高執行複雜數據提取的程式碼可讀性。例如下段程式碼提取嵌套 Point
的 x
座標,同時明確表示 y
和 Color
組件值未被提取:
if (r instanceof ColoredPoint(Point(int x, _), _)) {
// 僅使用 x
}
回顧 Box
和 Ball
的例子,我們可以使用未命名模式而不是 var _
來進一步簡化其最後一個 case
標籤:
switch (box) {
case Box(RedBall _), Box(BlueBall _) -> processBox(box);
case Box(GreenBall _) -> stopProcessing();
case Box(_) -> pickAnotherBox(); // 原本是 var itsNull -> var _ -> _}
總結
JEP 456 未命名變數與模式的引入是對語言表達能力的重要提升,代表了 Java 語言持續朝向更簡潔、更具表達力的方向演進。這項功能不僅能幫助開發人員撰寫更清晰的程式碼,也能更好地表達程式設計的意圖。
它提高了程式的可讀性和可維護性,特別是在處理複雜的數據結構和模式匹配時,使得 Java 開發者能夠在保持程式碼結構清晰的同時,也能享受更簡潔的語法帶來的便利性。不過,開發人員在使用未命名變數時到需要注意潛在的可讀性和命名衝突問題。
本篇文章的內容為老喬原創、二創或翻譯而來。雖已善盡校對、順稿與查核義務,但人非聖賢,多少仍會有疏漏之處難以避免。如果大家有任何問題、建議或指教,都歡迎在底下留言與老喬討論!