细数Java的语法糖(一): 用于字符串拼接的 "+" 运算符

前言

语法糖(Syntactic Sugar),又称糖衣语法,是由英国计算机学家 Peter.J.Landin 发明的一个术语,指在计算机语言中添加的某种语法。这种语法对语言的功能并没有影响,但往往能让程序更加简洁,并有更高的可读性,从而方便程序员使用,减少代码出错的机会,并提升开发效率。简单来说,语法糖就是对现有语法的一种包装。

很多编程语言中都有语法糖,Java 也是如此。要明白,语法糖仅存在于编译时,JVM 并不认识这些语法糖。因此,在编译阶段,Java 编译器会将语法糖还原为基础的语法结构,有些文章将这个过程称为 "脱糖",也有称 "解语法糖"(Reference 2)。在 com.sun.tools.javac.main.JavaCompiler 的 compile 方法中,有一个步骤(compile2)会调用它的 desugar 方法,这个方法就是用来实现脱糖处理的。

顺便提下,从 Java 6 开始,Java 提供了编译器API,使得开发者们可以更灵活地使用编译。如果你有兴趣,它的入口在 javax.tools.JavaCompiler 接口(从父接口继承来)的 run 方法。com.sun.tools.javac.api.JavacTool 是它的一个实现,它最终会将编译委托给 com.sun.tools.javac.main.JavaCompiler 的 compile 方法。

从本文开始,笔者将试图总结和介绍 Java 语言中的常见语法糖,并将尽量按照由简到繁、并将相似或相关内容放在一起的顺序来组织。对于涉及到的关联知识,也会稍做介绍,但可能不会深究太多细节。欢迎有兴趣的同学一起探讨,如有不足,还请指正。
这个系列的目录如下(为了不剧透,先只列出部分):

注: 如果没有特别指明,所有的介绍及示例均基于 Java 8。

用于字符串拼接的 "+" 运算符

在Java中,字符串是最常使用的类型,字符串的拼接又是字符串最常用的操作。有些语言(如: C++) 允许程序员对运算符做重载,从而实现定制化的操作。Java 不支持运算符重载,但它为程序员简化了字符串拼接操作,允许通过二元操作符 "+" 完成字符串拼接。

更进一步地,根据 Java 语言规范,"+" 操作符的运算规则如下:

  1. 如果有一个操作数是 String,则将另一个操作数也转为 String。
  2. 否则,(如有必要,插入拆箱转换),如果任意一个操作数是 double,则将另一个转为 double。
  3. 否则,如果任意一个操作数是 float,则将另一个转为 float。
  4. 否则,如果任意一个操作数是 long,则将另一个转为 long。
  5. 否则,将两个操作数都转为 int。

这条规则的最后一句也就是两个 byte 相加,结果是 int 的原因。

回到正题,先看一个字符串拼接的例子:

       String t = "b";
       String s = "a" + t + "c";
复制代码

反编译这段代码,得到:

       0: ldc           #2                  // String b
       2: astore_1
       3: new           #3                  // class java/lang/StringBuilder
       6: dup
       7: invokespecial #4                  // Method java/lang/StringBuilder."<init>":()V
      10: ldc           #5                  // String a
      12: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      15: aload_1
      16: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      19: ldc           #7                  // String c
      21: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      24: invokevirtual #8                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      27: astore_2
复制代码

简单解释,首先将字符串 "b" 存入第一个变量(即: t) 中。接着,构造一个 StringBuilder,并通过其 append 方法存入字符串 "a"。然后,将第一个变量(t)中的字符串("b")取出,并再次通过在刚才的 StringBuilder 对象上调用 append 方法存入。接着,以类似的方法再存入字符串 "c"。最后,通过在 StringBuilder 对象上调用 toString 方法得到最后的结果,存入第二个变量(即: s)中。

可见,在编译过后,Java 的字符串拼接其实是通过构造 StringBuilder 对象,并不断调用其 append 方法将字符串放入,再通过 toString 得到最终结果。这样的做的好处在于可以避免产生很多无用的中间字符串对象。

事实上,Java 编译器做的还不止于此,如果字符串是常量值,那么在编译期,Java 会直接将常量字符串替换为其字面值。考虑如下代码:

       final String t = "b";
       String s = "a" + t + "c";
复制代码

与前述例子相比,这里仅仅是将 t 声明为了 final。反编译后的代码如下:

       0: ldc           #2                  // String b
       2: astore_1
       3: ldc           #3                  // String abc
       5: astore_2
复制代码

看,由于所有的字符串都是常量值,Java 可以在编译阶段就直接计算出结果。

不过,需要指出,Java 编译器也聪明得有限。考虑下面字符串连续拼接的场景:

        String s = "a";
        s += "b";
        s += "c";
复制代码

反编译后得到如下代码:

       0: ldc           #2                  // String a
       2: astore_1
       3: new           #3                  // class java/lang/StringBuilder
       6: dup
       7: invokespecial #4                  // Method java/lang/StringBuilder."<init>":()V
      10: aload_1
      11: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      14: ldc           #6                  // String b
      16: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      19: invokevirtual #7                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      22: astore_1
      23: new           #3                  // class java/lang/StringBuilder
      26: dup
      27: invokespecial #4                  // Method java/lang/StringBuilder."<init>":()V
      30: aload_1
      31: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      34: ldc           #8                  // String c
      36: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      39: invokevirtual #7                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      42: astore_1
复制代码

可以看出,相比之前的例子,这个过程多了很多中间计算。首先将字符串 "a" 存入变量1(即: s)中。接着,在拼接字符串 "b" 时,构造了一个 StringBuilder,依次放入变量1中的值("a")、字符串 "b"。然后通过 StringBuilder#toString 得到结果并再次存入变量1中。此时变量1中存的值是 "ab"。接着,再次构造一个 StringBuilder,依次放入变量1的值("ab")、字符串 "c"。然后又一次调用 StringBuilder#toString,得到最终结果 "abc"。

也就是说,每执行一条拼接语句,就会构造一个 StringBuilder,并对之前的结果和当前要拼入的后续字符串依次调用 append 放入,然后再用 toString 得到该语句的结果。如果你有类似于下面这样的拼接,那么可能会产生极大的浪费:

        String result = ...;
        List<String> strsToAppend = ...;
        for (String s : strsToAppend) {
            result += s;
        }
复制代码

总结一下,如果我们要在一条语句中进行字符串拼接,那么可以直接使用 "+" 运算符,这样做的代码十分简洁,同时也具有很高的可读性。但如果拼接涉及多条语句,那么就需要考虑使用类似于 StringBuilder 的技术来避免或减少先创建然后又丢弃中间对象的事情发生,以提高拼接的性能。

References

  1. baike.baidu.com/item/%E8%AF…
  2. blog.csdn.net/GitChat/art…
  3. Java语言规范(Java SE 8)



猜你喜欢

转载自juejin.im/post/5c74b664f265da2dcd79ed6c