追隨 Kotlin/Scala,看 Java 12-15 的現代語言特性

jywhltj發表於2020-06-15

本文原發於我的個人部落格:https://hltj.me/java/2020/06/14/java-12-15-lang-features.html。本副本只用於圖靈社群,禁止第三方轉載。

Java 14 釋出已經過去了三個月,Java 15 目前也已經到了“Rampdown Phase One ”階段,其新特性均已敲定。 由於 12-15 都是短期版本,無需考慮也不應該將其用於生產環境。但可以提前瞭解新特性,以免在下一個 LTS(Java17)正式釋出時毫無心理準備。 Java 12-15 引入了一系列改進,本文只討論語言層面的新特性,它們看起來似曾相識——沒錯,這些特性讓人感覺 Java 在沿 Kotlin/Scala 走過的路線前行。

雖然不能說 Java 就是借鑑的它們(畢竟這些特性既非它們獨有也非它們首創),但可以說是 Java 官方對 Kotlin/Scala 這些特性的充分肯定。而這些新特性也讓 Java 向現代語言的方向又邁進了一些,我們逐個來看。

switch 表示式

Java 12 預覽Java 13 二次預覽Java 14 正式

相當於只支援值匹配的 Kotlin when 表示式/Scala match 表示式

我們看一個不嚴謹的示例:判斷一個非空物件對應哪種 JSON 型別,使用傳統的 switch 語句實現如下:

switch (obj.getClass().getSimpleName()) {
    case "Integer":
    case "Long":
    case "Float":
    case "Double":
        System.out.println("number");
        break;
    case "List":
    case "Set":
        System.out.println("array");
        break;
    case "String":
        System.out.println("string");
        break;
    case "Boolean":
        System.out.println("boolean");
        break;
    case "Map":
        System.out.println("object");
        break;
    default:
        System.out.println("object/unknown");
}

而使用 switch 表示式可以簡化為:

switch (obj.getClass().getSimpleName()) {
    case "Integer", "Long", "Float", "Double" -> System.out.println("number");
    case "List", "Set" -> System.out.println("array");
    case "String" -> System.out.println("string");
    case "Boolean" -> System.out.println("boolean");
    case "Map" -> System.out.println("object");
    default -> System.out.println("object/unknown");
}

是不是簡潔了很多?當然如果只能這樣用,那還稱不上 switch 表示式,既然是表示式那麼一定可以有一個返回值。 我們可以利用 switch 表示式的返回值來進一步重構上述程式碼:

String jsonType = switch (obj.getClass().getSimpleName()) {
    case "Integer", "Long", "Float", "Double" -> "number";
    case "List", "Set" -> "array";
    case "String" -> "string";
    case "Boolean" -> "boolean";
    case "Map" -> "object";
    default -> "object/unknown";
};
System.out.println(jsonType);

switch 表示式中箭頭的右側不僅可以是常規表示式,還可以是一個程式碼塊,在塊中通過 yield 來指定返回值。 例如在上述 default 分支打一條錯誤資訊,可以這樣改:

String javaType = obj.getClass().getSimpleName();
String jsonType = switch (javaType) {
    case "Integer", "Long", "Float", "Double" -> "number";
    case "List", "Set" -> "array";
    case "String" -> "string";
    case "Boolean" -> "boolean";
    case "Map" -> "object";
    default -> {
        System.err.println("unknown type: " + javaType);
        yield "object";
    }
};

是不是有點類似 Kotlin 的 when 與 Scala 的 match 了?非常像,只是目前只支援簡單的值匹配,還不支援 Kotlin whenis/in 以及 Scala match 的模式匹配。 當然如果綜合 Java 12-15 引入的所有語言特性來看,不難預見 switch 表示式未來會支援模式匹配的。

