Loading

Rust 智能指针

本文在原文基础上有删减,原文参考Rust 智能指针

指针 (pointer)是一个包含内存地址的变量的通用概念,这个地址引用或 “指向”(points at)一些其他数据。Rust 中最常见的指针是引用(reference),引用以 & 符号为标志并借用了它们所指向的值。

普通引用和智能指针的一个额外的区别是引用是一类只借用数据的指针,在大部分情况下 智能指针拥有它们指向的数据

智能指针通常使用结构体实现,智能指针不同于结构体的地方在于其实现了 DerefDrop trait:

  • Deref trait 允许智能指针结构体实例表现的像引用一样,可以编写既用于引用、又用于智能指针的代码。
  • Drop trait 允许自定义当智能指针离开作用域时运行的代码。

使用Box指向堆上的数据

最简单直接的智能指针是 box,其类型是 Box<T>允许将一个值放在堆上而不是栈上,留在栈上的则是指向堆数据的指针。

box 没有性能损失,多用于如下场景:

  • 当有一个在编译时未知大小的类型,而又想要在需要确切大小的上下文中使用这个类型值的时候
  • 当有大量数据并希望在确保数据不被拷贝的情况下转移所有权的时候
  • 当希望拥有一个值并只关心它的类型是否实现了特定 trait 而不是其具体类型的时候

使用 Box 在堆上储存数据

熟悉一下语法以及如何与储存在 Box<T> 中的值进行交互:

fn main() {
    //使用 box 在堆上储存一个 i32 值
    let b = Box::new(5);
    println!("b = {}", b);
}

Box 允许创建递归类型

递归类型(recursive type)的值可以拥有另一个同类型的值作为其自身的一部分,编译时 Rust 不知道递归类型需要多少空间。因为 box 有一个已知的大小,所以通过在循环类型定义中插入 box 就可以创建递归类型了。

cons list 的更多内容

cons list 是一个来源于 Lisp 编程语言及其方言的数据结构,它由嵌套的列表组成。一个包含列表 1,2,3 的 cons list 的伪代码表示,其每一个列表在一个括号中:

(1, (2, (3, Nil)))

cons list 的每一项都包含两个元素:当前项的值和下一项,其最后一项值包含一个叫做 Nil 的值且没有下一项。cons list 通过递归调用 cons 函数产生。代表递归的终止条件(base case)的规范名称是 Nil,它宣布列表的终止。

cons list 并不是一个 Rust 中常见的类型。大部分在 Rust 中需要列表的时候,Vec<T> 是一个更好的选择。

计算非递归类型的大小

回忆之前定义的 Message 枚举:

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

因为 enum 实际上只会使用其中的一个成员,所以 Message 值所需的空间等于储存其最大成员的空间大小。

使用 Box<T> 给递归类型一个已知的大小

Box<T> 是一个指针:指针的大小并不会根据其指向的数据量而改变,可以将 Box 放入 Cons 成员中而不是直接存放另一个 List 值。Box 会指向另一个位于堆上的 List 值,而不是存放在 Cons 成员中。

为了拥有已知大小而使用 Box<T> 的 List 定义:

enum List {
    Cons(i32, Box<List>),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
}

任何 List 值最多需要一个 i32 加上 box 指针数据的大小,通过使用 box,打破了这无限递归的连锁:
image

通过 Deref trait 将智能指针当作常规引用处理

实现 Deref trait 允许我们重载 解引用运算符(dereference operator)*(不要与乘法运算符或通配符相混淆)。通过这种方式实现 Deref trait 的智能指针可以被当作常规引用来对待,可以编写操作引用的代码并用于智能指针。

追踪指针的值

常规引用是一个指针类型,一种理解指针的方式是将其看成指向储存在其他某处值的箭头。使用解引用运算符来跟踪 i32 值的引用:

fn main() {
    let x = 5;
    let y = &x;

    assert_eq!(5, x);
    //对 y 的值做出断言,必须使用 *y 来追踪引用所指向的值(也就是 解引用)
    assert_eq!(5, *y);
    //编译报错:不允许比较数字的引用与数字
    assert_eq!(5, y);
}

像引用一样使用 Box<T>

可以使用 Box<T> 代替引用来重写上面的示例,在 Box<i32> 上使用解引用运算符:

fn main() {
    let x = 5;
    //将 y 设置为一个指向 x 值拷贝的 Box<T> 实例,而不是指向 x 值的引用
    let y = Box::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

自定义智能指针

从根本上说,Box<T> 被定义为包含一个元素的元组结构体,以相同的方式定义 MyBox<T> 类型:

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}
  • MyBox 是一个包含 T 类型元素的元组结构体。
  • MyBox::new 函数获取一个 T 类型的参数并返回一个存放传入值的 MyBox 实例。

尝试以使用引用和 Box<T> 相同的方式使用 MyBox<T>:

fn main() {
    let x = 5;
    let y = MyBox::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

编译报错,MyBox<T> 类型不能解引用,为了启用 *** 运算符**的解引用功能,需要实现 Deref trait。

通过实现 Deref trait 将某类型像引用一样处理

Deref trait 由标准库提供,要求实现名为 deref 的方法,其借用 self 并返回一个内部数据的引用。MyBox<T> 上的 Deref 实现:

use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    //定义了用于此 trait 的关联类型
    type Target = T; 

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

deref 方法体中写入了 &self.0,这样 deref 返回了我希望通过 * 运算符访问的值的引用, .0 用来访问元组结构体的第一个元素

输入 *y 时,Rust 事实上在底层运行了如下代码:

*(y.deref())
  • Rust 将 * 运算符替换为先调用 deref 方法再进行普通解引用的操作,不需要手动调用 deref 方法。
  • deref 方法返回值的引用,以及 *(y.deref()) 括号外边的普通解引用仍为必须的原因在于所有权。

注:每次在代码中使用 * 时, * 运算符都被替换成了先调用 deref 方法再接着使用 * 解引用的操作,且只会发生一次,不会对 * 操作符无限递归替换。

函数和方法的隐式 Deref 强制转换

Deref 强制转换(deref coercions)将实现了 Deref trait 的类型的引用转换为另一种类型的引用,如将 &String 转换为 &str。
Deref 强制转换是 Rust 在函数或方法传参上的一种便利操作,并且只能作用于实现了 Deref trait 的类型,当这种特定类型的引用作为实参传递给和形参类型不同的函数或方法时将自动进行

一个有着字符串 slice 参数的函数定义:

fn hello(name: &str) {
    println!("Hello, {name}!");
}

因为 Deref 强制转换,使用 MyBox<String> 的引用调用 hello 是可行的:

fn main() {
    let m = MyBox::new(String::from("Rust"));
    hello(&m);
}
  • 使用 &m 调用 hello 函数,其为 MyBox<String> 值的引用。
  • Rust 可以通过 deref 调用将 &MyBox<String> 变为 &String。
  • 标准库中提供了 String 上的 Deref 实现,其会返回字符串 slice,Rust 再次调用 deref 将 &String 变为 &str.

如果 Rust 没有 Deref 强制转换则必须编写的代码:

fn main() {
    let m = MyBox::new(String::from("Rust"));
    hello(&(*m)[..]);
}
  • (*m) 将 MyBox 解引用为 String。
  • 接着 & 和 [..] 获取了整个 String 的字符串 slice 来匹配 hello 的签名。

这些解析都发生在编译时,所以利用 Deref 强制转换并没有运行时损耗。

Deref 强制转换如何与可变性交互

类似于如何使用 Deref trait 重载不可变引用的 * 运算符,Rust 提供了 DerefMut trait 用于重载可变引用的 * 运算符

Rust 在发现类型和 trait 实现满足三种情况时会进行 Deref 强制转换:

  • 当 T: Deref<Target=U> 时从 &T 到 &U。
  • 当 T: DerefMut<Target=U> 时从 &mut T 到 &mut U。
  • 当 T: Deref<Target=U> 时从 &mut T 到 &U。

使用 Drop Trait 运行清理代码

第二个重要的 trait 是 Drop,其允许在值要离开作用域时执行一些代码,所指定的代码被用于释放类似于文件或网络连接的资源。

Drop trait 要求实现一个叫做 drop 的方法,它获取一个 self 的可变引用。结构体 CustomSmartPointer 实现了放置清理代码的 Drop trait:

//Drop trait 包含在 prelude 中无需导入
struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        //打印一些文本以可视化地展示 Rust 何时调用 drop
        println!("Dropping CustomSmartPointer with data `{}`!", self.data);
    }
}

fn main() {
    let c = CustomSmartPointer {
        data: String::from("my stuff"),
    };
    let d = CustomSmartPointer {
        data: String::from("other stuff"),
    };
    println!("CustomSmartPointers created.");
}
//当实例离开作用域 Rust 会自动调用 drop,并调用指定的代码
//变量以被创建时相反的顺序被丢弃,所以 d 在 c 之前被丢弃

