(转)Java 中 List.subList() 方法的使用陷阱

原文:http://blog.csdn.net/cleverGump/article/details/51105235

转载请注明本文出自 clevergump 的博客:http://blog.csdn.net/clevergump/article/details/51105235, 谢谢!


前言

本文原先发表在我的 iteye博客: http://clevergump.iteye.com/admin/blogs/2211979, 但由于在 iteye发表的这篇文章的某些渲染曾经出现过一些问题, 我曾发过多封邮件向 iteye 的工作人员进行反馈, 官方只是在我第一封邮件中回复说会联系技术人员处理, 但是此后就再也没有收到他们的任何回复了, 我后来多次邮件询问进度, 也没有收到新的回复, 及时响应用户的提问和需求, 是提高用户体验和满意度的重要因素. 我想, 即使你们不能搞定或者不想去处理, 也应该给个回复, 即使回复 “我们暂时无法处理”, 也比不回复邮件要好啊. 于是我就决定今后永远放弃 iteye, 但自认为原博客中的这篇文章还是有一定价值的, 于是就在此重新发表一次, 并且今后就维护这篇文章吧.

另外多说一句, 不论你是从事什么行业的什么工作, 只要是和响应客户需求相关的岗位, 都应该主动及时地让客户知道他们所关心的事情的处理进度, 这样才能提高用户体验和满意度. 包括我们做手机 APP 或者 PC 客户端开发的岗位也是如此, 我们的客户端在遇到网络异常, 或者内存不足, 或者一段时间内无响应等特殊情况, 都应该及时主动地给用户弹出一个提示框或对话框. 我们在下载软件时, 显示下载进度条, 在加载图片时, 显示加载进度条. 在用户点击任何一个可能会让他们认为 (包括误认为) 可以点击的地方, 都必须要么进行页面变化, 要么弹出一个对话框或提示框, 不能什么都不处理, 尤其是对于那些设计时没有添加点击功能, 但却有可能被用户误认为可以点击的地方, 也要做相应的用户点击的响应处理……以上这些做法, 都是为了让用户及时知道他们关心的事情的进度, 都是提升用户体验的做法. 好了, 前言就扯这么多吧.


正文

做 Java 或 Android 开发的朋友, 一定都很熟悉 String 类中的 subString() 方法. 下面我们先来看一个关于该方法的小例子. 假如我们有如下需求: 随意设定一个字符串, 然后从中取出一个子字符串, 然后在该子字符串的末尾添加一些新的字符, 但要保证原先的字符串不变. 这个需求对你来说实在是 so easy, 于是你迅速写出了如下代码:

public class SubStringDemo {

    private static String str;
    private static String subStr;

    public static void main(String[] args) {
        subStringTest();
    }

    private static void subStringTest() {
        str = "01234";
        subStr = str.substring(2, str.length());

        print();
        subStr += "5";
        System.out.println("---------此时将 subStr 中增加一个字符 '5' ----------");
        print();
    }

    private static void print() {
        System.out.println("str    = " + str);
        System.out.println("subStr = " + subStr);
    }
}

你假设原字符串为 “01234”, 通过 subString() 方法从该字符串中取出一个子字符串 “234”, 然后在这个取出的子字符串的末尾添加一个新的字符’5’, 这样子字符串就变为 “2345”, 而原字符串则不变, 仍为 “01234”.

我们看下运行结果:

这里写图片描述

从运行结果来看, 代码确实没问题. IQ极高的你甚至有些愤愤不平, “这么 low 的需求, 简直就是在欺 (wu) 负 (ru) 哥的智商嘛”, 不知情的人, 还以为你在卫生间看到了下面这张图呢:

这里写图片描述

哈哈, 你可能确实有点屈才了. ^_^

没关系, 既然你智商很高, 我们就改个需求吧, 要求你能快速响应我们的需求变化, 要体现在代码中. 你说, 没问题, 尽管放马过来吧, 哥都能 hold 住. 于是需求改为如下内容: 将原需求中的字符串改为 List (也就是 java.util.List ), 将原需求中所有对字符串的要求都移植到对 List 的要求中. 具体来说就是, 随意设定一个 List 的实现类对象, 然后从中取出一个子 List , 然后向该子 List 中添加一些新的元素, 但要保证原先的 List 不变.