switch 表示式的優點不僅是簡潔且具有返回值,還避免了傳統 switch 語句的一些坑(如忘記寫 break 語句,再如各 case/default 子句共享同一個區域性作用域)。 因此,在 Java 14 及以上版本中,應該儘量採用新語法、避免使用傳統的 switch 語句。IDEA 甚至會對傳統 switch 語句標記警告,並且提供了自動將傳統語法重構為新語法的 quick fix 功能。

文字塊

Java 13 預覽Java 14 二次預覽Java 15 正式

Java 的文字塊(多行字串)語法與 Kotlin 原始字串/Scala 多行字串類似,都是採用三重雙引號括起,不過具體語法、語義不盡相同。 Java 文字塊起始的三重雙引號後只能跟空白符和換行,因此不能像 Kotlin/Scala 那樣寫 """hello""",而必須這樣寫:

"""
hello"""

Java 會自動去掉第一個換行以及每行末尾的空白,因此上述字串等同於 "hello",但是結尾處三重引號前的換行並不會去掉,例如:

// 等同於 "hello"
var s1 = """
        hello""";

// 等同於 "hello"
var s2 = """
        hello  """;

// 等同於 "hello\n"
var s3 = """
        hello
        """;

文字塊中的少於連續三個的雙引號都無需轉義,這也是文字塊的用途之一,例如:

System.out.println("""                 
        This is a string literal in Java: "Hello".""");

這段程式碼會輸出 This is a string literal in Java: "Hello".。 編譯過程中會自動去掉縮排用的空白符,如果存在多行,會以前導空白最少的一行作為基準。

文字塊的另一個用途是便於書寫預排版的文字,例如 ASCII Art 或者豎排文字:

String ci = "┆蝶┆觀┆月┆池┆遊┆ ┆獨┆錢┆古┆來┆端┆\n" +
        "┆自┆音┆老┆畔┆人┆ ┆賞┆江┆塔┆客┆陽┆\n" +
        "┆舞┆堂┆祠┆問┆醉┆ ┆亦┆西┆聽┆尚┆至┆\n" +
        "┆翩┆外┆前┆奇┆ ┆ ┆悠┆子┆濤┆流┆ ┆\n" +
        "┆躚┆雨┆人┆緣┆ ┆ ┆然┆匯┆於┆連┆ ┆\n" +
        "┆ ┆連┆絡┆ ┆ ┆ ┆ ┆吳┆越┆ ┆ ┆\n" +
        "┆ ┆綿┆繹┆ ┆ ┆ ┆ ┆山┆地┆ ┆ ┆\n";

這段豎排文字是我幾年前寫的《雙調憶江南·庚寅年端午遊杭州》,可以看到自動格式化後第一行沒有與後續幾行對齊,雖然還有變通辦法,但是這本身就已經比較複雜了。 而如果採用文字塊就會簡單很多:

String ci = """
        ┆蝶┆觀┆月┆池┆遊┆ ┆獨┆錢┆古┆來┆端┆
        ┆自┆音┆老┆畔┆人┆ ┆賞┆江┆塔┆客┆陽┆
        ┆舞┆堂┆祠┆問┆醉┆ ┆亦┆西┆聽┆尚┆至┆
        ┆翩┆外┆前┆奇┆ ┆ ┆悠┆子┆濤┆流┆ ┆
        ┆躚┆雨┆人┆緣┆ ┆ ┆然┆匯┆於┆連┆ ┆
        ┆ ┆連┆絡┆ ┆ ┆ ┆ ┆吳┆越┆ ┆ ┆
        ┆ ┆綿┆繹┆ ┆ ┆ ┆ ┆山┆地┆ ┆ ┆
        """;

而既有雙引號又有預排版的多行文字就更適合使用文字塊了,例如 XML 、JSON 或其他配置/程式碼文字的字面值:

var languages = "[\n" +
        "  {\n" +
        "    \"name\": \"kotlin\",\n" +
        "    \"type\": \"static\"\n" +
        "  },\n" +
        "  {\n" +
        "    \"name\": \"julia\",\n" +
        "    \"type\": \"dynamic\"\n" +
        "  }\n" +
        "]";

