本章包括37个问题,涵盖了4个主要主题:文本块、区域设置、数字和数学运算。我们将从文本块开始(在 JDK 13(JEP 355,预览版)/ JDK 15(JEP 378,最终版)中引入的优雅多行字符串),继续解决创建 Java 区域设置的问题,包括本地化区域设置(JDK 19的ofLocalizedPattern()),最后解决有关数字和数学的问题,例如用于计算平方根的巴比伦方法以及结果溢出的不同边界情况。本章的最后部分专门介绍了 JDK 17(JEP 356,最终版)的新 API,用于伪随机生成器。 通过本章的学习,您将了解所有与这四个主题相关的新 JDK 功能。

Problems

使用以下问题来测试您的字符串操作、Java 区域设置和数学边界情况编程技能。我强烈建议您在查看解决方案并下载示例程序之前尝试解决每个问题:

  1. 创建多行 SQL、JSON 和 HTML 字符串:编写一个声明多行字符串的程序(例如,SQL、JSON 和 HTML 字符串)。
  2. 演示文本块定界符的使用:编写一个程序,逐步演示文本块的定界符如何影响结果字符串。
  3. 处理文本块中的缩进:编写一个程序,演示不同的文本块缩进技术。解释偶然和必要的空格的含义。
  4. 移除文本块中的偶然空格:突出显示编译器用于移除文本块中的偶然空格的主要步骤。
  5. 仅仅为了可读性而使用文本块:编写一个创建类似于文本块(多行字符串)的字符串但行为类似于单行字符串字面值的程序。
  6. 在文本块中转义引号和行终止符:编写一个程序,演示如何处理 Java 转义序列(包括引号、“”,以及行终止符、“n” 和 “r”)。
  7. 编程方式转换转义序列:编写一个程序,程序化地访问文本块中的转义序列的转换。考虑到文本块包含嵌入的转义序列,并且必须将其传递给一个不含此类序列的字符串的函数。
  8. 使用变量/表达式格式化文本块:编写一个程序,展示几种使用变量/表达式格式化文本块的技术。从可读性的角度评论每种技术。同时,为这些技术提供一个 Java 微基准测试框架(JMH)基准测试。
  9. 在文本块中添加注释:解释如何在文本块中添加注释。
  10. 将普通字符串字面量与文本块混合使用:编写一个将普通字符串字面量与文本块混合使用的程序,例如通过连接。此外,如果普通字符串字面量和文本块具有相同的内容,它们是否相等?
  11. 将正则表达式与文本块混合使用:编写一个示例,展示将具有命名组的正则表达式与文本块混合使用的方法。
  12. 检查两个文本块是否同构:编写一个检查两个文本块是否同构的程序。如果我们可以将第一个字符串的每个字符与第二个字符串的每个字符一一映射,那么这两个字符串被认为是同构的(例如,“xxyznnxiz” 和 “aavurraqu” 是同构的)。
  13. 字符串连接 vs. StringBuilder:为比较字符串连接(通过 “+” 运算符)和 StringBuilder 方法编写一个 JMH 基准测试。
  14. 将 int 转换为 String:编写一个程序,提供几种常见的将 int 转换为 String 的技术。此外,对于提出的解决方案,提供一个 JMH 基准测试。
  15. 引入字符串模板:解释和演示 JDK 21(JEP 430,预览版)的字符串模板功能的用法。
  16. 编写自定义模板处理器:介绍一个用于编写用户定义模板处理器的 API。然后,提供几个自定义模板处理器的示例。
  17. 创建区域设置:编写一个程序,展示创建区域设置的不同方法。同时,创建语言范围和语言优先列表。
  18. 自定义本地化日期时间格式:编写一个程序,演示自定义本地化日期时间格式的用法。
  19. 恢复始终严格的浮点语义:解释 strictfp 修饰符是什么,以及在 Java 应用程序中如何/何时使用它。
  20. 计算 int/long 的数学绝对值和结果溢出:编写一个程序,演示一个特殊情况,即将数学绝对值应用于 int/long 会导致结果溢出。同时,提供此问题的解决方案。
  21. 计算参数的商和结果溢出:编写一个程序,演示一个特殊情况,即计算参数的商会导致结果溢出。同时,提供此问题的解决方案。
  22. 计算最大/最小值,使其小于/大于或等于代数商:编写一个程序,依赖于 java.util.Math 方法来计算小于/大于或等于代数商的最大/最小值。不要忘记处理结果溢出的特殊情况。
  23. 从 double 中获取整数和小数部分:编写一个程序,展示几种获取 double 的整数和小数部分的技术。
  24. 测试 double 数字是否为整数:编写一个程序,展示几种测试 double 数字是否为整数的方法。此外,为提出的解决方案提供一个 JMH 基准测试。
  25. Java 中钩取(无)符号整数:解释并以代码示例说明 Java 中使用有符号/无符号整数的方法。
  26. 返回底数/上数模数:基于底数和上数操作定义底数/上数模数,并在代码行中演示结果。
  27. 收集给定数字的所有素数因子:素数是只能被自身和1整除的数(例如,2、3和5是素数)。编写一个程序,收集给定正数的所有素数因子。
  28. 使用巴比伦方法计算一个数的平方根:解释用于计算平方根的巴比伦方法,详细说明这种方法的步骤,并基于此算法编写代码。
  29. 将浮点数四舍五入到指定小数位数:编写一个包含多种方法的程序,用于将给定的浮点数四舍五入到指定的小数位数。
  30. 将值限制在最小值和最大值之间:提供一个将给定值限制在给定最小值和最大值之间的解决方案。
  31. 不使用循环、乘法、位运算、除法和操作符相乘两个整数:编写一个程序,不使用循环、乘法、位运算、除法和操作符相乘两个整数。例如,从特殊的二项式乘积公式开始。
  32. 使用 TAU:解释几何/三角学中 TAU 的含义,并编写一个解决以下问题的程序:一个圆的周长为 21.33 厘米。圆的半径是多少?
  33. 选择一个伪随机数生成器:提供一个关于 JDK 17(JEP 356,最终版)中引入的生成伪随机数的新 API 的简短论文。此外,演示选择伪随机数生成器的不同技术。
  34. 使用伪随机数填充一个长整型数组:编写一个程序,以并行和非并行的方式用伪随机数填充一个长整型数组。
  35. 创建一个伪随机数生成器的流:编写一个程序,创建一个伪随机数流和一个伪随机数生成器流。
  36. 从 JDK 17 的新伪随机数生成器获取传统的伪随机数生成器:编写一个程序,实例化一个传统的伪随机数生成器(例如,Random),它可以将方法调用委托给 JDK 17 的 RandomGenerator。
  37. 在线程安全的方式中使用伪随机数生成器(多线程环境):解释并演示在多线程环境中使用伪随机数生成器的方法(例如,使用 ExecutorService)。

以下部分描述了前述问题的解决方案。请记住,通常没有一种单一的正确方法来解决特定的问题。此外,请记住,这里显示的解释仅包含解决问题所需的最有趣和重要的细节。要查看附加详细信息并尝试使用程序,请下载示例解决方案:github.com/PacktPublis…

1. 创建多行 SQL、JSON 和 HTML 字符串 让我们考虑以下 SQL 多行字符串:

UPDATE "public"."office"
SET ("address_first", "address_second", "phone") =
  (SELECT "public"."employee"."first_name",
          "public"."employee"."last_name", ?
   FROM "public"."employee"
   WHERE "public"."employee"."job_title" = ?

JDK 8 之前

众所周知,在 JDK 8 之前,我们可以用几种方式将此 SQL 包装为 Java 字符串(字符串字面值)。 在 JDK 8 之前可能最常见的方法依赖于通过众所周知的“+”运算符进行简单连接。通过这种方式,我们得到多行字符串表示,如下所示:

String sql =
"UPDATE "public"."office"n"
+ "SET ("address_first", "address_second", "phone") =n"
+ "  (SELECT "public"."employee"."first_name",n"
+ "          "public"."employee"."last_name", ?n"
+ "   FROM "public"."employee"n"
+ "   WHERE "public"."employee"."job_title" = ?";

编译器应该(并且通常会)足够智能,内部将“+”操作转换为 StringBuilder/StringBuffer 实例,并使用 append() 方法构建最终字符串。但是,我们也可以直接使用 StringBuilder(非线程安全)或 StringBuffer(线程安全),如以下示例所示:

StringBuilder sql = new StringBuilder();
sql.append("UPDATE "public"."office"n")
   .append("SET ...n")
   .append("  (SELECT...n")
   ...

另一种方法(通常不像前两种那样流行)是使用 String.concat() 方法。这是一个不可变的操作,基本上是将给定的字符串附加到当前字符串的末尾。最后,它返回新的组合字符串。尝试附加空值会导致 NullPointerException(在前两个示例中,我们可以附加空值而不会收到任何异常)。链式调用 concat() 允许我们表达多行字符串,如以下示例所示:

String sql = "UPDATE "public"."office"n"
  .concat("SET...n")
  .concat("  (SELECT...n")
  ...

此外,我们还有 String.format() 方法。通过简单地使用 %s 格式说明符,我们可以在多行字符串中连接多个字符串(包括空值),如下所示:

String sql = String.format("%s%s%s%s%s%s",
  "UPDATE "public"."office"n",
  "SET ...n",
  "  (SELECT ...n",
  ...

尽管这些方法在当今仍然很受欢迎,但让我们看看 JDK 8 对这个主题有什么看法。

从 JDK 8 开始

从 JDK 8 开始,我们可以使用 String.join() 方法表示多行字符串。该方法也专门用于字符串连接,它允许我们在示例中轻松阅读。怎么样?该方法将作为第一个参数的分隔符,将该分隔符用于将要连接的字符串之间。因此,如果我们将 n 视为我们的行分隔符,则只需指定一次即可,如下所示:

String sql = String.join("n"
 ,"UPDATE "public"."office""
 ,"SET ("address_first", "address_second", "phone") ="
 ,"  (SELECT "public"."employee"."first_name","
 ,"          "public"."employee"."last_name", ?"
 ,"   FROM "public"."employee""
 ,"   WHERE "public"."employee"."job_title" = ?;");

除了 String.join() 方法,JDK 8 还提供了 java.util.StringJoiner。StringJoiner 支持一个分隔符(与 String.join() 一样),但也支持前缀和后缀。表示我们的多行 SQL 字符串不需要前缀/后缀;因此,分隔符仍然是我们最喜欢的特性:

StringJoiner sql = new StringJoiner("n");
sql.add("UPDATE "public"."office"")
   .add("SET ("address_first", ..., "phone") =")
   .add("  (SELECT "public"."employee"."first_name",")
   ...

最后,我们不能不提 JDK 8 的强大 Stream API。更准确地说,我们对 Collectors.joining() 收集器感兴趣。该收集器的工作方式与 String.join() 相同,在我们的案例中如下所示:

String sql = Stream.of(
 "UPDATE "public"."office"",
 "SET ("address_first", "address_second", "phone") =",
 "  (SELECT "public"."employee"."first_name",",
 "          "public"."employee"."last_name", ?",
 "   FROM "public"."employee"",
 "   WHERE "public"."employee"."job_title" = ?;")
 .collect(Collectors.joining(String.valueOf("n")));

所有前面的示例都有一堆共同的缺点。其中最重要的是,这些示例都不表示真正的多行字符串字面值,而且由于每行分隔需要转义字符和额外的引号,可读性受到严重影响。幸运的是,从 JDK 13(作为未来预览)开始,到 JDK 15(作为最终特性)结束,新的文本块已经成为表示多行字符串字面值的标准。让我们看看是怎么样的。

介绍文本块(JDK 13/15)

JDK 13(JEP 355)提供了一个预览功能,旨在增加对多行字符串字面值的支持。在两个版本中,在JDK 15(JEP 378)中,文本块功能已经成为最终和永久可用。但是,这就足够的历史了;让我们快速看看文本块如何塑造我们的多行 SQL 字符串:

String sql="""
           UPDATE "public"."office"
           SET ("address_first", "address_second", "phone") =
             (SELECT "public"."employee"."first_name",
                     "public"."employee"."last_name", ?
              FROM "public"."employee"
              WHERE "public"."employee"."job_title" = ?""";

这太酷了,对吧?!我们立即看到我们的 SQL 的可读性已经恢复了,我们没有用分隔符、行终止符和连接把它搞砸。文本块简洁,易于更新,易于理解。我们的 SQL 字符串中额外代码的印记为零,Java 编译器将尽最大努力以最可预测的方式创建字符串。以下是嵌入了一段 JSON 信息的另一个示例:

String json = """
              {
                "widget": {
                  "debug": "on",
                  "window": {
                    "title": "Sample Widget 1",
                    "name": "back_window"
                  },
                  "image": {
                    "src": "imagessw.png"
                  },
                  "text": {
                    "data": "Click Me",
                    "size": 39
                  }
                }
              }""";

如何用文本块表示一段 HTML 呢?当然,这是:

String html = """
              <table>
                <tr>
                  <thcolspan="2">Name</th>
                  <th>Age</th>
                </tr>
                <tr>
                  <td>John</td>
                  <td>Smith</td>
                  <td>22</td>
                </tr>
              <table>""";

那么文本块的语法是什么?

文本块语法

文本块的语法非常简单。没有花里胡哨,没有复杂的东西 – 只需牢记两个方面:

  • 文本块必须以 “””(即三个双引号)和一个换行符开始。我们将此结构称为开放分隔符。
  • 文本块必须以 “””(即三个双引号)结束。””” 可以单独位于一行(作为新行),也可以位于文本的最后一行(就像我们的示例中一样)。我们将此结构称为闭合分隔符。

然而,这两种方法之间存在语义上的差异(在下一个问题中进行了解析)。 在这种情况下,以下示例在语法上是正确的:

String tb = """
            I'm a text block""";
String tb = """
            I'm a text block
            """;
String tb = """
            I'm a text block""";
String tb = """
            I'm a text block
            """;
String tb = """
            I'm a text block
            """;

另一方面,以下示例是不正确的,并导致编译器错误:

String tb = """I'm a text block""";
String tb = "I'm a text block""";
String tb = """I'm a text block";
String tb = ""I'm a text block""";
String tb = """I'm a text block"";
String tb = ""I'm a text block
            """;

然而,请考虑以下最佳实践。

重要说明 通过查看上述代码片段,我们可以形成文本块的最佳实践:仅当您有多行字符串时才使用文本块;如果字符串适合一行代码(如上述代码片段中所示),则使用普通字符串字面值,因为使用文本块不会增加任何重要的价值。 在捆绑的代码中,您可以练习此问题中的所有示例,包括 SQL、JSON 和 HTML。

重要说明 对于第三方库支持,请考虑 Apache Commons 的 StringUtils.join() 和 Guava 的 Joiner.on()。

接下来,让我们专注于使用文本块分隔符。

2. 举例说明文本块分隔符的用法

请记住前面的问题中,创建多行 SQL、JSON 和 HTML 字符串,文本块在语法上由一个开放分隔符和一个闭合分隔符限定,由三个双引号 “”” 表示。 举例说明这些分隔符的使用的最佳方法包括三个简单的步骤:考虑一个例子,检查输出,并提供结论。说到这一点,让我们从模仿 JEP 示例的一个例子开始:

String sql= """
            UPDATE "public"."office"
            SET ("address_first", "address_second", "phone") =
              (SELECT "public"."employee"."first_name",
                      "public"."employee"."last_name", ?
               FROM "public"."employee"
               WHERE "public"."employee"."job_title" = ?)""";

按照 JEP 示例的方法,我们必须将内容与开放分隔符对齐。这种对齐样式可能与我们代码的其余部分不一致,也不是一种很好的做法。如果我们将 sql 变量重命名为 updateSql、updateOfficeByEmployeeJobTitle 或其他内容,那么文本块内容会发生什么变化?显然,为了保持对齐,这将进一步将我们的内容向右推。幸运的是,我们可以将内容向左移动,而不会影响最终结果,如下所示:

String sql = """
  UPDATE "public"."office"
  SET ("address_first", "address_second", "phone") =
    (SELECT "public"."employee"."first_name",
            "public"."employee"."last_name", ?
     FROM "public"."employee"
     WHERE "public"."employee"."job_title" = ?""";

将开放/闭合分隔符向右移动本身不会影响结果字符串。您可能不太可能有充分的理由这样做,但为了完整起见,以下示例产生与前两个示例相同的结果:

String sql = """
  UPDATE "public"."office"
  SET ("address_first", "address_second", "phone") =
    (SELECT "public"."employee"."first_name",
            "public"."employee"."last_name", ?
     FROM "public"."employee"
     WHERE "public"."employee"."job_title" = ?       """;

现在,让我们看一些更有趣的东西。开放分隔符不接受同一行上的内容,而闭合分隔符位于内容末尾的右侧。然而,如果我们将闭合分隔符移动到自己的一行,会发生什么,就像以下两个示例中一样?

String sql= """
            UPDATE "public"."office"
            SET ("address_first", "address_second", "phone") =
              (SELECT "public"."employee"."first_name",
                      "public"."employee"."last_name", ?
               FROM "public"."employee"
               WHERE "public"."employee"."job_title" = ?
            """;
String sql= """ 
  UPDATE "public"."office"
  SET ("address_first", "address_second", "phone") =
    (SELECT "public"."employee"."first_name",
            "public"."employee"."last_name", ?
     FROM "public"."employee"
     WHERE "public"."employee"."job_title" = ?
  """;

这一次,结果字符串在内容末尾包含一个换行符。请参考下图(文本–在文本块之前–和–在文本块之后–只是通过 System.out.println() 添加的指南,帮助您确定文本块本身的范围;它们不是必需的,也不是文本块的一部分):

老司机带你看Java 编程问题——文本块、区域设置、数字和数学

在左图(A)中,闭合分隔符位于内容末尾。然而,在右图(B)中,我们将闭合分隔符移到自己的一行,正如您所看到的,结果字符串末尾添加了一个新的换行符。

因此,请注意如何放置闭合分隔符。 您觉得这有点奇怪吗?嗯,还不止这些!在前面的示例中,闭合分隔符被放置在自己的一行,但与开放分隔符垂直对齐。让我们更进一步,将结束分隔符向左移动,就像以下示例中一样:

String sql= """
            UPDATE "public"."office"
            SET ("address_first", "address_second", "phone") =
              (SELECT "public"."employee"."first_name",
                      "public"."employee"."last_name", ?
               FROM "public"."employee"
               WHERE "public"."employee"."job_title" = ?
""";

以下图表显示了此操作的效果:

老司机带你看Java 编程问题——文本块、区域设置、数字和数学

在左图(A)中,我们将闭合分隔符放置在自己的一行,并与开放分隔符对齐。在右图(B)中,我们看到了前面代码的效果。将闭合分隔符向左移动导致内容向右额外缩进。额外的缩进取决于我们将闭合分隔符向左移动的量。

另一方面,如果我们将闭合分隔符移到自己的一行并向右移动,它不会影响最终的字符串:

String sql= """
            UPDATE "public"."office"
            SET ("address_first", "address_second", "phone") =
              (SELECT "public"."employee"."first_name",
                      "public"."employee"."last_name", ?
               FROM "public"."employee"
               WHERE "public"."employee"."job_title" = ?
                                              """;

这段代码向最终字符串添加了一个新行,但不影响缩进。为了更好地理解开放/闭合分隔符的行为,您需要探索下一个问题。

3. 在文本块中处理缩进

如果我们对两个术语有清晰的了解,那么在文本块中处理缩进就很容易理解:

  • 附带的(或非必要的)空白 – 代表由代码格式化(IDE通常添加的前导空白)或故意/意外地添加到文本末尾的空白(尾随空白)而产生的无意义的空白。
  • 必要的空白 – 代表我们明确添加的空白,这对于最终的字符串是有意义的。

在图1.3中,您可以看到JSON文本块中的附带和必要的空白对比:

老司机带你看Java 编程问题——文本块、区域设置、数字和数学

在左图中,当闭合分隔符放置在内容末尾时,您可以看到附带与必要的空白对比。在中间图中,闭合分隔符被移到了自己的一行,而在右图中,我们还向左移动了。

附带(非必要的)空格会被Java编译器自动移除。编译器会移除所有附带的尾随空格(以强制不同文本编辑器中的相同外观,这些编辑器可能会自动移除尾随空格),并使用特殊的内部算法(在下一个问题中分解)来确定和移除附带的前导空格。此外,值得一提的是,包含闭合分隔符的行始终是此检查的一部分(这被称为重要尾随行策略)。

必要的空格会保留在最终字符串中。基本上,正如您可以从前面的图表中直观地推测出的那样,必要的空格可以通过两种方式添加,如下所示:

  • 通过将闭合分隔符向左移动(当此分隔符位于自己的一行时)
  • 通过将内容向右移动(通过显式添加空格或使用专门用于控制缩进的辅助方法)

移动闭合分隔符和/或内容

让我们从以下代码开始:

String json = """
--------------{
--------------++"widget": {
--------------++++"debug": "on",
--------------++++"window": {
--------------++++++"title": "Sample Widget 1",
--------------++++++"name": "back_window"
--------------++++},
--------------++++"image": {
--------------++++++"src": "imagessw.png"
--------------++++},
--------------++++"text": {
--------------++++++"data": "Click Me",
--------------++++++"size": 39
--------------++++}
--------------++}
--------------}""";

用“-”符号突出显示的白色空格表示偶发的前导空格(没有偶发的尾随空格),而用“+”符号突出显示的白色空格表示您将在最终字符串中看到的必要空格。如果我们将整个内容向右移动,而闭合分隔符位于内容末尾,则显式添加的空格被视为偶发的,并由编译器移除:

String json = """
----------------------{
----------------------++"widget": {
----------------------++++"debug": "on",
----------------------++++"window": {
----------------------++++++"title": "Sample Widget 1",
----------------------++++++"name": "back_window"
----------------------++++},
----------------------++++"image": {
----------------------++++++"src": "imagessw.png"
----------------------++++},
----------------------++++"text": {
----------------------++++++"data": "Click Me",
----------------------++++++"size": 39
----------------------++++}
----------------------++}
----------------------}""";

然而,如果我们将闭合分隔符移到自己的一行(与开头分隔符垂直对齐)并仅将内容向右移动,那么我们会得到最终字符串中保留的必要空格:

String json = """
--------------++++++++{
--------------++++++++++"widget": {
--------------++++++++++++"debug": "on",
--------------++++++++++++"window": {
--------------++++++++++++++"title": "Sample Widget 1",
--------------++++++++++++++"name": "back_window"
--------------++++++++++++},
--------------++++++++++++"image": {
--------------++++++++++++++"src": "imagessw.png"
--------------++++++++++++},
--------------++++++++++++"text": {
--------------++++++++++++++"data": "Click Me",
--------------++++++++++++++"size": 39
--------------++++++++++++}
--------------++++++++++}
--------------++++++++}
              """;

当然,我们可以通过左移闭合分隔符来添加相同的必要空格:

String json = """
-------+++++++{
-------++++++++++"widget": {
-------++++++++++++"debug": "on",
-------++++++++++++"window": {
-------++++++++++++++"title": "Sample Widget 1",
-------++++++++++++++"name": "back_window"
-------++++++++++++},
-------++++++++++++"image": {
-------++++++++++++++"src": "imagessw.png"
-------++++++++++++},
-------++++++++++++"text": {
-------++++++++++++++"data": "Click Me",
-------++++++++++++++"size": 39
-------++++++++++++}
-------++++++++++}
-------++++++++}
       """;

此外,我们可以通过手动添加空格来调整文本的每一行,如下例所示:

String json = """
--------------{
--------------++++"widget": {
--------------++++++++"debug": "on",
--------------++++++++"window": {
--------------+++++++++++++++++++++"title": "Sample Widget 1",
--------------+++++++++++++++++++++"name": "back_window"
--------------++++++++},
--------------++++++++"image":  {
--------------+++++++++++++++++++++"src": "imagessw.png"
--------------++++++++},
--------------++++++++"text":   {
--------------+++++++++++++++++++++"data": "Click Me",
--------------+++++++++++++++++++++"size": 39
--------------++++++++}
--------------++++}
--------------}""";

接下来,让我们看一些用于缩进的辅助方法。

使用缩进方法

从JDK 12开始,我们可以通过String.indent(int n)方法向字面字符串添加必要的空格,其中n表示空格的数量。该方法还可以应用于缩进文本块的整个内容,如下所示:

String json = """
--------------********{
--------------********++"widget": {
--------------********++++"debug": "on",
--------------********++++"window": {
--------------********++++++"title": "Sample Widget 1",
--------------********++++++"name": "back_window"
--------------********++++},
--------------********++++"image": {
--------------********++++++"src": "imagessw.png"
--------------********++++},
--------------********++++"text": {
--------------********++++++"data": "Click Me",
--------------********++++++"size": 39
--------------********++++}
--------------********++}
--------------********}""".indent(8);

显然,通过indent()方法添加的空格在IDE的代码编辑器中不可见,但是通过“*”符号在这里突出显示,只是为了说明对最终字符串的影响。然而,当使用indent()时,即使闭合分隔符位于内容末尾,也会附加一个新行。在这种情况下,将闭合分隔符移动到自己的一行会产生相同的效果,因此不要期望看到附加两个新行。当然,可以随意在捆绑代码中进行实践,以获得真正的体验。

indent()方法可能对齐包含在同一级别缩进的文本行的内容块有用,就像下面的诗歌一样:

String poem = """
              I would want to establish strength; root-like,
              anchored in the hopes of solidity.
              Forsake the contamination of instability.
              Prove I'm the poet of each line of prose.""";

如果我们在诗歌的每一行前手动添加空格,那么编译器将会移除它们,因此无法全局添加必要的空格。我们可以将闭合分隔符移到自己的一行并向左移动,或者向右移动内容以获得所需的必要空格。但是,在这种情况下,您仍然需要删除添加的新行(由于将闭合分隔符移到自己的一行而导致)。最简单的方法是通过JDK 14的新转义序列。通过在行尾添加这个转义序列,我们指示编译器不要附加换行符到该行:

String poem = """
              I would want to establish strength; root-like,
              anchored in the hopes of solidity.
              Forsake the contamination of instability.
              Prove I'm the poet of each line of prose.
   """;

虽然这个转义序列()将在问题5中进行解剖,但让我们看一些基于字符串API的方法。

在JDK 11之前,我们可以通过简单的正则表达式(如replaceFirst(“s++$”, “”))来移除这一行,或者依赖第三方工具(如Apache Commons StringUtils.stripEnd()方法)。但是,从JDK 11开始,我们可以通过String.stripTrailing()来实现这个目标:

String poem = """
              I would want to establish strength; root-like,
              anchored in the hopes of solidity.
              Forsake the contamination of instability.
              Prove I'm the poet of each line of prose.
   """.stripTrailing();

现在,由于将闭合分隔符向左移动,内容块被缩进,而自动添加的新行已被删除,这要归功于stripTrailing()方法。

重要提示 除了stripTrailing()之外,JDK 11还带有stripLeading()和strip()。此外,从JDK 15开始,我们还有stripIndent(),它会像编译器一样移除前导和尾随空格。

然而,从JDK 12开始,我们可以使用String.indent(int n),它可以使我们免于手动添加空格:

String poem = """
              I would want to establish strength; root-like,
              anchored in the hopes of solidity.
              Forsake the contamination of instability.
              Prove I'm the poet of each line of prose."""
  .indent(6)
  .stripTrailing();

现在,是时候继续前进并解剖去除偶发空格的算法了。

4.移除文本块中的偶发空格

移除文本块中的偶发空格通常是编译器通过特殊算法完成的任务。为了理解该算法的主要方面,让我们通过以下示例来了解:

String json = """                     |编译器:
----{                                 |第 01 行:4 个 lws
----++"widget": {                     |第 02 行:6 个 lws
----++++"debug": "on",                |第 03 行:8 个 lws
----++++"window": {                   |第 04 行:8 个 lws
----++++++"title": "Sample Widget 1", |第 05 行:10 个 lws
----++++++"name": "back_window"       |第 06 行:10 个 lws
----++++},                            |第 07 行:8 个 lws
----++++"image": {                    |第 08 行:8 个 lws
----++++++"src": "imagessw.png"      |第 09 行:10 个 lws
----++++},                            |第 10 行:8 个 lws
----++++"text": {                     |第 11 行:8 个 lws
----++++++"data": "Click Me",         |第 12 行:10 个 lws
----++++++"size": 39                  |第 13 行:10 个 lws
----++++}                             |第 14 行:8 个 lws
----++}                               |第 15 行:6 个 lws
----}                                 |第 16 行:4 个 lws
----""";                              |第 17 行:4 个 lws

我们特别关注通过“-“符号表示的前述代码片段中的偶发前导空格的移除。 为了移除偶发前导空格,编译器必须检查所有非空行(仅包含空白字符的行),因此在我们的案例中,它将检查 17 行。其中有 16 行 JSON 代码和闭合分隔符行。

编译器扫描这 17 行的每一行并计算前导空格的数量。在此计数中,用于表示空白的字符并不重要——它可以是简单的空格、制表符等。它们的权重相同,都是 1,因此单个空格等同于单个制表符。这是必要的,因为编译器无法知道制表符在不同的文本编辑器中将如何显示(例如,制表符可能由四个或八个字符组成)。一旦完成了算法的这一步,编译器就会知道每行的准确前导空格数量。例如,第 1 行有 4 个前导空格(lws),第 2 行有 6 个 lws,第 3 行有 8 个 lws,依此类推(请查看前述代码片段以查看所有数字)。

重要提示 让我们快速看一下另一个文本块的最佳实践:不要在同一个文本块中混合使用空格和制表符。这样,您可以强制执行缩进一致性,避免任何潜在的不规则缩进。

在这一点上,编译器计算这些数字的最小值,结果(在这种情况下为 4)表示应从这 17 行中的每一行中移除的偶发前导空格数量。因此,在最终结果中,至少有一行没有前导空格。当然,基本空格(通过“+”符号表示的额外缩进)保持不变。例如,在第 5 行,我们有 10 个 lws,减去 4 个偶发 lws,剩下 6 个未受影响的 lws。

在捆绑代码中,您可以找到另外三个 JSON 示例,您可以使用这些示例来练习该算法。现在,我们将解决一些文本块可读性方面的问题。

5. 仅为了可读性而使用文本块

仅为了可读性而使用文本块可以理解为使字符串看起来像文本块,但在行为上表现为单行字符串文字。这对于格式化长文本行特别有用。例如,我们可能希望以下 SQL 字符串看起来像一个文本块(出于可读性的目的),但在传递给数据库时表现为单行字符串文字(即紧凑的):

SELECT "public"."employee"."first_name"
FROM "public"."employee" 
WHERE "public"."employee"."job_title" = ?

从 JDK 14 开始,我们可以通过新的转义序列 (单个反斜杠)来实现此目标。通过在行末添加此转义序列,我们告诉编译器不要追加换行符到该行。因此,在我们的例子中,我们可以将 SQL 表示为单行字符串文字,如下所示:

String sql = """
             SELECT "public"."employee"."first_name" 
             FROM "public"."employee" 
             WHERE "public"."employee"."job_title" = ?
             """;

请注意,不要在 后添加任何空格,否则会出错。

如果我们将此文本块传递给 System.out.println(),则输出会显示为单行字符串文字,如下所示:

SELECT "public"."employee"."first_name" FROM "public"."employee" WHERE "public"."employee"."job_title" = ?

接下来,让我们看另一个示例,如下所示:

String sql = """
  UPDATE "public"."office" 
  SET ("address_first", "address_second", "phone") = 
    (SELECT "public"."employee"."first_name", 
            "public"."employee"."last_name", ? 
     FROM "public"."employee" 
     WHERE "public"."employee"."job_title" = ?
  """;

这次,生成的字符串不完全是我们想要的,因为必要的空格被保留了。这意味着单行字符串中散布了多个空格序列,我们应该将它们减少为单个空格。这就是正则表达式能够帮助的地方:

sql.trim().replaceAll(" +", " ");

完成了!现在,我们有了一个看起来像 IDE 中的文本块的单行 SQL 字符串。

接下来,假设我们想要在一个漂亮的背景上打印以下包装在文本块中的诗歌:

String poem = """
                 An old silent pond...
              A frog jumps into the pond,
                 splash!! Silence again.
              """;

将背景添加到这首诗歌将会产生如下图所示的结果:

老司机带你看Java 编程问题——文本块、区域设置、数字和数学

由于编译器会移除尾部的空白字符,我们将得到左图所示的结果。显然,我们希望得到右图所示的效果,因此我们需要找到一种方法将尾部的空白字符保留为必要的。从 JDK 14 开始,我们可以通过新的转义序列 s 来实现这一点。

我们可以按如下方式重复此转义序列,为每个空格添加转义序列(我们在第一行添加了三个空格,并在最后一行添加了两个空格;这样,我们得到一个对称的文本块):

String poem = """
                 An old silent pond...sss
              A frog jumps into the pond,
                splash!! Silence again.ss
              """;

或者,我们可以手动添加空格和一个单独的 s 到每行的末尾。这是可能的,因为编译器会保留 s 前面的任何空格:

String poem = """
                 An old silent pond...  s
              A frog jumps into the pond,
                splash!! Silence again. s
              """;

完成!现在,我们已经保留了空格,因此当应用背景颜色时,我们将得到右图所示的效果。 接下来,让我们专注于字符转义。

6. 在文本块中转义引号和行终止符

只有当我们想要在文本块中嵌入三个双引号序列(”””)时,才需要转义双引号,如下所示:

String txt = """
             She told me 
                    """I have no idea what's going on""" 
             """;

转义 “”” 可以用 “”” 来实现。不需要写成 “””。 生成的字符串将如下所示:

She told me
        """I have no idea what's going on"""

每当你需要嵌入 ” 或 “” 时,只需按照以下方式操作:

String txt = """
             She told me 
                     "I have no idea what's going on"
             """;
String txt = """
             She told me 
                     ""I have no idea what's going on"" 
             """;

因此,即使它可以工作,也不要这样做,因为没有必要:

String txt = """
             She told me 
                     "I have no idea what's going on"
             """;
String txt = """
             She told me 
                     ""I have no idea what's going on"" 
             """;

然而,类似 “”””(其中第一个 ” 表示双引号,最后的 “”” 表示文本块的结束定界符)会导致错误。在这种情况下,你可以添加一个空格作为 ” “”” 或将双引号转义为 “”””。 根据定义,文本块代表跨越多行的字符串字面量,因此不需要显式转义行终止符(换行符),如 n、r 或 f。只需在文本块中添加新的文本行,编译器将负责处理行终止符。当然,这并不意味着使用它们是无效的。例如,通过 n 可以获得一个包含间隔空白行的文本块,如下所示:

String sql = """
             SELECT "public"."employee"."first_name",n
                    "public"."employee"."last_name", ?n
             FROM "public"."employee"n
             WHERE "public"."employee"."job_title" = ?
             """;

在文本块中使用转义序列(例如 b、t、r、n、f 等)可以像在传统的字符串字面量中那样操作。例如,以下操作并没有问题:

String txt = """
               bbShe told men
             t""I have no idea what's going on"" 
             """;

然而,可以在不使用转义序列的情况下获得相同的结果(将 t(制表符)视为八个空格):

String txt = """
             She told me
                    ""I have no idea what's going on"" 
             """;

你可以在捆绑的代码中练习所有这些示例。

谈到 n 行终止符(换行符),重要的是要注意以下说明。

如果你需要使用特定于你操作系统的行终止符,那么在文本块归一化之后,你必须通过 String.replaceAll() 明确地替换它,如 String::replaceAll(“n”, System.lineSeparator())。 通过 结构像往常一样可以在文本块中嵌入转义序列。以下是嵌入 ” 转义序列为 ” 的示例:

String sql = """
  SELECT "public"."employee"."first_name",   
         "public"."employee"."last_name", ?
  FROM "public"."employee"
  WHERE "public"."employee"."job_title" = ?
  """;

你可以在捆绑的代码中检查输出。现在,让我们看看如何以编程方式翻译转义序列。

7. 通过编程方式翻译转义序列

我们已经知道编译器负责翻译转义序列,大多数情况下,我们无需显式干预这个过程。但有些情况下,我们可能需要以编程方式访问这个过程(例如,显式地反转义字符串,然后再传递给函数)。

从 JDK 15 开始,我们可以通过 String.translateEscapes() 实现这一点,它能够反转义序列,如 t、n、b 等,以及八进制数字 (–377)。然而,这个方法不会翻译 Unicode 转义 (uXXXX)。

我们可以进行相等性测试,以揭示 translateEscapes() 的工作原理:

String newline = "n".translateEscapes();
System.out.println(("n".equals(newline)) ? "yes" : "no");

你可能已经直觉到了,结果是 yes。

接下来,假设我们想要使用一个外部服务,在包裹上打印地址。负责这项任务的函数得到一个表示地址的字符串,但不包含转义序列。问题是,我们客户的地址经过格式化处理,其中包含转义序列,如下例所示:

String address = """
                 JASON MILLER ("BIGBOY")n
                 tMOUNT INCn
                 t104 SEAL AVEn
                 tMIAMI FL 55334 1200n
                 tUSA
                 """;

下图显示了如果我们不翻译地址的转义内容时结果字符串的样子(左侧),以及如果我们翻译时的样子(右侧)。当然,我们的目标是从右侧获取地址并将其发送打印:

老司机带你看Java 编程问题——文本块、区域设置、数字和数学

转义内容的翻译可以通过 String.translateEscapes() 在将结果发送到外部服务之前以编程方式完成。以下是代码示例:

String translatedAddress = address.translateEscapes();

现在,可以将 translatedAddress 传递给外部打印服务。作为练习,你可以考虑如何利用这个方法编写一个源代码解析器,该解析器可以解析通过 Java 或其他编程语言提供的源代码。

接下来,让我们谈谈如何在文本块中嵌入表达式。

8. 使用变量/表达式格式化文本块

在Java中,常见的做法是使用变量/表达式格式化字符串文字,以获取动态字符串。例如,我们可以通过以下众所周知的拼接创建一个动态的XML字符串:

String fn = "Jo";
String ln = "Kym";
String str = "<user><firstName>" + fn
  + "</firstName><lastName>" + ln + "</lastName></user>";
// output
<user><firstName>Jo</firstName>
<lastName>Kym</lastName></user>

当然,从可读性的角度来看,这种简单的构造方式存在严重问题。XML代码只有在根据格式进行了缩进和排版时才易于阅读;否则,很难跟踪其层次结构。那么,我们能否将这个XML表示成如下图所示的形式呢?

老司机带你看Java 编程问题——文本块、区域设置、数字和数学

当然可以!通过使用一些转义序列(例如,n、t 和 s)、空格等,我们可以构造一个字符串,使其看起来像图 1.6 那样。不过,最好通过文本块来表达这个连接。也许我们可以在IDE的代码编辑器和控制台(在运行时)实现相同的可读性。一个可能的方法如下所示:

String xml = """
            <user>
               <firstName>
            """
        + fn
        + """
             </firstName>
                <lastName>
             """
         + ln
         + """
             </lastName>
             </user>
             """;

因此,我们可以通过“+”运算符完全像字符串字面量一样连接文本块。很酷吧!这段代码的输出对应于图 1.6 的左侧。另一方面,图 1.6 的右侧可以通过以下方式实现:

String xml = """
            <user>
               <firstName>
            """
        + fn.indent(4)
        + """
               </firstName>
               <lastName>
            """
        + ln.indent(4)
        + """
               </lastName>
            </user>
            """;

虽然在这两种情况下,生成的字符串看起来都不错,但我们无法说代码本身也是如此。它仍然具有较低的可读性。

让我们尝试另一种方法。这次,让我们使用 StringBuilder 来获取图 1.6 左侧的结果:

StringBuilder sbXml = new StringBuilder();
sbXml.append("""
            <user>
               <firstName>""")
       .append(fn)
       .append("""
               </firstName>
                  <lastName>""")
       .append(ln)
       .append("""
            </lastName>
            </user>""");

然后,获取图 1.6 右侧的结果可以这样做:

StringBuilder sbXml = new StringBuilder();
sbXml.append("""
            <user>
               <firstName>
            """)
      .append(fn.indent(4))
      .append("""
             </firstName>
             <lastName>
           """)
      .append(ln.indent(4))
      .append("""
             </lastName>
           </user>
           """);

因此,我们可以在 StringBuilder/StringBuffer 中像使用字符串字面量一样使用文本块。虽然生成的字符串与图 1.6 中的示例相对应,但代码本身在可读性方面仍然令人不满意。

让我们尝试通过 JDK 1.4 的 MessageFormat.format() 再次尝试。首先,让我们构建图 1.6 左侧的示例:

String xml = MessageFormat.format("""
                           <user>
                               <firstName>{0}</firstName>
                               <lastName>{1}</lastName>
                           </user>
                           """, fn, ln);

然后,获取图 1.6 的结果(右侧)可以这样做:

String xml = MessageFormat.format("""
                           <user>
                               <firstName>
                                {0}
                               </firstName>
                               <lastName>
                                {1}
                               </lastName>
                           </user>
                           """, fn, ln);

文本块和 MessageFormat.format() 的组合是一种成功的方法。显然,代码的可读性更好了。但是,让我们更进一步,让我们尝试一下 JDK 5 的 String.format()。像往常一样,首先是图 1.6(左侧):

String xml = String.format("""
                           <user>
                               <firstName>%s</firstName>
                               <lastName>%s</lastName>
                           </user>
                           """, fn, ln);

然后,获取图 1.6 的结果(右侧)可以这样做:

String xml = String.format("""
                           <user>
                               <firstName>
                                %s
                               </firstName>
                               <lastName>
                                %s
                               </lastName>
                           </user>
                           """, fn, ln);

文本块和 String.format() 的组合是另一种成功的方法,但不是我们可以利用的最新功能。从 JDK 15 开始,String.format() 有一个更方便的伴侣,名为 formatted()。以下是 String.formatted() 的工作原理,以重现图 1.6(左侧):

String xml = """
             <user>
                 <firstName>%s</firstName>
                 <lastName>%s</lastName>
             </user>
             """.formatted(fn, ln);

然后,获取图 1.6 的结果(右侧)可以这样做:

String xml = """
             <user>
                 <firstName>
                  %s
                 </firstName>
                 <lastName>
                  %s
                 </lastName>
             </user>
             """.formatted(fn, ln);

这就是我们能做的最好的方法。我们成功实现了在包含动态部分(变量)的文本块中达到与 IDE 的代码编辑器和运行时相同级别的可读性。酷,不是吗?!

从性能的角度来看,您可以在捆绑的代码中找到这些方法的基准测试。在下图中,您可以看到在一台安装有 Windows 10 的 Intel Core™ i7-3612QM CPU @ 2.10GHz 机器上进行的基准测试的结果,但请随时在不同的机器上进行测试,因为结果高度依赖于机器。

根据这些结果,通过“+”运算符进行连接是最快的,而 MessageFormat.format() 则是最慢的。

9. 在文本块中添加注释

问题: 我们能在文本块中添加注释吗?

官方答案(根据 Java 语言规范): 词法语法意味着注释不会出现在字符字面值、字符串字面值或文本块内。

你可能会尝试像这样的东西,认为这是一个快速的技巧,但我真的不推荐这样做:

String txt = """
             foo  /* some comment */
             buzz //another comment
             """.replace("some_regex","");

简短回答: 不,我们不能在文本块中添加注释。

让我们继续谈论混合普通字符串字面值与文本块的问题。

10. 混合普通字符串字面值与文本块

在混合普通字符串字面值与文本块之前,让我们考虑以下陈述:普通字符串字面值与文本块有多大不同?我们可以通过以下代码片段回答这个问题:

String str = "I love Java!";
String txt = """
             I love Java!""";
System.out.println(str == txt);      // true
System.out.println(str.equals(txt)); // true

哇!我们的代码片段打印了两次 true。这意味着普通字符串字面值和文本块在运行时是相似的。我们可以将文本块定义为跨越多行文本的字符串字面值,并使用三个引号作为其开头和结尾的分隔符。为什么这样?首先,由普通字符串字面值和文本块产生的实例均为 java.lang.String 类型。其次,我们必须查看编译器内部。基本上,编译器将字符串添加到一个名为 String Constant Pool(SCP)的特殊缓存池中(有关 SCP 的更多细节,请参阅《Java 编码问题,第一版,问题 48,不可变字符串》),以优化内存使用,并且从 JDK 13 开始,文本块可以在与字符串相同的池中找到。

既然我们知道普通字符串字面值和文本块在内部处理上没有太大的区别,我们可以放心地在简单的连接中混合它们(基本上,文本块可以在普通字符串字面值可以使用的任何地方使用):

String tom = "Tom";
String jerry = """
               Jerry""";
System.out.println(tom + " and " + jerry); // Tom and Jerry

此外,由于文本块返回一个 String,我们可以使用我们用于普通字符串字面值的全部方法。以下是一个示例:

System.out.println(tom.toUpperCase() + " AND "
  + jerry.toUpperCase()); // TOM AND JERRY

而且,正如您在问题 8 中刚刚看到的那样,《格式化带有变量/表达式的文本块》,文本块可以在 StringBuilder(Buffer)、MessageFormat.format()、String.format() 和 String.formatted() 中使用和混合普通字符串字面值。

11. 在文本块中混合正则表达式

正则表达式可以与文本块一起使用。让我们考虑一个简单的字符串,如下所示:

String nameAndAddress = "Mark Janson;243 West Main St;Louisville;40202;USA";

这里我们有一个名字(Mark Janson)和关于他地址的一些详细信息,由分号(;)分隔。通过正则表达式提取这些信息作为命名组是一种常见的情况。在这个例子中,我们可以按照以下方式考虑五个命名组:

  • name:应该包含人的姓名(Mark Janson)
  • address:应该包含人的街道信息(243 West Main St)
  • city:应该包含人的城市(Louisville)
  • zip:应该包含城市的邮政编码(40202)
  • country:应该包含国家的名称(USA)

一个可以匹配这些命名组的正则表达式可能如下所示:

(?<name>[ a-zA-Z]+);(?<address>[ 0-9a-zA-Z]+);(?<city>[ a-zA-Z]+);(?<zip>[d]+);(?<country>[ a-zA-Z]+)$

这是一个单行字符串,所以我们可以通过 Pattern API 使用它,如下所示:

Pattern pattern = Pattern.compile("(?<name>[ a-zA-Z]+);(?<address>[ 0-9a-zA-Z]+);(?<city>[ a-zA-Z]+);(?<zip>[d]+);(?<country>[ a-zA-Z]+)$");

然而,正如你所见,像这样编写我们的正则表达式对可读性有着严重的影响。幸运的是,我们可以使用文本块来解决这个问题,如下所示:

Pattern pattern = Pattern.compile("""
         (?<name>[ a-zA-Z]+); 
         (?<address>[ 0-9a-zA-Z]+); 
         (?<city>[ a-zA-Z]+); 
         (?<zip>[d]+); 
         (?<country>[ a-zA-Z]+)$"""); 

这样更易读,对吧?我们唯一需要注意的是要使用 JDK 14 的新转义序列 “(一个反斜杠),以移除每行末尾的换行符。

接下来,你可以简单地匹配地址并提取命名组,如下所示:

if (matcher.matches()) {
  String name = matcher.group("name"); 
  String address = matcher.group("address"); 
  String city = matcher.group("city"); 
  String zip = matcher.group("zip"); 
  String country = matcher.group("country"); 
} 

如果你只想提取命名组的名称,那么你可以依赖于 JDK 20 的 namedGroups()

// {country=5, city=3, zip=4, name=1, address=2}
System.out.println(matcher.namedGroups()); 

实际上,namedGroups() 返回一个不可修改的 Map<String, Integer>,其中键是组名,值是组号。此外,JDK 20 还添加了 hasMatch() 方法,如果匹配器包含来自先前匹配或查找操作的有效匹配,则返回 true:

if (matcher.hasMatch()) { ... }

请注意,hasMatch() 不会像 matches() 那样尝试触发模式的匹配。当你需要在代码的不同位置检查有效的匹配时,hasMatch() 更可取,因为它不会执行匹配。因此,你只需要调用一次 matches(),在随后的有效匹配检查中,只需调用 hasMatch()

此外,如果你只需要通过给定的分隔符提取每个命名组的输入子序列,那么你可以依赖于 JDK 21 的 splitWithDelimiters(CharSequence input, int limit)。例如,我们的字符串可以通过分号(正则表达式,;+)分割,如下所示:

String[] result = Pattern.compile(";+").splitWithDelimiters(nameAndAddress, 0);

返回的数组包含提取的数据和分隔符,如下所示:

[Mark Janson, ;, 243 West Main St, ;, Louisville, ;, 40202, ;, USA]

splitWithDelimiters() 的第二个参数是一个整数,表示要应用正则表达式的次数。如果 limit 参数为 0,则模式将尽可能多地应用,尾随的空字符串(无论是子字符串还是分隔符)将被丢弃。如果它是正数,则模式将至多应用 limit – 1 次,如果它是负数,则模式将尽可能多地应用。

12. 检查两个文本块是否同构

如果两个文本块生成的字符串是同构的,则这两个文本块是同构的。如果我们可以将第一个字符串的每个字符与第二个字符串的每个字符进行一一映射,那么两个字符串字面量被认为是同构的。 例如,假设第一个字符串是“abbcdd”,第二个字符串是“qwwerr”。一对一的字符映射如图1.8所示:

老司机带你看Java 编程问题——文本块、区域设置、数字和数学

因此,如图1.8所示,第一个字符串的字符“a”可以被第二个字符串的字符“q”替换。此外,第一个字符串的字符“b”可以被第二个字符串的字符“w”替换,“c”可以被“e”替换,“d”可以被“r”替换。显然,反之亦然。换句话说,这两个字符串是同构的。

那么字符串“aab”和“que”呢?这两个字符串不是同构的,因为“a”不能同时映射到“q”和“u”。 如果我们将这个逻辑推广到文本块,那么图1.9正是我们所需要的:

老司机带你看Java 编程问题——文本块、区域设置、数字和数学

两个文本块在字符行上按一对一方式同构。此外,请注意,必要的空格和行终止符(LF)也应该被映射,而偶发的前导/尾随空格应被忽略。

普通字符串字面量和文本块的算法完全相同,它依赖于哈希(有关此主题的更多详细信息,请参见《Java完整编程面试指南》中的示例6:哈希表),包括以下步骤:

  1. 检查两个文本块(s1和s2)是否具有相同的长度。如果它们的长度不同,则这些文本块不是同构的。
  2. 创建一个空映射,将来自s1的字符(作为键)映射到s2的字符(作为值)。
  3. 从s1(chs1)和s2(chs2)中选取第一个/下一个字符。
  4. 检查chs1是否作为键存在于映射中。
  5. 如果chs1作为键存在于映射中,则它必须映射到s2中等于chs2的值;否则,文本块不是同构的。
  6. 如果chs1作为键不存在于映射中,则映射不应包含chs2作为值;否则,文本块不是同构的。
  7. 如果chs1作为键不存在于映射中且映射不包含chs2作为值,则将(chs1和chs2)放入映射中——chs1作为键,chs2作为值。
  8. 重复步骤3,直到整个文本块(s1)被处理。
  9. 如果整个文本块(s1)被处理,则文本块是同构的。

在代码行中,这个O(n)的算法可以表达如下:

public static boolean isIsomorphic(String s1, String s2) {
    // 步骤1
    if (s1 == null || s2 == null || s1.length() != s2.length()) {
        return false;
    }
    // 步骤2
    Map<Character, Character> map = new HashMap<>();
    // 步骤3(8)
    for (int i = 0; i < s1.length(); i++) {
        char chs1 = s1.charAt(i);
        char chs2 = s2.charAt(i);
        // 步骤4
        if (map.containsKey(chs1)) {
            // 步骤5
            if (map.get(chs1) != chs2) {
                return false;
            }
        } else {
            // 步骤6
            if (map.containsValue(chs2)) {
                return false;
            }
            // 步骤7
            map.put(chs1, chs2);
        }
    }
    // 步骤9
    return true;
}

完成!您可以在捆绑的代码中练习此示例。这是涵盖文本块主题的最后一个问题。现在是时候继续讨论字符串连接了。

13.字符串连接与 StringBuilder

请看下面的普通字符串连接:

String str1 = "I love";
String str2 = "Java";
String str12 = str1 + " " + str2; 

我们知道String类是不可变的(创建的String无法修改)。这意味着创建str12需要一个中间字符串,它表示str1与空格的连接。因此,创建完str12后,我们知道str1 + ” “只是噪音或垃圾,因为我们无法进一步引用它。

在这种情况下,推荐使用StringBuilder,因为它是一个可变的类,我们可以向其附加字符串。因此,下面的语句就诞生了:在Java中,不要使用“+”运算符连接字符串!使用StringBuilder,它更快。 您以前听过这个说法吗?我相信您肯定听说过,特别是如果您仍然在JDK 8甚至更早的版本上运行应用程序。好吧,这个说法不是一个谎言,它在某个时间点肯定是真实的,但在智能编译器的时代它仍然有效吗? 例如,考虑以下两个代码片段,它们表示简单的字符串连接:

老司机带你看Java 编程问题——文本块、区域设置、数字和数学

在 JDK 8 中,从图 1.10 中哪种方法更好?

JDK 8

让我们检查这两个代码片段产生的字节码(使用 javap -c -p 或 Apache Commons Byte Code Engineering Library (BCEL);我们使用了 BCEL)。concatViaPlus() 字节码如下:

老司机带你看Java 编程问题——文本块、区域设置、数字和数学

DK 8编译器足够智能,会在幕后使用StringBuilder来塑造我们通过“+”运算符进行的连接。如果你检查从concatViaStringBuilder()生成的字节码(为简洁起见这里跳过了),你会看到与图1.11大致相似的内容。

在JDK 8中,编译器知道何时以及如何通过StringBuilder优化字节码。换句话说,显式使用StringBuilder并没有显著的优势,超过了通过“+”运算符进行简单连接的方式。在许多简单情况下,这个说法是适用的。那么基准测试的结果如何呢?看看结果:

老司机带你看Java 编程问题——文本块、区域设置、数字和数学

显然,“+”运算符进行的连接在这场比赛中获胜。让我们将这种逻辑重复一次,针对JDK 11。

JDK 11

JDK 11 为 concatViaPlus() 生成了以下字节码:

老司机带你看Java 编程问题——文本块、区域设置、数字和数学

我们可以立即观察到这里有一个很大的不同。这次,拼接是通过调用 invokedynamic(这是一个动态调用)完成的,它充当了我们代码的委托者。在这里,它将代码委托给 makeConcatWithConstants(),这是 StringConcatFactory 类的一个方法。虽然你可以在 JDK 文档中找到这个信息,但请注意,这个类的 API 并不是为了直接调用而创建的。这个类是专门为 invokedynamic 指令的引导方法而设计和创建的。在继续之前,让我们看一个你应该考虑的重要注意事项。

有趣的是,“indify”这个术语来自于 invokedynamic,也称为 indy。它是在 JDK 7 中引入的,并且在 JDK 8 的 lambda 实现中使用。由于这个指令非常有用,它成为了许多其他功能的解决方案,包括 JDK 9 中引入的 JEP 280:Indify String Concatenation。我在这里更喜欢在 JDK 11 中使用它,但这个功能从 JDK 9+ 开始就已经可用了,所以你可以尝试在 JDK 17 或 20 中使用它。

简而言之,invokedynamic 的工作原理如下:

  1. 编译器在拼接点处附加了一个 invokedynamic 调用。
  2. invokedynamic 调用首先执行引导方法 makeConcat[WithConstants]。
  3. invokedynamic 方法调用 makeConcat[WithConstants],这是一个用于调用实际负责拼接的代码的引导方法。
  4. makeConcat[WithConstants] 使用内部策略确定最佳的解决拼接的方法。
  5. 调用最佳方法,进行拼接逻辑。

这样,JEP 280 增加了很大的灵活性,因为 JDK 10、11、12、13 等版本可以使用不同的策略和方法,以最佳方式适应我们上下文中的字符串拼接需求。

那么 concatViaStringBuilder() 的字节码是怎样的呢?这个方法不利用 invokedynamic(它依赖于经典的 invokevirtual 指令),你可以在这里看到:

老司机带你看Java 编程问题——文本块、区域设置、数字和数学

我相信你一定很好奇哪种字节码的性能更好,所以这里是结果:

老司机带你看Java 编程问题——文本块、区域设置、数字和数学

这些基准测试的结果是在一台配有Windows 10的Intel Core™ i7-3612QM CPU @ 2.10GHz的计算机上获得的,但请随时在不同的计算机和不同的JDK版本上进行测试,因为结果高度依赖于计算机。同样,concatViaPlus()再次获胜。在捆绑的代码中,你可以找到这个示例的完整代码。此外,你还会找到检查字节码和通过“+”操作符和StringBuilder在循环中进行连接的基准测试的代码。不妨试试看!

14. 将整数转换为字符串

通常情况下,在Java中,我们可以用多种方式完成一个任务。例如,我们可以通过Integer.toString()将一个int(原始整数)转换为String,如下所示:

public String intToStringV1(int v) {
  return Integer.toString(v);
}

或者,你可以通过一个相当常见的技巧来完成这个任务(代码审查员会在这里挑眉),即将一个空字符串与整数连接起来:

public String intToStringV2(int v) {
  return "" + v;
}

也可以使用String.valueOf(),如下所示:

public String intToStringV3(int v) {
  return String.valueOf(v);
}

通过String.format()的更加晦涩的方法如下:

public String intToStringV4(int v) {
  return String.format("%d", v);
}

这些方法同样适用于包装整数,因此也适用于Integer对象。由于装箱和拆箱是昂贵的操作,我们努力避免它们,除非真的必要。然而,你永远不知道何时一个拆箱操作会“悄悄地”潜入并破坏你的应用程序的性能。为了验证这一点,想象一下,对于前述每种方法,我们还有一个等价的方法,它接收一个Integer而不是一个int。以下是其中的一个示例(其他示例由于篇幅原因被省略):

public String integerToStringV1(Integer vo) {
  return Integer.toString(vo);
}

对所有这些方法进行基准测试的结果如下图所示:

老司机带你看Java 编程问题——文本块、区域设置、数字和数学

从这里我们可以得出两个非常明显的结论:

  1. 使用String.format()非常慢,应该避免在int和Integer的情况下使用它。
  2. 所有使用Integer的解决方案都比使用int原始类型的解决方案慢。因此,即使在这种简单的情况下,也应避免不必要的拆箱,因为它们可能会导致严重的性能损失。

这些基准测试结果是在一台搭载Intel Core™ i7-3612QM CPU @ 2.10GHz处理器的Windows 10机器上获得的,但请随时在不同的机器上进行测试,因为结果高度依赖于机器。

接下来,让我们改变话题,谈谈Java的Locale。

15. 引入字符串模板

直到 JDK 21,Java 允许我们通过不同的方法执行 SQL、JSON、XML 等的字符串组合,之前在问题 8 中有所涵盖。在那个问题中,你可以看到如何使用文本块和嵌入式表达式,通过简单的串联,使用加号 (+) 运算符、StringBuilder.append()、String.format()、formatted() 等。虽然使用加号 (+) 运算符和 StringBuilder.append() 可能会很繁琐并影响可读性,但是 String.format() 和 formatted() 可能会导致类型不匹配。例如,在以下示例中,很容易搞乱数据类型(LocalDate、double 和 String)和格式说明符(%d、%s 和 %.2f):

LocalDate fiscalDate = LocalDate.now();
double value = 4552.2367;
String employeeCode = "RN4555";
String jsonBlock = """
                 {"sale": {
                     "id": 1,
                     "details": {
                         "fiscal_year": %d,
                         "employee_nr": "%s",
                         "value": %.2f
                     }
                 }
                 """.formatted(
                  fiscalDate.getYear(), employeeCode, value);

此外,任何这些方法都无法覆盖输入有效性(因为我们不知道表达式是否有效)和安全性问题(注入,通常会影响 SQL 字符串)。 从 JDK 21 开始,我们可以通过字符串模板(JEP 430)解决这些问题。

什么是字符串模板?

字符串模板(模板表达式)是 JDK 21 中引入的一种预览特性,可以帮助我们高效且安全地执行字符串插值。该特性由三部分组成,如下所示:

  1. 模板处理器(RAW、STR、FMT、用户定义等)
  2. 一个点字符
  3. 包含嵌入式表达式({expression})的字符串模板

RAW、STR 和 FMT 是 JDK 21 提供的三种模板处理器,但是正如你将看到的,我们也可以编写自己的模板处理器。 模板处理器接受一个字符串字面量和适当的表达式,并且能够验证和插值,生成最终结果,该结果可以是字符串或其他领域特定对象(例如 JSON 对象)。如果模板处理器无法成功创建结果,则可能会抛出异常。

STR 模板处理器

STR 模板处理器作为 java.lang.StringTemplate 中的静态字段可用。其目标是为简单的字符串拼接任务提供服务。例如,我们可以使用 STR 重写前面的示例,如下所示:

import static java.lang.StringTemplate.STR;
String jsonBlockStr = STR."""
       {"sale": {
           "id": 1,
           "details": {
               "fiscal_year": {fiscalDate.getYear()},
               "employee_nr": "{employeeCode}",
               "value": {value}
           }
       }
       """;

在这里,我们有三个嵌入式表达式({fiscalDate.getYear()}、{employeeCode} 和 {value}),STR 将处理这些表达式以获得最终的字符串:

{"sale": {
    "id": 1, 
    "details": { 
        "fiscal_year": 2023, 
        "employee_nr": "RN4555", 
        "value": 4552.2367 
    } 
}

正如你所看到的,STR 处理器已经用每个嵌入表达式的字符串值替换了它们。返回的结果是一个字符串,我们可以使用任意数量的嵌入表达式。如果表达式很大,那么你可以在你的 IDE 中将其拆分为多行,而不会在最终结果中引入新的换行符。

FMT 模板处理器

在前面的例子中,我们有一个嵌入表达式 {value},由 STR 评估为 4552.2367。这是正确的,但我们可能想将此值格式化为两位小数,如 4552.24。在这种情况下,我们需要 FMT 处理器,它作为 java.util.FormatProcessor 的静态字段提供,并且能够解释嵌入表达式中存在的格式说明符(STR 无法做到这一点)。因此,使用 FMT 重新编写我们的示例可以如下所示:

String jsonBlockFmt = FMT."""
       {"sale": { 
           "id": 1, 
           "details": { 
               "fiscal_year": {fiscalDate.getYear()}, 
               "employee_nr": "{employeeCode}", 
               "value": %.2f{value}  
           } 
       } 
       """;

请注意,在反斜杠字符之前添加了格式说明符到嵌入表达式(%.2f{value})。这将导致以下字符串:

... 
"value": 4552.24 
...

同样,您可以使用任何其他格式说明符。FMT 将考虑其中的每一个,以返回预期的结果。

RAW 模板处理器

RAW 模板处理器作为 java.lang.StringTemplate 的静态字段提供。调用 RAW 将返回一个 StringTemplate 实例,稍后可以使用它。例如,以下是使用 RAW 单独提取的 StringTemplate:

StringTemplate templateRaw = RAW."""
           "employee_nr": "{employeeCode}", 
           """; 

接下来,我们可以重复使用 templateRaw,就像下面的示例中那样:

LocalDate fiscalDate1 = LocalDate.of(2023, 2, 4); 
LocalDate fiscalDate2 = LocalDate.of(2024, 3, 12); 
double value1 = 343.23; 
double value2 = 1244.33; 
String jsonBlockRaw = STR."""  
       {"sale": { 
           "id": 1, 
           "details": { 
               "fiscal_year": {fiscalDate1.getYear()}, 
               {templateRaw.interpolate()} 
               "value": {value1}      
           } 
       }, 
       {"sale": { 
           "id": 2, 
           "details": { 
               "fiscal_year": {fiscalDate2.getYear()}, 
               {templateRaw.interpolate()} 
               "value": {value2}     
           } 
       }            
       """;  

{templateRaw.interpolate()} 表达式调用 interpolate() 方法,负责处理 templateRaw 中定义的字符串。这就像调用 interpolate() 一样,如下所示:

String employeeCodeString = templateRaw.interpolate(); 

最终结果是以下字符串:

{"sale": {
    "id": 1, 
    "details": { 
       "fiscal_year": 2023, 
       "employee_nr": "RN4555", 
       "value": 343.23 
    } 
}, 
{"sale": { 
    "id": 2, 
     "details": { 
        "fiscal_year": 2024, 
        "employee_nr": "RN4555", 
        "value": 1244.33 
     } 
}

员工代码被评估为 RN4555 字符串。

在嵌入表达式之前的字符序列和在最后一个嵌入表达式之后的字符序列被称为片段。如果字符串模板以嵌入表达式开头,则其片段长度为零。直接相邻的嵌入表达式也是如此。例如,templateRaw (“employee_nr”: “{employeeCode}”,) 的片段是 “employee_nr”: ” 和 “,。我们可以通过 fragments() 方法以 List<String> 的形式访问这些片段。

List<String> trFragments = templateRaw.fragments();

此外,可以通过 values() 方法将嵌入表达式的结果作为 List<Object> 获取,如下所示:

List<Object> trValues = templateRaw.values();

对于 templateRaw,此列表将包含一个条目,即 RN4555。 在捆绑的代码中,您可以找到更多示例,包括使用简单字符串(而不是文本块)的 STR、FMT 和 RAW。

16. 编写自定义模板处理器

内置的 STR 和 FMT 只能返回 String 实例,而且不能抛出异常。但是,它们实际上都是 StringTemplate.Processor<R,E extends Throwable> 函数接口的实例,该接口定义了 process() 方法:

R process(StringTemplate stringTemplate) throws E

通过实现 Processor<R,E extends Throwable> 接口,我们可以编写自定义的模板处理器,返回 R(任何结果类型),而不仅仅是 String。此外,如果在处理过程中出现了问题(例如,存在验证问题),我们可以抛出已检查异常(E extends Throwable)。 例如,假设我们需要插入包含表示电话号码的表达式的字符串。因此,我们只接受符合以下正则表达式的表达式:

private static final Pattern PHONE_PATTERN = Pattern.compile(
  "d{10}|(?:d{3}-){2}d{4}|(d{3})d{3}-?d{4}");

在这种情况下,结果是一个字符串,因此我们的自定义模板处理器可以编写如下:

public class PhoneProcessor implements Processor<String, IllegalArgumentException> {
  private static final Pattern PHONE_PATTERN = ...; 
  @Override
  public String process(StringTemplate stringTemplate) throws IllegalArgumentException { 
    StringBuilder sb = new StringBuilder(); 
    Iterator<String> fragmentsIter = stringTemplate.fragments().iterator(); 
    for (Object value : stringTemplate.values()) { 
      sb.append(fragmentsIter.next()); 
      if (!PHONE_PATTERN.matcher((CharSequence) value).matches()) { 
        throw new IllegalArgumentException("This is not a valid phone number"); 
      } 
      sb.append(value); 
    } 
    sb.append(fragmentsIter.next()); 
    return sb.toString(); 
  } 
} 

现在,我们可以使用一个简单的消息来测试我们的处理器(在这里,我们使用有效的电话号码):

PhoneProcessor pp = new PhoneProcessor();
String workPhone = "072-825-9009"; 
String homePhone = "(040)234-9670"; 
String message = pp."""
   You can contact me at work at {workPhone}
   or at home at {homePhone}. 
   """;

生成的字符串如下:

You can contact me at work at 072-825-9009
or at home at (040)234-9670.

正如您所看到的,我们的处理器依赖于一个 StringBuilder 来获取最终的字符串。然而,我们也可以使用 StringTemplate.interpolate(List<String> fragments, List<?> values) 方法,获得一个更简洁的解决方案,如下所示:

public class PhoneProcessor implements
Processor<String, IllegalArgumentException> { 
  private static final Pattern PHONE_PATTERN = ...; 
  @Override
  public String process(StringTemplate stringTemplate)
  throws IllegalArgumentException { 
    for (Object value : stringTemplate.values()) { 
      if (!PHONE_PATTERN.matcher( 
         (CharSequence) value).matches()) { 
       throw new IllegalArgumentException( 
         "This is not a valid phone number"); 
      } 
    } 
    return StringTemplate.interpolate( 
      stringTemplate.fragments(), stringTemplate.values()); 
  } 
} 

然而,正如我们之前所说,模板处理器可以返回任何类型(R)。例如,假设我们将之前的消息格式化为 JSON 字符串,如下所示:

{
"contact": {
"work": "072-825-9009",
"home": "(040)234-9670"
}
}

这次,我们希望使用表示电话号码的变量来插入字符串,并返回一个 JSON 对象。更确切地说,我们想返回 com.fasterxml.jackson.databind.JsonNode 的实例(这里我们使用 Jackson 库,但也可以使用 GSON、JSON-B 等):

@Override
public JsonNode process(StringTemplate stringTemplate)
throws IllegalArgumentException { 
  for (Object value : stringTemplate.values()) { 
    if (!PHONE_PATTERN.matcher( 
       (CharSequence) value).matches()) { 
      throw new IllegalArgumentException( 
        "This is not a valid phone number"); 
    } 
  } 
  ObjectMapper mapper = new ObjectMapper();
  try { 
    return mapper.readTree(StringTemplate.interpolate( 
      stringTemplate.fragments(), stringTemplate.values())); 
  } catch (IOException ex) { 
      throw new RuntimeException(ex);
  }
} 

这次,返回的类型是 JsonNode:

PhoneProcessor pp = new PhoneProcessor(); 
String workPhone = "072-825-9009"; 
String homePhone = "(040)234-9670"; 
JsonNode jsonMessage = pp.""" 
  { "contact": { 
       "work": "{workPhone}", 
       "home": "{homePhone}" 
       } 
  }   
  """;

在捆绑的代码中,您还可以找到一个示例,该示例使用 lambda 表达式编写了前面的自定义模板处理器。此外,您还可以找到一个示例,在该示例中,我们不是为无效的表达式抛出异常,而是将无效的值替换为默认值。

17. 创建 Locale

Java Locale(java.util.Locale)代表一个对象,其中包装了有关特定地理、政治或文化区域的信息 – 也就是说,这是一个用于国际化目的的实用对象。Locale通常与 DateFormat/DateTimeFormatter 结合使用,用于表示特定国家的日期时间格式,使用 NumberFormat(或其子类 DecimalFormat)表示特定国家的数字格式(例如,表示特定货币的金额),或者与 MessageFormat 一起用于为特定国家创建格式化消息。

对于最流行的区域设置,Java提供了一组常量(例如,Locale.GERMANY、Locale.CANADA等)。对于不在此列表中的区域设置,我们必须使用几个 RFC 中定义的格式。最常见的是使用语言模式(例如,罗马尼亚语的 ro)或语言_国家模式(例如,罗马尼亚的 ro_RO,美国的 en_US等)。有时,我们可能需要语言_国家_变体模式,其中变体可用于映射由软件供应商(如浏览器或操作系统)添加的附加功能,例如,de_DE_WIN 是用于德国的德语区域设置(适用于Windows)。然而,有两个区域设置被视为不符合规范:ja_JP_JP(表示日本使用的日语)和 th_TH_TH(表示泰国使用的泰语,包括泰文数字)。

虽然您可以从其全面的文档中了解有关 Locale 的更多信息,但我们要提到,在 JDK 19 之前,我们可以通过其三个构造函数之一创建 Locale – 最常见的是通过 Locale(String language, String country) 构造函数,如下所示:

Locale roDep = new Locale("ro", "RO"); // 用于罗马尼亚的区域设置

当然,如果您的 Locale 已经有一个已定义的常量,您可以在代码中需要的地方直接嵌入该常量,或者简单地声明一个 Locale,如下所示(这里是德国):

Locale de = Locale.GERMANY; // de_DE

另一种方法依赖于 Locale.Builder,通过一系列的 setter 方法:

Locale locale = new Locale.Builder()
  .setLanguage("ro").setRegion("RO").build(); 

或者,可以通过 Locale.forLanguageTag() 方法来执行此操作,以遵循 IETF BCP 47 标准的语言标签(这对于表示诸如中国特定的中文、普通话、简化脚本和“zh-cmn-Hans-CN”等复杂标签可能很有用):

Locale locale = Locale.forLanguageTag("zh-cmn-Hans-CN");

此外,Java 支持语言范围。这意味着我们可以定义一组具有特定属性的语言标签。例如,“de-*”表示一个语言范围,以识别任何地区的德语:

Locale.LanguageRange lr1
= new Locale.LanguageRange("de-*", 1.0);
Locale.LanguageRange lr2
= new Locale.LanguageRange("ro-RO", 0.5);
Locale.LanguageRange lr3
= new Locale.LanguageRange("en-*", 0.0);

前面的 Locale.LanguageRange() 构造函数接受两个参数:语言范围及其权重(1.0、0.5、0.0)。通常,此权重反映用户的偏好(1.0 最高,0.0 最低)。权重对于定义优先级列表非常有用,如下所示(我们更喜欢 Castilian Spanish(西班牙)而不是 Mexican Spanish 以及 Brazilian Portuguese):

String rangeString = "es-ES;q=1.0,es-MX;q=0.5,pt-BR;q=0.0";
List<Locale.LanguageRange> priorityList
  = Locale.LanguageRange.parse(rangeString);

请注意,要定义有效的偏好字符串,以便 parse() 方法能够正常工作。 从 JDK 19 开始,Locale 的三个构造函数已被弃用,我们可以依靠三个静态的 of() 方法。通过适当的 of() 方法,上述代码的等效形式是:

Locale ro = Locale.of("ro", "RO"); // ro_RO

这里还有两个例子:

Locale de = Locale.of("de" ,"DE", "WIN");
Locale it = Locale.of("it"); // 类似于 Locale.ITALIAN

使用 Locale 很简单。以下是使用之前的 ro 来格式化罗马尼亚和意大利的日期时间的示例:

// 2023年1月7日,14:57:42 EET
DateFormat rodf = DateFormat.getDateTimeInstance(
  DateFormat.LONG, DateFormat.LONG, ro);
// 2023年1月7日 下午3:05:29 OEZ
DateFormat dedf = DateFormat.getDateTimeInstance(
  DateFormat.LONG, DateFormat.LONG, de);

在下一个问题中,我们将继续探讨区域设置的旅程。

18. 自定义本地化日期时间格式

从 JDK 8 开始,我们拥有一个包含诸如 LocalDate、LocalTime、LocalDateTime、ZonedDateTime、OffsetDateTime 和 OffsetTime 等类的全面日期时间 API。 我们可以通过 DateTimeFormatter.ofPattern() 轻松地格式化这些类返回的日期时间输出。例如,在这里,我们使用 y-MM-dd HH:mm:ss 模式格式化一个 LocalDateTime:

// 2023-01-07 15:31:22
String ldt = LocalDateTime.now()
  .format(DateTimeFormatter.ofPattern("y-MM-dd HH:mm:ss"));

更多示例可在捆绑的代码中找到。 那么,如何根据给定的区域设置(例如,德国)自定义我们的格式呢?

Locale.setDefault(Locale.GERMANY);

我们可以通过 ofLocalizedDate()、ofLocalizedTime() 和 ofLocalizedDateTime() 来实现,如下例所示:

// 7. Januar 2023
String ld = LocalDate.now().format(
  DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG));
// 15:49
String lt = LocalTime.now().format(
  DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT));
// 07.01.2023, 15:49:30
String ldt = LocalDateTime.now().format(
  DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM));

我们也可以使用:

// Samstag, 7. Januar 2023 um 15:49:30
// Osteuropische Normalzeit
String zdt = ZonedDateTime.now().format(
  DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL));
// 07.01.2023, 15:49:30
String odt = OffsetDateTime.now().format(
  DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM));
