小言_互联网的博客

Rust中,常会用到的3种指针有哪些?

354人阅读  评论(0)

如果我们的讨论中没有包含指针,那么关于内存管理的介绍是不完整的,因为它是任何低级语言操作内存的主要方式。指针只是指向进程地址空间中内存位置的变量。在Rust中,我们主要会用到3种指针。

5.8.1 引用—— 安全的指针

在介绍借用时已经详细阐述了这类指针。引用类似于C语言中的指针,但同时会检查它们的正确性。它们永远不会为空值,并且指向拥有某些数据的变量。它们指向的数据既可以位于堆上,也可以位于堆栈上,或者位于二进制文件的数据段中。它们是通过&或者&mut运算符创建的。该运算符作为类型T的前缀时,会创建一个引用,&T表示不可变引用,&mut T表示可变引用。让我们重温一下这些内容。

  • &T:它是对类型T的不可变引用。&T指针就是一种Copy类型,这只是意味着你可以对类型T进行多次不可变引用。如果你将其赋给另一个变量,那么将得到一个指针的副本,指向相同的数据。这对于指向引用的引用也一样,例如&&T。
  • &mut T:它是对类型T的可变引用。在任意作用域内部,根据借用规则,你不能对类型T进行两次可变引用。这意味着&mut T类型没有实现Copy特征。它们也无法发送到线程。

5.8.2 原始指针

这类指针拥有一个比较奇怪的类型签名,其前缀为*,这也恰好是解引用运算符。它们主要用于不安全代码中。人们需要一个不安全的代码块来解引用它们。Rust中有两种原始指针。

  • *const T:表示指向类型T的不可变原始指针。它是Copy类型。这类似于&T,只是它可以为空值。
  • *mut T:一个指向T的可变原始指针,它不支持Copy特征(non-Copy)。

需要补充说明的是,可以将引用强制转换为原始指针,如以下代码所示:


  
  1. let a = & 56;
  2. let a_raw_ptr = a as * const u32;
  3. // or
  4. let b = & mut 5634.3;
  5. let b_mut_ptr = b as * mut T;

不过我们不能将&T转换为*mut T,因为这违反了只允许进行一次可变借用的借用规则。

对于可变引用,我们可以将它们转换为*mut T甚至*const T,这被称为指针弱化(我们将更强大的指针&mut T转换成功能较弱的*const T指针)。对于不可变引用,我们只能将它们转换为* const T。

但是解引用原始指针是一种不安全的操作。当我们学习第10章时,将会了解如何使用原始指针。

5.8.3 智能指针

管理原始指针非常不安全,开发者在使用它们时需要注意很多细节。不恰当地使用它们可能会以非常隐蔽的方式导致诸如内存泄漏、引用挂起,以及大型代码库中的双重释放等问题。为了解决这些问题,我们可以使用C++中广泛采用的智能指针。

Rust中也包含多种智能指针。之所以叫它们智能指针,是因为它们还具有与之相关联的额外元数据和代码,它们会在创建和销毁指针时被调用和执行。智能指针超出作用域时能够自动释放底层资源是采用它们的主要原因之一。

智能指针的大部分特性要归功于两个特征,它们被称为Drop和Deref。在我们介绍Rust中可用的智能指针之前,让我们先了解一下这些特征。

Drop

这是我们多次提及的特征,它可以自动释放相关值超出作用域后占用的资源。Drop特征类似于你在其他语言中遇到的被称为对象析构函数的东西。它包含一个drop方法,当对象超出作用域时就会被调用。该方法将&mut self作为参数。使用drop释放值是以LIFO的方式进行的。也就是说,无论最后构建的是什么,都首先会被销毁。以下代码说明了这一点:


  
  1. // drop.rs
  2. struct Character {
  3. name: String,
  4. }
  5. impl Drop for Character {
  6. fn drop(& mut self) {
  7. println!( "{} went away", self.name)
  8. }
  9. }
  10. fn main() {
  11. let steve = Character {
  12. name: "Steve".into(),
  13. };
  14. let john = Character {
  15. name: "John".into(),
  16. };
  17. }

输出结果如下所示:

 