上述 JSON 字面值看起來很凌亂,而用文字塊就會清晰很多:

var languages = """
        [
          {
            "name": "kotlin",
            "type": "static"
          },
          {
            "name": "julia",
            "type": "dynamic"
          }
        ]""";

instanceof 模式匹配

Java 14 預覽Java 15 二次預覽,預計 Java 16 正式。

類似於 Kotlin 的智慧轉換,但語法不同,在 Scala 中沒有直接對應。

傳統的 instanceof 判斷成功之後仍然需要強制轉換才能按相應型別使用,例如:

if (obj instanceof String) {
    System.out.println(((String) obj).length());
}

而使用模式匹配之後,可以在判斷成功時繫結為一個對應型別的變數,之後直接使用該變數即可:

if (obj instanceof String s) {
    System.out.println(s.length());
}

需要注意的是,只有成功匹配的分支才能繫結該變數:

if (obj instanceof String s) {
    System.out.println(s.length());
} else {
    // 錯誤:此處 s 不可用
    // System.out.println(s.length());
}

instanceof 模式匹配不僅能用於 if 語句中,還可以用於 while 語句、三目運算子以及 &&|| 等能使用布林邏輯的地方,例如:

int i = obj instanceof String s ? s.length() : -1;
var isEmptyString = obj instanceof String s && s.isEmpty();
var isNotEmptyString = !(obj instanceof String s) || !s.isEmpty();

目前 Java 中只引入了這一種非常簡單的模式匹配形式,未來應該會引入更多模式匹配語法。

記錄型別

Java 14 預覽Java 15 二次預覽,預計 Java 16 正式。

記錄型別(record)類似於 Kotlin 的資料類(data class)與 Scala 的樣例類(case class),只是更加嚴格。

在沒有記錄型別之前,建立一個具有各欄位對應 getter、為所有欄位初始化的建構函式、基於所有欄位的 equals()/hashCode()/toString() 的簡單類卻需要寫一大堆程式碼,其中大部分都是樣板程式碼。 例如:

class Font {
    private final String name;
    private final int size;

    public Font(String name, int size) {
        this.name = name;
        this.size = size;
    }

    public String name() {
        return name;
    }

    public int size() {
        return size;
    }

    public boolean equals(Object o) {
        if (!(o instanceof Font other)) return false;
        return other.name.equals(name) && other.size == size;
    }

    public int hashCode() {
        return Objects.hash(name, size);
    }

    public String toString() {
        return String.format("Font[name=%s, size=%d]", name, size);
    }
}

上述程式碼中,除了類名、欄位型別與欄位名之外,其他的全部都是樣板程式碼。而使用記錄只需非常簡單的一行程式碼即可:

record Font(String name, int size) { }

跟一般類相比,記錄有以下限制:

  • 總是隱式繼承自 java.lang.Record 而無法顯式繼承任何任何類
  • 記錄隱含了 final 並且不能宣告為抽象
  • 不能顯式宣告欄位,也不能定義初始化塊
  • 隱式宣告的所有欄位均為 final
  • 如果顯式宣告任何會隱式生成的成員,其型別必須嚴格匹配
  • 不能宣告 native method(通常譯為“本地方法”,按說應該叫“原生方法”)

除了這些限制之外,它與普通類一致:

  • new 例項化
  • 可以在頂層宣告,也可以在類內部、區域性作用域中宣告
  • 可以宣告靜態方法與例項方法
  • 可以宣告靜態欄位與靜態初始化塊
  • 可以實現介面
  • 可以有其內部型別
  • 可以標註註解

記錄型別還可以與接下來提到的密封類/密封介面很好協作,另外記錄還適用於未來版本的模式匹配。

密封類與密封介面

Java 15 預覽,預計 Java 16 二次預覽、Java 17 正式。

Java 15 引入的密封類(sealed class)類似於 Kotlin/Scala 的密封類、密封介面類似於 Scala 的密封特質(sealed trait)。 不妨將二者統稱為密封型別,與普通類/介面不同的是,密封型別限定了哪些類/介面作為其直接子型別。例如:

sealed interface JvmLanguage
    permits DynamicTypedJvmLanguage, StaticTypedJvmLanguage {}

sealed interface StaticTypedJvmLanguage extends JvmLanguage {}
sealed class DynamicTypedJvmLanguage implements JvmLanguage
    permits Clojure, JvmScriptLanguage {}

final class Java implements StaticTypedJvmLanguage {}
final class Scala implements StaticTypedJvmLanguage {}
final class Kotlin implements StaticTypedJvmLanguage {}

final class Clojure extends DynamicTypedJvmLanguage {}
non-sealed abstract class JvmScriptLanguage extends DynamicTypedJvmLanguage {}

class Groovy extends JvmScriptLanguage {}

在密封型別的宣告中可以通過 permits 顯式宣告其直接子型別列表,也可以省略——編譯器會根據當前檔案中的直接子型別的宣告推斷出來。 一個密封型別的直接子型別必須標註 sealednon-sealedfinal 三者之一。

密封型別與記錄是相互獨立的功能,但是二者能夠很好協作,例如:

sealed interface Json {}
record NullVal() implements Json {}
record BooleanVal(boolean val) implements Json {}
record NumberVal(double val) implements Json {}
record StringVal(String val) implements Json {}
record ArrayVal(Object...vals) implements Json {}
record ObjectVal(Map<String, Object> kvs) implements Json {}

此外,還可以用記錄與密封型別來實現代數資料型別(ADT):記錄為積型別、密封型別為和型別。 與記錄類似,密封型別也將適用於未來版本的模式匹配。

小結

Java 12-15 引入了 switch 表示式、文字塊、instanceof 模式匹配、記錄、密封型別這幾個語言新特性,這些特性在 Kotlin/Scala 中基本上都有對應,如同 Java 在追隨 Kotlin/Scala 的步伐。

另外,不知大家有沒有注意到這一點:除了文字塊外,其他幾個特性都直接或間接指向了同一個關鍵詞——模式匹配。 這些特性除了自身價值之外,也都在為未來版本的模式匹配做鋪墊。因此不妨做個大膽預測:在未來的幾個版本中,Java 會引入更完善的模式匹配機制。

些許遺憾

Java 12-15 中引入語言層面的新特性並不很多,很多令人期待新特性都沒有包含在內。 當然語言需要漸進式演化,這也是情理之中的事。唯有兩點我覺得有些遺憾:

空安全

安全表達可選值是現代語言的一個特徵,隔壁 C# 8 引入空安全的經驗告訴我們: 即便語言當初做了錯誤的設計,倘若迷途知返,仍然能夠回到正軌。 Java會不會也有這麼一天?也許會,不過 Java 12-15 顯然沒有,在接下來的幾個版本中這麼做可能性也很渺茫,也許還會在“迷途”中繼續前行很久。

當然關於空安全這塊也不是什麼長進都沒有,Java 14 就有一處:JEP 358: Helpful NullPointerExceptions 改善了 NullPointerException 所攜帶的資訊,以便定位是哪個變數出現空值導致的。

協程

協程即將成為現代工業級語言的標配,不僅 Python、JavaScript、Rust 等語言紛紛引入了協程支援(async-await 尤為便利),就連隔壁 C++ 也都已將協程納入了今年的新標準(C++ 20)中。 再對比靈活、強大的 Kotlin 協程在非同步程式設計中所帶來的便利,還在堅守 Java 的開發者對協程的期待就不難理解了。 遺憾的是,Project Loom 未能合入 Java 15,那麼按照其他特性的演進週期來看,在下一個 LTS(即 Java 17)釋出時就算包含進來也不會是穩定特性。

當然,目前 JVM 平臺的 5 門主流工業級語言(Java、Kotlin、Scala、Groovy、Clojure)中,只有 Kotlin 具備上述兩個特性,歡迎來 Kotlin 中文站學習。

相關文章