深入理解java虚拟机(十二) Java 语法糖背后的真相

时间:2022-02-05 20:48:41

语法糖(Syntactic Sugar),也叫糖衣语法,是英国计算机科学家彼得·约翰·兰达(Peter J. Landin)发明的一个术语。指的是,在计算机语言中添加某种语法,这些语法糖虽然不会对语言的功能产生任何影响,却能使程序员更方便的使用语言开发程序,同时增强程序代码的可读性,避免出错的机会。但是如果只是大量添加和使用语法糖,却不去了解他,容易产生过度依赖,从而无法看清语法糖的糖衣背后,程序代码的真实面目。

总而言之,语法糖可以看做是编译器实现的一些“小把戏”,这些“小把戏”可能会使得效率“大提升”,但我们也应该去了解这些“小把戏”背后的真是世界,这样才能更好地利用它们,而不是被它们迷惑。

下面我们就 泛型,自动拆箱/装箱、遍历循环和条件编译做简单的介绍和分析。了解他们背后的真相。

1、泛型与类型擦除

泛型是 JDK 1.5 的一项新增类型,它的本质是参数化类型(Parametersized Type)的应用,也就是说所操作的数据类型被指定为一个参数。这种参数可以用在类、接口和方法的创建中,分别成为泛型类、泛型接口和泛型方法。

泛型思想早在 C++ 语言的模板(Template) 就开始生根发芽。在 Java 语言还处于没有出现泛型的版本的时候,只能通过 Object 类是所有类型的父类和类型强制转换两个特点的配合来实现类型泛化。 例如,在 HashMap 的存取中,get() 方法返回的就是一个 Object 对象,由于 Java 语言所有的类型都继承于 java.lang.Object , 所以 Object 转型成任何对象都是有可能的。但是也因为有无限的可能性,就只有程序员和运行期的虚拟机知道这个Object 到底是什么类型的对象。在编译期间,编译器无法检查这个 Object 的强制转型是否成功,如果仅仅依赖于程序员去保障这项操作的正确性,许多 ClassCastException 的风险就会转嫁到程序运行期中。

但是,泛型技术在 C# 和 Java 之中的使用方式看似相同,在实现上却有着根本性的分歧,C# 的泛型无论是在源码中、编译后的 IL 中(Intermediate Language , 中间语言买这时候泛型符是一个占位符),或是运行期的 CLR 中,都是切实存在的, List<int> 和 List<String> 就是两个不同的类型,它们在运行期间,有自己的虚方法表和类型数据,这种实现称为类型膨胀,基于这种方法实现的泛型称为真实泛型。

Java 中的泛型则不一样,它只在程序源码中存在,在编译后的字节码文件中,就已经替换为原来的原生类型(Raw Type , 也成为裸类型)了,并且在相应的地方插入了强制类型转换代码,因此,对于运行期的 Java 语言来说,ArrayList<Integer> 和 ArrayList<String> 就是同一个类,所以泛型技术实际上是 Java 语言的一颗语法糖,Java 语言中的泛型实现方法称为类型擦除,基于这种方法实现的泛型称为伪泛型。

下面我们来看看一段简单的泛型擦除的例子:

  1. public static void main(String[] args) {
  2. Map<String, String> map = new HashMap<String, String>();
  3. map.put("hello", "你好");
  4. map.put("how are you?", "聊天止于呵呵");
  5. System.out.println(map.get("hello"));
  6. System.out.println(map.get("how are you?"));
  7. }

上面这段代码经过编译后,我们可以借助反编译软件 XJad 来查看编译后的代码,如下:

  1. public static void main(String args[])
  2. {
  3. Map map = new HashMap();
  4. map.put("hello", "你好");
  5. map.put("how are you?", "聊天止于呵呵");
  6. System.out.println((String)map.get("hello"));
  7. System.out.println((String)map.get("how are you?"));
  8. }

可以看到,所有的泛型代码都不见了,程序又变回了 Java 泛型出现之前的写法,泛型类型都变回了原生类型。也因为这个原因,当使用泛型类型做方法参数的时候,无法根据泛型来实现重载。如下列代码:

  1. public static int method(List<String>list){
  2. return 1;
  3. }
  4. public static int method(List<Integer> list){
  5. return 2;
  6. }

编译器会拒绝进行编译。

2、自动装箱、拆箱与遍历循环

从技术上来讲,自动装箱、拆箱与遍历循环(Foreach循环) 这些语法糖,无论是从实现上还是从思想上都不能和放行相比,两者的难度和深度都有很大的差距。我们通过代码来看看这些语法糖的本质:

  1. public static void main(String[] args) {
  2. List<Integer> list = Arrays.asList(1,2,3,4);
  3. //如果是在 JDK 1.7 还有另外一颗语法糖
  4. // List<Integer> list = [1,2,3,4];
  5. int sum =0;
  6. for(int i : list){
  7. sum+=i;
  8. }
  9. System.out.print(sum);
  10. }