如果有需要,drop方法是你为自己的结构体放置清理代码的理想场所。例如使用引用计数值或GC时,它尤其方便。当我们实例化任何Drop实现值时(任意堆分配类型),Rust编译器会在编译后的代码中每个作用域结束的位置插入drop方法调用。因此,我们不需要在这些实例上手动调用drop方法。这种基于作用域的自动回收机制借鉴了C++ RAII原则的某些理念。

Deref和DerefMut

为了提供与普通指针类似的行为,也就是说,为了能够解引用被指向类型的调用方法,智能指针类型通常会实现Deref特征,这允许用户对这些类型使用解引用运算符*。虽然Deref只为你提供了只读权限,但是还有DerefMut,它可以为你提供对底层类型的可变引用。Deref具有以下类型签名:


  
  1. pub trait Deref {
  2. type Target: ? Sized;
  3. fn deref(& self) -> &Self::Target;
  4. }

它定义了一个名为Deref的方法,并会通过引用获取self参数,然后返回对底层类型的不可变引用。这与Rust的deref强制性特征结合,能够大幅减少开发者编写代码的工作量。deref强制性特征是指类型自动从一种类型的引用转换成另一种类型的其他引用。我们将第7章详细介绍它。

智能指针的种类

标准库中的智能指针有如下几种。

  • Box<T>:它提供了最简单的堆资源分配方式。Box类型拥有其中的值,并且可用于保存结构体中的值,或者从函数返回它们。
  • Rc<T>:它用于引用计数。每当获取新引用时,计数器会执行递增操作,并在用户释放引用时对计数器执行递减操作。当计数器的值为零时,该值将被移除。
  • Arc<T>:它用于原子引用计数。这与之前的类型类似,但具有原子性以保证多线程的安全性。
  • Cell<T>:它为我们提供实现了Copy特征的类型的内部可变性。换句话说,我们有可能获得多个可变引用。
  • RefCell<T>:它为我们提供了类型的内部可变性,并且不需要实现Copy特征。它用于运行时的锁定以确保安全性。

Box<T>

标准库中的泛型Box为我们提供了在堆上分配值的最简单方法。它在标准库中被简单地声明为元组结构体,然后包装任何传递给它的类型,并将其放在堆上。如果你熟悉其他语言中的装箱和拆箱概念,例如Java,其中会把装箱后的整数当作整型类,那么通常较易理解Rust提供的类似的抽象。Box类型的所有权语义取决于包装类型。如果基础类型为Copy,那么Box实例将成为副本,否则默认情况下将发生移动。

要使用Box创建类型T的堆分配值,我们只需调用相关的new方法,并传入值。创建包装类型T的Box值会返回Box实例,该实例是堆栈上指向T的指针,而上述指针在堆上分配。以下示例演示了如何使用Box:


  
  1. // box_basics.rs
  2. fn box_ref<T>(b: T) -> Box<T> {
  3. let a = b;
  4. Box::new(a)
  5. }
  6. struct Foo;
  7. fn main() {
  8. let boxed_one = Box::new(Foo);
  9. let unboxed_one = *boxed_one;
  10. box_ref(unboxed_one);
  11. }

在main函数中,我们通过在boxed_one函数中调用Box::new(Foo)创建了一个堆分配值。

Box类型适用于以下情况。

  • 它可以用于创建递归类型定义。

这里有一个Node类型,它表示单链表中的节点:


  
  1. // recursive_type.rs
  2. struct Node {
  3. data: u32,
  4. next: Option<Node>
  5. }
  6. fn main() {
  7. let a = Node { data: 33, next: None };
  8. }

在编译上述代码时,我们得到以下错误提示:

 

我们不能这样定义Node类型,因为next有一个引用自身的类型。如果允许这样定义,编译器将无法分析我们的Node定义,因为编译器将持续对它进行评估计算,直到内存耗尽为止。使用以下代码片段能够更好地说明这一点:


  
  1. struct Node {
  2. data: u32,
  3. next: Some(Node {
  4. data: u32,
  5. next: Node {
  6. data: u32,
  7. next: ...
  8. }
  9. })
  10. }

对Node定义的评估将持续进行,直到编译器内存耗尽为止。此外,由于每个数据片段在编译时都需要确定静态的已知尺寸,因此在Rust中这是一种不可表示的类型。我们需要让next字段具有固定大小,可以通过将next放在指针后面来实现,因为指针总是具有固定大小。如果你看到编译器提供的上述错误消息,我们将使用Box类型来修改Node结构体的定义:


  
  1. struct Node {
  2. data: u32,
  3. next: Option< Box<Node>>
  4. }

