深入理解Java虚拟机:(十一)Java语法糖详解

一、概述

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 I n n e r C l a s s . c l a s s O u t t e r C l a s s . c l a s s O u t t e r C l a s s . c l a s s P a r s i n g O u t t e r C l a s s . c l a s s . . . P a r s i n g i n n e r c l a s s O u t t e r C l a s s InnerClass.class 、OutterClass.class 。当我们尝试对OutterClass.class文件进行反编译的时候,命令行会打印以下内容:Parsing OutterClass.class...Parsing inner 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认识的语法。当我们把语法糖解糖之后,你就会发现其实我们日常使用的这些方便的语法,其实都是一些其他更简单的语法构成的。

有了这些语法糖,我们在日常开发的时候可以大大提升效率,但是同时也要避免过渡使用。使用之前最好了解下原理,避免掉坑。

发布了337 篇原创文章 · 获赞 206 · 访问量 13万+

猜你喜欢

转载自blog.csdn.net/riemann_/article/details/104098016