看到这个需求后, 估计你的心情可能又会像上面那张图那样吧. 这个变化, so easy. String 有 subString() 方法, 难道 List 就没有 subList() 方法??? 人要学会融会贯通嘛, 所以答案是显而易见的. 如果这都不是欺 (wu) 负 (ru) 哥的智商的话, 那么世界上就不存在 “欺 (wu) 负 (ru) 智商” 的说法了. 但是, 你终究还是平复了你的心情, 然后奋笔疾书, 快速写下了如下代码:

private static List list;
private static List subList;

private static void subListTest(Class<? extends List> listClazz) 
        throws IllegalAccessException, InstantiationException {
    if (listClazz == null) {
        throw new IllegalArgumentException(listClazz + " is null.");
    }

    list = listClazz.newInstance();
    list.clear();
    for (int i = 0; i < 5; i++) {
        list.add(i);
    }

    subList = list.subList(2, list.size());
    subList.add(5);
}

和先前 String 需求中设定的数字类似, 你在原 List 中设定该 List 中存有5个元素, 分别是整数 0, 1, 2, 3, 4. 然后将第2个元素到最末一个元素全部取出, 作为子 List. 然后向取出的这个子 List 中添加一个整数5. 写完这个代码后, 你甚至根本没有进行自测, 就非常自信地把代码直接交给了测试MM.

然而, 过了一会儿, 测试MM反馈说, 你的代码有bug. 在子 List 新增元素后, 原 List 也变了. 你很诧异, 不可能呀, 不应该呀, 子 List 的变化, 怎么会影响到原 List 呢? 不可能的, 一定是测试MM搞错了, 你心里或许在想, 难道是因为哥长得帅, 妹子想借此搭讪哥? ^_^ 但是, 测试MM一脸正经地告诉你, 确实有bug, 你的确需要修复, 先提个 bug 跟进的单子吧. 此刻, 你感觉到情况似乎有些不妙, 为了谨慎起见, 你立刻对原先的代码进行自测, 在原先代码的基础上增加了一些日志输出语句, 于是就有了如下代码:

public class SubListDemo {
    private static List list;
    private static List subList;