当定义需要隐藏在不定长结构后面的递归类型时,也可以使用Box类型。因此,在这种情况下,包含对其自身引用的变体的枚举可以使用Box类型来隐藏变体。

  • 当你需要将类型存储为特征对象时。
  • 当你需要将函数存储在集合中时。

5.8.4 引用计数的智能指针

所有权规则只允许某个给定作用域中存在一个所有者。但是,在某些情况下你需要与多个变量共享类型。例如在GUI库中,每个子窗体小部件都需要具有对其父容器窗口小部件的引用,以便基于用户的resize事件来调整子窗口的布局。虽然有时生命周期允许你将父节点存储为&'a Parent,但是它通常受到'a值生命周期的限制,一旦作用域结束,你的引用将失效。在这种情况下,我们需要更灵活的方法,并且需要使用引用计数类型。程序中的这些智能指针类型会提供值的共享所有权。

引用计数类型支持某个粒度级别的垃圾回收。在这种方法中,智能指针类型允许用户对包装值进行多次引用。在内部,智能指针使用引用计数器(这里是refcount)来统计已发放的并且活动的引用数量,不过它只是一个整数值。当引用包装的智能指针值的变量超出作用域时,refcount的值就会递减。一旦该对象的所有引用都消失,refcount的值也会变成0,之后该值会被销毁。这就是引用计数指针的常见工作模式。

Rust为我们提供了两种引用计数指针类型。

  • Rc<T>:这主要用于单线程环境。
  • Arc<T>:这主要用于多线程环境。

让我们先探讨一下单线程的引用。多线程将在第8章详细介绍。

Rc<T>

当我们与一个Rc类型交互时,其内部会发生如下变化。

  • 当你通过调用Clone()获取对Rc的一个新共享引用时,Rc会增加其内部引用计数。Rc内部使用Cell类型处理其引用计数。
  • 当引用超出作用域时,它会对引用计数器执行递减操作。
  • 当所有共享引用计数超出作用域时,refcount会变成0。此时,Rc上的最后一次drop调用会执行相关的资源清理工作。

使用引用计数器为我们的实现提供了更大的灵活性:我们可以将值的副本分发为新的副本,而无须精确跟踪引用何时超出作用域,但这并不意味着我们可以对内部的值指定可变别名。

Rc<T>主要通过两种方式使用。

  • 静态方法Rc::new会生成一个新的引用计数器。
  • clone方法会增加强引用计数并分发一个新的Rc<T>。

Rc内部会保留两种引用:强引用(Rc<T>)和弱引用(Weak<T>)。二者都会维护每种类型的引用数量的计数,但是仅在强引用计数值为零时,才会释放该值。这样做的目的是数据结构的实现可能需要多次指向同一事物。例如,树的实现可能包含若干子节点和其父节点的引用,但是为每个引用递增引用计数器可能会导致循环引用。下图说明了循环引用的情况:

 

在上图中,我们有两个变量var1和var2,它们分别引用资源Obj1和Obj2。除此之外,Obj1还引用了Obj2,而Obj2也引用了Obj1。Obj1和Obj2的引用计数均为2,当var1和var2超出作用域时,Obj1和Obj2的引用计数都会变成1。它们不会被释放,因为它们仍然存在相互引用。

可以使用弱引用打破引用循环。作为另一个示例,链表可以通过如下方式实现,即它通过将引用计数分别指向下一个元素和上一个元素的方式来维护链接。更好的方法是对一个方向使用强引用,而对另一个方向使用弱引用。

让我们看一下它是如何工作的。下面可能是最不实用,却是学习数据结构的最佳材料,即单链表:


  
  1. // linked_list.rs
  2. use std::rc::Rc;
  3. #[derive(Debug)]
  4. struct LinkedList<T> {
  5. head: Option<Rc<Node<T>>>
  6. }
  7. #[derive(Debug)]
  8. struct Node<T> {
  9. next: Option<Rc<Node<T>>>,
  10. data: T
  11. }
  12. impl<T> LinkedList<T> {
  13. fn new() -> Self {
  14. LinkedList { head: None }
  15. }
  16. fn append(& self, data: T) -> Self {
  17. LinkedList {
  18. head: Some(Rc::new(Node {
  19. data: data,
  20. next: self.head.clone()
  21. }))
  22. }
  23. }
  24. }
  25. fn main() {
  26. let list_of_nums = LinkedList::new().append( 1).append( 2);
  27. println!( "nums: {:?}", list_of_nums);
  28. let list_of_strs = LinkedList::new().append( "foo").append( "bar");
  29. println!( "strs: {:?}", list_of_strs);
  30. }

