Java21手册(五):字符串

avatar
@比心

一直以来,Java字符串在使用上都有诸多不便之处:长文本、字符串拼接、占位符赋值,这些能力要么是实现复杂、可读性差,要么是原生无法支持,各三方库都有不同的实现方案。Java21不但在很大程度上解决了这些问题,让字符串操作变得更灵活,而且引入了强大的模板处理器,创造性地扩展了Java语言的工具定义。文章中代码较多,适合在PC上查看,源码可查看原文链接。

5.1 文本块(Text Blocks)

你是否经常会写类似这种拼接多行字符串的代码?

String query = "SELECT "EMP_ID", "LAST_NAME" FROM "EMPLOYEE_TB"\n" +
               "WHERE "CITY" = 'INDIANAPOLIS'\n" +
               "ORDER BY "EMP_ID", "LAST_NAME";\n";

在很多情况下,拼接多行字符串是一个既必要,又很痛苦的事,大量的转义让我们很难一眼看出最终输出的结果是什么。很多时候我们会刻意避免在代码里写跨多行的文本字符串,可能会改用StringBuilder等方式来拼接,但无可否认我们需要让跨多行文本段的代码也能够所见即所得。文本块这个特性就是用来解决这个问题,上面这段代码可以重写为:

String query = "SELECT "EMP_ID", "LAST_NAME" FROM "EMPLOYEE_TB"\n" +
               "WHERE "CITY" = 'INDIANAPOLIS'\n" +
               "ORDER BY "EMP_ID", "LAST_NAME";\n";
  • 基本语法

