Java 8 (5) Stream 流 - 收集数据

在前面已经使用过collect终端操作了,主要是用来把Stream中的所有元素结合成一个List,在本章中,你会发现collect是一个归约操作,就像reduce一样可以接受各种做法作为参数,将流中的元素累计成一个汇总结果。

看这个例子:按照菜类进行分组

List<Dish> menu = Arrays.asList(
                new Dish("猪肉炖粉条", false, 800, Type.MEAT),
                new Dish("小炒牛肉", false, 700, Type.MEAT),
                new Dish("宫保鸡丁", false, 400, Type.MEAT),
                new Dish("地三鲜", true, 530, Type.OTHER),
                new Dish("水煮菠菜", true, 350, Type.OTHER),
                new Dish("拔丝地瓜", true, 120, Type.OTHER),
                new Dish("火山下雪", true, 550, Type.OTHER),
                new Dish("水煮鱼", false, 330, Type.FISH),
                new Dish("于是乎", false, 450, Type.FISH)
        );

        //按照类型分组 java 7
        Map<Type, List<Dish>> DishsByTypes = new HashMap<>();
        for (Dish dish : menu) {
            Type type = dish.getType();
            List<Dish> dishForType = DishsByTypes.get(type);
            if (dishForType == null) {
                dishForType = new ArrayList<>();
                DishsByTypes.put(type, dishForType);
            }
            dishForType.add(dish);
        }

如果用java 8的话..

Map<Type,List<Dish>> dishs = menu.stream().collect(groupingBy(Dish::getType));

 

收集器简介

  在上一个例子中,你只需要给出指令“做什么”,而不是编写实现步骤“如何做”。以前toList()方法只是说按顺序给每一个元素生成一个列表。在这个例子中,groupingBy说的是生成一个Map,它的键是菜的种类,他们值是那些菜。

  1.收集器用作高级归约

    对流调用collect方法将对流中的元素触发一个归约操作,它遍历流中的每一个元素,并让Collector进行处理。如toList静态方法,他会把流中的每一个元素收集到一个list中。

  2.预定义收集器

     Collectors类提供的工厂方法创建的收集器,他们主要提供了三大功能:将流元素归约和汇总为一个值、元素分组、元素分区。

 

归约和汇总

  Collectors.counting方法返回的收集器,查看集合数量:

long count1 = menu.stream().collect(counting());

  也可以写为:

long count2 = menu.stream().count();

  1.查找流中的最大值和最小值

Collectors.myBy collectors.minBy 返回最大值和最小值,是可空的。

        Optional<Dish> max = menu.stream().collect(maxBy(Comparator.comparing(Dish::getCalories)));
        Optional<Dish> min = menu.stream().collect(minBy(Comparator.comparing(Dish::getCalories)));

        max.ifPresent(System.out::println);
        min.ifPresent(System.out::println);

  也可以写为:

        Optional<Dish> max1 = menu.stream().max(Comparator.comparing(Dish::getCalories));
        Optional<Dish> min1 = menu.stream().min(Comparator.comparing(Dish::getCalories));

  2.汇总

Collectors.summingInt, 他接受一个把对象映射为求和的int函数,summingDouble ,summingLong用法一样:

        int totalCalories = menu.stream().collect(summingInt(Dish::getCalories));

Collectors.averagingInt,平均值,还有averagingDouble, averaginLong 用法 一样:

double avgCalories = menu.stream().collect(averagingInt(Dish::getCalories));

summarizing 可以返回以上所有方法的数字:

IntSummaryStatistics menuStatics = menu.stream().collect(summarizingInt(Dish::getCalories));
        menuStatics.getCount();
        menuStatics.getMax();
        menuStatics.getMin();
        menuStatics.getSum();
        menuStatics.getAverage();

  3.连接字符串

 joining方法返回的收集器会把对流中的每一个对象应用toString()然后连接成一个字符串。内部使用StringBuilder。 重载:参数是 分隔符

        String names = menu.stream().map(Dish::getName).collect(joining());

        String names1 = menu.stream().map(Dish::getName).collect(joining(","));

  4.广义的归约汇总

 其实我们讨论的所有收集器,都是一个reducing工厂方法定义的归约过程的特殊情况而已。Colectors.reducing工厂方法是所有这些特殊情况的一般化。例如总热量:

