一、概述
1、语法糖
语法糖(Syntactic sugar),也译为糖衣语法,是由英国计算机科学家彼得·兰丁发明的一个术语,指计算机语言中添加的某种语法,这种语法对语言的功能没有影响,但是更方便程序员使用。语法糖让程序更加简洁,有更高的可读性。
我们所熟知的编程语言中几乎都有语法糖。笔者认为,语法糖的多少是评判一个语言够不够牛逼的标准之一。很多人说Java是一个“低糖语言”,其实从Java 7开始Java语言层面上一直在添加各种糖,主要是在“Project Coin”项目下研发。尽管现在Java有人还是认为现在的Java是低糖,未来还会持续向着“高糖”的方向发展。
2、解语法糖
前面提到过,语法糖的存在主要是方便开发人员使用。但其实,Java虚拟机并不支持这些语法糖。这些语法糖在编译阶段就会被还原成简单的基础语法结构,这个过程就是解语法糖。
说到编译,大家肯定都知道,Java语言中,javac命令可以将后缀名为.java的源文件编译为后缀名为.class的可以运行于Java虚拟机的字节码。如果你去看com.sun.tools.javac.main.JavaCompiler
的源码,你会发现在compile()
中有一个步骤就是调用desugar()
,这个方法就是负责解语法糖的实现的。
Java 中最常用的语法糖主要有泛型、变长参数、条件编译、自动拆装箱、内部类等。本文主要来分析下这些语法糖背后的原理。一步一步剥去糖衣,看看其本质。
二、语法糖详解
1、糖块一:switch 支持 String 与枚举
前面提到过,从 Java 7 开始,Java语言中的语法糖在逐渐丰富,其中一个比较重要的就是 Java 7 中 switch 开始支持 String。
在开始 coding 之前先科普下,Java中的 switch 自身原本就支持基本类型。比如int、char等。对于int类型,直接进行数值
的比较。对于char类型则是比较其ascii码
。所以,对于编译器来说,switch中其实只能使用整型,任何类型的比较都要转换成整型。比如byte。short,char(ackii码是整型)以及int。
那么接下来看下switch对String的支持,如下代码:
package com.syntacticSugar;
public class SwitchDemoString {
public static void main(String[] args) {
String str = "hello";
switch (str) {
case "hello":
System.out.println("hello");
break;
case "world":
System.out.println("world");
break;
default:
break;
}
}
}
反编译后内容如下:
package com.syntacticSugar;
public class SwitchDemoString {
public SwitchDemoString() {
}
public static void main(String[] var0) {
String var1 = "hello";
byte var3 = -1;
switch(var1.hashCode()) {
case 99162322:
if (var1.equals("hello")) {
var3 = 0;
}
break;
case 113318802:
if (var1.equals("world")) {
var3 = 1;
}
}
switch(var3) {
case 0:
System.out.println("hello");
break;
case 1:
System.out.println("world");
}
}
}
看到这个代码,你知道原来字符串的switch是通过equals()和hashCode()方法来实现的。还好hashCode()方法返回的是int,而不是long。
仔细看下可以发现,进行switch的实际是哈希值,然后通过使用equals方法比较进行安全检查,这个检查是必要的,因为哈希可能会发生碰撞。因此它的性能是不如使用枚举进行switch或者使用纯整数常量,但这也不是很差。
2、糖块二:自动装箱与自动拆箱
我们知道,Java 语言拥有 8 个基本类型,每个基本类型都有对应的包装(wrapper)类型。
之所以需要包装类型,是因为许多 Java 核心类库的 API 都是面向对象的。举个例子,Java 核心类库中的容器类,就只支持引用类型。
当需要一个能够存储数值的容器类时,我们往往定义一个存储包装类对象的容器。
对于基本类型的数值来说,我们需要先将其转换为对应的包装类,再存入容器之中。在 Java 程序中,这个转换可以是显式,也可以是隐式的,后者正是 Java 中的自动装箱。
来看下面例子:
public int foo() {
ArrayList<Integer> list = new ArrayList<>();
list.add(0);
int result = list.get(0);
return result;
}
以上面的 Java 代码为例。我构造了一个 Integer 类型的 ArrayList,并且向其中添加一个 int 值 0。然后,我会获取该 ArrayList 的第 0 个元素,并作为 int 值返回给调用者。这段代码对应的 Java 字节码如下所示:
public int foo();
Code:
0: new java/util/ArrayList
3: dup
4: invokespecial java/util/ArrayList."<init>":()V
7: astore_1
8: aload_1
9: iconst_0
10: invokestatic java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
13: invokevirtual java/util/ArrayList.add:(Ljava/lang/Object;)Z
16: pop
17: aload_1
18: iconst_0
19: invokevirtual java/util/ArrayList.get:(I)Ljava/lang/Object;
22: checkcast java/lang/Integer
25: invokevirtual java/lang/Integer.intValue:()I
28: istore_2
29: iload_2
30: ireturn
当向泛型参数为 Integer 的 ArrayList 添加 int 值时,便需要用到自动装箱了。在上面字节码偏移量为 10 的指令中,我们调用了 Integer.valueOf 方法,将 int 类型的值转换为 Integer 类型,再存储至容器类中。
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
这是 Integer.valueOf 的源代码。可以看到,当请求的 int 值在某个范围内时,我们会返回缓存了的 Integer 对象;而当所请求的 int 值在范围之外时,我们则会新建一个 Integer 对象。
当从泛型参数为 Integer 的 ArrayList 取出元素时,我们得到的实际上也是 Integer 对象。如果应用程序期待的是一个 int 值,那么就会发生自动拆箱。
在我们的例子中,自动拆箱对应的是字节码偏移量为 25 的指令。该指令将调用 Integer.intValue
方法。这是一个实例方法,直接返回 Integer 对象所存储的 int 值。
3、糖块三:泛型与类型擦除
我们都知道,很多语言都是支持泛型的,但是很多人不知道的是,不同的编译器对于泛型的处理方式是不同的,通常情况下,一个编译器处理泛型有两种方式:Code specialization和Code sharing。C++和C#是使用Code specialization的处理机制,而Java使用的是Code sharing的机制。
Code sharing方式为每个泛型类型创建唯一的字节码表示,并且将该泛型类型的实例都映射到这个唯一的字节码表示上。将多种泛型类形实例映射到唯一的字节码表示是通过类型擦除(type erasue)实现的。
也就是说,对于Java虚拟机来说,他根本不认识ArrayList<Integer> list
这样的语法。需要在编译阶段通过类型擦除
的方式进行解语法糖。
类型擦除的主要过程如下:
- 将所有的泛型参数用其最左边界(最顶级的父类型)类型替换。
- 移除所有的类型参数。
你可能已经留意到了,在前面例子生成的字节码中,往 ArrayList 中添加元素的 add 方法,所接受的参数类型是 Object;而从 ArrayList 中获取元素的 get 方法,其返回类型同样也是 Object。
前者还好,但是对于后者,在字节码中我们需要进行向下转换,将所返回的 Object 强制转换为 Integer,方能进行接下来的自动拆箱。
13: invokevirtual java/util/ArrayList.add:(Ljava/lang/Object;)Z
...
19: invokevirtual java/util/ArrayList.get:(I)Ljava/lang/Object;
22: checkcast java/lang/Integer
之所以会出现这种情况,是因为 Java 泛型的类型擦除。这是个什么概念呢?简单地说,那便是 Java 程序里的泛型信息,在 Java 虚拟机里全部都丢失了。这么做主要是为了兼容引入泛型之前的代码。
当然,并不是每一个泛型参数被擦除类型后都会变成 Object 类。对于限定了继承类的泛型参数,经过类型擦除后,所有的泛型参数都将变成所限定的继承类。也就是说,Java 编译器将选取该泛型所能指代的所有类中层次最高的那个,作为替换泛型的类。
class GenericTest<T extends Number> {
T foo(T t) {
return t;
}
}
举个例子,在上面这段 Java 代码中,我定义了一个 T extends Number 的泛型参数。它所对应的字节码如下所示。可以看到,foo 方法的方法描述符所接收参数的类型以及返回类型都为 Number。方法描述符是 Java 虚拟机识别方法调用的目标方法的关键。
T foo(T);
descriptor: (Ljava/lang/Number;)Ljava/lang/Number;
flags: (0x0000)
Code:
stack=1, locals=2, args_size=2
0: aload_1
1: areturn
Signature: (TT;)TT;
不过,字节码中仍存在泛型参数的信息,如方法声明里的 T foo(T),以及方法签名(Signature)中的“(TT;)TT;”。这类信息主要由 Java 编译器在编译他类时使用。
既然泛型会被类型擦除,那么我们还有必要用它吗?
我认为是有必要的。Java 编译器可以根据泛型参数判断程序中的语法是否正确。举例来说,尽管经过类型擦除后,ArrayList.add 方法所接收的参数是 Object 类型,但是往泛型参数为 Integer 类型的 ArrayList 中添加字符串对象,Java 编译器是会报错的。
ArrayList<Integer> list = new ArrayList<>();
list.add("0"); // 编译出错
4、糖块四:方法变长参数
可变参数(variable arguments)是在Java 1.5中引入的一个特性。它允许一个方法把任意数量的值作为参数。
看下以下可变参数代码,其中print方法接收可变参数:
package com.syntacticSugar;
public class VariableArgsDemo {
public static void main(String[] args) {
print("riemann", "blog:https://blog.csdn.net/riemann_", "公众号:程序猿屌爷");
}
public static void print(String... args) {
for (int i = 0; i < args.length; i++) {
System.out.println(args[i]);
}
}
}
反编译后代码:
package com.syntacticSugar;
public class VariableArgsDemo {
public static void main(String[] paramArrayOfString) {
print(new String[] { "riemann", "blog:https://blog.csdn.net/riemann_", "});
}
public static void print(String... paramVarArgs) {
for (byte b = 0; b < paramVarArgs.length; b++)
System.out.println(paramVarArgs[b]);
}
}
从反编译后代码可以看出,可变参数在被使用的时候,他首先会创建一个数组,数组的长度就是调用该方法是传递的实参的个数,然后再把参数值全部放到这个数组当中,然后再把这个数组作为参数传递到被调用的方法中。
5、糖块五:枚举
Java SE5提供了一种新的类型-Java的枚举类型,关键字enum可以将一组具名的值的有限集合创建为一种新的类型,而这些具名的值可以作为常规的程序组件使用,这是一种非常有用的功能。
要想看源码,首先得有一个类吧,那么枚举类型到底是什么类呢?是enum吗?答案很明显不是,enum就和class一样,只是一个关键字,他并不是一个类,那么枚举是由什么类维护的呢,我们简单的写一个枚举:
package com.syntacticSugar;
public enum EnumDemo {
SUCCESS("成功"),FAIL("失败");
String des;
EnumDemo(String des) {
this.des = des;
}
}
命令行: java -jar "D:\Tools\jdk\jdk-8u91\lib\cfr-0.148.jar" EnumDemo.class --sugarenums false
然后我们使用反编译,看看这段代码到底是怎么实现的,反编译后代码内容如下:
/*
* Decompiled with CFR 0.148.
*/
package com.syntacticSugar;
public final class EnumDemo
extends Enum<EnumDemo> {
public static final /* enum */ EnumDemo SUCCESS = new EnumDemo("SUCCESS", 0, "\u93b4\u612c\u59db");
public static final /* enum */ EnumDemo FAIL = new EnumDemo("FAIL", 1, "\u6fb6\u8fab\u89e6");
String des;
private static final /* synthetic */ EnumDemo[] $VALUES;
public static EnumDemo[] values() {
return (EnumDemo[])$VALUES.clone();
}
public static EnumDemo valueOf(String string) {
return Enum.valueOf(EnumDemo.class, string);
}
private EnumDemo(String string, int n, String string2) {
super(string, n);
this.des = string2;
}
static {
$VALUES = new EnumDemo[]{SUCCESS, FAIL};
}
}
通过反编译后代码我们可以看到,public final class EnumDemo extends Enum<EnumDemo>
,说明,该类是继承了Enum类的,同时final关键字告诉我们,这个类也是不能被继承的。当我们使用enmu来定义一个枚举类型的时候,编译器会自动帮我们创建一个final类型的类继承Enum类,所以枚举类型不能被继承。
6、糖块六:内部类
内部类又称为嵌套类,可以把内部类理解为外部类的一个普通成员。
内部类之所以也是语法糖,是因为它仅仅是一个编译时的概念,outer.java里面定义了一个内部类inner,一旦编译成功,就会生成两个完全不同的.class文件了,分别是outer.class和outer$inner.class。所以内部类的名字完全可以和它的外部类名字相同。
public class OutterClass {
private String userName;
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public static void main(String[] args) {
}
class InnerClass{
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
}
以上代码编译后会生成两个class文件:OutterClass InnerClass.class… Generating OutterClass.jad 。他会把两个文件全部进行反编译,然后一起生成一个OutterClass.jad文件。文件内容如下:
public class OutterClass
{
class InnerClass
{
public String getName()
{
return name;
}
public void setName(String name)
{
this.name = name;
}
private String name;
final OutterClass this$0;
InnerClass()
{
this.this$0 = OutterClass.this;
super();
}
}
public OutterClass()
{
}
public String getUserName()
{
return userName;
}
public void setUserName(String userName){
this.userName = userName;
}
public static void main(String args1[])
{
}
private String userName;
}
7、糖块七:条件编译
—般情况下,程序中的每一行代码都要参加编译。但有时候出于对程序代码优化的考虑,希望只对其中一部分内容进行编译,此时就需要在程序中加上条件,让编译器只对满足条件的代码进行编译,将不满足条件的代码舍弃,这就是条件编译。
如在C或CPP中,可以通过预处理语句来实现条件编译。其实在Java中也可实现条件编译。我们先来看一段代码:
public class ConditionalCompilation {
public static void main(String[] args) {
final boolean DEBUG = true;
if(DEBUG) {
System.out.println("Hello, DEBUG!");
}
final boolean ONLINE = false;
if(ONLINE){
System.out.println("Hello, ONLINE!");
}
}
}
反编译后代码如下:
public class ConditionalCompilation
{
public ConditionalCompilation()
{
}
public static void main(String args[])
{
boolean DEBUG = true;
System.out.println("Hello, DEBUG!");
boolean ONLINE = false;
}
}
首先,我们发现,在反编译后的代码中没有System.out.println("Hello, ONLINE!");
,这其实就是条件编译。当if(ONLINE)为false的时候,编译器就没有对其内的代码进行编译。
所以,Java语法的条件编译,是通过判断条件为常量的if语句实现的。其原理也是Java语言的语法糖。根据if判断条件的真假,编译器直接把分支为false的代码块消除。通过该方式实现的条件编译,必须在方法体内实现,而无法在正整个Java类的结构或者类的属性上进行条件编译,这与C/C++的条件编译相比,确实更有局限性。在Java语言设计之初并没有引入条件编译的功能,虽有局限,但是总比没有更强。
8、糖块八:断言
在Java中,assert关键字是从 JAVA SE 1.4 引入的,为了避免和老版本的Java代码中使用了assert关键字导致错误,Java在执行的时候默认是不启动断言检查的(这个时候,所有的断言语句都将忽略!),如果要开启断言检查,则需要用开关-enableassertions或-ea来开启。
看一段包含断言的代码:
package com.syntacticSugar;
public class AssertDemo {
public void assertTest(String s) {
assert (!s.equals("riemann"));
System.out.println(s);
}
}
命令行: java -jar "D:\Tools\jdk\jdk-8u91\lib\cfr-0.148.jar" AssertDemo.class --sugarasserts false
反编译后代码如下:
/*
* Decompiled with CFR 0.148.
*/
package com.syntacticSugar;
import java.io.PrintStream;
public class AssertDemo {
static final /* synthetic */ boolean $assertionsDisabled;
public void assertTest(String string) {
if (!$assertionsDisabled && string.equals("riemann")) {
throw new AssertionError();
}
System.out.println(string);
}
static {
$assertionsDisabled = !AssertDemo.class.desiredAssertionStatus();
}
}
很明显,反编译之后的代码要比我们自己的代码复杂的多。所以,使用了assert这个语法糖我们节省了很多代码。其实断言的底层实现就是if语言,如果断言结果为true,则什么都不做,程序继续执行,如果断言结果为false,则程序抛出AssertError来打断程序的执行。-enableassertions会设置$assertionsDisabled字段的值。
9、糖块九:数值字面量
在java 7中,数值字面量,不管是整数还是浮点数,都允许在数字之间插入任意多个下划线。这些下划线不会对字面量的数值产生影响,目的就是方便阅读。
比如:
public class Test {
public static void main(String... args) {
int i = 10_000;
System.out.println(i);
}
}
反编译后:
public class Test
{
public static void main(String[] args)
{
int i = 10000;
System.out.println(i);
}
}
反编译后就是把_删除了。也就是说 编译器并不认识在数字字面量中的_,需要在编译阶段把他去掉。
10、糖块十:for-each
增强for循环(for-each)相信大家都不陌生,日常开发经常会用到的,他会比for循环要少写很多代码,那么这个语法糖背后是如何实现的呢?
package com.syntacticSugar;
import java.util.Arrays;
import java.util.List;
public class ForEachDemo {
public static void main(String[] args) {
String[] strs = {"riemann", "blog:https://blog.csdn.net/riemann_", "公众号:程序员屌爷"};
for (String s : strs) {
System.out.println(s);
}
List<String> strList = Arrays.asList(strs);
for (String s : strList) {
System.out.println(s);
}
}
}
反编译后代码如下:
/*
* Decompiled with CFR 0.148.
*/
package com.syntacticSugar;
import java.io.PrintStream;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
public class ForEachDemo {
public static void main(String[] arrstring) {
String[] arrstring2 = new String[]{"riemann", "blog:https://blog.csdn.net/riemann_", "\u516c\u4f17\u53f7:\u7a0b\u5e8f\u5458\u5c4c\u7237"};
for (String string : arrstring2) {
System.out.println(string);
}
List<String> list = Arrays.asList(arrstring2);
Iterator iterator = list.iterator();
while (iterator.hasNext()) {
String string = (String)iterator.next();
System.out.println(string);
}
}
}
代码很简单,for-each的实现原理其实就是使用了普通的for循环和迭代器。
11、糖块十一:try-with-resource
Java里,对于文件操作IO流、数据库连接等开销非常昂贵的资源,用完之后必须及时通过close方法将其关闭,否则资源会一直处于打开状态,可能会导致内存泄露等问题。
关闭资源的常用方式就是在finally块里是释放,即调用close方法。比如,我们经常会写这样的代码:
package com.syntacticSugar;
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
public class TryWithResource {
public static void main(String[] args) {
BufferedReader br = null;
try {
String line;
br = new BufferedReader(new FileReader("d:\\test.xml"));
while ((line = br.readLine()) != null) {
System.out.println(line);
}
} catch (IOException e) {
// handle exception
} finally {
try {
if (br != null) {
br.close();
}
} catch (IOException ex) {
// handle exception
}
}
}
}
从Java 7开始,jdk提供了一种更好的方式关闭资源,使用try-with-resources语句,改写一下上面的代码,效果如下:
命令行: java -jar "D:\Tools\jdk\jdk-8u91\lib\cfr-0.148.jar" TryWithResource2.class --tryresources false
/*
* Decompiled with CFR 0.148.
*/
package com.syntacticSugar;
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.io.PrintStream;
import java.io.Reader;
public class TryWithResource2 {
public static void main(String ... arrstring) {
try {
BufferedReader bufferedReader = new BufferedReader(new FileReader("d:\\ test.xml"));
Throwable throwable = null;
try {
String string;
while ((string = bufferedReader.readLine()) != null) {
System.out.println(string);
}
}
catch (Throwable throwable2) {
throwable = throwable2;
throw throwable2;
}
finally {
if (bufferedReader != null) {
if (throwable != null) {
try {
bufferedReader.close();
}
catch (Throwable throwable3) {
throwable.addSuppressed(throwable3);
}
} else {
bufferedReader.close();
}
}
}
}
catch (IOException iOException) {
// empty catch block
}
}
}
看,这简直是一大福音啊,虽然我之前一般使用IOUtils去关闭流,并不会使用在finally中写很多代码的方式,但是这种新的语法糖看上去好像优雅很多呢。看下他的背后:
其实背后的原理也很简单,那些我们没有做的关闭资源的操作,编译器都帮我们做了。所以,再次印证了,语法糖的作用就是方便程序员的使用,但最终还是要转成编译器认识的语言。
12、糖块十二:Lambda表达式
关于lambda表达式,有人可能会有质疑,因为网上有人说他并不是语法糖。其实我想纠正下这个说法。Labmda表达式不是匿名内部类的语法糖,但是他也是一个语法糖。实现方式其实是依赖了几个JVM底层提供的lambda相关api。
先来看一个简单的lambda表达式。遍历一个list:
package com.syntacticSugar;
import java.util.Arrays;
import java.util.List;
public class LambdaDemo {
public static void main(String[] args) {
String[] strs = {"riemann", "blog:https://blog.csdn.net/riemann_", "公众号:程序员屌爷"};
List<String> strList = Arrays.asList(strs);
strList.forEach( s -> { System.out.println(s); } );
}
}
为啥说他并不是内部类的语法糖呢,前面讲内部类我们说过,内部类在编译之后会有两个class文件,但是,包含lambda表达式的类编译后只有一个文件。
命令行: java -jar "D:\Tools\jdk\jdk-8u91\lib\cfr-0.148.jar" LambdaDemo.class --decodelambdas false
反编译后代码如下:
/*
* Decompiled with CFR 0.148.
*/
package com.syntacticSugar;
import java.io.PrintStream;
import java.lang.invoke.LambdaMetafactory;
import java.util.Arrays;
import java.util.List;
import java.util.function.Consumer;
public class LambdaDemo {
public static void main(String[] arrstring) {
String[] arrstring2 = new String[]{"riemann", "blog:https://blog.csdn.net/riemann_", "\u516c\u4f17\u53f7:\u7a0b\u5e8f\u5458\u5c4c\u7237"};
List<String> list = Arrays.asList(arrstring2);
list.forEach((Consumer<String>)LambdaMetafactory.metafactory(null, null, null, (Ljava/lang/Object;)V, lambda$main$0(java.lang.String ), (Ljava/lang/String;)V)());
}
private static /* synthetic */ void lambda$main$0(String string) {
System.out.println(string);
}
}
所以,lambda表达式的实现其实是依赖了一些底层的api,在编译阶段,编译器会把lambda表达式进行解糖,转换成调用内部api的方式。
三、总结
前面介绍了12种Java中常用的语法糖。所谓语法糖就是提供给开发人员便于开发的一种语法而已。但是这种语法只有开发人员认识。要想被执行,需要进行解糖,即转成JVM认识的语法。当我们把语法糖解糖之后,你就会发现其实我们日常使用的这些方便的语法,其实都是一些其他更简单的语法构成的。
有了这些语法糖,我们在日常开发的时候可以大大提升效率,但是同时也要避免过渡使用。使用之前最好了解下原理,避免掉坑。