// 15:49:30
String ot = OffsetTime.now().format(
  DateTimeFormatter.ofLocalizedTime(FormatStyle.MEDIUM));

本地化的日期、时间或日期时间格式化器支持四种格式样式:

  • FULL:使用所有详细信息进行格式化。
  • LONG:使用大量细节但不是全部。
  • MEDIUM:具有一些详细信息的格式。
  • SHORT:尽可能简短的格式(通常是数字)。

根据本地化部件和格式样式之间的组合,代码可能会出现异常,如 DateTimeException: Unable to extract….. 如果您看到这样的异常,那么现在是查阅以下表格的时候了,该表格提供了被接受的组合:

老司机带你看Java 编程问题——文本块、区域设置、数字和数学

此外,从 JDK 19 开始,我们也可以使用 ofLocalizedPattern(String pattern)。 我们可以传递任何在图 1.18 中显示的模式。

老司机带你看Java 编程问题——文本块、区域设置、数字和数学

注意格式:接下来,让我们将当前区域设置更改为罗马尼亚:

Locale.setDefault(Locale.of("ro", "RO"));

我们还来看一些 ofLocalizedPattern() 的示例:

// 01.2023
String ld = LocalDate.now().format(
  DateTimeFormatter.ofLocalizedPattern("yMM"));