int totalCalories1 = menu.stream().collect(reducing(0,Dish::getCalories,(i,j)->i+j));

最大值:

Optional<Dish> max2 = menu.stream().collect(reducing((Dish d1, Dish d2)->d1.getCalories() > d2.getCalories() ? d1:d2));

 

本章中的collect归约操作和上一章中reduce又有区别呢?

int totalCalories3 = menu.stream().map(Dish::getCalories).reduce(0, ( d1,  d2) -> d1 + d2);

使用collect方法更利于并行操作。

 

counting方法也是使用的reducing工厂方法实现的:

    public static <T> Collector<T, ?, Long>
    counting() {
        return reducing(0L, e -> 1L, Long::sum);
    }

这里的 ?通配符代表累加器类型未知,累加器本身可以是任何类型。

还有另一种不使用收集器也能执行相同操作,聚合:

int totalCalories2 = menu.stream().mapToInt(Dish::getCalories).sum();

还有:

int totalCalories4 = menu.stream().map(Dish::getCalories).reduce(Integer::sum).get();

在这里是安全的,因为知道menu是不为空的。reduce返回的是Optional可空对象,最好是要配合orElse或orElseGet来获得他的值更为安全。

 

函数式编程通常提供了多种方法来执行同一个操作。收集器在某种程度上比Stream接口上直接提供的方法用起来更复杂,但好处在于它们能提供更高水平的抽象和概括,也更容易重用和自定义。 尽可能的为手头的问题探索不同的解决方案,比如聚合,更倾向于mapToInt,因为他最简单明了,而且避免了自动装箱。

 

分组 

上面已经介绍过按菜的类型进行分组:

Map<Type, List<Dish>> group = menu.stream().collect(groupingBy(Dish::getType));
{OTHER=[Dish{Name='地三鲜', vegetarian=true, Calories=530, type=OTHER}, Dish{Name='水煮菠菜', vegetarian=true, Calories=350, type=OTHER}, Dish{Name='拔丝地瓜', vegetarian=true, Calories=120, type=OTHER}, 
Dish{Name='火山下雪', vegetarian=true, Calories=550, type=OTHER}], MEAT=[Dish{Name='猪肉炖粉条', vegetarian=false, Calories=800, type=MEAT}, Dish{Name='小炒牛肉', vegetarian=false, Calories=700, type=MEAT}, Dish{Name='宫保鸡丁', vegetarian=false, Calories=400, type=MEAT}], 
FISH=[Dish{Name='水煮鱼', vegetarian=false, Calories=330, type=FISH}, Dish{Name='于是乎', vegetarian=false, Calories=450, type=FISH}]}

我们给groupingBy方法传递了一个Function(引用方法方式),它提取了流中每一道Dish的Dish.type。我们把这个Function叫做分类函数,因为它把流中的元素分成不同的组。分组的操作结果是一个Map,把分组函数返回的值作为映射的键,把流中所有具有这个分类值的列表作为对应的映射值。

分类函数还可以使用lmabda表达式来区分 高热量 400-700的,低热量 0-400的。

Map<String, List<Dish>> group1 = menu.stream().collect(groupingBy(c -> {
            if (c.getCalories() <= 400) {
                return "low";
            } else if (c.getCalories() > 400) {
                return "higt";
            } else {
                return "other";
            }
        }));
{low=[Dish{Name='宫保鸡丁', vegetarian=false, Calories=400, type=MEAT}, Dish{Name='水煮菠菜', vegetarian=true, Calories=350, type=OTHER}, Dish{Name='拔丝地瓜', vegetarian=true, Calories=120, type=OTHER}, Dish{Name='水煮鱼', vegetarian=false, Calories=330, type=FISH}], 
higt=[Dish{Name='猪肉炖粉条', vegetarian=false, Calories=800, type=MEAT}, Dish{Name='小炒牛肉', vegetarian=false, Calories=700, type=MEAT}, Dish{Name='地三鲜', vegetarian=true, Calories=530, type=OTHER}, Dish{Name='火山下雪', vegetarian=true, Calories=550, type=OTHER}, Dish{Name='于是乎', vegetarian=false, Calories=450, type=FISH}]}

