语法糖(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 语言规范,"+" 操作符的运算规则如下:
这条规则的最后一句也就是两个 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 的技术来避免或减少先创建然后又丢弃中间对象的事情发生,以提高拼接的性能。