// 15:49
String lt = LocalTime.now().format(
  DateTimeFormatter.ofLocalizedPattern("Hm"));
// 01.2023, 15:49
String ldt = LocalDateTime.now().format(
  DateTimeFormatter.ofLocalizedPattern("yMMHm"));

还有更多:

// 01.2023, 15:49:30 EET
String zdt = ZonedDateTime.now().format(
  DateTimeFormatter.ofLocalizedPattern("yMMHmsv"));
// 01.2023, 15:49:30
String odt = OffsetDateTime.now().format(
  DateTimeFormatter.ofLocalizedPattern("yMMHms"));
// 15:49:30
String ot = OffsetTime.now().format(
  DateTimeFormatter.ofLocalizedPattern("Hms"));

您可以在捆绑的代码中练习所有这些示例。此外,在捆绑的代码中,您可以找到一个应用程序,该应用程序使用区域设置和 NumberFormat 为不同的区域设置(货币)格式化版税金额。

19.恢复始终严格的浮点语义

浮点运算并不简单!甚至一些简单的算术性质也不适用于此类计算。例如,浮点加法或乘法不是结合的。换句话说,(x + y)+ z 不等于 x +(y + z),其中 x、y 和 z 是实数。下面是一个快速测试乘法结合性的示例:

double x = 0.8793331;
double y = 12.22933;
double z = 901.98334884433;
double m1 = (x * y) * z;   // 9699.617442382583 
double m2 = (x * (y * z)); // 9699.617442382581
// m1 == m2 返回 false