并且,还可以同时根据热量和type进行组合分组。

  1.多级分组

  想要实现多级分组,我们可以使用Collectors.groupingBy方法的双参版本,它除了普通的分类函数之外,还可以接受collector类型的第二个参数。那么进行二级分组的话,我们可以把一个内层groupingBy传递给外层的groupingBy。

Map<Type, Map<String, List<Dish>>> group2 = menu.stream().collect(
                groupingBy(Dish::getType,
                        groupingBy(c -> {
                            if (c.getCalories() <= 400) {
                                return "low";
                            } else if (c.getCalories() > 400) {
                                return "higt";
                            } else {
                                return "other";
                            }
                        })
                )
        );
{OTHER={
    low=[Dish{Name='水煮菠菜', vegetarian=true, Calories=350, type=OTHER}, Dish{Name='拔丝地瓜', vegetarian=true, Calories=120, type=OTHER}], 
    higt=[Dish{Name='地三鲜', vegetarian=true, Calories=530, type=OTHER}, Dish{Name='火山下雪', vegetarian=true, Calories=550, type=OTHER}]}, 
MEAT={
    low=[Dish{Name='宫保鸡丁', vegetarian=false, Calories=400, type=MEAT}], 
    higt=[Dish{Name='猪肉炖粉条', vegetarian=false, Calories=800, type=MEAT}, Dish{Name='小炒牛肉', vegetarian=false, Calories=700, type=MEAT}]}, 
FISH={
    low=[Dish{Name='水煮鱼', vegetarian=false, Calories=330, type=FISH}], 
    higt=[Dish{Name='于是乎', vegetarian=false, Calories=450, type=FISH}]}}

首先最外层是type,list值中又分 高地热量分组 ,这种多级分组可以扩展至任意层级。

  2.按子组收集数据

上面已经说过,可以把第二个groupingBy传递给第一个groupingBy,第二个收集器可以是任何类型,不一定是groupingBy,比如聚合coungting,来数一数每个类型下有多少菜。

Map<Type, Long> group3 = menu.stream().collect(groupingBy(Dish::getType, counting()));
{OTHER=4, MEAT=3, FISH=2}

其实groupingBy(f)实际上是groupingBy(f,toList())的简单写法。再举一个例子,查看每个类型中最高热量的菜:

Map<Type, Optional<Dish>> group4 = menu.stream().collect(groupingBy(Dish::getType, maxBy(Comparator.comparing(Dish::getCalories))));
{OTHER=Optional[Dish{Name='火山下雪', vegetarian=true, Calories=550, type=OTHER}], 
MEAT=Optional[Dish{Name='猪肉炖粉条', vegetarian=false, Calories=800, type=MEAT}],
FISH=Optional[Dish{Name='于是乎', vegetarian=false, Calories=450, type=FISH}]}

这个map中的值是Optional,因为这是maxBy方法生成的收集器类型,但实际上,如果menu中没有某一类型的Dish,这个类型就不会对应一个Optional.empty()值,而且根本不会出现在Map键值对中。

 

1.把收集器的结果转换为另一种类型

  因为分组操作的Map结果中的每个值上包装的Optional没什么用,所以想把它给去掉,也就是把收集器结果转换为另一种类型,可以使用Collectors.collectingAndThen方法返回收集器。

        Map<Type, Dish> group5 = menu.stream().collect(groupingBy(Dish::getType,
                collectingAndThen(
                        maxBy(Comparator.comparing(Dish::getCalories)), Optional::get)));
{OTHER=Dish{Name='火山下雪', vegetarian=true, Calories=550, type=OTHER}, 
MEAT=Dish{Name='猪肉炖粉条', vegetarian=false, Calories=800, type=MEAT}, 
FISH=Dish{Name='于是乎', vegetarian=false, Calories=450, type=FISH}}

collectingAndThen方法参数1:要转换的收集器,参数2:转换函数。返回另一个收集器。 相当于旧收集器的包装,collect操作的最后一步就是将返回值用转换函数做一个映射。上面的例子,被包起来的收集器就是用maxBy建立的这个,而转换函数Optional::get则把返回的Optional中的值提取出来。这个操作是安全的,因为reducing收集器永远不会返回Optional.empty().

 

2.与groupingBy联合使用的其他收集器的例子

  每组菜热量求和:

