在 JDK8 中,新增了许多新特性,可以让开发人员更优雅的编写代码,那么下面跟随本文来了解一下吧!
Lambda 表达式
在 JDK8 中,新增的 Lambda 表达式可以大大地简化了我们的代码量,其使用起来也非常简单。
在使用之前,我们先了解下函数式的编程思想。
什么是函数式的编程思想?
在数学中,函数是有输入量、输出量的一套计算方案,即“拿什么东西做什么事情”。
在面向对象中,过分强调着“必须通过对象的形式来做事情“,而函数式思想则尽量忽略该概念,强调做什么,却不注重它以什么形式做。
因此,引出一个新的名词——函数式接口。
函数式接口:即有且只有一个抽象方法的接口。
如何标注一个接口为函数式接口呢?
在 Java 中,可以用@FunctionalInterface
注解标识该接口是否函数式接口,是才能编译成功。
那么,为什么要了解什么是函数式接口呢?
这是因为,Lambda 表达式使用的前提就是其作用的接口必须为函数式接口。
传统的线程代码
若我们想使用一个线程,方法之一是实现 Runnable 接口然后再将其作为参数传入。
在传统的做法中,我们有 2 种实现方案:
- 接口实现类
- 匿名内部类
接口实现类线程实现
首先我们需要创建一个 Runnable 接口并重写 run()
方法:1
2
3
4
5
6public class RunnableImpl implements Runnable {
public void run() {
System.out.println(Thread.currentThread().getName() + " 这个新线程被创建了");
}
}
其次将接口实现类作为 Thread 的参数,然后调用start()
方法即可:1
2
3
4new Thread(new RunnableImpl()).start();
// 与下面这两句等效哦
// Runnable r = new RunnableImpl();
// new Thread(r).start();
测试结果:1
Thread-0 这个新线程被创建了
匿名内部类线程实现
1 | new Thread(new Runnable() { |
测试结果与第一种做法相同。
思考
阅读前述代码时,我们会发现,有许多冗余代码并不需要被关注。
为何不需要被关注呢?
因为此种接口只有一个方法,使用该接口肯定知道这个接口及其方法是什么,你还展示给我看干嘛!这信息对我无用啊,我已经知道了呀!
那 Lambda 表达式就说了:“我~来~帮~你~把~它~隐藏掉!”
这里涉及到一个编程思想的转换,重点关注做什么,而不是怎么做。
我们真的希望创建一个匿名内部类嘛?
不,我们只是为了做这件事情而不得不创建一个对象,我们真正希望做的事情是:将run()
方法内的代码传递给Thread
类知晓,,因为调用Thread
类的start()
方法时其实调用的是run()
方法。
传递一段代码——这才是我们的真正目的,而创建对象只是受限于面向对象语法而不得不采用的一种手段。
那么,是否存在一种更简单的办法呢?
自然是有的,使用 Lambda 表达式就好了,下面我们来使用它吧!!!
Lambda 表达式的线程实现
1 | new Thread(()->{ |
此方式的运行结果与前两种做法是一样的,却更为简单优雅。
既然这么好用,那 Lambda 表达式到底是个什么东东呢?标准定义又是怎样的呢?
Lambda 表达式标准格式
在Lambda 表达式中,省去了面向对象的条条框框,其格式由三个部分组成:
- 一个
()
及其中的一些参数(也可以无参数) - 一个箭头
->
- 一个大括号
{}
及一段本来写在重写方法中的代码
因此,Lambda 表达式的标准格式为:1
(参数类型 参数名称)->{代码语句}
示例:加深 Lambda 表达式理解
无参数无返回值的自定义函数式接口
创建一个Student
接口,之后将接口作为参数传递调用其方法:1
2
3
4// 接口
public interface Student {
void work();
}
下面分别为匿名内部类和 Lambda 表达式的写法:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22// 将接口作为参数传递调用其方法
public void invokeWork(Student student){
student.work();
}
// 匿名内部类
public void test1{
invokeWork(new Student() {
public void work() {
System.out.println("学生的任务是学习");
}
});
}
// Lambda 表达式
public void test2{
invokeWork(()->{
System.out.println("学生的任务是学习");
});
}
测试结果:1
学生的任务是学习
有参数有返回值函数式接口
需求:
- 创建
Teacher
对象 - 通过
Arrays.sort()
方法对不同Teacher
对象按年龄大小排序(重写Comparator
接口排序规则)
1 | public class Teacher { |
下面分别为匿名内部类和 Lambda 表达式的写法:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public void before{
Teacher[] teachers = {new Teacher("王老师",18),
new Teacher("李老师",20),
new Teacher("赵老师",22)};
}
// 匿名内部类
public void test1(){
Arrays.sort(teachers, new Comparator<Teacher>() {
public int compare(Teacher o1, Teacher o2) {
return o1.getAge() - o2.getAge();
}
});
}
// Lambda 表达式
public void test2(){
Arrays.sort(teachers,(Teacher o1,Teacher o2)->{
return o1.getAge() - o2.getAge();
});
}
public void after{
for (Teacher teacher : teachers) {
System.out.println(teacher);
}
}
运行结果:1
2
3Teacher{name='王老师', age=18}
Teacher{name='李老师', age=20}
Teacher{name='赵老师', age=22}
有参数有返回值的自定义函数式接口
编写一个用于求和的接口:1
2
3public interface Sum {
int sumNumber(int a,int b);
}
下面分别为匿名内部类和 Lambda 表达式求和的写法:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26public int invokeSum(int a, int b, Sum sum) {
int result = sum.sumNumber(a, b);
return result;
}
// 匿名内部类
public void testSum1() {
int r = invokeSum(66, 88, new Sum() {
public int sumNumber(int a, int b) {
return a + b;
}
});
System.out.println(r);
}
// Lambda 表达式
public void testSum2() {
int r = invokeSum(66, 88, (int a,int b) -> {
return a + b;
}
);
System.out.println(r);
}
运行结果:1
154
与匿名内部类相比,Lambda 表达式除了可以简化代码,其编译的代码还无匿名内部类的标识$
呢,如:1
2Demo$1.class // 匿名内部类方式编译完的代码
Demo.class // Lambda 表达式方式编译完的代码
Lambda 表达式的进一步省略写法
对 Lambda 表达式而言,它是可推导可省略的,凡是根据上下文推导出来的内容,都可以省略书写,可省略内容包括:
(参数列表)
:括号中参数列表的数据类型可以省略不写(参数列表)
:若括号中的参数只有一个,那么类型和参数都可以省略不写{代码}
:若{}
中的代码只有一行,则无论是否有返回值,其中的return
、{}
及省略的}
前的;
都可以省略不写。
下面使用更省略的 Lambda 表达式重写前面的例子:1
2
3
4
5
6
7
8
9
10
11// 线程
new Thread(() ->System.out.println(Thread.currentThread().getName() + "这个新线程被创建了")).start();
// 学生接口
invokeWork(() -> System.out.println("学生的任务是学习"));
// 按年龄排序教师
Arrays.sort(teachers, (o1, o2) -> o1.getAge() - o2.getAge());
// 求和
int r = invokeSum(66, 88, (a,b) -> a + b);
Lambda 的延迟执行
有些场景的代码执行后,结果不一定会被使用,从而造成性能浪费。
而 Lambda 表达式具备延迟执行的特性,正好可以解决该问题。
假如现在我们要根据条件拼接字符串,条件正确时才能拼接字符串,如下面的代码:
1 | public class UnDelay{ |
在这种情况下,不论条件是否正确,s1
和s2
字符串都会拼接,浪费了部分性能。
而使用 Lambda 表达式只会在条件正确时才执行拼接,下面为修改后的代码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27// 接口
public interface SplitMsg {
String splitMessage();
}
// 测试代码
public class Delay{
private String s1 = "hello ";
private String s2 = "world";
public void splitByLambda(int number, SplitMsg splitMsg) {
if (number == 1) {
System.out.println(splitMsg.splitMessage());
}
}
public void Delay() {
// 条件正确则拼接,失败则不执行
splitByLambda(1, () -> {
System.out.println("条件正确你才能看到该代码");
return s1 + s2;
});
splitByLambda(2, () -> {
System.out.println("条件正确你才能看到该代码");
return s1 + s2;
});
}
}
最后的输出结果:1
2条件正确你才能看到该代码
hello world
常用的函数式接口
除Runnable
接口与Comparator<T>
接口外,JDK 中还提供了许多常用的函数式接口,它们主要分布在java.util.function
包中。
Supplier <T>
Supplier <T> 接口进包含一个无参的方法T get()
,用来获取一个泛型参数指定类型的对象数据,即生产一个数据。
比如我们可以将泛型指定为字符串类型,重写方法中返回自定义的字符串:
1 | public class TestSupplier { |
当然我们也可以获取 int 类型数据,获取一个数组的最小值:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22public class TestSupplier {
public int getMinNumber(Supplier<Integer> supplier) {
return supplier.get();
}
public void testGetMinNumber() {
int[] arr = {12, 48, 92, 8, 22, 3, 99, 1};
int min = getMinNumber(() -> {
int m = arr[0];
for (int i = 1; i < arr.length; i++) {
if (m > arr[i]) {
m = arr[i];
}
}
return m;
});
System.out.println(min);
}
}
// 运行结果
1
Consumer<T>
Consumer<T> 接口正好与 Supplier <T> 接口相反,它不是生产一个数据,而是消费一个数据,数据类型也由泛型 T 决定。该接口包含两个方法:
void accept(T t);
:消费传递进来的数据 TandThen(Consumer<? super T> after)
:拼接两个 Consumer<T> 接口
1 | // 消费一次数据 |
Predicate<T>
某些情况下需要对某种数据类型的数据进行判断,从而得到一个boolean
结果,这时可以使用 Predicate<T> 接口,该接口包含以下方法:
boolean test(T t)
:根据其中重写代码判断传递的数据返回为true
或false
;Predicate<T> and(Predicate<? super T> other)
: 将两个 Predicate 连接起来比较,相当于&&
;Predicate<T> or(Predicate<? super T> other)
:相当于||
;Predicate<T> negate()
:相当于!
,
1 | //测试test方法 |
or
和negate
方法与and
方法类似。
Function<T,R>
Function<T,R> 接口用来将一个类型的数据转换为另一个类型的数据,前者称为前置条件,后者称为后置条件。
该接口主要包含下面 2 个重要的方法:
R apply(T t);
:将 T 类型的数据转换为 R 类型的数据Function<T, V> andThen(Function<? super R, ? extends V> after)
:类似于 Consumer 接口的andThen
方法,拼接多个 Function 接口,可以进行多次转换。
1 | public int getTranslate(String s , Function<String,Integer> function){ |
注意事项
前面也提到过,使用 Lambda 表达式一定要注意接口必须为函数式接口。
Stream 流
我们先通过一个集合元素过滤的例子来引入 Stream 流的概念。
例子:集合元素的过滤
集合元素的过滤可以使用传统的增强for
循环方式遍历,
传统方式
若需要对集合中的元素进行过滤,如对“王”姓及姓名长度为 2 的人进行过滤,传统代码需要 2 步:
- 先将集合 A 根据条件一过滤为子集 B
- 然后再根据条件二过滤为子集 C
1 |
|
测试结果:1
2王五
王刘
从测试结果可以发现:循环遍历操作非常麻烦。
前面说过,Lambda 表达式可以让我们更加专注做什么,而不是怎么做。
但从上面的代码发现:
- for 循环的做法就是“怎么做”
- for 循环的循环体才是“做什么”
为什么使用循环?
因为要进行遍历,但循环是遍历的唯一方式吗?
遍历是指将每一个元素从集合中取出并逐一进行处理(不一定按顺序),而并不是从某个位置到某个位置顺次处理的循环。
前者(遍历)是目的,后者(循环)是方式。
一方面,每当需要对集合中的元素进行操作的时候,总是需要进行循环、循环、再循环。
这是理所当然的吗?
当然不是,循环是做事情(遍历)的方式(怎么做),而不是目的(做什么)。
另一方面,使用线性循环就意味着只能遍历一次。若希望再次遍历,只能再用另一个循环从头开始。
但是, Stream 流方式却有更优雅的做法。
Stream 流方式
1 |
|
测试结果与传统方式相同,但代码量却大大减少。
那么,到底什么是 Stream 流?
什么是 Stream 流?
要想回答这个问题,得先明白流式思想的概念,流式思想类似于工厂车间的生产流水线。
如啤酒的加工过程一般分为以下几步:
- 瓶子分类
- 洗涤
- 装酒
- 封口
- 装箱
- 打包
当需要对多个元素进行操作(特别是多步操作)的时候,考虑到性能及便利性,我们首先应该创建一个模型,然后按模型中的方案去执行它。
对 Stream 流来说,其过程如下:
上图展示了过滤、映射、跳过、计数等多步操作,这是一种集合元素的处理方案,而方案就是一种函数模型,图中的每一个方框都是一个流,调用指定的方法,可以从一个流模型转换为另一个流模型,而最右侧的数字3
便是最终结果。
上面的filter
、map
、skip
都是在对函数模型进行操作,集合元素并没有被真正处理,只有当终结方法count
执行的时候,整个模型才会按照指定策略执行操作,这得益于 Lambda 的延迟执行特性。
“Stream 流”其实是一个集合元素的函数模型,它并不是集合,也不是数据结构,其本身并不存储任何元素(或其地址值)。
Stream(流)是一个来自数据源的元素队列:
- 元素:特定类型的对象,形成一个队列,Java 中的 Stream 并不会存储元素,而是按需计算;
- 数据源:流的来源,可以是集合,数组等等;
- Pipelining:中间操作都会返回流对象本身,这样多个操作便可以串成一个管道,如同流式风格。这样做可以对操作进行优化,比如延迟执行和短路。
- 内部迭代:以前对集合遍历都是通过
Iterator
或者增强for
方式遍历,显式的在集合外部进行迭代,叫做外部迭代。而 Stream 提供了内部迭代,其可以直接调用遍历方法。
当使用一个流的时候,通常包括三个基本步骤:
- 获取一个数据源
- 数据转换
- 执行操作得到想要的结果
每次转换时原有 Stream 对象不变,但返回一个新的 Stream 对象,并且可以多次转换哟。
Stream 流获取的两种方式
java.util.stream.Stream<T>
是 Java 8 新加入的最常用的流接口。
要想获取一个流非常简单:
- 对所有的
Collection
集合,都可以通过stream
默认方法获取流; - 对数组而言,可通过
Stream
流接口的静态方法of
获取流。
根据 Colleantion 获取流
所有的Colleantion
集合都有一个stream
方法,通过该方法便可以获取流,其源码如下:1
2
3default Stream<E> stream() {
return StreamSupport.stream(spliterator(), false);
}
当然,根据集合的不同,获取流的代码可能略微存在差异。
List 集合获取流
1 | List<Integer> age = new ArrayList<>(); |
Set 集合获取流
1 | Set<String> name = new HashSet<>(); |
Map 集合转换后获取流
1 | Map<String,Integer> map = new HashMap<>(); |
根据数组获取流
1 | // 数组获取流 |
Stream 流中的方法
Stream 流模型的操作很丰富,这里介绍一些常用的 API。
对这些方法而言,又可以被分成 2 类:
- 延迟方法:返回值类型仍然是
Stream
接口自身类型的方法,因此支持链式调用; - 终结方法:返回值类型不再是
Stream
接口自身类型的方法,因此不在支持类似StringBuilder
那样的链式调用
延迟方法
延迟方法包括:filter
、map
、limit
、skip
、concat
。
过滤:filter
filter
方法接受一个Predicate
函数式接口,能将一个流转换为另一个子集流:1
Stream<T> filter(Predicate<? super T> predicate);
示例代码:1
2
3
4
5
6
7
public void testFilterOfStream() {
Stream.of("张三", "李四", "王五", "王老五")
.filter(name -> name.startsWith("王"))
.filter(name -> name.length() == 2)
.forEach(name -> System.out.println(name));
}
测试结果:1
王五
映射:map
map
方法接受一个Function
函数式接口,可以将流中的元素映射到另一个流中:1
<R> Stream<R> map(Function<? super T, ? extends R> mapper);
示例代码:1
2
3
4
5
6
public void testStreamMap() {
Stream.of("2131", "141", "8888", "66666")
.map(number -> Integer.parseInt(number))
.forEach(number -> System.out.println(number));
}
测试结果:1
2
3
42131
141
8888
66666
截取:limit
limit
方法可以对流进行截取,只取用前n
个:1
Stream<T> limit(long maxSize);
示例代码:1
Stream.of(1, 3, 6, 5, 8, 11).limit(3).forEach(number -> System.out.println(number));
运行结果:1
2
31
3
6
跳过:skip
若你希望跳过前几个元素,可以使用skip
方法获取一个截取之后的新流(若流的当前长度大于 n,则跳过前 n 个;否则将得到一个长度为 0 的新流):1
Stream<T> skip(long n);
示例代码:1
Stream.of(1, 3, 6, 5, 8, 11).skip(3).forEach(number -> System.out.println(number));
运行结果:1
2
35
8
11
组合:concat
concat
方法可以将两个流合并成为一个流:1
public static <T> Stream<T> concat(Stream<? extends T> a, Stream<? extends T> b) {...}
示例代码:1
2
3
4Stream<Integer> stream1 = Stream.of(1, 3, 5, 7, 9);
Stream<Integer> stream2 = Stream.of(2, 4, 6, 8, 10);
Stream<Integer> concat = Stream.concat(stream1, stream2);
concat.forEach(number -> System.out.println(number));
运行结果:1
2
3
4
5
6
7
8
9
101
3
5
7
9
2
4
6
8
10
终结方法
终结方法包括:forEach
、count
。
逐一处理:forEach
虽然方法名字叫forEach
,但是与 for 循环中的 for-each 称呼不同。
该方法接受一个Consumer
函数式接口,会将每一个流元素交给该函数进行处理:1
void forEach(Consumer<? super T> action);
示例代码:1
Stream.of("张三","李四","王五").forEach(name -> System.out.println(name));
运行结果:1
2
3张三
李四
王五
统计个数:count
正如旧集合Collection
当中的size
方法一样,Stream 流提供count
方法来统计流中的元素个数,该方法返回一个 long 值代表元素个数:1
long count();
示例代码:1
2long count = Stream.of(1, 3, 6, 5, 8, 11).count();
System.out.println(count);
运行结果:1
6
注意哦
: Stream
流属于管道流,只能被使用一次,第一个Stream
流的方法调用完毕后,数据就会转到下一个Stream
流上,这时第一个Stream
流就使用完毕被关闭,不能在调用方法了。
方法引用
在使用 Lambda 表达式的时候,我们实际上传递进去的代码就是一种解决方案:拿什么参数做什么操作。
那么现在考虑一种情况:若我们在 Lambda 中所指定的操作方案,已经有地方存在相同方案,那是否还有必要再写重复逻辑?
先看一个栗子:
冗余的 Lambda 代码
例如下面的代码:
1 | public interface Student { |
Student
接口当中唯一的抽象方法work
接受一个String
类型的参数,可以输入学生具体的工作内容。
若使用 Lambda 表达式方式:1
2
3
4
5
6
7
8
9
10public void getWork(Student student) {
student.work("学生的工作是学习。。。。");
}
public void testLambda() {
getWork(s -> System.out.println(s));
}
//运行结果
学生的工作是学习。。。。
问题分析
上面代码的问题在于,对字符串进行控制台打印输出的操作方案,明明已经有了现成的实现,那就是System.out
对象中的println(s)
方法。
既然 Lambda 希望做的事情就是调用println(s)
方法,那何必自己手动调用呢?
方法引用改进代码
能否省去 Lambda 的语法格式(尽管它已经相当简洁)呢?
可以的,我们只要“引用”过去就好了,代码简化如下:
1 | getWork((System.out::println)); |
双冒号::
为引用运算符,而它所在的表达式被称为方法引用。
方法引用种类
从这些例子可以看出, 要用::
操作符分隔对象与方法名或类名与方法名。主要有 3 种情况:
object::instanceMethod
:对象名引用成员方法;Class::static Method
:类名引用静态成员方法Class::instanceMethod
:类名引用成员方法
在前 2 种情况中, 方法引用等价于提供方法参数的 Lambda 表达式。
对于第 3 种情况, 第 1 个参数会成为方法的目标。注:
若有多个同名的重载方法,编译器就会尝试从上下文中找出你指的那一个方法。
对象名引用成员方法
前面已经提到,因为System.out
对象中有一个重载的println(String)
方法恰好就是我们所需要的,所以下面两行代码等价:1
2System.out::println
s -> System.out.println(s)
该对象为系统自带,看起来可能没那么直观,我们自己创建一个对象来举个例子:1
2
3
4
5
6
7
8
9
10
11// 学生接口
public interface Student {
void work(String str);
}
// 对象
public class MethodReferenctByObject {
public void noGame(String str){
str += "不要沉迷游戏";
System.out.println(str);
}
}
下面为分别使用 Lambda 表达式和方法引用的方式写的代码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16public void getWork(Student student) {
student.work("学生的工作是学习。。。。");
}
public void testStudent(){
// Lambda 表达式
getWork(str -> {
MethodReferenctByObject methodRefByObj = new MethodReferenctByObject();
methodRefByObj.noGame(str);
});
// 方法引用
MethodReferenctByObject methodRefByObj= new MethodReferenctByObject();
getWork(methodRefByObj::noGame);
}
运行结果都为:1
学生的工作是学习。。。。不要沉迷游戏
类名引用成员方法
类似地, 下面两行代码等价:1
2Math::pow
(x,y) -> Math.pow(x, y)
类名引用静态成员方法
类似地, 下面两行代码等价:1
2String::compareToIgnoreCase
(x, y) -> x.compareToIgnoreCase(y)
其他方法引用
this 引用本类的成员方法
对于接口中的方法,我们可以直接重写代码,也可以在其中通过this
调用本类的方法。下面有一个购买的方法,现在有一个消费者类通过该接口去购物:1
2
3
4
5
6
7
8
9
10
11
12
13
14// 接口
public interface Buy {
void buy();
}
// 消费者类
public class Consumer {
public void shopping(Buy buy) {
buy.buy();
}
public void buySomething() {
System.out.println("去超市买点生活用品");
}
}
下面分别为 Lambda 表达式方式和方法引用的测试代码:1
2
3
4
5
6
7
8
9
10
public void testLambda() {
shopping(() -> {
this.buySomething();
});
}
public void testMethodRef(){
shopping(this::buySomething);
}
运行结果都为:1
去超市买点生活用品
super 引用父类的成员方法
类似于this引用本类的成员方法,只是把当前类为子类且继承了一个父类,此处代码省略。
类的构造器引用
由于构造器的名称与类名完全一样,并不固定,所以构造器引用类名称::new
的格式表示。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23// Person 类
public class Person {
private String name;
public Person(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
// Person 接口,根据姓名返回 Person
public interface PersonInterface {
Person getPersonName(String name);
}
// 测试 Person 类
public class TestPerson {
public void getPerson(String name, PersonInterface personInter) {
Person person = personInter.getPersonName(name);
System.out.println(person.getName());
}
}
下面分别为Lambda表达式方式和方法引用的测试代码:1
2
3
4
5
6
7
8
9
10
11
12
public void testLambda() {
getPerson("王五", name -> new Person(name));
}
public void testMethodRer(){
getPerson("王五",Person::new);
}
// 运行结果都为
王五
数组的构造器引用
数组也是Object
的子类对象,所以同样具有构造器,只是语法稍有不同。
若对应到 Lambda 的使用场景中时,需要一个函数式接口:1
2
3
4// 根据长度返回一个新的数组
public interface ArrayInterface {
int[] getArrayBylength(int lenth);
}
下面分别为 Lambda 表达式方式和方法引用的测试代码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15public class TestArray {
public void getArray(int length, ArrayInterface arrayInterface) {
int[] arrayBylength = arrayInterface.getArrayBylength(length);
}
public void testLambda() {
getArray(10, length -> new int[length]);
}
public void testMethodRef() {
getArray(10, int[]::new);
}
}
文章信息
时间 | 说明 |
---|---|
2020-07-08 | 部分文字描述修正 |