这意味着浮点运算是对实数运算的一种系统化近似。由于某些限制,计算机必须进行近似。例如,精确的浮点输出很快就会变得非常大。此外,确切的输入是未知的,因此在不精确的输入情况下,很难获得精确的输出。 为解决这个问题,Java 必须采用舍入策略。换句话说,Java 必须使用一种特殊的函数,能够将实值映射到浮点值。

如今,Java 使用所谓的“四舍五入到最近值”策略。该策略试图将不精确的值四舍五入到最接近无限精确结果的值。在相等的情况下(其中可表示的值与不精确的值等距离),具有零最高有效位的值为获胜者。 此外,浮点运算可能在不同平台上产生不同的输出。换句话说,在不同的芯片架构上运行浮点计算(例如,16位、32位或64位处理器)可能会导致不同的结果。Java 通过 strictfp 修饰符解决了这个问题。

该关键字遵循 IEEE 754 浮点计算标准,并在 JDK 1.2 中引入。

假设我们需要实现一个科学计算器。显然,我们的计算器必须在各个平台上提供一致的结果,因此我们使用 strictfp,如下所示:

public strictfp final class ScientificCalculator {
  private ScientificCalculator() {
    throw new AssertionError("Cannot be instantiated");
  }
  public static double multiply(final double v1, final double v2) {
    return v1 * v2;
  }
  public static double division(final double v1, final double v2) { 
    return v1 / v2;
  }
  // 更多计算方法
}