Map<Type, Integer> group6 = menu.stream().collect(groupingBy(Dish::getType, summingInt(Dish::getCalories)));
{OTHER=1550, MEAT=1900, FISH=780}

  groupingBy还常常和mapping方法组合,这个方法接受两个参数:一个函数流中的元素做变换,另一个则将变换的结果对象收集起来。其目的是累加之前对每个输入 元素应用一个映射函数,这样就可以接受特定类型元素的收集器适应不同类型的对象,举个例子,每个类型的菜,都有哪些超高热量的菜:

        Map<Type, Set<String>> group7 = menu.stream().collect(groupingBy(Dish::getType,
                mapping(c -> {
                    if (c.getCalories() > 700) {
                        return "super higt";
                    } else {
                        return "super low";
                    }
                }, toSet())
        ));
{OTHER=[super low], MEAT=[super higt, super low], FISH=[super low]}

传递给映射方法的转换函数将Dish映射成了super higt 或super low字符串,传递给了一个toSet收集器,它和toList类似,不过是把流中的元素累计到了一个Set集合中。

还可以指定具体由哪个set类型,比如HashSet,可以使用toCollection:

        Map<Type, HashSet<String>> group8 = menu.stream().collect(groupingBy(Dish::getType,
                mapping(c -> {
                    if (c.getCalories() > 700) {
                        return "super higt";
                    } else {
                        return "super low";
                    }
                }, toCollection(HashSet::new))
        ));

 

分区

   分区是分组的特殊情况:由一个谓词作为分类函数,它称为分区函数。分区函数返回一个布尔值,这意味着得到的分组Map的键Boolean,于是他最多可以分为两组true或false。例如:素食和荤菜分开:

Map<Boolean, List<Dish>> group9 = menu.stream().collect(groupingBy(Dish::isVegetarian));
{false=[Dish{Name='猪肉炖粉条', vegetarian=false, Calories=800, type=MEAT}, Dish{Name='小炒牛肉', vegetarian=false, Calories=700, type=MEAT}, Dish{Name='宫保鸡丁', vegetarian=false, Calories=400, type=MEAT}, Dish{Name='水煮鱼', vegetarian=false, Calories=330, type=FISH}, Dish{Name='于是乎', vegetarian=false, Calories=450, type=FISH}], 
true=[Dish{Name='地三鲜', vegetarian=true, Calories=530, type=OTHER}, Dish{Name='水煮菠菜', vegetarian=true, Calories=350, type=OTHER}, Dish{Name='拔丝地瓜', vegetarian=true, Calories=120, type=OTHER}, Dish{Name='火山下雪', vegetarian=true, Calories=550, type=OTHER}]}

想获取素食可以使用:

group9.get(true);

  也可以使用Stream API:

List<Dish> stream = menu.stream().filter(Dish::isVegetarian).collect(toList());

  1.分区的优势

分区带来的好处有两点:1,因为保留了true和false,可以轻易获取到false那一组。2,可以把分区作为groupingBy的第二个参数进行传递,产生一个二级分组:

Map<Type, Map<Boolean, List<Dish>>> group10 = menu.stream().collect(groupingBy(Dish::getType,
                partitioningBy(Dish::isVegetarian)));
{OTHER={
    true=[Dish{Name='地三鲜', vegetarian=true, Calories=530, type=OTHER}, Dish{Name='水煮菠菜', vegetarian=true, Calories=350, type=OTHER}, Dish{Name='拔丝地瓜', vegetarian=true, Calories=120, type=OTHER}, Dish{Name='火山下雪', vegetarian=true, Calories=550, type=OTHER}]}, 
MEAT={
    false=[Dish{Name='猪肉炖粉条', vegetarian=false, Calories=800, type=MEAT}, Dish{Name='小炒牛肉', vegetarian=false, Calories=700, type=MEAT}, Dish{Name='宫保鸡丁', vegetarian=false, Calories=400, type=MEAT}]}, 
FISH={
    false=[Dish{Name='水煮鱼', vegetarian=false, Calories=330, type=FISH}, Dish{Name='于是乎', vegetarian=false, Calories=450, type=FISH}]}}

再比如,素食和非素食的最高热量的菜:

        Map<Boolean, Dish> group11 = menu.stream().collect(
                partitioningBy(Dish::isVegetarian,
                        collectingAndThen(maxBy(Comparator.comparing(Dish::getCalories)), Optional::get)));
{false=Dish{Name='猪肉炖粉条', vegetarian=false, Calories=800, type=MEAT}, 
true=Dish{Name='火山下雪', vegetarian=true, Calories=550, type=OTHER}}