文本块是Java语言中的一种新的字面量(literal ),可用于任意能使用字符串字面量的地方,相比字符串,文本块提供了更好的可读性和更明确的输出。文本块由零个或多个内容字符组成,由开放和关闭分隔符括起来,具体规则为:

  • 开放分隔符是三个双引号字符(")的序列,后跟零个或多个空格,后跟行终止符,内容从开放分隔符的换行符之后的第一个字符开始。
  • 关闭分隔符是三个双引号字符的序列,内容在关闭分隔符的第一个双引号字符之前的最后一个字符结束。
  • 内容可以直接包括双引号字符,与字符串字面量中的字符不同。可以在文本块中使用 " ,但没必要;内容可以直接包括行终止符,与字符串字面量中的字符不同;可以在文本块中使用\n,但也没有必要。例如,文本块:
"""
line 1
line 2
line 3
"""

等价于

"line 1\nline 2\nline 3\n"
//或着
"line 1\n" +
"line 2\n" +
"line 3\n"

如果文本块结尾不需要换行符可以写为:

"""
line 1
line 2
line 3
"""

//等价于
"line 1\nline 2\nline 3"

文本块字面量和字符串字面量一样,是一种类型为String的常量表达式。文本块的内容在 Java 编译器中处理为三个不同的步骤:

  1. 内容中的行终止符被转换为 LF (\u000A),这种转换的目的是在跨平台移动 Java 源代码时遵循最小惊奇原则(the principle of least surprise )。
  2. 包围内容的附带空格,用于匹配 Java 源代码的缩进,将被删除。
  3. 内容中的转义序列被解释。将解释作为最后一步意味着开发人员可以编写转义序列,如 \n,这些不会被修改或删除。

处理后的内容以 CONSTANT_String_info 条目的形式记录在类文件中的常量池中,就像字符串字面量的字符一样。类文件不记录 CONSTANT_String_info 条目是从文本块还是字符串字面量派生的。

  • 内容缩进

由于文本块在代码中编写会有缩进问题,包括每行的尾部多余空格也不容易被察觉,为了更好的符合开发者习惯,编译器会对文本块内容进行一些处理,举例来说,我们用 . 来代表多余的缩进和空格,有这样一个文本块:

String html = """
..............<html>...
..............    <body>
..............        <p>Hello, world</p>....
..............    </body>.
..............</html>...
..............""";

最终的html会去掉. 所代表的空格,最终的结果使用 | 来可视化边距如下:

String html = """
..............<html>...
..............    <body>
..............        <p>Hello, world</p>....
..............    </body>.
..............</html>...
..............""";

intellij等支持Text Blocks语法的IDE会有自动对齐的功能,写起来其实非常方便。

  • 转义字符

在内容被重新缩进后,内容中的任何转义序列都会被解释。文本块支持所有在字符串文字中支持的转义序列,包括\n、\t、"和\等。如果要在文本块中打印""",需要使用到转义字符,例如:

String code = 
    """
    String text = """
        A text block inside a text block
    """;
    """;

================
输出:
String text = """
    A text block inside a text block
""";
================

String tutorial1 =
    """
    A common character
    in Java programs
    is """";

================
输出:
A common character
in Java programs
is "
================
  
String tutorial2 =
    """
    The empty string literal
    is formed from " characters
    as follows: """"";

================
输出:
The empty string literal
is formed from " characters
as follows: ""
================

System.out.println("""
     1 "
     2 ""
     3 """
     4 """"
     5 """""
     6 """"""
     7 """""""
     8 """"""""
     9 """""""""
    10 """"""""""
    11 """""""""""
    12 """"""""""""
""");

================
输出:
     1 "
     2 ""
     3 """
     4 """"
     5 """""
     6 """"""
     7 """""""
     8 """"""""
     9 """""""""
    10 """"""""""
    11 """""""""""
    12 """"""""""""
================

为了允许更精细地控制换行和空白处理,文本块引入了两个新的转义字符。第一个新转义字符是 <line-terminator>表示明确地禁止插入换行符。例如,通常的做法是将非常长的字符串字面量分割成更小的子字符串的连接,然后将结果字符串表达式写为多行:

String literal = "Lorem ipsum dolor sit amet, consectetur adipiscing " +
                 "elit, sed do eiusmod tempor incididunt ut labore " +
                 "et dolore magna aliqua.";

使用 <line-terminator>可以改写为:

String literal = "Lorem ipsum dolor sit amet, consectetur adipiscing " +
                 "elit, sed do eiusmod tempor incididunt ut labore " +
                 "et dolore magna aliqua.";

第二个新的转义字符是 \s 它会简单地转换为一个空格 (\u0020)。在消除空格之前,转义序列不会被转换,因此 \s 可以作为防止尾随空格去除的屏障。在以下示例中,在每行的末尾使用 \s 确保每行恰好有六个字符:

String colors = """
    red  \s
    green\s
    blue \s
    """;

除了文本块外,\s还可以用于传统字符串字面量和字符字面量。

  • 文本块串联

文本块可以在任何可以使用字符串字面量的地方使用,例如,文本块和字符串字面量可以互换串联:

String colors = """
    red  \s
    green\s
    blue \s
    """;

如果要灵活替换文本块中的变量,除了串联拼接的方式,我们还可以用String::replace 或 String::format,例如:

// 原本代码
String code = """
              public void print(""" + type + """
               o) {
                  System.out.println(Objects.toString(o));
              }
              """;
 
// String::replace
String code = """
              public void print($type o) {
                  System.out.println(Objects.toString(o));
              }
              """.replace("$type", type);

// String::format
String code = String.format("""
              public void print(%s o) {
                  System.out.println(Objects.toString(o));
              }
              """, type);
  • 使用建议

文本块是一个非常实用的新特性,在一些场景下直接使用字符串进行编程会非常方便。例如,我们在使用类似于Mybatis等OR映射类工具时,由于Java代码中的多行字符串书写非常繁琐,可读性很差,大部分情况下我们会放弃使用在代码中直接写方法关联的SQL,转而使用xml来写SQL。这类不必要的代码分隔实际上会显著地降低开发效率,使用文本块可以在很多类似的场景下实现开发和维护的提效。

5.2 字符串模板(String Templates)

字符串模板在java21中是一个预览版(Preview)特性,要使用这个特性需要通过添加VM选项 --enable-preview 来开启。

字符串模板的首要目标是简化字符串在包含变量拼接时的写法,目前我们实现这个功能,常见有这几种都不太理想的方式:

一、通过 + 操作符拼接,可读性较差:

String s = x + " plus " + y + " equals " + (x + y);

二、使用StringBuilder,代码过于冗余:

String s = new StringBuilder()                 
    .append(x)                 
    .append(" plus ")                 
    .append(y)                 
    .append(" equals ")                 
    .append(x + y)                 
    .toString();

三、String::format 和 String::formatted,这两个方法会把字符串模板和参数分开,容易引发参数数量和类型不匹配的问题:

String s = String.format("%2$d plus %1$d equals %3$d", x, y, x + y);
String t = "%2$d plus %1$d equals %3$d".formatted(x, y, x + y);

字符串模板的思路,是通过字符串插值的方式来提高代码可读性,同时基于模板的机制,提高字符串插值的安全性(类比SQL基于模板拼接,防止SQL注入风险)。

  • STR 模板处理器

STR是新引入的字符串模板的处理器,是一个全局静态字段,会自动导入到每个java源文件中。使用方式如下:

var x = 10;var y = 20;String s = STR."{y} plus {x} equals {x + y}";

使用上很直观,STR.后面的字符串是使用的模板,{y} {x} {x + y} 这三个嵌入表达式会在运行时动态计算填充,嵌套表达式可以是变量,可以是对象取值,也可以是方法调用。看一个更复杂的例子:

// 嵌入表达式为字符串
String firstName = "Bill";
String lastName  = "Duck";
String fullName  = STR."{firstName} {lastName}";
| "Bill Duck"
String sortName  = STR."{lastName}, {firstName}";| "Duck, Bill"
// 嵌入表达式可以调用方法或取字段String s = STR."You have a {getOfferType()} waiting for you!";| "You have a gift waiting for you!"String t = STR."Access at {req.date} {req.time} from {req.ipAddress}";| "Access at 2022-03-25 15:34 from 8.8.8.8"

嵌入表达式作为可编程的代码也在语法上做了一些优化,可以直接使用"而无需转义为",对于使用+操作符拼接的字符串重构为字符串模板也就变得非常方便,例如:

String filePath = "tmp.dat";
File   file     = new File(filePath);
String old = "The file " + filePath + " " + (file.exists() ? "does" : "does not") + " exist";

// {file.exists() ? "does" : "does not"} 这段是嵌入表达式
String msg = STR."The file {filePath} {file.exists() ? "does" : "does not"} exist";
| "The file tmp.dat does exist" or "The file tmp.dat does not exist"

嵌入表达式还可以直接跨行编写,不会影响输出结果,例如:

String time = STR."The time is {    
    // The java.time.format package is very useful    
    DateTimeFormatter      
        .ofPattern("HH:mm:ss")      
        .format(LocalTime.now())
} right now";
| "The time is 12:34:56 right now"