使用 strictfp 修饰符的类保证了该类的所有成员方法都利用了其效果。现在,我们在各个平台上都有一致的结果。您可以在捆绑的代码中找到此示例。

当在接口上使用 strictfp 修饰符时,有一些重要的要点需要考虑,如下所示:

  • 它不适用于接口中声明的抽象方法。
  • 它适用于接口中声明的默认方法。
  • 它不适用于实现接口的类中定义的方法。
  • 它适用于接口内部类中声明的所有方法。

例如,考虑以下 strictfp 接口:

public strictfp interface Rectangle {
  default double area(double length, double width) {
    ...
  } 
  double diagonal(double length, double width);
  public class Trigonometry { 
    public static double smallAngleOfDiagonals(
 double length, double width) {
      ...
    }
    public static double bigAngleOfDiagonals(
 double length, double width) {
      ...
    }
  }
}

此外,还有一个不是 strictfp 的类实现了上述 strictfp 接口:

public class Main implements Rectangle {
  @Override
public double diagonal(double length, double width) {
    ...
  }
  public double perimeter(double length, double width) {
    ...
  }
}

为了找出哪些成员是 strictfp 的,让我们运行一小段 Java 反射代码来显示每个方法的修饰符:

public static void displayModifiers(
                          Class clazz, String member) {
  try {
    int modifiers = clazz.getDeclaredMethod(member, 
      double.class, double.class).getModifiers();
    System.out.println(member + " has the following 
    modifiers: " + Modifier.toString(modifiers));
  } catch (NoSuchMethodException | SecurityException e) {
    e.printStackTrace(System.out);
  }
}

然后,让我们调用这个方法:

// public
displayModifiers(Main.class, "diagonal");
// public
displayModifiers(Main.class, "perimeter");
// public abstract
displayModifiers(Main.class.getInterfaces()[0], "diagonal");
// public strictfp
displayModifiers(Main.class.getInterfaces()[0], "area");
// public static strictfp
displayModifiers(Rectangle.Trigonometry.class,  
  "smallAngleOfDiagonals");
// public static strictfp
displayModifiers(Rectangle.Trigonometry.class,  
  "bigAngleOfDiagonals");

正如您所见,对于我们所有的方法,都没有 strictfp 修饰符。因此,如果我们需要在 perimeter() 和 diagonal() 上使用 strictfp,则必须手动添加:

@Override
strictfp public double diagonal(double length, double width) {
  ...
}
strictfp public double perimeter(double length, double width) {
  ...
}

然而,从 JDK 17 开始,这个领域有了一些重大的变化。

JEP 306 的好消息不仅对我们开发者是一个利好,还支持了几个 Java 类,比如 java.lang.Math 和 java.lang.StrictMath,使它们变得更加健壮和易于实现。

20. 计算 int/long 的数学绝对值和结果溢出

数学绝对值用两个竖线符号包围数值表示,并计算如下:∣x∣=x,∣−x∣=x|x| = x, |-x| = x

它通常用于计算/表示距离。例如,想象一下,0 表示海平面,我们有一个潜水员和一个登山者。潜水员在水下的深度为 -45 英尺(注意我们使用负数来表示潜水员在水中的深度)。同时,登山者已经爬了 30 英尺高。谁更接近海平面(0)?我们可能认为由于 -45 < 30,潜水员更接近,因为其值更小。然而,我们可以通过应用数学绝对值轻松找到正确的答案,如下所示: ∣−45∣=45,∣30∣=30∣−45∣=45,∣30∣=30

45 > 30,因此登山者更接近海平面(0)。

现在,让我们通过以下示例深入了解解决方案:

int x = -3;
int absofx = Math.abs(x); // 3

这是 Math.abs() 的一个非常简单的用例,它返回给定整数的数学绝对值。现在,让我们将此方法应用于以下大数字:

int x = Integer.MIN_VALUE; // -2,147,483,648    
int absofx = Math.abs(x);  // -2,147,483,648

这不好!由于 ∣Integer.MINVALUE∣>∣Integer.MAXVALUE∣∣Integer.MIN_VALUE∣>∣Integer.MAX_VALUE∣int 域已经溢出了。预期结果是 2,147,483,648 的正值,这不适合 int 域。然而,将 x 类型从 int 更改为 long 将解决该问题:

javaCopy code
long x = Integer.MIN_VALUE; // -2,147,483,648    
long absofx = Math.abs(x);  // 2,147,483,648

但是,如果不是 Integer.MIN_VALUE,而是 Long.MIN_VALUE,问题将重新出现:

javaCopy code
long y = Long.MIN_VALUE;// -9,223,372,036,854,775,808
long absofy = Math.abs(y); // -9,223,372,036,854,775,808

从 JDK 15 开始,Math 类新增了两个 absExact() 方法,一个用于 int,一个用于 long。如果数学绝对值的结果可能导致 int 或 long 域溢出(例如,Integer/Long.MIN_VALUE 值溢出了正 int/long 范围),在这种情况下,这些方法会抛出 ArithmeticException,而不是返回误导性的结果,示例如下:

javaCopy code
int absofxExact = Math.absExact(x);  // ArithmeticException
long absofyExact = Math.absExact(y); // ArithmeticException

在函数式风格上下文中,一个潜在的解决方案将依赖于 UnaryOperator 函数接口,如下所示:

javaCopy code
IntUnaryOperator operatorInt = Math::absExact;
LongUnaryOperator operatorLong = Math::absExact;
// 两者都会抛出 ArithmeticException
int absofxExactUo = operatorInt.applyAsInt(x);
long absofyExactUo = operatorLong.applyAsLong(y);

在处理大数字时,还要关注 BigInteger(不可变的任意精度整数)和 BigDecimal(不可变的任意精度有符号十进制数)。

  1. 计算参数的商和结果溢出

让我们从两个简单的计算开始,如下所示:−4/−1=4,4/−1=−4-4/-1 = 4, 4/-1 = -4

这是一个非常简单的使用案例,按预期工作。现在,让我们保持除数为 -1,并将被除数更改为 Integer.MIN_VALUE(-2,147,483,648):

int x = Integer.MIN_VALUE;
int quotient = x/-1; // -2,147,483,648

这次,结果是不正确的。由于 ∣Integer.MIN_VALUE∣>∣Integer.MAX_VALUE∣,int 域已经溢出了。应该是正值 2,147,483,648,这不适合 int 域。然而,将 x 类型从 int 更改为 long 将解决该问题:

long x = Integer.MIN_VALUE;
long quotient = x/-1; // 2,147,483,648

但是,如果不是 Integer.MIN_VALUE,而是 Long.MIN_VALUE,则问题将重新出现:

long y = Long.MIN_VALUE; // -9,223,372,036,854,775,808
long quotient = y/-1;    // -9,223,372,036,854,775,808

从 JDK 18 开始,Math 类新增了两个 divideExact() 方法,一个用于 int,一个用于 long。如果除法结果可能导致 int 或 long 溢出(例如,Integer/Long.MIN_VALUE 值溢出了正 int/long 范围),在这种情况下,这些方法会抛出 ArithmeticException,而不是返回误导性的结果,示例如下:

// 抛出 ArithmeticException
int quotientExact = Math.divideExact(x, -1);

在函数式风格上下文中,一个潜在的解决方案将依赖于 BinaryOperator 函数接口,如下所示:

// 抛出 ArithmeticException
BinaryOperator<Integer> operator = Math::divideExact;
int quotientExactBo = operator.apply(x, -1);

正如我们在前面的问题中提到的,处理大数字时,还要关注 BigInteger(不可变的任意精度整数)和 BigDecimal(不可变的任意精度有符号十进制数)。

22. 计算比代数商小/大的最大/最小值

所谓最大值,我们理解为最接近正无穷的值,而所谓最小值,我们理解为最接近负无穷的值。 通过 floorDiv(int x, int y) 和 floorDiv(long x, long y) 可以计算比代数商小于或等于的最大值,这从 JDK 8 开始就可以实现。从 JDK 9 开始,我们还可以使用 floorDiv(long x, int y)。

通过 ceilDiv(int x, int y)、ceilDiv(long x, int y) 和 ceilDiv(long x, long y) 可以计算比代数商大于或等于的最小值,这从 JDK 18 开始可行。 然而,这些函数都无法处理前面问题中提到的特殊情况,即 Integer.MIN_VALUE/-1 和 Long.MIN_VALUE/-1:

int x = Integer.MIN_VALUE; // 或者 x = Long.MIN_VALUE
Math.floorDiv(x, -1); // -2,147,483,648
Math.ceilDiv(x, -1);  // -2,147,483,648

从 JDK 18 开始,每当 floorDiv()/ceilDiv() 返回的结果可能溢出 int 或 long 域时,我们可以使用 floorDivExact() 和 ceilDivExact()。这些方法具有 int 和 long 参数的不同版本。正如你可能已经直觉到的那样,这些方法会抛出 ArithmeticException,而不是返回误导性的结果,如下例所示:

// 抛出 ArithmeticException
int resultFloorExact = Math.floorDivExact(x, -1);
// 抛出 ArithmeticException
int resultCeilExact = Math.ceilDivExact(x, -1);

在函数式风格上下文中,一个潜在的解决方案将依赖于 BinaryOperator 函数接口,如下所示:

// 抛出 ArithmeticException
BinaryOperator<Integer> operatorf = Math::floorDivExact;
int floorExactBo = operatorf.apply(x, -1);
// 抛出 ArithmeticException
BinaryOperator<Integer> operatorc = Math::ceilDivExact;
int ceilExactBo = operatorc.apply(x, -1);

完成!正如你已经知道的,处理大数字时,还要关注 BigInteger(不可变的任意精度整数)和 BigDecimal(不可变的任意精度有符号十进制数)。它们可能会拯救你的一天。

23. 从双精度数获取整数部分和小数部分

你知道那种问题,如果你知道解决方案,就非常容易,但如果不知道,看起来就很困难?这正是那种类型的问题。解决方案非常简单,如下所示的代码所示:

double value = -9.33543545;
double fractionalPart = value % 1;
double integralPart = value - fractionalPart;

这很简单;我不认为你需要进一步的解释。但这种方法并不是很准确。我的意思是,整数部分是 -9,但返回的是 -9.0。而且,小数部分是 -0.33543545,但返回的值是 -0.3354354500000003。 如果我们需要更准确的结果,那么使用 BigDecimal 更有用:

BigDecimal bd = BigDecimal.valueOf(value);
int integralPart = bd.intValue();
double fractionalPart = bd.subtract(
       BigDecimal.valueOf(integralPart)).doubleValue();

这一次,结果是 -9 和 -0.33543545。

24. 测试一个双精度数是否为整数

首先,让我们考虑以下预期结果(false 意味着双精度数不是整数):

double v1 = 23.11;                    // false
double v2 = 23;                       // true
double v3 = 23.0;                     // true
double v4 = Double.NaN;               // false
double v5 = Double.NEGATIVE_INFINITY; // false
double v6 = Double.POSITIVE_INFINITY; // false

最有可能的是,测试双精度数是否为整数的第一个解决方案包括一个简单的类型转换,如下所示:

public static boolean isDoubleIntegerV1(double v) {
  return v == (int) v;
}

然而,还有几种其他选择。例如,我们可以依赖于取模运算,如下所示:

public static boolean isDoubleIntegerV2(double v) {
  return v % 1 == 0;
}

或者,我们可以依赖于 Math.floor() 和 Double.isFinite() 方法。如果给定的双精度数是有限数,并且等于 Math.floor() 的结果,那么它就是一个整数:

public static boolean isDoubleIntegerV3(double v) {
  return ((Math.floor(v) == v) && Double.isFinite(v));
}

我们还可以通过 Math.ceil() 来替换这个等式:

public static boolean isDoubleIntegerV4(double v) {
  return (Math.floor(v) == Math.ceil(v) 
                        && Double.isFinite(v));
}

此外,我们还可以将 Double.isFinite() 与 Math.rint() 结合起来,如下所示:

public static boolean isDoubleIntegerV5(double v) {
  return ((Math.rint(v) == v) && Double.isFinite(v));
}

最后,我们可以依赖于 Guava 的 DoubleMath.isMathematicalInteger() 方法:

public static boolean isDoubleIntegerV6(double v) {
  return DoubleMath.isMathematicalInteger(v);
}

但是这些方法中哪一个性能更好呢?你会选择哪一个?好吧,让我们看看基准测试的结果。

老司机带你看Java 编程问题——文本块、区域设置、数字和数学

基于这些结果,结论非常明显 – 应该避免依赖取模运算的解决方案。此外,Guava解决方案似乎比其他解决方案稍慢。

25. Java中(无)符号整数的概述

有符号值(或变量)如有符号整数或有符号长整数允许我们表示负数和正数。

无符号值(或变量)如无符号整数或无符号长整数只允许我们表示正数。

相同类型的有符号和无符号值(变量)共享相同的范围。然而,如下图所示,无符号变量覆盖了更大数量级的数字。

老司机带你看Java 编程问题——文本块、区域设置、数字和数学

有符号的32位整数范围从-2,147,483,648到2,147,483,647(约40亿个值)。无符号的32位整数范围从0到4,294,967,295(同样约40亿个值)。

因此,当我们使用有符号整数变量时,我们可以使用20亿个正值,但当我们使用无符号整数变量时,我们可以使用40亿个正值。图1.20中的阴影部分代表额外的20亿个正整数值。

通常,当我们根本不需要负值时(例如,用于计数事件发生次数等情况),我们需要使用图1.20中阴影区域中的值。

Java仅支持使用流行的二进制补码表示法的有符号整数(关于二进制补码表示法和位操作的详细解释,请参阅《Java完全编码面试指南》第9章《位操作》)。然而,从JDK 8开始,我们还拥有了Unsigned Integer API,它增加了对无符号算术的支持。

此外,JDK 9带来了一个名为Math.multiplyHigh(long x, long y)的方法。此方法返回一个long,表示两个64位因数的128位乘积的最高64位。下图阐明了这个说法:

老司机带你看Java 编程问题——文本块、区域设置、数字和数学

例如:

long x = 234253490223L;
long y = -565951223449L;
long resultSigned = Math.multiplyHigh(x, y); // -7187

返回的结果(-7187)是一个有符号值。这个方法的无符号版本unsignedMultiplyHigh(long x, long y)是在JDK 18中引入的,它的工作原理如下:

// 234253483036
long resultUnsigned = Math.unsignedMultiplyHigh(x, y);

因此,unsignedMultiplyHigh(long x, long y)返回一个long,表示两个无符号64位因数的无符号128位乘积的最高64位。

然而,请记住,Java支持无符号算术,而不是无符号值/变量。不过,感谢Data Geekery公司(以著名的jOOQ而闻名),我们有了jOOU(Java面向对象的无符号)项目,旨在将无符号数类型引入Java。虽然您可以在github.com/jOOQ/jOOU 探索该项目,以下是定义无符号long的示例:

// 使用jOOU
ULong ux = ulong(234253490223L);  // 234253490223
ULong uy = ulong(-565951223449L); // 18446743507758328167

下面是在unsignedMultiplyHigh(long x, long y)中使用的例子:

long uResultUnsigned = Math.unsignedMultiplyHigh(
    ux.longValue(), uy.longValue());

您可以在捆绑的代码中找到这些示例。

26.返回 floor/ceil 模数

基于被除数/除数=商的计算,我们知道应用于(被除数,除数)对的 floor 操作返回小于或等于代数商的最大整数。所谓的最大整数是指最接近正无穷大的整数。从 JDK 8 开始,可以通过 Math.floorDiv() 来获得此操作,从 JDK 18 开始,可以通过 Math.floorDivExact() 来获得。

另一方面,应用于(被除数,除数)对的 ceil 操作返回大于或等于代数商的最小整数。所谓的最小整数是指最接近负无穷大的整数。从 JDK 18 开始,可以通过 Math.ceilDiv() 和 Math.ceilDivExact() 来获得此操作。

更多细节请参见问题 22。

现在,基于 floor 和 ceil 操作,我们可以定义如下 floor/ceil 模数关系:

  • Floor_Modulus = 被除数 – (floorDiv(被除数,除数) * 除数)
  • Ceil_Modulus = 被除数 – (ceilDiv(被除数,除数) * 除数)

因此,我们可以在代码中写成:

int dividend = 162;
int divisor = 42;   // 162 % 42 = 36
int fd = Math.floorDiv(dividend, divisor);
int fmodJDK8 = dividend - (fd * divisor); // 36
int cd = Math.ceilDiv(dividend, divisor);
int cmodJDK18 = dividend - (cd * divisor); // -6

从 JDK 8 开始,可以通过 Math.floorMod() 获得 floor 模数,如下所示:

int dividend = 162;
int divisor = 42;
int fmodJDK8 = Math.floorMod(dividend, divisor); // 36 

这里,我们使用了 floorMod(int dividend, int divisor)。但我们还可以使用两种更多的变体:floorMod(long dividend, long divisor) 和从 JDK 9 开始的 floorMod(long dividend, int divisor)。

如果被除数 % 除数为 0,则 floorMod() 为 0。如果被除数 % 除数和 floorMod() 都不为 0,则它们的结果只有在参数的符号不同时才不同。

从 JDK 18 开始,可以通过 Math.ceilMod() 获得 ceil 模数,如下所示:

int cmodJDK18 = Math.ceilMod(dividend, divisor); // -6

这里,我们使用了 ceilMod(int dividend, int divisor)。但我们还可以使用两种更多的变体:ceilMod(long dividend, int divisor) 和 ceilMod(long dividend, long divisor)。

如果被除数 % 除数为 0,则 ceilMod() 为 0。如果被除数 % 除数和 ceilMod() 都不为 0,则它们的结果只有在参数的符号相同时才不同。

此外,floorMod() 和 floorDiv() 之间的关系如下:

被除数 == floorDiv(被除数, 除数) * 除数 + floorMod(被除数, 除数)

而 ceilMod() 和 ceilDiv() 之间的关系如下:

被除数 == ceilDiv(被除数, 除数) * 除数 + ceilMod(被除数, 除数)

请注意,如果除数为 0,则 floorMod() 和 ceilMod() 都会抛出 ArithmeticException。

27.收集给定数的所有质因数

一个质数是一个只能被自身和1整除的数(例如,2、3和5都是质数)。对于给定的数,我们可以提取其质因数,如下图所示:

老司机带你看Java 编程问题——文本块、区域设置、数字和数学

90的质因数是2、3、3和5。根据图1.22,我们可以创建一个解决这个问题的算法,如下所示:

  1. 定义一个列表来收集给定v的质因数。
  2. 使用2(最小的质数)初始化变量s。
  3. 如果v % s等于0,则将s作为质因数收集,并计算新的v为v / s。
  4. 如果v % s不等于0,则将s增加1。
  5. 重复步骤3,直到v大于1为止。

在代码中,这个O(n)算法(对于复合数是O(log n))可以表示如下:

public static List<Integer> factors(int v) {
  List<Integer> factorsList = new ArrayList<>();
  int s = 2;
  while (v > 1) {
    // 每次完全除法都给我们一个质因数
    if (v % s == 0) {
      factorsList.add(s);
      v = v / s;
    } else {
      s++;
    }
  }
  return factorsList;
}

在捆绑的代码中,您可以找到另外两种方法。此外,您还会找到一个应用程序,用于计算小于给定数v(v应为正数)的质数数量。

28. 使用巴比伦方法计算一个数的平方根

信不信由你,古巴比伦人(约公元前1500年)早在牛顿发现的流行方法之前就知道如何估计平方根了。

从数学上讲,用于估算v > 0的平方根的巴比伦方法是根据以下图中的递推关系:

老司机带你看Java 编程问题——文本块、区域设置、数字和数学

递归公式从初始猜测值x0开始。接下来,我们通过将xn-1代入右侧公式并评估表达式来计算x1、x2、…、xn。

例如,让我们尝试将此公式应用于估算65的平方根(结果为8.06)。让我们将x0设为65/2,即x0 = 32.5,并计算x1如下:

有了x1,我们可以如下计算x2:

有了x2,我们可以如下计算x3:

我们越来越接近最终结果。有了x3,我们可以如下计算x4:

完成!经过四次迭代,我们发现65的平方根为8.06。当然,作为真实值的近似值,我们可以继续直到达到所需的精度。更高的精度需要更多的迭代。

基于巴比伦方法来近似计算v > 0的平方根的算法有以下几个步骤:

  1. 首先,选择一个任意的正值x(它越接近最终结果,需要的迭代次数就越少)。例如,我们从x = v/2作为初始猜测开始。

  2. 初始化y = 1,并选择所需的精度(例如,e = 0.000000000001)。

  3. 直到达到精度(e)为止,执行以下操作:

    • 将下一个近似值(xnext)计算为x和y的平均值。
    • 使用下一个近似值设置y为v/xnext。

因此,代码行中,我们有以下片段:

public static double squareRootBabylonian(double v) {
  double x = v / 2;
  double y = 1;
  double e = 0.000000000001; // 精度
while (x - y > e) {
    x = (x + y) / 2;
    y = v / x;
  }
  return x;
}

在捆绑的代码中,您还可以看到一个实现,如果知道v是一个完全平方数(例如,25、144、169等),则很有用。

29. 将浮点数四舍五入到指定的小数位数

考虑以下浮点数以及我们想要保留的小数位数:

float v = 14.9877655f;
int d = 5;

因此,经过四舍五入后的期望结果是14.98777。

我们可以以至少三种简单直接的方式解决这个问题。例如,我们可以依赖于BigDecimal API,如下所示:

public static float roundToDecimals(float v, int decimals) {
  BigDecimal bd = new BigDecimal(Float.toString(v));
  bd = bd.setScale(decimals, RoundingMode.HALF_UP);
  return bd.floatValue();
}

首先,我们从给定的float创建一个BigDecimal数。其次,我们将这个BigDecimal缩放到所需的小数位数。最后,我们返回新的float值。

另一种方法可以依赖于DecimalFormat,如下所示:

public static float roundToDecimals(float v, int decimals) {
  DecimalFormat df = new DecimalFormat();
  df.setMaximumFractionDigits(decimals);
  return Float.parseFloat(df.format(v));
}

我们通过setMaximumFractionDigits()定义格式,然后简单地在给定的float上使用此格式。返回的字符串通过Float.parseFloat()转换为最终的float。

最后,我们可以应用一种更加玄妙但自解释的方法,如下所示:

public static float roundToDecimals(float v, int decimals) {
  int factor = Integer.parseInt(
               "1".concat("0".repeat(decimals)));
  return (float) Math.round(v * factor) / factor;
}

您可以在捆绑代码中练习这些示例。随意添加您自己的解决方案。

30. 将值夹在最小值和最大值之间

假设我们有一个压力调节器,它能够在一定范围内调节给定的压力。例如,如果传入的压力低于最小压力,则调节器会将压力增加到最小压力。另一方面,如果传入的压力高于最大压力,则调节器会将压力减少到最大压力。此外,如果传入的压力在最小(包括)和最大(包括)压力之间,则什么都不会发生——这是正常的压力。

编写这种情景的代码可以直接进行,如下所示:

private static final int MIN_PRESSURE = 10;
private static final int MAX_PRESSURE = 50; 
public static int adjust(int pressure) { 
  if (pressure < MIN_PRESSURE) { 
    return MIN_PRESSURE; 
  } 
  if (pressure > MAX_PRESSURE) { 
    return MAX_PRESSURE; 
  } 
  return pressure; 
}

不错!您可以发现用更短更巧妙的方式表达这段代码的不同方法,但是从JDK 21开始,我们可以通过Math.clamp()方法来解决这个问题。这个方法的一个版本是clamp(long value, int min, int max),它将给定值夹在给定的最小值和最大值之间。例如,我们可以通过clamp()方法重写前面的代码,如下所示:

public static int adjust(int pressure) {
  return Math.clamp(pressure, MIN_PRESSURE, MAX_PRESSURE);
}

酷,对吧!clamp()方法背后的逻辑依赖于以下代码行:

return (int) Math.min(max, Math.max(value, min));

clamp()的其他版本有clamp(long value, long min, long max)、clamp(float value, float min, float max)和clamp(double value, double min, double max)。

31.在不使用循环、乘法、位运算、除法和操作符的情况下,计算两个整数的乘积。

这个问题的解决方案可以从以下代数公式开始,也被称为特殊二项式乘积公式:

老司机带你看Java 编程问题——文本块、区域设置、数字和数学

现在我们已经得到了ab的乘积,只剩下一个问题。ab的公式包含除以2的操作,而我们不允许显式使用除法运算。然而,可以通过递归方式模拟除法操作,如下所示:

javaCopy code
private static int divideByTwo(int d) {
  if (d < 2) {
    return 0;
  }
  return 1 + divideByTwo(d - 2);
}

现在我们可以利用这个递归代码来实现a*b,如下所示:

public static int multiply(int p, int q) {
  // p * 0 = 0, 0 * q = 0
  if (p == 0 || q == 0) {
    return 0;
  }
  int pqSquare = (int) Math.pow(p + q, 2);
  int pSquare = (int) Math.pow(p, 2);
  int qSquare = (int) Math.pow(q, 2);
  int squareResult = pqSquare - pSquare - qSquare;
  int result;
  if (squareResult >= 0) {
    result = divideByTwo(squareResult);
  } else {
    result = 0 - divideByTwo(Math.abs(squareResult));
  }
  return result;
}

在绑定的代码中,您还可以练习解决这个问题的递归方法。

32. 使用TAU

什么是TAU?

简短回答:它是希腊字母。 详细回答:它是一个希腊字母,用于定义圆周与其半径的比例。简单来说,TAU是整个圆的一周,因此是2*。 TAU使我们能够以更直观简单的方式表达正弦、余弦和角度。例如,众所周知的角度300、450、900等可以通过TAU作为圆的一部分轻松以弧度形式表达,如下图所示:

老司机带你看Java 编程问题——文本块、区域设置、数字和数学

这比PI更直观。这就像把一个派切成相等的部分一样。例如,如果我们在TAU/8(450)处切割,这意味着我们把派切成了八份相等的部分。如果我们在TAU/4(900)处切割,这意味着我们把派切成了四份相等的部分。

TAU的值为6.283185307179586 = 2 * 3.141592653589793。因此,TAU和PI之间的关系是TAU=2*PI。在Java中,著名的PI通过Math.PI常量表示。从JDK 19开始,Math类还增加了Math.TAU常量。

让我们考虑以下简单的问题:一个圆的周长是21.33厘米。圆的半径是多少?

我们知道C = 2PIr,其中C是周长,r是半径。因此,r = C/(2*PI)或r = C/TAU。在代码中,我们有:

// 在JDK 19之前,使用PI
double r = 21.33 / (2 * Math.PI);
// 从JDK 19开始,使用TAU
double r = 21.33 / Math.TAU;

这两种方法都返回半径等于3.394。

33.选择伪随机数生成器

当我们抛硬币或掷骰子时,我们说我们看到了“真实”或“自然”的随机性在起作用。尽管如此,有些工具假装能够预测抛硬币、掷骰子或旋转轮盘的路径,尤其是在某些情境条件下。

计算机可以使用算法通过所谓的随机生成器生成随机数。由于涉及算法,生成的数字被认为是伪随机的。这被称为“伪”随机性。显然,伪随机数也是可预测的。为什么会这样呢?

伪随机生成器通过种子数据开始其工作。这是生成器的秘密(种子),它表示用作生成伪随机数起点的一部分数据。如果我们知道算法的工作原理和种子是什么,那么输出是可预测的。如果不知道种子,那么可预测性的程度就非常低。因此,选择适当的种子对于每个伪随机生成器来说都是一个重要步骤。

直到JDK 17之前,Java用于生成伪随机数的API有点晦涩。基本上,我们有一个健壮的API封装在众所周知的java.util.Random类中,以及Random的两个子类:SecureRandom(加密伪随机生成器)和ThreadLocalRandom(非线程安全的伪随机生成器)。从性能的角度来看,这些伪随机生成器之间的关系是SecureRandom比Random慢,而Random比ThreadLocalRandom慢。

除了这些类之外,我们还有SplittableRandom。这是一个非线程安全的伪生成器,能够在每次调用其split()方法时旋转一个新的SplittableRandom。这样,每个线程(例如,在fork/join架构中)都可以使用自己的SplittableGenerator。

直到JDK 17,伪随机生成器的类层次结构如下图所示:

老司机带你看Java 编程问题——文本块、区域设置、数字和数学

从这个架构可以看出,切换伪随机数生成器或在不同类型的算法之间进行选择真的很麻烦。看看那个SplittableRandom——就像迷失在无人之境。

从JDK 17开始,我们拥有了一个更灵活、更强大的用于生成伪随机数的API。这是一个基于接口的API(在JEP 356发布),围绕着新的RandomGenerator接口展开。以下是JDK 17的增强类层次结构:

老司机带你看Java 编程问题——文本块、区域设置、数字和数学

RandomGenerator接口代表了该API的顶峰。它代表了生成伪随机数的一种通用和统一协议。这个接口接管了Random API并添加了一些新的功能。 RandomGenerator接口由五个子接口扩展,旨在为五种不同类型的伪随机生成器提供特殊协议。

  • StreamableGenerator可以返回RandomGenerator对象的流
  • SplittableGenerator可以从当前生成器中返回一个新的生成器(分割自身)
  • JumpableGenerator可以跳过适度数量的绘制
  • LeapableGenerator可以跳过大量数量的绘制
  • ArbitrarilyJumpableGenerator可以跳过任意数量的绘制

获取默认的RandomGenerator可以通过以下方式进行(这是开始生成伪随机数的最简单方法,但您无法控制所选择的内容):

RandomGenerator defaultGenerator = RandomGenerator.getDefault();
// 开始生成伪随机数
defaultGenerator.nextInt/Float/...();
defaultGenerator.ints/doubles/...();

除了这些接口之外,新API还配备了一个类(RandomGeneratorFactory),它是基于所选算法的伪随机生成器的工厂。有三组新算法(很可能还有更多的正在路上);这些组如下:

  • LXM组;

    • L128X1024MixRandom
    • L128X128MixRandom
    • L128X256MixRandom
    • L32X64MixRandom
    • L64X1024MixRandom
    • L64X128MixRandom
    • L64X128StarStarRandom
    • L64X256MixRandom
  • Xoroshiro组:

    • Xoroshiro128PlusPlus
  • Xoshiro组:

    • Xoshiro256PlusPlus 突出显示的算法是默认算法(L32X64MixRandom)。 根据伪随机生成器的类型,我们可以选择所有/部分以前的算法。例如,L128X256MixRandom算法可以与SplittableGenerator一起使用,但不能与LeapableGenerator一起使用。所选算法与伪随机生成器之间的不匹配会导致IllegalArgumentException异常。以下图片可以帮助您决定使用哪种算法。

老司机带你看Java 编程问题——文本块、区域设置、数字和数学

这张图是通过以下代码生成的,该代码列出了所有可用的算法及其属性(可流式、可跃进、统计性等):

Stream<RandomGeneratorFactory<RandomGenerator>> all
     = RandomGeneratorFactory.all();
Object[][] data = all.sorted(Comparator.comparing(
                   RandomGeneratorFactory::group))
   .map(f -> {
      Object[] obj = new Object[]{
        f.name(),
        f.group(),
        f.isArbitrarilyJumpable(),
        f.isDeprecated(),
        f.isHardware(),
        f.isJumpable(),
        f.isLeapable(),
        f.isSplittable(),
        f.isStatistical(),
        f.isStochastic(),
        f.isStreamable()
     };
     return obj;
  }).toArray(Object[][]::new);

通过名称或属性轻松选择算法。

通过名称选择算法

通过一组静态的 of() 方法,可以通过名称选择算法。在 RandomGeneratorRandomGeneratorFactory 中都有一个 of() 方法,用于根据特定算法创建伪随机数生成器,如下所示:

RandomGenerator generator
  = RandomGenerator.of("L128X256MixRandom");
RandomGenerator generator
= RandomGeneratorFactory.of("Xoroshiro128PlusPlus")
                          .create();

接下来,我们可以通过调用一组常用的 API(例如 ints()doubles()nextInt()nextFloat() 等)生成伪随机数。

如果我们需要特定的伪随机生成器和算法,则可以使用该生成器的 of() 方法,示例如下(在此示例中,我们创建了一个 LeapableGenerator):

LeapableGenerator leapableGenerator
= LeapableGenerator.of("Xoshiro256PlusPlus");
LeapableGenerator leapableGenerator = RandomGeneratorFactory
  .<LeapableGenerator>of("Xoshiro256PlusPlus").create();

对于 SplittableRandom,您也可以使用构造函数,但是不能指定算法:

SplittableRandom splittableGenerator = new SplittableRandom();

在捆绑的代码中,您可以看到更多示例。

通过属性选择算法

正如您在图1.28中所看到的,算法具有一组属性(是否可跳跃、是否统计等)。让我们选择一个具有统计和可跳跃属性的算法:

RandomGenerator generator = RandomGeneratorFactory.all()
  .filter(RandomGeneratorFactory::isLeapable)
  .filter(RandomGeneratorFactory::isStatistical)
  .findFirst()
  .map(RandomGeneratorFactory::create)
  .orElseThrow(() -> new RuntimeException(
       "Cannot find this kind of generator"));

返回的算法可能是 Xoshiro256PlusPlus

34. 用伪随机数填充长数组

当我们想用数据填充一个大数组时,我们可以考虑使用 Arrays.setAll() 和 Arrays.parallelSetAll() 方法。这些方法可以通过将生成器函数应用于计算数组的每个元素来填充数组。

由于我们必须用伪随机数据填充数组,所以生成器函数应该是一个伪随机生成器。如果我们想并行执行此操作,那么我们应该考虑到 SplittableRandom(JDK 8+)/SplittableGenerator(JDK 17+),它们专门用于在独立的并行计算中生成伪随机数。总之,代码可能如下所示(JDK 17+):

SplittableGenerator splittableRndL64X256
= RandomGeneratorFactory
     .<SplittableGenerator>of("L64X256MixRandom").create();
long[] arr = new long[100_000_000];
Arrays.parallelSetAll(arr, 
                      x ->splittableRndL64X256.nextLong());

或者,我们也可以使用 SplittableRandom(这次我们无法指定算法,JDK 8+):

SplittableRandom splittableRandom = new SplittableRandom();
long[] arr = new long[100_000_000];
Arrays.parallelSetAll(arr, x ->splittableRandom.nextLong());

接下来,让我们看看如何创建一个伪随机生成器流。

35. 创建伪随机生成器流

在创建伪随机生成器流之前,让我们首先使用传统的 Random、SecureRandom 和 ThreadLocalRandom 来创建伪随机数流。

由于这三个伪随机生成器包含诸如 ints() 返回 IntStream、doubles() 返回 DoubleStream 等方法,我们可以轻松地生成伪随机数的(无限)流,如下所示:

Random rnd = new Random();
// ints() 方法返回一个无限流
int[] arrOfInts = rnd.ints(10).toArray(); // 10 个整数的流
// 或者简写为
int[] arrOfInts = new Random().ints(10).toArray();

在我们的示例中,我们将生成的伪随机数收集到一个数组中。当然,你可以按照自己的需要对它们进行处理。我们可以通过 SecureRandom 获得类似的结果,如下所示:

SecureRandom secureRnd = SecureRandom.getInstanceStrong();
int[] arrOfSecInts = secureRnd.ints(10).toArray();
// 或者简写为
int[] arrOfSecInts = SecureRandom.getInstanceStrong()
  .ints(10).toArray();

ThreadLocalRandom 呢?如下所示:

ThreadLocalRandom tlRnd = ThreadLocalRandom.current();
int[] arrOfTlInts = tlRnd.ints(10).toArray();
// 或者简写为
int[] arrOfTlInts = ThreadLocalRandom.current()
  .ints(10).toArray();

如果你只需要一个介于 0.0 和 1.0 之间的双精度流,则可以依赖于 Math.random(),它在内部使用 java.util.Random 的实例。下面的示例收集了一个介于 0.0 和 0.5 之间的双精度数组。当生成的第一个大于 0.5 的双精度数时,流将停止:

Supplier<Double> doubles = Math::random;
double[] arrOfDoubles = Stream.generate(doubles)
   .takeWhile(t -> t < 0.5d)
   .mapToDouble(i -> i)
   .toArray();

那么使用新的 JDK 17 API 呢?RandomGenerator 包含众所周知的方法 ints()、doubles() 等,并且它们在所有子接口中都可用。例如,可以使用 StreamableGenerator,如下所示:

StreamableGenerator streamableRnd
= StreamableGenerator.of("L128X1024MixRandom");
int[] arrOfStRndInts = streamableRnd.ints(10).toArray();
// 或者简写为
StreamableGenerator.of("L128X1024MixRandom")
  .ints(10).toArray();

类似地,我们可以使用 JumpableGenerator、LeapableGenerator 等。

好的,现在让我们回到我们的问题。我们如何生成一个伪随机生成器流呢?所有 RandomGenerator 子接口都包含一个名为 rngs() 的方法,它有不同的用法。无参数时,此方法返回一个新的伪随机生成器流,该流实现了 RandomGenerator 接口。下面的代码生成了五个 StreamableGenerator 实例,每个生成器生成了 10 个伪随机整数:

StreamableGenerator streamableRnd
= StreamableGenerator.of("L128X1024MixRandom");
List<int[]> listOfArrOfIntsSG
   = streamableRnd.rngs(5) // 获取 5 个伪随机生成器
    .map(r -> r.ints(10))  // 每个生成器生成 10 个整数
    .map(r -> r.toArray())
    .collect(Collectors.toList());

我们可以使用 JumpableGenerator 完成相同的任务,但是除了 rngs(),我们可能更喜欢使用 jumps(),它实现了特定于此类型的生成器的行为:

JumpableGenerator jumpableRnd
= JumpableGenerator.of("Xoshiro256PlusPlus");
List<int[]> listOfArrOfIntsJG = jumpableRnd.jumps(5)
   .map(r -> {
        JumpableGenerator jg = (JumpableGenerator) r;
        int[] ints = new int[10];
        for (int i = 0; i < 10; i++) {
           ints[i] = jg.nextInt();
           jg.jump();
        }
        return ints;
   })
   .collect(Collectors.toList());

LeapableGenerator 也可以完成相同的任务。这次,我们可以使用 rngs() 或 leaps(),它们实现了特定于此类型的生成器的行为:

LeapableGenerator leapableRnd
= LeapableGenerator.of("Xoshiro256PlusPlus");
List<int[]> listOfArrOfIntsLG = leapableRnd.leaps(5)
   .map(r -> {
        LeapableGenerator lg = (LeapableGenerator) r;
        int[] ints = new int[10];
        for (int i = 0; i < 10; i++) {
           ints[i] = lg.nextInt();
           lg.leap();
        }
        return ints;
   })
   .collect(Collectors.toList());

接下来,让我们看看如何交错使用传统和新的伪随机生成器。

36. 从 JDK 17 的新生成器获取传统伪随机生成器

传统的伪随机生成器,如 Random、SecureRandom 或 ThreadLocalRandom,可以将方法调用委托给 RandomGenerator,作为参数传递给 Random.from()、SecureRandom.from() 或 ThreadLocalRandom.from(),如下所示:

Random legacyRnd = Random.from(
   RandomGenerator.of("L128X256MixRandom"));
// 或者
Random legacyRnd = Random.from(RandomGeneratorFactory.
   of("Xoroshiro128PlusPlus").create());
// 或者
Random legacyRnd = Random.from(RandomGeneratorFactory
   .<RandomGenerator.SplittableGenerator>of(
      "L128X256MixRandom").create());

from() 方法从 JDK 19 开始提供。在捆绑的代码中,你可以看到更多示例。

37. 在多线程环境中使用伪随机生成器

Random 和 SecureRandom 实例是线程安全的。虽然这种说法是正确的,但要注意,当多个线程(多线程环境)使用 Random 实例(或 Math.random())时,你的代码容易受到线程争用的影响,因为这些线程共享相同的种子。共享相同的种子涉及对种子访问的同步;因此,它打开了线程争用的大门。显然,线程争用会导致性能下降,因为线程可能会在队列中等待获取种子的访问权限。同步通常是昂贵的。

Random 的一种替代方案是 ThreadLocalRandom,它为每个线程使用一个 Random 实例,并提供了防止线程争用的保护,因为它不包含同步代码或原子操作。缺点是 ThreadLocalRandom 使用每个线程的内部种子,我们无法控制或修改。

SplittableRandom 不是线程安全的。此外,由 RandomGenerator 实现组成的新 API 也不是线程安全的。

话虽如此,可以通过使用线程安全的生成器或为每个新线程拆分一个新实例来在多线程环境中使用伪随机生成器。当我说“拆分”时,我的意思是使用 SplittableGenerator.splits(long n),其中 n 是拆分数。查看下面的代码,该代码使用 10 个线程填充一个 Java 列表(每个线程使用自己的伪随机生成器):

List<Integer> listOfInts = new CopyOnWriteArrayList<>();
ExecutorService executorService = Executors.newCachedThreadPool();
SplittableGenerator splittableGenerator = RandomGeneratorFactory
     .<SplittableGenerator>of("L128X256MixRandom").create();
splittableGenerator.splits(10)
  .forEach((anotherSplittableGenerator) -> {
    executorService.submit(() -> {
      int nextInt = anotherSplittableGenerator.nextInt(1_000);
      logger.info(() -> "Added in list " 
          + nextInt + " by generator " 
          + anotherSplittableGenerator.hashCode()
          + " running in thread"
          + Thread.currentThread().getName());
      listOfInts.add(nextInt);
    });
});
shutdownExecutor(executorService);

部分输出片段:

INFO: Added in list 192 by generator 1420516714 running in threadpool-1-thread-3
INFO: Added in list 366 by generator 1190794841 running in threadpool-1-thread-8
INFO: Added in list 319 by generator 275244369 running in threadpool-1-thread-9
...

你也可以使用 JumpableGenerator 或 LeapableGenerator。唯一的区别是,JumpableGenerator 使用 jumps(),而 LeapableGenerator 使用 leaps()。