Java8笔记(2)
用流收集数据
收集器
函数式编程相对于指令式编程的一个主要优势:你只需指出希望的结果——“做什么”,而不用操心执行的步骤——“如何做”
收集器用作高级归约
收集器非常有用,因为用它可以简洁而灵活地定义 collect 用来生成结果集合的标准。更具体地说,对流调用collect 方法将对流中的元素触发一个归约操作(由 Collector 来参数化)
一般来说, Collector 会对元素应用一个转换函数(很多时候是不体现任何效果的恒等转换,例如 toList ),并将结果累积在一个数据结构中,从而产生这一过程的最终输出
预定义收集器
预定义收集器的功能,也就是那些可以从 Collectors类提供的工厂方法(例如 groupingBy )创建的收集器。它们主要提供了三大功能
-
将流元素归约和汇总为一个值
-
元素分组
-
元素分区
归约和汇总
在需要将流项目重组成集合时,一般会使用收集器( Stream 方法 collect的参数)。再宽泛一点来说,但凡要把流中所有的项目合并成一个结果时就可以用。这个结果可以是任何类型,可以复杂如代表一棵树的多级映射,或是简单如一个整数
public class M1 {
public static void main(String[] args) {
List<Dish> menu = Data.create();
// 利用 counting 工厂方法返回的收集器,数一数菜单里有多少
//种菜:
long howManyDishes = menu.stream()
.collect(Collectors.counting());
System.out.println(howManyDishes);
// 还可以写得更为直接:
long howManyDishes_1 = menu.stream()
.count();
System.out.println(howManyDishes_1);
}
}
查找流中的最大值和最小值
假设你想要找出菜单中热量最高的菜。你可以使用两个收集器, Collectors.maxBy 和
Collectors.minBy ,来计算流中的最大或最小值
这两个收集器接收一个 Comparator 参数来比较流中的元素。你可以创建一个 Comparator 来根据所含热量对菜肴进行比较,并把它传递给Collectors.maxBy
public class M1 {
public static void main(String[] args) {
List<Dish> menu = Data.create();
Comparator<Dish> dishCaloriesComparator =
Comparator.comparingInt(Dish::getCalories);
// System.out.println(dishCaloriesComparator);
Optional<Dish> mostCalorieDish =
menu.stream()
.collect(maxBy(dishCaloriesComparator));
System.out.println(mostCalorieDish.get());
}
}
汇总
Collectors 类专门为汇总提供了一个工厂方法: Collectors.summingInt 。它可接受一
个把对象映射为求和所需 int 的函数,并返回一个收集器;该收集器在传递给普通的collect方法后即执行我们需要的汇总操作
public class M1 {
public static void main(String[] args) {
List<Dish> menu = Data.create();
//你可以这样求出菜单列表的总热量
int totalCalories = menu.stream()
.collect(summingInt(Dish::getCalories));
System.out.println(totalCalories);
}
}
但汇总不仅仅是求和;还有 Collectors.averagingInt ,连同对应的 averagingLong 和
averagingDouble 可以计算数值的平均数:
public class M2 {
public static void main(String[] args) {
List<Dish> menu = Data.create();
double avgCalories = menu.stream()
.collect(averagingInt(Dish::getCalories));
System.out.println(avgCalories);
}
}
你已经看到了如何使用收集器来给流中的元素计数,找到这些元素数值属性的最大值和最小值,以及计算其总和和平均值
不过很多时候,你可能想要得到两个或更多这样的结果,而且你希望只需一次操作就可以完成。在这种情况下,你可以使用 summarizingInt 工厂方法返回的收集器。例如,通过一次 summarizing 操作你可以就数出菜单中元素的个数,并得到菜肴热量总和、平均值、最大值和最小值:
public class M3 {
public static void main(String[] args) {
List<Dish> menu = Data.create();
IntSummaryStatistics menuStatistics =
menu.stream()
.collect(summarizingInt(Dish::getCalories));
System.out.println(menuStatistics);
//IntSummaryStatistics{count=9, sum=4200, min=120, average=466.666667, max=800}
}
}
同样,相应的 summarizingLong 和 summarizingDouble 工厂方法有相关的 LongSummary-
Statistics 和 DoubleSummaryStatistics 类型,适用于收集的属性是原始类型 long 或double 的情况
连接字符串
joining 工厂方法返回的收集器会把对流中每一个对象应用 toString 方法得到的所有字符串连接成一个字符串
public class M1 {
public static void main(String[] args) {
//把菜单中所有菜肴的名称连接起来
List<Dish> menu = Data.create();
String shortMenu = menu.stream()
.map(Dish::getName).collect(joining());
System.out.println(shortMenu);
}
}
该字符串的可读性并不好。幸好, joining 工厂方法有一个重载版本可以接受元素之间的
分界符,这样你就可以得到一个逗号分隔的菜肴名称列表:
String shortMenu_1 = menu.stream()
.map(Dish::getName)
.collect(joining(", "));
System.out.println(shortMenu_1);
广义的归约汇总
事实上,我们已经讨论的所有收集器,都是一个可以用 reducing 工厂方法定义的归约过程
的特殊情况而已。 Collectors.reducing 工厂方法是所有这些特殊情况的一般化。可以说,先前讨论的案例仅仅是为了方便程序员而已
可以用 reducing 方法创建的收集器来计算你菜单的总热量
public static void main(String[] args) {
List<Dish> menu = Data.create();
int totalCalories = menu.stream()
.collect(reducing(
0,
Dish::getCalories,
(i,j) -> i + j
));
System.out.println(totalCalories);
}
需要三个参数
-
第一个参数是归约操作的起始值,也是流中没有元素时的返回值,所以很显然对于数值
和而言 0 是一个合适的值 -
第二个参数就一个功能函数,将菜肴转换成一个表示其所含热量的 int
-
第三个参数是一个 BinaryOperator ,将两个项目累积成一个同类型的值。这里它就是
对两个 int 求和
同样,你可以使用下面这样单参数形式的 reducing 来找到热量最高的菜
public static void main(String[] args) {
List<Dish> menu = Data.create();
Optional<Dish> mostCalorieDish =
menu.stream()
.collect(reducing(
(d1,d2) -> d1.getCalories() > d2.getCalories() ? d1 : d2
));
System.out.println(mostCalorieDish);
}
收集框架的灵活性:以不同的方法执行同样的操作
简化前面使用 reducing 收集器的求和例子——引用 Integer 类的 sum 方法,而不用去写一个表达同一操作的Lambda表达式
public static void main(String[] args) {
List<Dish> menu = Data.create();
int totalCalories = menu.stream()
.collect(reducing(
0,
Dish::getCalories,
Integer::sum
));
System.out.println(totalCalories);
}
分组
一个常见的数据库操作是根据一个或多个属性对集合中的项目进行分组
假设你要把菜单中的菜按照类型进行分类,有肉的放一组,有鱼的放一组,其他的都放另一组。用 Collectors.groupingBy 工厂方法返回的收集器就可以轻松地完成这项任务
public static void main(String[] args) {
List<Dish> menu = Data.create();
Map<Dish.Type,List<Dish>> dishesByType =
menu.stream()
.collect(groupingBy(Dish::getType));
System.out.println(dishesByType);
for (Dish.Type type : dishesByType.keySet()){
System.out.println(type + " :");
System.out.println(dishesByType.get(type));
System.out.println("");
}
}
这里,你给 groupingBy 方法传递了一个 Function (以方法引用的形式),它提取了流中每一道 Dish 的 Dish.Type 。我们把这个 Function 叫作分类函数,因为它用来把流中的元素分成不同的组
但是,分类函数不一定像方法引用那样可用,因为你想用以分类的条件可能比简单的属性访
问器要复杂
例如,你可能想把热量不到400卡路里的菜划分为“低热量”(diet),热量400到700卡路里的菜划为“普通”(normal),高于700卡路里的划为“高热量”(fat)。由于 Dish 类的作者没有把这个操作写成一个方法,你无法使用方法引用,但你可以把这个逻辑写成Lambda表达式:
public enum CaloricLevel { DIET, NORMAL, FAT }
public static void main(String[] args) {
List<Dish> menu = Data.create();
Map<CaloricLevel, List<Dish>> dishesByCaloricLevel =
menu.stream()
.collect(
groupingBy(dish ->
{
if (dish.getCalories() <= 400) return CaloricLevel.DIET;
else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
else return CaloricLevel.FAT;
})
);
System.out.println(dishesByCaloricLevel);
}
多级分组
要实现多级分组,我们可以使用一个由双参数版本的 Collectors.groupingBy 工厂方法创
建的收集器,它除了普通的分类函数之外,还可以接受 collector 类型的第二个参数。那么要进行二级分组的话,我们可以把一个内层 groupingBy 传递给外层 groupingBy ,并定义一个为流中项目分类的二级标准
public class M1 {
public static void main(String[] args) {
List<Dish> menu = Data.create();
Map<Dish.Type,Map<CaloricLevel,List<Dish>>> dishesByTypeCaloricLevel =
menu.stream()
.collect(
groupingBy(Dish::getType,
groupingBy(dish ->
{
if (dish.getCalories() <=400) return CaloricLevel.DIET;
else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
else return CaloricLevel.FAT;
}))
);
System.out.println(dishesByTypeCaloricLevel);
// {
// OTHER={
// NORMAL=
// [Dish
// {name='french fries', vegetarian=true, calories=530, type=OTHER}, Dish{name='pizza', vegetarian=true, calories=550, type=OTHER}], DIET=[Dish{name='rice', vegetarian=true, calories=350, type=OTHER}, Dish{name='season fruit', vegetarian=true, calories=120, type=OTHER}]}, FISH={NORMAL=[Dish{name='salmon', vegetarian=false, calories=450, type=FISH}], DIET=[Dish{name='prawns', vegetarian=false, calories=300, type=FISH}]}, MEAT={FAT=[Dish{name='pork', vegetarian=false, calories=800, type=MEAT}], NORMAL=[Dish{name='beef', vegetarian=false, calories=700, type=MEAT}], DIET=[Dish{name='chicken', vegetarian=false, calories=400, type=MEAT}]}}
// 两级 Map
// 这里的外层 Map 的键就是第一级分类函数生成的值:“fish, meat, other”,而这个 Map 的值又是
//一个 Map ,键是二级分类函数生成的值:“normal, diet, fat”。最后,第二级 map 的值是流中元素构
//成的 List ,是分别应用第一级和第二级分类函数所得到的对应第一级和第二级键的值:“salmon、
//pizza…” 这种多级分组操作可以扩展至任意层级,n级分组就会得到一个代表n级树形结构的n级
//Map 。
}
}
按子组收集数据
传递给第一个 groupingBy 的第二个收集器可以是任何类型,而不一定是另一个groupingBy
数一数菜单中每类菜有多少个,可以传递 counting 收集器作为groupingBy 收集器的第二个参数
public static void main(String[] args) {
List<Dish> menu = Data.create();
Map<Dish.Type, Long> typesCount = menu.stream().collect(
groupingBy(Dish::getType, counting()));
System.out.println(typesCount);
}
把前面用于查找菜单中热量最高的菜肴的收集器改一改,按照菜的类型分类
public static void main(String[] args) {
List<Dish> menu = Data.create();
Map<Dish.Type, Optional<Dish>> mostCaloricByType =
menu.stream()
.collect(
groupingBy(Dish::getType
,
maxBy(Comparator.comparingInt(Dish::getCalories)))
);
System.out.println(mostCaloricByType);
}
这个分组的结果显然是一个 map ,以 Dish 的类型作为键,以包装了该类型中热量最高的 Dish的 Optional 作为值
把收集器的结果转换为另一种类型
一般地来说,把收集器返回的结果转换为另一种类型,你可以使用Collectors.collectingAndThen 工厂方法返回的收集器
查找每个子组中热量最高的 Dish
public static void main(String[] args) {
List<Dish> menu = Data.create();
Map<Dish.Type, Dish> mostCaloricByType =
menu.stream()
.collect(groupingBy(Dish::getType,
collectingAndThen(
maxBy(Comparator.comparingInt(Dish::getCalories)),
Optional::get
)));
System.out.println(mostCaloricByType);
// {
// OTHER=Dish{name='pizza', vegetarian=true, calories=550, type=OTHER},
// FISH=Dish{name='salmon', vegetarian=false, calories=450, type=FISH},
// MEAT=Dish{name='pork', vegetarian=false, calories=800, type=MEAT}}
}
这个工厂方法接受两个参数——要转换的收集器以及转换函数,并返回另一个收集器。这个
收集器相当于旧收集器的一个包装, collect 操作的最后一步就是将返回值用转换函数做一个映射。在这里,被包起来的收集器就是用 maxBy 建立的那个,而转换函数Optional::get 则把返回的 Optional 中的值提取出来。前面已经说过,这个操作放在这里是安全的,因为 reducing收集器永远都不会返回 Optional.empty()
与 groupingBy 联合使用的其他收集器的例子
一般来说,通过 groupingBy 工厂方法的第二个参数传递的收集器将会对分到同一组中的所
有流元素执行进一步归约操作
public static void main(String[] args) {
//求出所有菜肴热量总和的收集器,不过这次是对
//每一组 Dish 求和
List<Dish> menu = Data.create();
Map<Dish.Type, Integer> totalCaloriesByType =
menu.stream()
.collect(groupingBy(Dish::getType,
summingInt(Dish::getCalories)));
System.out.println(totalCaloriesByType);
}
然而常常和 groupingBy 联合使用的另一个收集器是 mapping 方法生成的。这个方法接受两个参数:一个函数对流中的元素做变换,另一个则将变换的结果对象收集起来。其目的是在累加之前对每个输入元素应用一个映射函数,这样就可以让接受特定类型元素的收集器适应不同类型的对象
比方说你想要知道,对于每种类型的 Dish ,菜单中都有哪些 CaloricLevel 。我们可以把 groupingBy 和 mapping 收集器结合起来
public static void main(String[] args) {
List<Dish> menu = Data.create();
Map<Dish.Type, Set<CaloricLevel>> caloricLevelsByType =
menu.stream()
.collect(
groupingBy(Dish::getType,
mapping(
dish ->
{
if (dish.getCalories()<=400) return CaloricLevel.DIET;
else if (dish.getCalories()<=700) return CaloricLevel.NORMAL;
else return CaloricLevel.FAT;
},
toSet()
))
);
System.out.println(caloricLevelsByType);
}
这里,就像我们前面见到过的,传递给映射方法的转换函数将 Dish 映射成了它的CaloricLevel :生成的 CaloricLevel 流传递给一个 toSet 收集器,它和 toList 类似,不过是把流中的元素累积到一个 Set 而不是 List 中,以便仅保留各不相同的值
分区
分区是分组的特殊情况:由一个谓词(返回一个布尔值的函数)作为分类函数,它称分区函数。分区函数返回一个布尔值,这意味着得到的分组 Map 的键类型是 Boolean ,于是它最多可以分为两组—— true 是一组, false 是一组
把菜单按照素食和非素食分开:
public static void main(String[] args) {
List<Dish> menu = Data.create();
Map<Boolean, List<Dish>> partitionedMenu =
menu.stream()
.collect(partitioningBy(Dish::isVegetarian));
System.out.println(partitionedMenu);
}
那么通过 Map 中键为 true 的值,就可以找出所有的素食菜肴了:
List<Dish> vegetarianDishes = partitionedMenu.get(true);
用同样的分区谓词,对菜单 List 创建的流作筛选,然后把结果收集到另外一个 List中也可以获得相同的结果
public static void main(String[] args) {
List<Dish> menu = Data.create();
List<Dish> vegetarianDishes =
menu.stream().filter(Dish::isVegetarian).collect(toList());
System.out.println(vegetarianDishes);
}
分区的优势
分区的好处在于保留了分区函数返回 true 或 false 的两套流元素列表
partitioningBy工厂方法有一个重载版本,可以像下面这样传递第二个收集器
public static void main(String[] args) {
List<Dish> menu = Data.create();
Map<Boolean, Map<Dish.Type, List<Dish>>> vegetarianDishesByType =
menu.stream()
.collect(
partitioningBy(
Dish::isVegetarian,
groupingBy(Dish::getType)
)
);
System.out.println(vegetarianDishesByType);
}
// 这里,对于分区产生的素食和非素食子流,分别按类型对菜肴分组,得到了一个二级 Map
重用前面的代码来找到素食和非素食中热量最高的菜
public static void main(String[] args) {
List<Dish> menu = Data.create();
Map<Boolean, Dish> mostCaloricPartitionedByVegetarian =
menu.stream().collect(
partitioningBy(Dish::isVegetarian,
collectingAndThen(
maxBy(comparingInt(Dish::getCalories)),
Optional::get)));
System.out.println(mostCaloricPartitionedByVegetarian);
}
将数字按质数和非质数分区
写一个方法,它接受参数 int n,并将前n个自然数分为质数和非质数
- 首先,找出能够测试某一个待测数字是否是质数的谓词
// 第一个版本
public boolean isPrime(int candidate){
// 产生一个自然数//范围,从2开始,//直至但不包括待//测数
return IntStream.range(2,candidate)
.noneMatch(i -> candidate % i == 0);
}
// 优化版本
public boolean isPrime_2(int candidate) {
int candidateRoot = (int) Math.sqrt((double) candidate);
return IntStream.rangeClosed(2, candidateRoot)
.noneMatch(i -> candidate % i == 0);
}
最终实现:
public static Map<Boolean, List<Integer>> partitionPrimes(int n) {
return IntStream.rangeClosed(2, n).boxed()
.collect(
partitioningBy(candidate -> isPrime_2(candidate)));
}
收集器接口
Collector 接口包含了一系列方法,为实现具体的归约操作(即收集器)提供了范本。我们
已经看过了 Collector 接口中实现的许多收集器,例如 toList 或 groupingBy 。这也意味着,你可以为 Collector 接口提供自己的实现,从而自由地创建自定义归约操作
toList 工厂方法,它会把流中的所有元素收集成一个 List
Collector 接口
public interface Collector<T, A, R> {
Supplier<A> supplier();
BiConsumer<A, T> accumulator();
Function<A, R> finisher();
BinaryOperator<A> combiner();
Set<Characteristics> characteristics();
}
本列表适用以下定义:
- T 是流中要收集的项目的泛型。
- A 是累加器的类型,累加器是在收集过程中用于累积部分结果的对象。
- R 是收集操作得到的对象(通常但并不一定是集合)的类型
Collector 接口声明的方法
建立新的结果容器: supplier 方法
supplier 方法必须返回一个结果为空的 Supplier ,也就是一个无参数函数,在调用时它会创建一个空的累加器实例,供数据收集过程使用
将元素添加到结果容器: accumulator 方法
accumulator 方法会返回执行归约操作的函数。当遍历到流中第n个元素时,这个函数执行
时会有两个参数:保存归约结果的累加器(已收集了流中的前 n1 个项目),还有第n个元素本身。该函数将返回 void ,因为累加器是原位更新,即函数的执行改变了它的内部状态以体现遍历的元素的效果
对结果容器应用最终转换: finisher 方法
在遍历完流后, finisher 方法必须返回在累积过程的最后要调用的一个函数,以便将累加器对象转换为整个集合操作的最终结果
合并两个结果容器: combiner 方法
四个方法中的最后一个—— combiner 方法会返回一个供归约操作使用的函数,它定义了对流
的各个子部分进行并行处理时,各个子部分归约所得的累加器要如何合并
characteristics 方法
最后一个方法—— characteristics 会返回一个不可变的 Characteristics 集合,它定义了收集器的行为——尤其是关于流是否可以并行归约,以及可以使用哪些优化的提示
全部融合到一起
//开发自己的 ToListCollector
public class ToListCollector<T> implements Collector<T, List<T>,List<T>> {
// 创建集合操//作的起始点
@Override
public Supplier<List<T>> supplier() {
return ArrayList::new;
}
// 累 积 遍 历 过的//项目,原位修改//累加器
@Override
public BiConsumer<List<T>, T> accumulator() {
return List::add;
}
@Override
public BinaryOperator<List<T>> combiner() {
return (list1,list2) ->{
// 修 改 第 一 个 累 加//器,将其与第二个//累加器的内容合并
list1.addAll(list2);
// 返回修改后的//第一个累加器
return list1;
};
}
// 恒等//函数
@Override
public Function<List<T>, List<T>> finisher() {
return Function.identity();
}
// 为收集器添加 IDENTITY//_FINISH 和 CONCURRENT 标志
@Override
public Set<Characteristics> characteristics() {
return Collections.unmodifiableSet(EnumSet.of(
IDENTITY_FINISH, CONCURRENT));
}
// 这个实现与 Collectors.toList 方法并不完全相同,但区别仅仅是一些小的优化。
//这些优化的一个主要方面是Java API所提供的收集器在需要返回空列表时使用了 Collections.
//emptyList() 这个单例(singleton)
}
开发自己的收集器以获得更好的性能
对 将前n个自然数划分为质数和非质数 这个例子进行优化
仅用质数做除数
一个可能的优化是仅仅看看被测试数是不是能够被质数整除。要是除数本身都不是质数就用不着测了
必须自己开发一个收集器的原因在于,在收集过程中是没有办法访问部分结果的。这意味着,当测试某一个数字是否是质数的时候,你没法访问目前已经找到的其他质数的列表
- 需要想办法在下一个质数大于被测数平方根时立即停止测试
创建一个名为 takeWhile 的方法,给定一个排序列表和一个谓词,它会返回元素满足谓词的最长前缀
public static <A>List<A> takeWhile(List<A> list, Predicate<A> predicate){
int i = 0;
for (A item : list) {
// 检查列表中的当前//项目是否满足谓词
if (!predicate.test(item)){
// 如果不满足,返回该//项目之前的前缀子//列表
return list.subList(0,i);
}
i++;
}
// 列表中的所有项目//都满足谓词,因此返//回列表本身
return list;
}
优化:
// 利用这个方法,你就可以优化 isPrime 方法,只用不大于被测数平方根的质数去测试了
public static boolean isPrime(List<Integer> primes, int candidate){
int candidateRoot = (int) Math.sqrt((double) candidate);
return takeWhile(primes,i->i<=candidateRoot)
.stream()
.noneMatch(p-> candidate % p == 0);
}
有了这个新的 isPrime 方法,你就可以实现自己的自定义收集器了。首先要声明一个实现 Collector 接口的新类,然后要开发 Collector 接口所需的五个方法
- 第一步:定义 Collector 类的签名
Collector 接口的定义是
public interface Collector<T, A, R>
T 、 A 和 R 分别是
-
流中元素的类型
-
用于累积部分结果的对象类型
-
collect 操作最终结果的类型
这里应该收集 Integer 流,而累加器和结果类型则都是 Map<Boolean,List>
键是 true 和 false ,值则分别是质数和非质数的 List
最终实现:
public class PrimeNumbersCollector implements Collector<Integer, Map<Boolean, List<Integer>>,Map<Boolean, List<Integer>>> {
// 归约过程
@Override
public Supplier<Map<Boolean, List<Integer>>> supplier() {
return ()-> new HashMap<Boolean,List<Integer>>(){{
put(true,new ArrayList<Integer>());
put(false,new ArrayList<Integer>());
}
};
// 这里不但创建了用作累加器的 Map ,还为 true 和 false 两个键下面初始化了对应的空列表。
//在收集过程中会把质数和非质数分别添加到这里
}
@Override
public BiConsumer<Map<Boolean, List<Integer>>, Integer> accumulator() {
return (Map<Boolean,List<Integer>> acc,Integer candidate) ->
{
// 根据 isPrime 的//结果,获取质数//或非质数列表
acc.get(M1.isPrime(acc.get(true),candidate))
// 将被测数添加到//相应的列表中
.add(candidate);
};
}
// 在并行收集时把两个部分累加器合并起来,这里,它只需要合并两个 Map ,即
//将第二个 Map 中质数和非质数列表中的所有数字合并到第一个 Map 的对应列表中就行了
@Override
public BinaryOperator<Map<Boolean, List<Integer>>> combiner() {
return (Map<Boolean, List<Integer>> map1,
Map<Boolean, List<Integer>> map2) ->
{
map1.get(true).addAll(map2.get(true));
map1.get(false).addAll(map2.get(false));
return map1;
};
}
@Override
public Function<Map<Boolean, List<Integer>>, Map<Boolean, List<Integer>>> finisher() {
return Function.identity();
}
@Override
public Set<Characteristics> characteristics() {
return Collections.unmodifiableSet(EnumSet.of(IDENTITY_FINISH));
}
}
测试:
public class M2 {
public static Map<Boolean, List<Integer>>
partitionPrimesWithCustomCollector(int n) {
return IntStream.rangeClosed(2, n).boxed()
.collect(new PrimeNumbersCollector());
}
public static void main(String[] args) {
System.out.println(partitionPrimesWithCustomCollector(500));
}
}
来源:CSDN
作者:Coder_py
链接:https://blog.csdn.net/Coder_py/article/details/104093316