partitioningBy需要一个谓词,也就是一个返回布尔表达的函数。

  2.将数字按质数和非质数分区

一个大于1的自然数,除了1和它自身外,不能整除其他自然数的数叫做质数

    public static boolean isPrime(int num) {
        return IntStream.range(2, num)
                .noneMatch(i -> num % i == 0);
    }
        Map<Boolean, List<Integer>> group12 = IntStream.rangeClosed(2, 15).boxed()
                .collect(
                        partitioningBy(n -> isPrime(n))
                );
{false=[4, 6, 8, 9, 10, 12, 14, 15], true=[2, 3, 5, 7, 11, 13]}

 

Collectors类的静态工厂方法
toList 返回类型:List<T> ,把流中的所有项目收集到一个List。

toSet 返回类型:Set<T>, 把流中的所有项目收集到一个Set。

toCollection 返回类型:Collection<T>, 把流中的所有项目收集到给定的供应源创建的集合。

counting 返回类型:Long , 计算流中元素的个数。

summingInt 返回类型:Integer , 对流中项目的一个整数属性求和。

averagingInt 返回类型:Double , 计算流中项目Integer属性的平均值。

summarizingInt 返回类型:IntSummaryStatistics , 收集关于流中项目Integer属性的统计值,如最大、最小、总数、平均值。

joining 返回类型:String , 连接对流中每个项目调用toString方法锁生成的字符串。

maxBy 返回类型:Optional<T> , 最大元素,如果流为空则Optional.empty();

minBy 返回类型:Optional<T> , 最小元素, 如果流为空则Optional.empty();

reducing 归约操作产生的类型 , 从一个初始值开始,逐个累加,归约为单个值。

collectingAndThen 转换函数返回的类型 , 包裹另一个收集器,对其结果应用转换函数。

groupingBy 返回类型:Map<K,List<T>> , 分组,将属性值当做Map的键。

partitioningBy 返回类型 Map<Boolean,List<T>> , 分区,使用谓词返回true或false的Map。

 

收集器接口

所有的收集器都是对Collector接口的实现,Collector接口包含了一系列方法,我们也可以自己提供实现,从而自由的创建自定义归约操作。 首先我们来看一下Collector接口的定义:

public interface Collector<T, A, R> {
    
    Supplier<A> supplier();

    BiConsumer<A, T> accumulator();

    BinaryOperator<A> combiner();

    Function<A, R> finisher();

    Set<Characteristics> characteristics();
}

T:流中要收集的项目的泛型。

A:累加器的类型,累加器实在手机过程中用于累积部分结果的对象。

R:是收集操作得到的对象的类型。

  1.理解Collector接口声明的方法

    前四个方法都会返回一个会被collect方法调用的函数,第五个方法characteristics则提供了一系列特征,也就是一个提示列表,告诉collect方法在执行归约操作的时候可以应用哪些优化。

    首先要创建一个类实现Collector接口