链表由两种结构组成:LinkedList提供对列表的第一个元素的引用和公共API,Node包含实际的元素。注意思考我们如何使用Rc,并复制每个append方法调用后的下一个数据指针。让我们看看在append方法中发生了什么。

1.LinkedList::new()为我们生成一个新的列表,其中head的值为None。

2.我们将1附加到列表中,head现在是包含数据1的节点,下一个元素是head:None。

3.我们将2附加到列表中,head现在是包含数据2的节点,下一个元素是之前的头节点,即包含数据1的节点。

来自println!宏的调试信息证明了这一点:


  
  1. nums: LinkedList { head: Some(Node { next: Some(Node { next: None, data: 1
  2. }), data: 2 }) }
  3. strs: LinkedList { head: Some(Node { next: Some(Node { next: None, data:
  4. "foo" }), data: "bar" }) }

这是一种相当实用的结构体应用形式,每个append方法通过将数据添加到头部的方式运行,这意味着我们不必使用引用和实际的列表引用就可以确保不变性。如果我们想要保持这个结构体的简单性,但是仍然拥有一个双链表,就需要改变一下现有的结构。

可以使用downgrade方法将一个Rc<T>类型转换成一个Weak<T>类型。类似地,可以使用upgrade方法将一个Weak<T>类型转换成一个R<T>类型。downgrade方法将始终有效,而在弱引用上调用upgrade方法时,实际的值可能已经被删除,在这种情况下,你将获得的值是None。所以,让我们添加一个指向上一个节点的弱指针:


  
  1. // rc_weak.rs
  2. use std::rc::Rc;
  3. use std::rc::Weak;
  4. #[derive(Debug)]
  5. struct LinkedList<T> {
  6. head: Option<Rc<LinkedListNode<T>>>
  7. }
  8. #[derive(Debug)]
  9. struct LinkedListNode<T> {
  10. next: Option<Rc<LinkedListNode<T>>>,
  11. prev: Option<Weak<LinkedListNode<T>>>,
  12. data: T
  13. }
  14. impl<T> LinkedList<T> {
  15. fn new() -> Self {
  16. LinkedList { head: None }
  17. }
  18. fn append(& mut self, data: T) -> Self {
  19. let new_node = Rc::new(LinkedListNode {
  20. data: data,
  21. next: self.head.clone(),
  22. prev: None
  23. });
  24. match self.head.clone() {
  25. Some(node) => {
  26. node.prev = Some(Rc::downgrade(&new_node));
  27. },
  28. None => {
  29. }
  30. }
  31. LinkedList {
  32. head: Some(new_node)
  33. }
  34. }
  35. }
  36. fn main() {
  37. let list_of_nums = LinkedList::new().append( 1).append( 2).append( 3);
  38. println!( "nums: {:?}", list_of_nums);
  39. }

append方法中的内容增加了一些,现在我们需要在返回新创建的头节点之前更新当前头节点的前一个节点。这已经足够好,但并不完备。编译器不允许我们执行无效操作:

 

我们可以让append接收一个指向self的可变引用,但这意味着如果所有节点的绑定是可变的,那么只能在附加元素时强制要求整个结构体都是可变的。我们真正想要的是这样一种方法,只让结构体的某个部分可变,幸运的是我们可以通过RefCell来做到这一点。

1.为RefCell添加use引用:

use std::cell::RefCell;

2.将之前在LinkedListNode中的字段包装到RefCell中:


  
  1. // rc_3.rs
  2. #[derive(Debug)]
  3. struct LinkedListNode< T> {
  4. next: Option<Rc<LinkedListNode< T>>>,
  5. prev: RefCell<Option<Weak<LinkedListNode< T>>>>,
  6. data: T
  7. }