通过 std::mem::drop 提早丢弃值

Rust 并不允许主动调用 Drop trait 的 drop 方法,如果想在作用域结束之前就强制释放变量的话,应该使用的是由标准库提供的 std::mem::drop

尝试手动调用 Drop trait 的 drop 方法提早清理,编译会报错:

fn main() {
    let c = CustomSmartPointer {
        data: String::from("some data"),
    };
    println!("CustomSmartPointer created.");
    c.drop();
    println!("CustomSmartPointer dropped before the end of main.");
}

在值离开作用域之前调用 std::mem::drop 显式清理:

fn main() {
    let c = CustomSmartPointer {
        data: String::from("some data"),
    };
    println!("CustomSmartPointer created.");
    drop(c);
    println!("CustomSmartPointer dropped before the end of main.");
}

Rc<T> 引用计数智能指针

为了启用多所有权需要显式地使用 Rust 类型 Rc<T>,其为引用计数(reference counting)的缩写。
注: Rc<T> 只能用于单线程场景

使用 Rc<T> 共享数据

两个列表,b 和 c, 共享第三个列表 a 的所有权:
image

使用 Rc<T> 定义的 List:

enum List {
    Cons(i32, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::rc::Rc;  //将 Rc<T> 引入作用域

fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    let b = Cons(3, Rc::clone(&a));
    let c = Cons(4, Rc::clone(&a));
}

Rc::clone 的实现并不像大部分类型的 clone 实现那样对所有数据进行深拷贝。Rc::clone 只会增加引用计数。

克隆 Rc 会增加引用计数

修改了 main 以便将列表 c 置于内部作用域中,观察当 c 离开作用域时引用计数如何变化:

fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    println!("count after creating a = {}", Rc::strong_count(&a));
    let b = Cons(3, Rc::clone(&a));
    println!("count after creating b = {}", Rc::strong_count(&a));
    {
        let c = Cons(4, Rc::clone(&a));
        println!("count after creating c = {}", Rc::strong_count(&a));
    }
    println!("count after c goes out of scope = {}", Rc::strong_count(&a));
}

在程序中每个引用计数变化的点,会打印出引用计数,其值可以通过调用 Rc::strong_count 函数获得。

RefCell 和内部可变性模式

内部可变性(Interior mutability)是 Rust 中的一个设计模式,它允许你即使在有不可变引用时也可以改变数据,这通常是借用规则所不允许的。

通过 RefCell 在运行时检查借用规则

对于引用和 Box<T> 借用规则的不可变性作用于编译时,对于 RefCell<T>这些不可变性作用于 运行时:

  • 在编译时检查借用规则的优势是这些错误将在开发过程的早期被捕获,同时对运行时没有性能影响。
  • 在运行时检查借用规则的好处则是允许出现特定内存安全的场景,而它们在编译时检查中是不允许的。

RefCell<T> 只能用于单线程场景,如下为选择 Box<T>、Rc<T> 或 RefCell<T> 的理由:

  • Rc<T> 允许相同数据有多个所有者,Box<T> 和 RefCell<T> 有单一所有者。
  • Box<T> 允许在编译时执行不可变或可变借用检查,Rc<T>仅允许在编译时执行不可变借用检查,RefCell<T> 允许在运行时执行不可变或可变借用检查。
  • RefCell<T> 允许在运行时执行可变借用检查,可以在即便 RefCell<T> 自身是不可变的情况下修改其内部的值。

内部可变性:不可变值的可变借用

借用规则的一个推论是当有一个不可变值时,不能可变地借用它。
令一个值在其方法内部能够修改自身而在其他代码中仍视为不可变,特定情况下是很有用的,RefCell 是一个获得内部可变性的方法。

内部可变性的用例:mock 对象

有时在测试中程序员会用某个类型替换另一个类型以便观察特定的行为并断言它是被正确实现的,这个占位符类型被称为 测试替身(test double),mock 对象** 是特定类型的测试替身,它们记录测试过程中发生了什么以便可以断言操作是正确的。

一个记录某个值与最大值差距的库,并根据此值的特定级别发出警告:

pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
where
    T: Messenger,
{
    pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
            self.messenger
                .send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger
                .send("Warning: You've used up over 75% of your quota!");
        }
    }
}