public class ToListCollector<T> implements Collector<T,List<T>,List<T>> {
    
}

    然后要建立新的结果容器:supplier方法

      supplier方法必须返回一个结果为空的supplier,也就是一个无参函数,在调用时它会创建一个空的累加器实例,供数据收集过程使用。

    @Override
    public Supplier<List<T>> supplier() {
//        return ()->new ArrayList<T>(); //也可以使用方法引用如下:
        return ArrayList::new;
    }

    将元素添加到结果容器:accumulator方法

      accumulator方法会返回执行归约操作的函数。当遍历到流中第n个元素时,这个函数执行时会有两个参数:保存归约操作的累加器,还有第n个元素本身。该函数返回void,因为累加器是原始数据更新,即函数的执行改变了它的内部状态以体现遍历的元素的效果。

  @Override
    public BiConsumer<List<T>, T> accumulator() {
//        return (list,item) -> list.add(item); //也可以使用方法引用
        return List::add;
    }

    对结果容器应用最终转换:finisher方法

      在遍历完流后,finisher方法必须返回在累积过程的最后要调用的一个函数,以便将累加器对象转换为整个集合做操的最终结果。通常,累加器对象恰好符合预期的最终结果,无需进行转换,返回identity函数即可。

    @Override
    public Function<List<T>, List<T>> finisher() {
        return Function.identity();
    }

    理论上这三个方法已经完成了归约操作,实践中实现细节可能还要更复杂一点,一方面是因为流的延迟性质,可能在collect操作之前还需要完成其他中间操作,另一方面则是理论上可能要进行并行归约。

    合并两个结果容器:combiner方法

      四个方法中的最后一个-combiner方法会返回一个供归约操作使用的函数,它定义了对流的各个子部分进行并行处理时,各个子部分归约所得的累加器要如何合并。对于toList而言,这个方法的实现非常简单,只需要把流的第二个部分收集到项目列表加到遍历第一部分时得到的列表后面就行了:

    @Override
    public BinaryOperator<List<T>> combiner() {
        return (list1,list2)->{
            list1.addAll(list2);
            return list1;
        };
    }

      有了第四个方法,我们就可以并行归约了。

    最后一个:characteristics方法

      characteristics会返回一个不可变的Characteristics集合,它定义了收集器的行为-尤其是关于流是否可以进行并行归约,以及可以使用那些优化的提示。Characteristics是一个包含三个项目的枚举:

    UNORDERED:归约结果不受流中项目的遍历和累积顺序影响。

    CONCURRENT:accumulator函数可以从多个线程同时调用,且该收集器可以并行归约流。如果收集器没有标为UNORDERED,那它仅在用于无需数据源时才可以并行归约。

    IDENTITY_FINISH:这表明完成器方法返回的函数是一个恒等函数,可以跳过。此时,累加器对象将会直接用作归约过程的最终结果。意味着累加器A不加检查的转换为结果R是安全的。

      我们开发的ToListCollector是IDENTITY_FINISH的,因为用来累积流中的元素的List已经是最终的结果了,用不着进一步转换。但它不是UNORDERED,因为用在有序流上的时候,我们还是希望顺序可以保留到List中,它是CONCURRENT的,仅仅在背后的数据源无序时才会并行处理。

 

  2.全部融合到一起

public class ToListCollector<T> implements Collector<T,List<T>,List<T>> {

    @Override
    public Supplier<List<T>> supplier() {
//        return ()->new ArrayList<T>(); //也可以使用方法引用如下:
        return ArrayList::new;
    }

    @Override
    public BiConsumer<List<T>, T> accumulator() {
//        return (list,item) -> list.add(item); //也可以使用方法引用
        return List::add;
    }

    @Override
    public Function<List<T>, List<T>> finisher() {
        return Function.identity();
    }

    @Override
    public BinaryOperator<List<T>> combiner() {
        return (list1,list2)->{
            list1.addAll(list2);
            return list1;
        };
    }


    @Override
    public Set<Characteristics> characteristics() {
        return Collections.unmodifiableSet(
                EnumSet.of(
                        Characteristics.IDENTITY_FINISH,
                        Characteristics.CONCURRENT
                )
        );
    }
}

这个实现与Collectors.toList方法并不完全相同,区别仅仅是一些小的优化。这个类锁提供的收集器在返回空列表时使用了Collections.emptyList()。

        menu.stream().collect(toList()); //工厂方法
        menu.stream().collect(new ToListCollector<Dish>()); //需要实例化

对于IDENTITY_FINISH的收集操作,还可以不写类实现Collector接口来自定义,Stream有一个重载的collect方法,可以接受另外三个函数-supplier、accmulator和combiner。

        menu.stream().collect(
                ArrayList::new, //供应源
                List::add, //累加器
                List::addAll); //组合器

这种方式,写法更为简洁,这种方式不能传递任何Characteristics,所以他永远都是一个IDENTITY_FINISH和CONCURRENT,但非UNORDERED。

 

小结:

1.collect是一个终端操作,它接受的参数是将流中元素累积到汇总结果的各种方式(收集器)。

2.预定义收集器包括将流元素归约和汇总到一个值,例如最小值、最大值、平均值。

3.预定义收集器可以用groupingBy对流中的元素进行分组,或用partitioningBy进行分区。

4.收集器可以高效的组合使用,进行多级分组、分区和归约。

5.可以通过实现Collector接口来自定义收集器。

 

posted @ 2018-07-25 16:35  海盗船长  阅读(2201)  评论(0编辑  收藏  举报