3.我们修改了append方法以创建新的RefCell,并通过RefCell可变借用更新之前的引用:


  
  1. // rc_3.rs
  2. fn append(& mut self, data: T) -> Self {
  3. let new_node = Rc::new(LinkedListNode {
  4. data: data,
  5. next: self.head. Clone(),
  6. prev: RefCell::new( None)
  7. });
  8. match self.head. Clone() {
  9. Some(node) => {
  10. let mut prev = node.prev.borrow_mut();
  11. *prev = Some(Rc::downgrade(&new_node));
  12. },
  13. None => {
  14. }
  15. }
  16. LinkedList {
  17. head: Some(new_node)
  18. }
  19. }

每当我们使用RefCell借用时,仔细考虑以安全的方式使用它是一个好习惯,因为它出错可能会导致运行时异常。不过在这个实现中,很容易看出我们只采用了单个借用,并且代码运行结束后会立即丢弃。

除了共享所有权之外,我们还可以通过Rust的内部可变性,在运行时获得共享可变性,这些概念由特殊的包装器智能指针类型建模。

内部可变性

如前所述,Rust通过在任何给定作用域中仅允许一个可变引用,从而在编译时保证我们免受指针别名的影响。但是,在某些情况下,它会变得非常严格,因为严格的借用检查使我们知道由于代码的安全性而不能通过编译器的编译。对于这种情况,其中一个解决方法是将借用检查从编译时移动到运行时,这是通过内部可变性实现的。在讨论能够实现内部可变性的类型之前,我们需要了解内部可变性和继承可变性的概念。

  • 继承可变性:这是获得某些结构体的&mut引用时默认取得的可变性。这也意味着你可以修改结构体中的任意字段。
  • 内部可变性:在这种可变性中,即使你有一个引用某种类型的&SomeStruct,如果其中的字段类型为Cell<T>或RefCell<T>,那么仍然可以修改其字段。

内部可变性允许稍微放宽借用规则的限制,但是它也给程序员提出一些要求,从而确保在运行时不存在两个可变借用。这些类型将多个可变借用的检测从编译时移动到了运行时,如果存在对值的两个可变借用,就会发生异常。当你希望向用户公开不可变API时,通常会遇到内部可变性,不过上述API内部存在部分可变性。标准库中有两个通用的智能指针类型提供了共享可变性:Cell和RefCell。

Cell<T>

考虑如下程序,我们需要使用两个可变引用来修改bag中的内容:


  
  1. // without_cell.rs
  2. use std::cell::Cell;
  3. #[derive(Debug)]
  4. struct Bag {
  5. item: Box< u32>
  6. }
  7. fn main() {
  8. let mut bag = Cell::new(Bag { item: Box::new( 1) });
  9. let hand1 = & mut bag;
  10. let hand2 = & mut bag;
  11. *hand1 = Cell::new(Bag {item: Box::new( 2)});
  12. *hand2 = Cell::new(Bag {item: Box::new( 2)});
  13. }

不过由于借用规则的限制,上述代码不会被编译:

 

我们可以通过将bag的值封装到Cell中来让它正常运转。上述代码修改之后如下所示:


  
  1. // cell.rs
  2. use std::cell::Cell;
  3. #[derive(Debug)]
  4. struct Bag {
  5. item: Box< u32>
  6. }
  7. fn main() {
  8. let bag = Cell::new(Bag { item: Box::new( 1) });
  9. let hand1 = &bag;
  10. let hand2 = &bag;
  11. hand1.set(Bag { item: Box::new( 2)});
  12. hand2.set(Bag { item: Box::new( 3)});
  13. }

上述代码能够按照预期运行,唯一增加的成本是需要你多写一点代码。但是,额外的运行时成本为零,且对可变事物的引用仍然是不可变的。

Cell<T>类型是一种智能指针类型,可以为值提供可变性,甚至允许值位于不可引用之后。它以极低的开销提供此功能,并具有最简洁的API。

  • Cell::new方法允许你通过传递任意类型T来创建Cell类型的新实例。
  • get:get方法允许你复制单元(cell)中的值。仅当包装类型T为Copy时,该方法才有效。
  • set:允许用户修改内部的值,即使该值位于某个不可变引用的后面。

RefCell<T>

如果你需要某个非Copy类型支持Cell的功能,那么可以使用RefCell类型。