通过 RefCell 来储存 sent_messages,然后 send 将能够修改 sent_messages 并储存消息:

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::RefCell;

    //sent_messages 字段的类型是 RefCell<Vec<String>> 而不是 Vec<String>
    struct MockMessenger {
        sent_messages: RefCell<Vec<String>>,
    }

    impl MockMessenger {
        fn new() -> MockMessenger {
            MockMessenger {
                sent_messages: RefCell::new(vec![]),
            }
        }
    }

    impl Messenger for MockMessenger {
        fn send(&self, message: &str) {
            //调用 self.sent_messages 中 RefCell 的 borrow_mut 方法来获取 RefCell 中值的可变引用
            self.sent_messages.borrow_mut().push(String::from(message));
        }
    }

    #[test]
    fn it_sends_an_over_75_percent_warning_message() {
        // --snip--

        //调用 RefCell 的 borrow 以获取 vector 的不可变引用
        assert_eq!(mock_messenger.sent_messages.borrow().len(), 1);
    }
}

RefCell 在运行时记录借用

RefCell<T> 记录当前有多少个活动的 Ref<T> 和 RefMut<T> 智能指针,每次调用 borrow 时活动的不可变借用计数加一,当 Ref<T> 值离开作用域时不可变借用计数减一。

RefCell<T> 在任何时候只允许有多个不可变借用或一个可变借用,违反这些规则 RefCell<T> 的实现会在运行时出现 panic。在同一作用域中创建两个可变引用并观察 RefCell<T> panic:

impl Messenger for MockMessenger {
    fn send(&self, message: &str) {
        let mut one_borrow = self.sent_messages.borrow_mut();
        let mut two_borrow = self.sent_messages.borrow_mut();
        one_borrow.push(String::from(message));
        two_borrow.push(String::from(message));
    }
}

结合 Rc 和 RefCell 来拥有多个可变数据所有者

RefCell<T> 的一个常见用法是与 Rc<T> 结合,如果有一个储存了 RefCell<T> 的 Rc<T> 的话,就可以得到有多个所有者 并且 可以修改的值了:

//使用 Rc<RefCell<i32>> 创建可以修改的 List
#[derive(Debug)]
enum List {
    Cons(Rc<RefCell<i32>>, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

fn main() {
    let value = Rc::new(RefCell::new(5));

    let a = Rc::new(Cons(Rc::clone(&value), Rc::new(Nil)));

    let b = Cons(Rc::new(RefCell::new(3)), Rc::clone(&a));
    let c = Cons(Rc::new(RefCell::new(4)), Rc::clone(&a));

    *value.borrow_mut() += 10;

    println!("a after = {:?}", a);
    println!("b after = {:?}", b);
    println!("c after = {:?}", c);
}

当打印出 a、b 和 c 时,可以看到它们都拥有修改后的值 15 而不是 5:

a after = Cons(RefCell { value: 15 }, Nil)
b after = Cons(RefCell { value: 3 }, Cons(RefCell { value: 15 }, Nil))
c after = Cons(RefCell { value: 4 }, Cons(RefCell { value: 15 }, Nil))

注意:RefCell<T> 不能用于多线程代码,Mutex<T> 是一个线程安全版本的 RefCell<T>

引用循环与内存泄漏

制造引用循环

一个存放 RefCell 的 cons list 定义,这样可以修改 Cons 成员所引用的数据:

use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
enum List {
    Cons(i32, RefCell<Rc<List>>),
    Nil,
}

impl List {
    fn tail(&self) -> Option<&RefCell<Rc<List>>> {
        match self {
            Cons(_, item) => Some(item),
            Nil => None,
        }
    }
}

fn main() {}

增加一个 main 函数,创建一个引用循环——两个 List 值互相指向彼此:

fn main() {
    //在变量 a 中创建了一个 Rc<List> 实例来存放初值为 5, Nil 的 List 值
    let a = Rc::new(Cons(5, RefCell::new(Rc::new(Nil))));

    println!("a initial rc count = {}", Rc::strong_count(&a));
    println!("a next item = {:?}", a.tail());

    //在变量 b 中创建了存放包含值 10 和指向列表 a 的 List 的另一个 Rc<List> 实例
    let b = Rc::new(Cons(10, RefCell::new(Rc::clone(&a))));

    println!("a rc count after b creation = {}", Rc::strong_count(&a));
    println!("b initial rc count = {}", Rc::strong_count(&b));
    println!("b next item = {:?}", b.tail());

    //修改 a 使其指向 b 而不是 Nil
    if let Some(link) = a.tail() {
        *link.borrow_mut() = Rc::clone(&b);
    }

    println!("b rc count after changing a = {}", Rc::strong_count(&b));
    println!("a rc count after changing a = {}", Rc::strong_count(&a));

    // Uncomment the next line to see that we have a cycle;
    // it will overflow the stack
    // println!("a next item = {:?}", a.tail());
}

保持最后的 println! 行注释并运行代码,会得到如下输出:

a initial rc count = 1
a next item = Some(RefCell { value: Nil })
a rc count after b creation = 2
b initial rc count = 1
b next item = Some(RefCell { value: Cons(5, RefCell { value: Nil }) })
b rc count after changing a = 2
a rc count after changing a = 2

列表 a 和 b 彼此互相指向形成引用循环:
image

如果取消最后 println! 的注释并运行程序,Rust 会尝试打印出 a 指向 b 指向 a 这样的循环直到栈溢出。

避免引用循环:将 Rc 变为 Weak

调用 Rc::clone 会增加 Rc<T> 实例的 strong_count,strong_count 为 0 时才会被清理。可以通过调用 Rc::downgrade 并传递 Rc<T> 实例的引用来创建其值的 弱引用(weak reference)

  • 强引用代表如何共享 Rc<T> 实例的所有权。
  • 弱引用并不属于所有权关系,当 Rc<T> 实例被清理时其计数没有影响。
  • 任何涉及弱引用的循环会在其相关的值的强引用计数为 0 时被打断。

调用 Rc::downgrade 时会得到 Weak<T> 类型的智能指针,调用 Rc::downgrade 会将 weak_count 加 1,weak_count 无需计数为 0 就能使 Rc<T> 实例被清理。

调用 Weak<T> 实例的 upgrade 方法会返回 Option<Rc<T>>,Rust 会确保处理 Some 和 None 的情况不会返回非法指针。

创建树形数据结构:带有子节点的 Node

创建一个用于存放其拥有所有权的 i32 值和其子节点引用的 Node:

use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
struct Node {
    value: i32,
    children: RefCell<Vec<Rc<Node>>>,
}

创建没有子节点的 leaf 节点和以 leaf 作为子节点的 branch 节点:

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        children: RefCell::new(vec![]),
    });

    let branch = Rc::new(Node {
        value: 5,
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });
}