    public static void main(String[] args) {
        try {
            System.out.println("/*--------------------------- ArrayList -----------------------------------*/");
            subListTest(ArrayList.class);
            System.out.println("");
            System.out.println("/*--------------------------- LinkedList -----------------------------------*/");
            subListTest(LinkedList.class);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private static void subListTest(Class<? extends List> listClazz) throws IllegalAccessException, InstantiationException {
        if (listClazz == null) {
            throw new IllegalArgumentException(listClazz + " is null.");
        }

        list = listClazz.newInstance();
        list.clear();
        for (int i = 0; i < 5; i++) {
            list.add(i);
        }

        subList = list.subList(2, list.size());
        print();
        subList.add(5);
        System.out.println("---------此时将子list中增加一个元素 5 ----------");
        print();
    }

    private static void print() {
        System.out.println("原 list: " + list);
        System.out.println("子 list: " + subList);
    }
}

 

你对 List 接口最常用的两个实现类 ArrayList 和 LinkedList 都分别做了测试, 得到如下的打印结果:

这里写图片描述

在子 List 增加了元素5以后, 原先的 List 也相应增加了元素 5, 留意上图中的两个蓝色圆圈. 
于是你又将增加的元素改为另外一个数字, 比如: 10, 你会发现, 原 List 也会增加元素 10. 
而如果你将增加元素改为删除元素, 例如: 删除坐标为0的元素, 即: 将 subListTest() 方法改为如下代码:

private static void subListTest(Class<? extends List> listClazz) 
        throws IllegalAccessException, InstantiationException {
    if (listClazz == null) {
        throw new IllegalArgumentException(listClazz + " is null.");
    }

    list = listClazz.newInstance();
    list.clear();
    for (int i = 0; i < 5; i++) {
        list.add(i);
    }

    subList = list.subList(2, list.size());
    print();
    subList.remove(0);
    System.out.println("---------此时将子list中的第0个元素删除 ----------");
    print();
}

 

打印结果如下:

这里写图片描述

你会发现, 当你删除子 List 中的第0个元素, 也就是元素2的时候, 原先的 List 中的元素2也被一同删除了, 还是留意上图中的蓝色圆圈标注的数字, 这是原 List 中的元素2, 他们在子 List 执行删除动作以后, 也会被一同删除掉.

奇怪呀, 为什么向子 List 中增加或删除一个元素, 会同时让原 List 也增加或删除相同的元素呢? 此刻的你陷入了深深的疑惑与不解中…

这里写图片描述

要想解答这个疑惑, 唯有分析源码才是正确的方式啊. 那么, 我们就来分析一下相关的源码吧.

我们就以 ArrayList 为例来进行分析吧. 下面是 ArrayList 的 subList() 方法的源码:

public List<E> subList(int fromIndex, int toIndex) {  
    subListRangeCheck(fromIndex, toIndex, size);  
    return new SubList(this, 0, fromIndex, toIndex);  
}  

 

该方法其实返回的是 ArrayList 的内部类 SubList 的一个实例, 同时也将当前 ArrayList 对象作为传入该构造方法, 作为第一个参数的值. 我们看看这个构造方法的源码:

private class SubList extends AbstractList<E> implements RandomAccess {
    private final AbstractList<E> parent;

    SubList(AbstractList<E> parent,
            int offset, int fromIndex, int toIndex) {
        this.parent = parent;
        this.parentOffset = fromIndex;
        this.offset = offset + fromIndex;
        this.size = toIndex - fromIndex;
        this.modCount = ArrayList.this.modCount;
    }
}

由上述代码可知, 在创建这个内部类 ArrayList.SubList 的实例时, 会将外部类 ArrayList 的引用作为该内部类对象中 parent 字段的值, 也就是说, 这个 ArrayList.SubList 内部类实例中的 parent 字段会持有外部类 ArrayList 对象的一个引用, 只是添加了一定的偏移量而已. 由于 List 中存放的元素都是引用类型, 而非基本类型, 所以, 这个子 List 中的每一个元素所代表的引用, 其实就和原 List 中在相同索引处偏移 fromIndex 位置后的那个位置上的元素所代表的引用, 二者指向的是相同的对象. 我们换用更直白的方式来说, 就是: 
假设有 0~4 这5个整数, 先被分别装箱成5个 Integer对象, 然后被依次添加到原 List 中, 假设我们将原 List 称作 listA, 这时, 这5个对象中的每一个都分别被一个引用指向着, 这些引用刚好就是 listA 中存放的所有元素, 注意: listA中存放的元素其实是引用, 而不是对象本身. 这时, 对 listA 执行了 subList(2, listA.size()) 方法, 创建了一个子 List , 我们将这个子 List 称作 listB. 那么这时, 对象 Integer.valueOf(0) 和 Integer.valueOf(1) 各自还是只被一个引用指向着, 但是, 对象 Integer.valueOf(2), Integer.valueOf(3), Integer.valueOf(4) 却都分别被两个引用指向着, 一个引用来自 listA, 另一个引用来自 listB. 可能上述描述还是不够清晰, 我们用表格来解释吧.

在创建子 List ( 即: listB) 之前, 各个 Integer 对象被引用指向的情况如下: 
这里写图片描述

在创建子 List ( 即: listB) 之后, 各个 Integer 对象被引用指向的情况如下: 
这里写图片描述 
留意红色的字. 在创建了 listB, 也就是子 List 以后, 后三行的三个对象, 都分别被 listA 和 listB中各有一个引用所指向着. 而且还有个规律: listB 中每一个元素(其实这里的元素是引用, 不是对象本身) 所指向的对象, 都会同时被两个引用所指向着. 所以, 对于这些同时被两个引用所指向的对象来说, 不论是用哪个引用来修改这些对象的值, 或者对他们进行增删, 都将影响到另外一个引用的指向结果.

先看这个内部类 ArrayList.SubList 的新增元素的方法 add(E e) . 由于在这个类内部没有找到这个签名的方法, 所以只能到他的父类中去找, 看下该类的继承关系:

private class SubList extends AbstractList<E> implements RandomAccess
  • 1

在其父类 AbstractList 中找到了该方法的定义, 源码如下:

public boolean add(E e) {
    add(size(), e);
    return true;
}

该方法调用了 add(size(), e) 这个方法, 这个方法我们暂时先不分析, 留到后面分析. 先暂时做个记号, ——–标记0
我们先分析 size() 方法, size() 方法在 AbstractList 类中没有找到, 我们先向上寻找, 即: 向他的父类中去找, 先看下 AbstractList 这个类的继承关系:

public abstract class AbstractList<E> extends AbstractCollection<E> implements List<E>
  • 1

发现, AbstractList 的父类 AbstractCollection 中将 size() 定义为抽象方法, 所以, 我们只能向下去找, 也就是向 AbstractList 的子类, 即: 向本文分析的 ArrayList.SubList 这个内部类中去找, 我们在该内部类中找到了该方法的实现, 如下:

public int size() {
    checkForComodification();
    return this.size;
}

 

返回 this.size, 也就是 ArrayList.SubList 类中的 size字段的值, 而这个 size 字段其实在这个内部类的构造方法中就有赋值:

this.size = toIndex - fromIndex;

也就是对 listA 调用 subString() 方法时传入的两个索引值的差, 即: listB 中元素的总数.

好了, 我们绕的有点远, 我们再回到标记0处. 该分析 add(size(), e) 这个方法了. 这个方法在我们的内部类 ArrayList.SubList 中就有定义, 源码如下:

public void add(int index, E e) {
    rangeCheckForAdd(index);
    checkForComodification();
    parent.add(parentOffset + index, e);
    this.modCount = parent.modCount;
    this.size++;
}

 

第4行, 直接调用 parent 的 add() 方法, 也就是原 List ( listA ) 的 add() 方法, 该方法增加了偏移量 parentOffset, 并且 index 就等于 size()的返回值, 而我们前边分析过, size() 的返回值就是 listB 中元素的总数. 我们这里做个记号以便后边回到这里继续分析——— 标记1
这个 parentOffset 又是什么呢? 我们还是要看这个内部类 ArrayList.SubList 的构造方法:

SubList(AbstractList<E> parent,
        int offset, int fromIndex, int toIndex) {
    this.parent = parent;
    this.parentOffset = fromIndex;
    this.offset = offset + fromIndex;
    this.size = toIndex - fromIndex;
    this.modCount = ArrayList.this.modCount;
}

从第4行可知, parentOffset 就是 fromIndex, 而 fromIndex 其实就是我们创建子 List 时调用 ArrayList 的 subList(int fromIndex, int toIndex) 时为该方法中的 fromIndex 这个参数传入的值. 如果你不相信, 那就请再次回顾 subList(int fromIndex, int toIndex) 的源码吧:

public List<E> subList(int fromIndex, int toIndex) {
    subListRangeCheck(fromIndex, toIndex, size);
    return new SubList(this, 0, fromIndex, toIndex);
}

 

看第2行, 我们为 subList() 方法传入的 fromIndex, 作为 ArrayList.SubList 这个内部类的第三个参数, 而从该内部类的构造方法又可知, 这第三个参数最终会被赋值给该内部类的 parentOffset 字段, 也就是说, parentOffset 就是我们调用 subList() 方法获取子 List 时传入的起始坐标的值, 在我们这个例子中, 由于我们对 listA 调用 subList(2, 5) 获取到 listB, 所以, parentOffset 就是 2.

好了, 我们回到标记1处继续分析.

标记1处, 我们分析到了如下代码:

parent.add(parentOffset + index, e);
  • 1

并且也知道了, 子 List (即: listB) 调用 add(E e) 方法, 其实最终是调用 parent.add(parentOffset + index, e) 方法的, 而我们前面分析过:

parentOffset = fromIndex
index = listB.size() = toIndex - fromIndex
parentOffset  + index = toIndex  // 也就是调用 subString()方法时, 所传入的第二个值

 

也就是调用 listA 的 add(toIndex, e) 方法, 而 toIndex 位置所指向的对象, 是同时被两个引用所指向, 所以, 如果调用 listB 的 add()方法向其中增加一个元素, 那么也必定会同时向 listA 中增加相同的元素, 因为从根本上来说, 这其实就是两个引用同时指向同一个对象嘛. 但是, 如果将这个过程反过来, 即: 向原 List (listA) 中增加一个对象, 那么将会抛出 ConcurrentModificationException 并发修改异常. 我们可以通过运行如下代码来得到证实:

public class SubListDemo {
    private static List list;
    private static List subList;

    public static void main(String[] args) {
        try {
            System.out.println("/*--------------------------- ArrayList -----------------------------------*/");
            subListTest(ArrayList.class);           
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private static void subListTest(Class<? extends List> listClazz)
            throws IllegalAccessException, InstantiationException {
        if (listClazz == null) {
            throw new IllegalArgumentException(listClazz + " is null.");
        }

        list = listClazz.newInstance();
        list.clear();
        for (int i = 0; i < 5; i++) {
            list.add(i);
        }

        subList = list.subList(2, list.size());
        print();
        list.add(0, 10);
        System.out.println("---------此时在原list索引为0的位置上增加一个元素10, 同时将其他元素依次向后移动 ----------");
        print();
    }

    private static void print() {
        System.out.println("原 list: " + list);
        System.out.println("子 list: " + subList);
    }
}

得到的打印结果是:

这里写图片描述

子 List 的元素和原 List 中的后一部分是重合的, 而子 List 还在遍历过程中时, 向原 List 中新增元素, 这样给子 List 的遍历过程造成了干扰甚至困扰, 于是就抛出了并发修改异常. 同理, 我们也能合理推测出, 如果在遍历子 List 的过程中, 对原 List 执行的是删除元素的操作, 那么也必定会导致子 List 的遍历过程会抛出并发修改异常. 但是如果不是增删, 而是修改数值的操作, 就不会影响到子 List 的遍历过程, 所以就不会抛出并发修改异常.

我们还是简单看看这个内部类的 remove() 方法的源码吧, 如下:

public E remove(int index) {
    rangeCheck(index);
    checkForComodification();
    E result = parent.remove(parentOffset + index);
    this.modCount = parent.modCount;
    this.size--;
    return result;
}

 

看第4行, 还是调用了 parent 的 remove() 方法, 所以, 后续的分析完全和前面对 add() 方法的分析是同理的, 所以就不再分析了.

那我们再看看修改数值的方法, 也就是 set() 方法吧:

public E set(int index, E e) {
    rangeCheck(index);
    checkForComodification();
    E oldValue = ArrayList.this.elementData(offset + index);
    ArrayList.this.elementData[offset + index] = e;
    return oldValue;
}

 

第5行, 直接修改外部类 ArrayList 内部数组中相应元素的数值, 而由于子 List 使用的是原 List 的后一部分数据, 所以, 如果我们可以合理猜测, 如果此处修改的是数组中较为靠前的元素的数值, 那么只有原 List 中的数据会变化, 子 List 将不变. 而如果此处修改的是数组中较为靠后的元素的数值, 这个元素是被两个 List 中的元素共同指向着, 那么两个 List 中的数值将都会发生变化. 分析方法还是和分析 add() 方法同理.

其实, 我们可以继续修改上述代码, 来查看发生增删改查各自情况时的日志输出情况, 下面我对每种情况都分别进行一番实例测试, 将测试结果汇总成如下表格:

这里写图片描述

我们对该表格的测试结果进行总结, 可以得出如下结论:

这里写图片描述

这个结论对于我们日常的开发工作, 倒是起不到太大的帮助作用. 因为这些结论总结出的都是消极的结果, 而不是积极的结果. 不过, 这个结论倒是告诉我们:

如果你对一个 List 进行过 subList() 的操作之后, 

1. 千万不要再对原 List 进行任何改动的操作(例如: 增删改), 查询和遍历倒是可以. 因为如果对原 List 进行了改动, 那么后续只要是涉及到子 List 的操作就一定会出问题. 而至于会出现什么问题呢? 具体来说就是: 
(1) 如果是对原 List 进行修改 (即: 调用 set() 方法) 而不是增删, 那么子 List 的元素也可能会被修改 (这种情况下不会抛出并发修改异常). 
(2) 如果是对原 List 进行增删, 那么此后只要操作了子 List , 就一定会抛出并发修改异常.

2. 千万不要直接对子 List 进行任何改动的操作(例如: 增删改), 但是查询和间接改动倒是可以. 不要对子 List 进行直接改动, 是因为如果在对子 List 进行直接改动之前, 原 List 已经被改动过, 那么此后在对子 List 进行直接改动的时候就会抛出并发修改异常.

既然获取子 List 后会有这么多限制条件, 一不小心就会出错, 那我们还怎么操作这个子 List 呢? 或者说, 怎样才能安全地操作子 List 呢? 其实, 你可能已经注意到了我在上述结论中提到的间接二字. 是的, 我们可以通过间接的方式来安全地操作子 List . 怎么间接呢? 其实, “间接” 和 “直接” 是相对的, 因为根据前边的分析, 子 List 会共用原 List 中后一部分的元素, 他们共同指向相同的对象, 这种共用对象的特性就是导致产生各种不安全结果的罪魁祸首. 如果我们将二者分别指向不同的对象, 岂不是就能避免不安全结果的产生? 也就是说, 我们需要让子 List 指向新的对象, 并且让新对象每个位置上的数值要和原 List 中相关位置上的数值相等即可. 于是就想到了以下两种间接的处理方式:

  1. 创建一个新的对象作为我们最终要操作的对象, 在其构造方法中, 将通过 subList() 方法获取到的子 List 作为该构造方法的参数传入. 这时, 这个新对象内所包含的元素和子 List 的完全相同, 但却指向的是不同的对象. 我们只需使用这个新创建的对象即可. 
    对于 ArrayList

    List<Integer> subList = new ArrayList<>(list.subList(2, list.size()));  

 

    对于 LinkedList

    List<Integer> subList = new LinkedList<>(list.subList(2, list.size()));

 

  1. 创建一个新的对象作为我们最终要操作的对象, 然后调用这个新对象的 addAll() 方法, 将通过 subList() 方法获取到的子 List 作为 addAll() 方法的参数传入, 这时, 这个新对象内所包含的元素和子 List 的完全相同, 但却指向的是不同的对象. 我们只需使用这个新创建的对象即可. 
    对于 ArrayList:

    List<Integer> subList = new ArrayList<>();  
    subList.addAll(list.subList(2, list.size())); 

 

    对于 LinkedList:

    List<Integer> subList = new LinkedList<>();  
    subList.addAll(list.subList(2, list.size()));  

我们可以使用以上两种方式中的任意一种, 来解决我们在本文最开始遇到的那个 bug. 看如下代码:

 

public class SubListDemo {
    private static List list;
    private static List subList;

    public static void main(String[] args) {
        try {
            System.out.println("/*--------------------------- ArrayList -----------------------------------*/");
            subListTest(ArrayList.class);
            System.out.println("");
            System.out.println("/*--------------------------- LinkedList -----------------------------------*/");
            subListTest(LinkedList.class);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private static void subListTest(Class<? extends List> listClazz) throws IllegalAccessException, InstantiationException {
        if (listClazz == null) {
            throw new IllegalArgumentException(listClazz + " is null.");
        }

        list = listClazz.newInstance();
        list.clear();
        for (int i = 0; i < 5; i++) {
            list.add(i);
        }

        subList = listClazz.newInstance();
        List tempSubList = SubListDemo.list.subList(2, SubListDemo.list.size());
        subList.addAll(tempSubList);

        print();
        subList.add(5);
        System.out.println("---------此时将子list中增加一个元素 5 ----------");
        print();
    }

    private static void print() {
        System.out.println("原 list: " + list);
        System.out.println("子 list: " + subList);
    }
}

第28行, 我们为 subList 单独新建了一个对象, 让其指向这个新的对象. 然后在第30行, 调用 addAll() 将获取到的子 List 作为参数传入, 这样, subList 不仅指向了新的对象, 而且其内部的各个数值还和子 List 都是相同的. 运行结果如下:

这里写图片描述

我们发现, 为子 List 添加一个新元素5, 将不再影响原 List 了. 原 List 内的元素依然是 [0, 1, 2, 3, 4], 而不会再像先前的 bug那样也增加一个元素5了. 其他情况, 大家就自己测试吧.

好了, 这篇文章就到此为止. 通过本文的分析, 我们得出一个结论, 那就是, 经验主义有时会让你很受伤, 千万不要乱用经验主义. 本文所描述的主人公, 就是因为看到subList() 和 subString() 这两个方法的命名方式类似, 于是根据经验主义而写出了错误的代码.


参考资料:

posted @ 2017-10-10 11:01  LatteYan  阅读(10192)  评论(0编辑  收藏  举报