它采用了和借用类似的读/写模式,但是将借用检查移动到了运行时,这很方便,但不是零成本的。RefCell分发值的引用不是像Cell类型那样按值返回。以下是一个示例程序:


  
  1. // refcell_basics.rs
  2. use std::cell::RefCell;
  3. #[derive(Debug)]
  4. struct Bag {
  5. item: Box< u32>
  6. }
  7. fn main() {
  8. let bag = RefCell::new(Bag { item: Box::new( 1) });
  9. let hand1 = &bag;
  10. let hand2 = &bag;
  11. *hand1.borrow_mut() = Bag { item: Box::new( 2)};
  12. *hand2.borrow_mut() = Bag { item: Box::new( 3)};
  13. let borrowed = hand1.borrow();
  14. println!( "{:?}", borrowed);
  15. }

如你所见,我们可以从hand1和hand2可变地借用bag,即使它们被声明为不可变变量。为了修改bag中的元素,我们在hand1和hand2上调用了borrow_mut方法。之后,我们对它进行了不可变借用,并将其中的内容输出。

RefCell类型为我们提供了以下两种借用方法。

  • 使用borrow方法会接收一个新的不可变引用。
  • 使用borrow_mut方法会接收一个新的可变引用。

现在,假如我们对上述代码中的最后一行进行修改,尝试在同一作用域中调用上述两种方法,如下所示:

println!("{:?} {:?}", hand1.borrow(), hand1.borrow_mut());

我们会在运行程序时得到如下信息:


  
  1. thread 'main' panicked at 'already borrowed: BorrowMutError',
  2. src /libcore/result. rs: 1009: 5
  3. note: Run with 'RUST_BACKTRACE=1' for a backtrace.

上述内容表示出现了运行时故障,这是因为独占可变访问具有相同的所有权规则。但是,对于RefCell,这会在运行时进行检查。对于这种情况,必须明确使用单独的代码块分隔借用,或者使用drop方法删除引用。

注意

Cell和RefCell类型不是线程安全(thread-safety)的。这意味着Rust不允许用户在多线程环境中共享这些类型。

5.8.5 内部可变性的应用

在5.8.4小节中,关于Cell和RefCell的应用示例已经经过简化,你可能并不需要在实际工作中以这种形式使用它们。让我们来看看这些类型在实际应用中的优点。

如前所述,绑定的可变性并不是细粒度的,值既可以是不可变的,也可以是可变的,并且如果它是结构体或枚举,那么它还将包括其所有字段。Cell和RefCell可以将不可变的内容转换成可变的,允许我们将不可变的结构体中的某个部分定义为可变的。

下列代码将使用两个整数和sum方法来扩展结构体,以缓存求和的结果,并返回缓存的值(如果存在):


  
  1. // cell_cache.rs
  2. use std::cell::Cell;
  3. struct Point {
  4. x: u8,
  5. y: u8,
  6. cached_sum: Cell< Option< u8>>
  7. }
  8. impl Point {
  9. fn sum(& self) -> u8 {
  10. match self.cached_sum.get() {
  11. Some(sum) => {
  12. println!( "Got from cache: {}", sum);
  13. sum
  14. },
  15. None => {
  16. let new_sum = self.x + self.y;
  17. self.cached_sum.set( Some(new_sum));
  18. println!( "Set cache: {}", new_sum);
  19. new_sum
  20. }
  21. }
  22. }
  23. }
  24. fn main() {
  25. let p = Point { x: 8, y: 9, cached_sum: Cell::new( None) };
  26. println!( "Summed result: {}", p.sum());
  27. println!( "Summed result: {}", p.sum());
  28. }

以下是上述程序的执行结果:

 


本文摘自《精通Rust 第2版》

 

Rust作为新时代编程语言中一颗新星,得到了越来越多开发者的追捧。

Rust为成为段位更高的开发者,的一块重要的敲门砖。

Rust作为一门多范式语言,支持函数式、命令式以及泛型等编程范式。Rust在语法上和C++类似,兼具快速、可靠、安全等优良特性,它提供了甚至超过C/C++的性能和安全保证,同时它也是一种学习曲线比较平滑的热门编程语言。

本书就是为想学习Rust编程的读者准备的,只要读者具备一定的编程基础,就可以通过本书全面地了解Rust特性和编程语言,并通过丰富的代码示例,夯实Rust的实用技能。


转载:https://blog.csdn.net/epubit17/article/details/113474178
查看评论
* 以上用户言论只代表其个人观点,不代表本网站的观点或立场