嵌入表达式的数量没有限制,并且会从前到后依次执行,嵌入表达式还可以嵌套STR,例如:

String s = STR."{fruit[0]}, {    
    STR."{fruit[1]}, {fruit[2]}"
}";
//结果等价于
String tmp = STR."{fruit[1]}, {fruit[2]}";
String s = STR."{fruit[0]}, {tmp}";
  • 多行模板表达式

STR定义的模板还可以使用文本块实现多行模板表达式,例如拼装html:

String title = "My Web Page";
String text  = "Hello, world";
String html = STR."""
        <html>
          <head>
            <title>{title}</title>
          </head>
          <body>
            <p>{text}</p>
          </body>
        </html>
        """;

| """
| <html>
|   <head>
|     <title>My Web Page</title>
|   </head>
|   <body>
|     <p>Hello, world</p>
|   </body>
| </html>
| """

拼装json:

String name    = "Joan Smith";
String phone   = "555-123-4567";
String address = "1 Maple Drive, Anytown";
String json = STR."""
    {
        "name":    "{name}",
        "phone":   "{phone}",
        "address": "{address}"
    }
    """;

| """
| {
|     "name":    "Joan Smith",
|     "phone":   "555-123-4567",
|     "address": "1 Maple Drive, Anytown"
| }
| """

拼装表格:

record Rectangle(String name, double width, double height) {
    double area() {
        return width * height;
    }
}
Rectangle[] zone = new Rectangle[] {
    new Rectangle("Alfa", 17.8, 31.4),
    new Rectangle("Bravo", 9.6, 12.4),
    new Rectangle("Charlie", 7.1, 11.23),
};
String table = STR."""
    Description  Width  Height  Area
    {zone[0].name}  {zone[0].width}  {zone[0].height}     {zone[0].area()}
    {zone[1].name}  {zone[1].width}  {zone[1].height}     {zone[1].area()}
    {zone[2].name}  {zone[2].width}  {zone[2].height}     {zone[2].area()}
    Total {zone[0].area() + zone[1].area() + zone[2].area()}
    """;

| """
| Description  Width  Height  Area
| Alfa  17.8  31.4     558.92
| Bravo  9.6  12.4     119.03999999999999
| Charlie  7.1  11.23     79.733
| Total 757.693
| """

除STR外,内置的模板处理器还有RAW和FMT。RAW 是一个标准的模板处理器,它生成未处理的 StringTemplate 对象。FMT和STR很类似,区别在于FMT可以在嵌入表达式左侧增加格式说明符(format specifiers),例如上面表格的例子,用FMT可以输出字段对齐的表格:

String table = FMT."""
    Description     Width    Height     Area
    %-12s{zone[0].name}  %7.2f{zone[0].width}  %7.2f{zone[0].height}     %7.2f{zone[0].area()}
    %-12s{zone[1].name}  %7.2f{zone[1].width}  %7.2f{zone[1].height}     %7.2f{zone[1].area()}
    %-12s{zone[2].name}  %7.2f{zone[2].width}  %7.2f{zone[2].height}     %7.2f{zone[2].area()}
    {" ".repeat(28)} Total %7.2f{zone[0].area() + zone[1].area() + zone[2].area()}
    """;
| """
| Description     Width    Height     Area
| Alfa            17.80    31.40      558.92
| Bravo            9.60    12.40      119.04
| Charlie          7.10    11.23       79.73
|                              Total  757.69
| """
  • 模板处理器原理

前面我们看到了模板处理器STR或FMT,看起来像是一个可直接访问的常量对象,这样理解没有问题,但更确切地说,模板处理器是实现函数接口StringTemplate.Processor的对象实例。该对象的类实现了该接口的单个抽象方法process,它接受一个StringTemplate并返回一个对象。StringTemplate的定义如下:

package java.lang;
public interface StringTemplate {    
    ...    
    @FunctionalInterface    
    public interface Processor<R, E extends Throwable> {        
        R process(StringTemplate st) throws E;    
    }    
    ...
}

StringTemplate的实例表示作为模板表达式中模板或文本块模板的字符串模板,它包含两个核心对象:fragments 和 values。考虑以下代码:

int x = 10, y = 20;
StringTemplate st = RAW."{x} plus {y} equals {x + y}";
String s = st.toString();

| StringTemplate{ fragments = [ "", " plus ", " equals ", "" ], values = [10, 20, 30] }

StringTemplate::fragments返回一个文本片段的列表,该列表位于字符串模板或文本块模板中除去嵌入表达式之外的部分:

int x = 10, y = 20;
StringTemplate st = RAW."{x} plus {y} equals {x + y}";
List<String> fragments = st.fragments();
String result = String.join("\{}", fragments);
| "{} plus {} equals {}"

StringTemplate::values返回按照源代码中出现顺序计算嵌入表达式的值的列表:

int x = 10, y = 20;
StringTemplate st = RAW."{x} plus {y} equals {x + y}";
List<Object> values = st.values();
| [10, 20, 30]

StringTemplate的 fragments 是不变的,而 values 在每次执行时都是新计算的:

int y = 20;
for (int x = 0; x < 3; x++) {    
    StringTemplate st = RAW."{x} plus {y} equals {x + y}";    
    System.out.println(st);
}
| ["Adding ", " and ", " yields ", ""](0, 20, 20)
| ["Adding ", " and ", " yields ", ""](1, 20, 21)
| ["Adding ", " and ", " yields ", ""](2, 20, 22)

理解了StringTemplate的结构,我们就可以通过实现StringTemplate.Processor接口,来自定义模板处理器了。

  • 自定义处理器的使用举例

1. 简单插值处理器

基于模板处理器的原理,我们可以很轻松地实现自定义模板处理器,例如只做简单的插值拼接:

var INTER = StringTemplate.Processor.of((StringTemplate st) -> {
    StringBuilder sb = new StringBuilder();
    Iterator<String> fragIter = st.fragments().iterator();
    for (Object value : st.values()) {
        sb.append(fragIter.next());
        sb.append(value);
    }
    sb.append(fragIter.next());
    return sb.toString();
});

int x = 10, y = 20;
String s = INTER."{x} plus {y} equals {x + y}";

| 10 and 20 equals 30

2. JSON处理器

我们还可以定义不返回String的模板处理器,例如返回JSONObject对象:

var JSON = StringTemplate.Processor.of(
        (StringTemplate st) -> JSONObject.parseObject(st.interpolate())
);

String name    = "Joan Smith";
String phone   = "555-123-4567";
String address = "1 Maple Drive, Anytown";
JSONObject doc = JSON."""
{
    "name":    "{name}",
    "phone":   "{phone}",
    "address": "{address}"
}
""";

| {"address":"1 Maple Drive, Anytown","phone":"555-123-4567","name":"Joan Smith"}

这段代码中用到的 StringTemplate::interpolate 是一个直接拼接 fragments 和 values 的方法,行为等同于第一个自定义模板处理器的例子。直接拼接字符串可能由于传入的字符串中带有"导致JSON注入的风险,我们可以增加校验入参的能力,对非法入参抛出异常,并对字符串、数字类型的值在json中做正常包装:

StringTemplate.Processor<JSONObject, JSONException> JSON_VALIDATE =
    (StringTemplate st) -> {
        String quote = """;
        List<Object> filtered = new ArrayList<>();
        for (Object value : st.values()) {
            if (value instanceof String str) {
                if (str.contains(quote)) {
                    throw new JSONException("Injection vulnerability");
                }
                filtered.add(quote + str + quote);
            } else if (value instanceof Number ||
                       value instanceof Boolean) {
                filtered.add(value);
            } else {
                throw new JSONException("Invalid value type");
            }
        }
        String jsonSource =
            StringTemplate.interpolate(st.fragments(), filtered);
        return JSONObject.parseObject(jsonSource);
    };

String name    = "Joan Smith";
String phone   = "555-123-4567";
String address = "1 Maple Drive, Anytown";
try {
    JSONObject doc = JSON_VALIDATE."""
        {
            "name":    {name},
            "phone":   {phone},
            "address": {address}
        };
        """;
} catch (JSONException ex) {
    ...
}

| {"address":"1 Maple Drive, Anytown","phone":"555-123-4567","name":"Joan Smith"}

3. SQL处理器定义一个SQL执行器,其中我们要用PreparedStatement来防止SQL注入,代码如下:

record QueryProcessor(Connection conn)
  implements StringTemplate.Processor<ResultSet, SQLException> {

    public ResultSet process(StringTemplate st) throws SQLException {
        // 1. Replace StringTemplate placeholders with PreparedStatement placeholders
        String query = String.join("?", st.fragments());

        // 2. Create the PreparedStatement on the connection
        PreparedStatement ps = conn.prepareStatement(query);

        // 3. Set parameters of the PreparedStatement
        int index = 1;
        for (Object value : st.values()) {
            switch (value) {
                case Integer i -> ps.setInt(index++, i);
                case Float f   -> ps.setFloat(index++, f);
                case Double d  -> ps.setDouble(index++, d);
                case Boolean b -> ps.setBoolean(index++, b);
                default        -> ps.setString(index++, String.valueOf(value));
            }
        }

        // 4. Execute the PreparedStatement, returning a ResultSet
        return ps.executeQuery();
    }
}

上面这段代码里,我们还用到了上一篇介绍过的switch模式匹配语法。接着我们再实例化一个模板处理器,这样就可以安全地实现SQL语句的构造和执行了:

StringTemplate.Processor<ResultSet, SQLException> DB = new QueryProcessor(conn);
ResultSet rs = DB."SELECT * FROM Person p WHERE p.last_name = {name}";

4. 本地语言处理器

以创建一个泰语版的处理器为例,我们用到了FMT的定义类java.util.FormatProcessor:

Locale thaiLocale = Locale.forLanguageTag("th-TH-u-nu-thai");
FormatProcessor THAI = FormatProcessor.create(thaiLocale);
for (int i = 1; i <= 10000; i *= 10) {    
    String s = THAI."This answer is %5d{i}";    
    System.out.println(s);
}
| This answer is     ๑
| This answer is    ๑๐
| This answer is   ๑๐๐
| This answer is  ๑๐๐๐
| This answer is ๑๐๐๐๐

总结一下,字符串模板无疑能够显著提高字符串编程效率,在语法的使用上也让人耳目一新。更让人感到惊喜的是,我们可以通过自定义模板处理器,完成很多定制化的功能,这在未来一定会推动 Java 语言能力在业务编码和框架开发方面,发展得更加广阔。

5.3 字符串的其他性能优化

  • value类型由char[]替换为byte[]

当前的String类实现将字符存储在char数组中,对于每个字符使用两个字节(16位)。从许多不同应用程序收集的数据表明,字符串是堆使用量的主要组成部分,而且大多数String对象仅包含Latin-1字符。这种字符仅需要一个字节的存储空间,因此这种String对象的内部char数组中有一半的空间未被使用。从java9开始,String类的内部表示从UTF-16 char数组更改为byte数组,占用的内存空间和GC次数都得到了相应的减少。

  • 优化字符串拼接实现

在Java8及之前的版本中,javac将字符串拼接转换为StringBuilder::append链。但有时这种转换不是最优的,有时我们需要适当地预先调整StringBuilder的大小。-XX:+OptimizeStringConcat选项在JIT编译器中启用了激进的优化,可以识别StringBuilder append链并进行预调整和原地复制,这些优化尽管有成效,但很脆弱,难以扩展和维护。

从java9开始,字符串拼接通过invokedynamic的能力来实现,提升了执行效率和扩展能力,也为上文中介绍到的文本块串联和字符串模板提供了基础支持。

image.png