增加从子到父的引用

parent 使用 Weak<T> 类型而不是 Rc<T>,具体来说是 RefCell<Weak<Node>t>:

use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

一个 leaf 节点,其拥有指向其父节点 branch 的 Weak 引用:


fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    //输出 leaf parent = None
    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());

    let branch = Rc::new(Node {
        value: 5,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });

    *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
}

Weak<Node> 引用被打印为 (Weak):

leaf parent = Some(Node { value: 5, parent: RefCell { value: (Weak) },
children: RefCell { value: [Node { value: 3, parent: RefCell { value: (Weak) },
children: RefCell { value: [] } }] } })

可视化 strong_count 和 weak_count 的改变

在内部作用域创建 branch 并检查其强弱引用计数:

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    //一旦创建了 leaf,其 Rc<Node> 的强引用计数为 1,弱引用计数为 0
    println!(
        "leaf strong = {}, weak = {}",
        Rc::strong_count(&leaf),
        Rc::weak_count(&leaf),
    );

    {
        //创建 branch 并与 leaf 相关联
        let branch = Rc::new(Node {
            value: 5,
            parent: RefCell::new(Weak::new()),
            children: RefCell::new(vec![Rc::clone(&leaf)]),
        });

        *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

        //branch 中 Rc<Node> 的强引用计数为 1,弱引用计数为 1
        println!(
            "branch strong = {}, weak = {}",
            Rc::strong_count(&branch),
            Rc::weak_count(&branch),
        );

        //leaf 的强引用计数为 2,弱引用计数仍然为 0
        println!(
            "leaf strong = {}, weak = {}",
            Rc::strong_count(&leaf),
            Rc::weak_count(&leaf),
        );
    }

    //leaf 中 Rc<Node> 的强引用计数为 1,弱引用计数为 0
    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
    println!(
        "leaf strong = {}, weak = {}",
        Rc::strong_count(&leaf),
        Rc::weak_count(&leaf),
    );
}
posted @ 2024-02-23 16:16  二次元攻城狮  阅读(127)  评论(0编辑  收藏  举报