上面代码一共包含了 泛型、自动装箱、自动拆箱、遍历循环与变长参数 5 种语法糖。经过反编译后得到如下代码:

  1. public static void main(String args[]) {
  2. List list = Arrays.asList(new Integer[] {
  3. Integer.valueOf(1),
  4. Integer.valueOf(2),
  5. Integer.valueOf(3),
  6. Integer.valueOf(4) });
  7. int sum = 0;
  8. for (Iterator iterator = list.iterator(); iterator.hasNext();) {
  9. int i = ((Integer) iterator.next()).intValue();
  10. sum += i;
  11. }
  12. System.out.print(sum);
  13. }

可以看到,

自动装箱、拆箱在编译之后被转化成了对应的包装和还原方法。如本例中的 Integer.valueof() 与 Integer.intValue() 方法,而遍历循环则把代码还原成了 迭代器的实现,这也是为何遍历循环需要被遍历的类实现 Iterable 接口的原因。

变长参数则在调用的时候变成了一个数组类型的参数,在变长类型出现之前,程序员就是使用数组来完成类似功能的。

有的时候,不节制的或不正确的使用装箱和拆箱会给我们带了极大的困扰,如下列代码:

  1. public static void main(String[] args) {
  2. Integer a = 1;
  3. Integer b = 2;
  4. Integer c = 3;
  5. Integer d = 3;
  6. Integer e = 321;
  7. Integer f = 321;
  8. Long g = 3L;
  9. System.out.println(c == d);
  10. System.out.println(e == f);
  11. System.out.println(c == (a + b));
  12. System.out.println(c.equals(a + b));
  13. System.out.println(g == (a + b));
  14. System.out.println(g.equals(a + b));
  15. }

读完上面代码,不妨自己思考一下,这六条输出语句的输出结果是什么? 这些语法糖解除之后的参数会是什么样子的呢?

下面来揭晓答案吧!

首先第一个:c ==d,因为“ ==” 号是用来判断两个引用指向的是否是同一个对象,因为在Integer 在构造对象的时候,128内的整数做了一个缓存优化,构造相同数值的代码会指向同一个对象,所以这个输出是true。

第二个, e == f ,类似于第一条,只是因为 e 与 f 不在优化范围之内,所以输出是 false。

第三个,c == ( a + b) , Java 中 == 号只有遇见运算符的情况下才会发生拆箱操作,这行代码被编译成 c.intValue() == a.intValue() + b.intValue(),比较的是值是否相等,所以输出结果为 true。

第四个,c.equals( a + b ),类似于上一条,会发生拆箱操作,编译为 c.equals(Integer.valueOf(a.intValue() + b.intValue())) ,输出结果为 true。

第五个,g == (a + b),类似于第三条,发生拆箱,同时因为有"=="号的存在,会发生强制类型转换, 被编译为  g.longValue() == (long)(a.intValue() + b.intValue())  ,输出结果为 true。

第六个,g.equals( a+b) ,类似于第四条,会发生拆箱操作,但是没有”==“号,不会放生强制类型转换。而第四条比较的都是Integer类型,而这里却是 Long 和 Integer 类型,equals 只能比较同种类型的对象,所以返回自然就是 false 了。

你做对了几道?

所以程序猿们做好少用,用者需谨慎!

3、条件编译

许多程序设计语言都提供了条件编译的途径,如C、C++ 中使用预处理器指示符(#ifdef)完成条件编译。C、C++ 的预处理器最初的任务是解决编译时的代码依赖关系(如#include),而在 Java 语言之中并没有使用预处理器,因为 Java 语言天然的编译方式(不一个个编译 Java 文件,而是将所有编译单元的语法树*结点输入到待处理列表后再进行编译,因此各个文件之间能够互相提供符号信息) 无需使用预处理器。

Java 语言也可以进行条件编译,方法就是使用条件为常量的 if 语句。如下面代码:

  1. public static void main(String[] args) {
  2. if(true){
  3. System.out.println("True");
  4. }else{
  5. System.out.println("False");
  6. }
  7. }

经过编译后会变成如下代码:

  1. public static void main(String args[])
  2. {
  3. System.out.println("True");
  4. }

而有的时候使用常量的判断语句会提示错误,被拒绝编译:

  1. while(false){
  2. System.out.println("True");
  3. }

Java 语言中条件编译的实现,也是 Java 语言的一颗语法糖,根据布尔常量的真假,编译器将会把分支中不成立的代码块消除掉,这一工作将在编译器接触语法糖阶段完成。由于这种条件编译的实现方式使用了 if 语句,所以它必须遵循最基本的Java 语法,只能写在方法体内部,因此它只能实现语句基本块级别的条件编译,而无法根据条件调整 Java 类的结构。

Java语言中还有其它的一些语法糖,如内部类、枚举类、断言语句、对枚举和字符串的switch 支持、try语句中定义和关闭资源等,读者可以跟踪 Javac源码、反编译 Class 文件等方式了解他们的本质实现。