智能指针:从所有权看智能指针

你好,我是Mike。从今天开始,我们进入Rust进阶篇。

相对于基础篇,进阶篇更像外功招式,主要是掌握一些实用的基础设施,提高编程效率。这节课我们就在所有权视角下来学习Rust中的智能指针。

智能指针

学习智能指针之前,我们先来了解一下指针是什么。

指针和指针的类型

如果一个变量,里面存的是另一个变量在内存里的地址值,那么这个变量就被叫做 指针。而我们前面讲到的引用(用&号表示)就是一种指针。

引用是必定有效的指针,它一定指向一个目前有效(比如没有被释放掉)的类型实例。而指针不一定是引用。也就是说,在Rust中,还有一些其他类型的指针存在,我们这节课就来学习其中一些。

我们这里要再次明晰一下 引用的类型。引用分为不同的类型,单独的&符号本身没有什么意义,但是它和其他类型组合起来就能形成各种各样的引用类型。比如:

  • &str 是字符串切片引用类型。
  • &String 是所有权字符串的引用类型。
  • &u32 是u32的引用类型。

注:&str、&String、&u32都是一个整体。

这三种都是引用类型,作为引用类型,它们之间是不同的。但是同一种引用类型的实例,比如 &10u32和&20u32,它们的类型是相同的。

那么,指针其实也类似,指向不同类型实例的指针,它的类型也是有区别的,这叫做 指针的类型

智能指针

Rust中指针的概念非常灵活,比如,它可以是一个结构体类型,只要其中的一个字段存储其他类型实例的地址,然后对这个结构体实现一些Rust标准库里提供的trait,就可以把它变成指针类型。这种指针可以在传统指针的基础上添加一些额外信息,比如放在额外的一些字段中;也可以做一些额外操作,比如管理引用计数,资源自动回收等。从而显得更加智能,所以被叫做 智能指针

其实,我们前面碰到的 String 和 Vec<T> 就是一种智能指针。我们来看标准库代码中 String 的定义和 Vec<T> 的定义。

pub struct String {
    vec: Vec<u8>,
}

pub struct Vec<T, #[unstable(feature = "allocator_api", issue = "32838")] A: Allocator = Global> {
    buf: RawVec<T, A>,
    len: usize,
}

通过代码我们可以看到,String和 Vec<T> 实际都定义为结构体。

注:Rust中智能指针的概念也直接来自于C++。C++里面有 unique_ptrshared_ptr

智能指针可以让代码的开发相对来说容易一些。经过前面的学习,我们知道Rust基于所有权出发,定义了一套完整的所有权和借用规则。很多我们习以为常的代码写法,在Rust中变成了“违法”,这导致很多人觉得学习Rust的门槛很高。而智能指针可以在某些方面降低这种门槛。

它如何能做到呢?我们稍后揭秘。

前面我们看到过这种代码:

fn foo() -> u32 {
    let i = 100u32;
    i
}
fn main() {
    let _i = foo();
}

我们看到, foo() 函数将i返回用的不是move行为,而是copy行为,将100u32这个值复制了一份,返回给外面的_i。 foo() 函数调用结束后, foo() 里的局部变量i被销毁。

我们再回忆一下另一段代码。

fn foo() -> String {
    let s = "abc".to_string();
    s
}
fn main() {
    let _s = foo();
}

上述代码可以在函数 foo() 里生成一个字符串实例,这个字符串实例资源在堆内存中分配,s是foo函数里的局部变量,拥有字符串资源的所有权。在代码的最后一行,s把所有权返回给外部调用者并传递给_s。 foo() 调用完成后,栈上的局部变量s被销毁。

这种写法可行是因为返回了资源的所有权。如果我们把代码里的String换成&String,把s换成&s就不行了。

fn foo() -> &String {
    let s = "abc".to_string();
    &s
}
fn main() {
    let _s = foo();
}

你可能会问,既然String资源本身是在堆中,为什么我们不能拿到这个资源的引用而返回呢?

我们来看看为什么不行。在 foo() 函数里,其实我们返回的并不是那个堆里字符串资源的引用,而是栈上局部变量s的引用。堆里的字符串资源由栈上的变量s管理,而s在 foo() 函数调用完成后,就被销毁了,堆里的字符串资源也一并被回收了,所以刚刚那段代码当然行不通了。

同样的,下面这段代码也是不允许的。

fn foo() -> &u32 {
    let i = 100u32;
    &i
}
fn main() {
    let _i = foo();
}

那么,我们有什么办法能让这种意图变得可行呢?其实是有的,比如使用 Box<T> 智能指针。

Box<T>

Box<T> 是一个类型整体,作为智能指针 Box<T> 可以把资源强行创建在堆上,并获得资源的所有权,让资源的生命期得以被程序员精确地控制。

注:堆上的资源,默认与整个程序进程的存在时间一样久。

我们来看使用 Box<T> 如何处理前面那个示例。

fn foo() -> Box<u32> {
    let i = 100u32;
    Box::new(i)
}
fn main() {
    let _i = foo();
}

通过Box,我们把栈上 i 的值,强行copy了一份并放在堆上某个地址,然后Box指针指向这个地址。

返回一个整数 i 的指针确实没多大用,如果我们定义了一个结构体,可以采用类似的办法从函数中返回结构体的Box指针。

struct Point {
    x: u32,
    y: u32
}

fn foo() -> Box<Point> {
    let p = Point {x: 10, y: 20};  // 这个结构体的实例创建在栈上
    Box::new(p)
}
fn main() {
    let _p = foo();
}

这就很有用了。

我们看示例的第7行,Point 的实例 p 实际是创建在栈上的。通过 Box::new(p),把p实例强行按位复制了一份,并且放到了堆上,我们记为 p’。然后 foo() 函数返回,把Box指针实例move给了_p。之后,_p拥有了对 p’ 的所有权。

Box<T> 中的所有权分析

我们继续深入,回顾一下上面示例里讲到的,编译期间已知尺寸的类型实例会默认创建在栈上。Point有两个字段:x、y,它们的尺寸是固定的,都是4个字节,所以Point的尺寸就是8个字节,它的尺寸也是固定的。所以它的实例会被创建在栈上。第7行的 p 拥有这个Point实例的所有权。注意Point并没有默认实现Copy,虽然它的尺寸是固定的。

在创建 Box<Point> 实例的时候会发生所有权转移:资源从栈上move到了堆上,原来栈上的那片资源被置为无效状态,因此下面的代码编译不会通过。

struct Point {
    x: u32,
    y: u32
}

fn foo() -> Box<Point> {
    let p = Point {x: 10, y: 20};
    let boxed = Box::new(p);  // 创建Box实例
    let q = p;                // 这一句用来检查p有没有被move走
    boxed
}
fn main() {
    let _p = foo();
}

编译提示:

error[E0382]: use of moved value: `p`
 --> src/main.rs:9:13
  |
7 |     let p = Point {x: 10, y: 20};
  |         - move occurs because `p` has type `Point`, which does not implement the `Copy` trait
8 |     let boxed = Box::new(p);     // 创建Box实例
  |                          - value moved here
9 |     let q = p;        // 这一句用来检查p有没有被move走
  |             ^ value used here after move

之所以会发生所有权这样的转移,是因为 Point 类型本身就是 move 语义的。作为对照,我们来看一个示例。

fn foo() -> Box<u8> {
    let i = 5;
    let boxed = Box::new(i);  // 创建Box实例
    let q = i;                // 这一句用来检查i有没有被move走
    boxed
}

fn main() {
    let _i = foo();
}

这个示例就可以编译通过。也就是说,在执行 Box::new() 创建 Box 实例时,具有copy语义的整数类型和具有move语义的Point类型行为不一样。整数会copy一份自己,Point实例会把自己move到Box里面去。

一旦创建好Box实例后,这个实例就具有了对里面资源的所有权了,它是move语义的,你可以看一下示例。

fn foo() -> Box<u8> {
    let i = 5;
    let boxed = Box::new(i);    // 创建Box实例
    let q = i;                  // 这一句用来检查i有没有被move走
    let boxed2 = boxed;         // 这一句检查boxed实例是不是move语义
    boxed
}

fn main() {
    let _i = foo();
}

不能编译通过,提示:

error[E0382]: use of moved value: `boxed`
 --> src/main.rs:6:5
  |
3 |     let boxed = Box::new(i);    // 创建Box实例
  |         ----- move occurs because `boxed` has type `Box<u8>`, which does not implement the `Copy` trait
4 |     let q = i;                  // 这一句用来检查i有没有被move走
5 |     let boxed2 = boxed;         // 这一句检查boxed实例是不是move语义
  |                  ----- value moved here
6 |     boxed
  |     ^^^^^ value used here after move

这个示例就验证了我们刚才的说法。

Box<T> 的解引用

前面我们讲过,创建一个Box实例把栈上的内容包起来,可以把栈上的值移动到堆上,比如:

let val: u8 = 5;
let boxed: Box<u8> = Box::new(val); // 这里 boxed 里面那个u8就是堆上的值

还可以在Box实例上使用 解引用符号*,把里面的堆上的值再次移动回栈上,比如:

let boxed: Box<u8> = Box::new(5);
let val: u8 = *boxed;    // 这里这个val整数实例就是在栈上的值

解引用是 Box::new()逆操作,可以看到整个过程是相反的。

对于具有copy语义的u8类型来说,解引用回来后,boxed还能使用,我们看下示例。

fn main() {
    let boxed: Box<u8> = Box::new(5);
    let val: u8 = *boxed;

    println!("{:?}", val);
    println!("{:?}", boxed);  // 用于u8类型,解引用后,boxed实例还能用
}
// 输出
5
5

而对于具有move语义的类型来说,情况就不一样了,会发生所有权的转移。比如:

#[derive(Debug)]
struct Point {
    x: u32,
    y: u32
}

fn main() {
    let p = Point {x: 10, y: 20};
    let boxed: Box<Point> = Box::new(p);
    let val: Point = *boxed;  // 这里做了解引用,Point实例回到栈上

    println!("{:?}", val);
    println!("{:?}", boxed);  // 解引用后想把boxed再打印出来
}

编译出错,提示 *boxed 已经 move了。

error[E0382]: borrow of moved value: `boxed`
  --> src/main.rs:13:22
   |
10 |     let val: Point = *boxed;
   |                      ------ value moved here
...
13 |     println!("{:?}", boxed);
   |                      ^^^^^ value borrowed here after move

因此 boxed 不能再使用。也就是说,如果 Box<T> 的 T 是 move 语义的,那么对这个Box实例做解引用操作,会把这个Box实例的所有权释放。

关于这些细节,其实你不用太担心能不能一次性掌握好,因为你用错的时候,Rustc小助手会贴心地准确提示你,所以不要有心理负担。

Box<T> 实现了trait

Box<T> 的好处在于它的明确性,它里面的资源一定在堆上,所以我们就不用再去关心资源是在栈上还是堆上这种细节问题了。一种类型,被 Box<> 包起来的过程就叫作这个类型的 盒化(boxed)。

Rust在标准库里为 Box<T> 实现了 DerefDropAsRef<T> 等trait,所以 Box<T> 可以直接调用T实例的方法,访问T实例的值。

#[derive(Debug)]
struct Point {
    x: u32,
    y: u32,
}

impl Point {
    fn play(&self) {
        println!("I'am a method of Point.");
    }
}

fn main() {
    let boxed: Box<Point> = Box::new(Point{x: 10, y: 20});
    boxed.play();    // 点操作符触发deref
    println!("{:?}", boxed);
}
// 输出
I'am a method of Point.
Point { x: 10, y: 20 }

Box<T> 拥有对T实例的所有权,所以可以对T实例进行写操作。

#[derive(Debug)]
struct Point {
    x: u32,
    y: u32,
}

fn main() {
    let mut boxed: Box<Point> = Box::new(Point{x: 10, y: 20});
    *boxed = Point {    // 这一行,使用解引用操作更新值
        x: 100,
        y: 200
    };
    println!("{:?}", boxed);
}
// 输出
Point { x: 100, y: 200 }

Box<T> 的Clone

Box<T> 能否Clone,需要看 T 是否实现了 Clone,因为我们也需要把 T 的资源克隆一份。你可以看一下我给出的示例。

#[derive(Debug, Clone)]
struct Point {
    x: u32,
    y: u32,
}

impl Point {
    fn play(&self) {
        println!("I'am a method of Point.");
    }
}

fn main() {
    let mut boxed: Box<Point> = Box::new(Point{x: 10, y: 20});
    let mut another_boxed = boxed.clone();   // 克隆
    *another_boxed = Point{x: 100, y: 200};  // 修改新的一份值
    println!("{:?}", boxed);                 // 打印原来一份值
    println!("{:?}", another_boxed);         // 打印新的一份值
}
// 输出
Point { x: 10, y: 20 }
Point { x: 100, y: 200 }

Box<T> 作为函数参数

我们可以把 Box<T> 作为参数传入函数,这个我们前面已经见过了。

#[derive(Debug)]
struct Point {
    x: u32,
    y: u32,
}

fn foo(p: Box<Point>) {    // 这里参数类型是 Box<Point>
    println!("{:?}", p);
}

fn main() {
    foo(Box::new(Point {x: 10, y: 20}));
}
// 输出
Point { x: 10, y: 20 }

&Box<T>

Box<T> 本身作为一种类型,对它做引用操作当然是可以的。

#[derive(Debug)]
struct Point {
    x: u32,
    y: u32,
}

impl Point {
    fn play(&self) {
        println!("I'am a method of Point.");
    }
}

fn main() {
    let boxed: Box<Point> = Box::new(Point{x: 10, y: 20});
    boxed.play();        // 调用类型方法
    let y = &boxed;      // 取boxed实例的引用
    y.play();            // 调用类型方法
    println!("{:?}", y);
}
// 输出
I'am a method of Point.
I'am a method of Point.
Point { x: 10, y: 20 }

在示例中,boxed是一个所有权型变量,y是一个引用型变量。它们都能调用到Point类型上的方法。

对Box实例做可变引用(&mut)也是可以的,你可以看一下示例。

#[derive(Debug)]
struct Point {
    x: u32,
    y: u32,
}

impl Point {
    fn play(&self) {
        println!("I'am a method of Point.");
    }
}

fn main() {
    let mut boxed: Box<Point> = Box::new(Point{x: 10, y: 20});
    let y = &mut boxed;     // 这里&mut Box<Point>
    y.play();               // 调用类型方法
    println!("{:?}", y);    // 修改前的值
    **y = Point {x: 100, y: 200};  // 注意这里用了二级解引用
    println!("{:?}", y);    // 修改后的值
}
// 输出
I'am a method of Point.
Point { x: 10, y: 20 }
Point { x: 100, y: 200 }

这个示例里值得注意的是第18行,做了两次解引用,第一次是对&mut 做的,第二次是对 Box<T> 做的。

Box<Self>

前面我们讲过,类型的方法可以用 self、&self、&mut self 三种形态传入Self参数。其中第一种self形态还有一种变体 Box<Self>,你可以看一下示例。

#[derive(Debug)]
struct Point {
    x: u32,
    y: u32,
}

impl Point {
    fn play_ref(&self) {
        println!("I'am play_ref of Point.");
    }
    fn play_mutref(&mut self) {
        println!("I'am play_mutref of Point.");
    }
    fn play_own(self) {
        println!("I'am play_own of Point.");
    }
    fn play_boxown(self: Box<Self>) {    // 注意这里
        println!("I'am play_boxown of Point.");
    }
}

fn main() {
    let mut boxed: Box<Point> = Box::new(Point{x: 10, y: 20});
    boxed.play_ref();
    boxed.play_mutref();
    boxed.play_boxown();
    // boxed.play_own();  // play_boxown()和 play_own() 只能同时打开一个
}

注意示例中, play_boxown()play_own() 只能同时打开一个,这是为什么呢?你思考一下。

结构体中的Box

Box<T> 作为类型,当然是可以出现在struct里的,你可以看一下示例。

struct Point {
    x: u32,
    y: u32,
}
struct Triangle {
    one: Box<Point>,    // 三个字段类型都是 Box<Point>
    two: Box<Point>,
    three: Box<Point>,
}
fn main() {
    let t = Triangle {
        one: Box::new(Point {
            x: 10,
            y: 10,
        }),
        two: Box::new(Point {
            x: 20,
            y: 20,
        }),
        three: Box::new(Point {
            x: 10,
            y: 20,
        }),
    };
}

Box<dyn trait>

回忆 第 10 讲 的trait object,它代表一种类型,这种类型可以代理一批其他的类型。但是 dyn trait 本身的尺寸在编译期是未知的,所以 dyn trait 的出现总是要借助于引用或智能指针。而 Box<dyn trait> 是最常见的,甚至比 &dyn trait 更常见。原因就是 Box<dyn Trait> 拥有所有权,这就是 Box<T> 方便的地方,而 &dyn Trait 不拥有所有权,有的时候就没那么方便。

我们来看使用 Box<dyn trait> 做函数参数的一个示例。

struct Atype;
struct Btype;
struct Ctype;

trait TraitA {}

impl TraitA for Atype {}
impl TraitA for Btype {}
impl TraitA for Ctype {}

fn doit(x: Box<dyn TraitA>) {}

fn main() {
    let a = Atype;
    doit(Box::new(a));
    let b = Btype;
    doit(Box::new(b));
    let c = Ctype;
    doit(Box::new(c));
}

这个示例里的 doit() 函数能接收 Atype、Btype、Ctype 三种不同类型的实例。

如果 dyn trait 出现在结构体里,那么 Box<dyn trait> 形式就比 &dyn trait 形式要方便得多。比如,下面示例里的结构体字段类型是 Box<dyn TraitA>,能正常编译。

struct Atype;
struct Btype;
struct Ctype;

trait TraitA {}

impl TraitA for Atype {}
impl TraitA for Btype {}
impl TraitA for Ctype {}

struct MyStruct {
    x: Box<dyn TraitA>  // 结构体的字段类型是 Box<dyn TraitA>
}

fn main() {
    let a = Atype;
    let t1 = MyStruct {x: Box::new(a)};
    let b = Btype;
    let t2 = MyStruct {x: Box::new(b)};
    let c = Ctype;
    let t3 = MyStruct {x: Box::new(c)};
}

而下面这个示例,结构体字段类型是 &dyn TraitA,就没办法通过编译。

struct Atype;
struct Btype;
struct Ctype;

trait TraitA {}

impl TraitA for Atype {}
impl TraitA for Btype {}
impl TraitA for Ctype {}

struct MyStruct {
    x: &dyn TraitA    // 结构体字段类型是 &dyn TraitA
}

报错如下:

error[E0106]: missing lifetime specifier
  --> src/lib.rs:12:8
   |
12 |     x: &dyn TraitA
   |        ^ expected named lifetime parameter
   |
help: consider introducing a named lifetime parameter
   |
11 ~ struct MyStruct<'a> {
12 ~     x: &'a dyn TraitA
   |

这个错误涉及到第20讲引用的生命期的概念,现在我们不去深究。

Box<T> 智能指针的内容就讲到这里,下面我们看另一种智能指针 Arc<T>

Arc<T>

Box<T> 是单所有权或独占所有权模型的智能指针,而 Arc<T> 是共享所有权模型的智能指针,也就是多个变量可以同时拥有一个资源的所有权。和 Box<T> 一样, Arc<T> 也会保证被包装的内容被分配在堆上。

clone

Arc的主要功能是和 clone() 配合使用。在Arc实例上每一次新的 clone() 操作,总是会将资源的引用数+1,而保持原来那一份资源不动,这个信息记录在Arc实例里面。每一个指向同一个资源的Arc实例走出作用域,就会给这个引用计数-1。直到最后一个Arc实例消失,目标资源才会被销毁释放。你可以看一下示例。

use std::sync::Arc;

#[derive(Debug)]    // 这里不需要目标type实现Clone trait
struct Point {
    x: u32,
    y: u32,
}

impl Point {
    fn play(&self) {
        println!("I'am a method of Point.");
    }
}

fn main() {
    let arced: Arc<Point> = Arc::new(Point{x: 10, y: 20});
    let another_arced = arced.clone();      // 克隆引用
    println!("{:?}", arced);                // 打印一份值
    println!("{:?}", another_arced);        // 打印同一份值
    arced.play();
    another_arced.play();
    let arc3_ref = &another_arced;
    arc3_ref.play();
}
// 输出
Point { x: 10, y: 20 }
Point { x: 10, y: 20 }
I'am a method of Point.
I'am a method of Point.
I'am a method of Point.

我们可以看到,相比于 Box<T>Arc<T> 的clone不要求T实现了Clone trait。 Arc<T> 的克隆行为只会改变Arc的引用计数,而不会克隆里面的内容。由于不需要克隆原始资源,所以性能是很高的。

类似于 Box<T>Arc<T> 也实现了Deref、Drop、Clone等trait。因此, Arc<T> 也可以符合人类的习惯,访问到里面类型T的方法。 Arc<T> 的不可变引用 &Arc<> 也可以顺利调用到T上的方法。

Arc

Box<T> 一样,Arc也可以用在方法中的self参数上面,作为所有权self的一个变体形式。

我们继续扩展上面的代码,你可以看一下扩展后的样子。

use std::sync::Arc;

#[derive(Debug)]
struct Point {
    x: u32,
    y: u32,
}

impl Point {
    fn play_ref(&self) {
        println!("I'am play_ref of Point.");
    }
    fn play_mutref(&mut self) {
        println!("I'am play_mutref of Point.");
    }
    fn play_own(self) {
        println!("I'am play_own of Point.");
    }
    fn play_boxown(self: Box<Self>) {    // 注意这里
        println!("I'am play_boxown of Point.");
    }
    fn play_arcown(self: Arc<Self>) {    // 注意这里
        println!("I'am play_arcown of Point.");
    }
}

fn main() {
    let mut boxed: Box<Point> = Box::new(Point{x: 10, y: 20});
    boxed.play_ref();
    boxed.play_mutref();
    boxed.play_boxown();
    // boxed.play_own();  // play_boxown()和 play_own() 只能同时打开一个

    let arced: Arc<Point> = Arc::new(Point{x: 10, y: 20});
    arced.play_ref();
    // arced.play_mutref();  // 不能用
    // arced.play_own();     // 不能用,Arc<T> 中的T无法被移出
    arced.play_arcown();
}
// 输出
I'am play_ref of Point.
I'am play_mutref of Point.
I'am play_boxown of Point.
I'am play_ref of Point.
I'am play_arcown of Point.

通过这个示例我们可以看到,不能通过 Arc<> 直接修改里面类型的值,也不能像 Box<> 的解引用操作那样,把里面的内容从 Arc<> 中移动出来。你可以试着打开示例里注释掉的几行看看Rustc小助手的提示信息。

Arc<dyn trait>

我们还可以把前面 Box<dyn trait> 的示例改编成 Arc<T> 的。

use std::sync::Arc;

struct Atype;
struct Btype;
struct Ctype;

trait TraitA {}

impl TraitA for Atype {}
impl TraitA for Btype {}
impl TraitA for Ctype {}

struct MyStruct {
    x: Arc<dyn TraitA>
}

fn main() {
    let a = Atype;
    let t1 = MyStruct {x: Arc::new(a)};
    let b = Btype;
    let t2 = MyStruct {x: Arc::new(b)};
    let c = Ctype;
    let t3 = MyStruct {x: Arc::new(c)};
}

值的修改

多所有权条件下,怎么修改Arc里面的值呢?答案是不能修改。虽然 Arc<T> 是拥有所有权的,但 Arc<T> 不提供修改T的能力,这也是 Arc<T>Box<T> 不一样的地方。后面我们在并发编程部分会讲到Mutex、RwLock等锁。想要修改Arc里面的内容,必须配合这些锁才能完成,比如 Arc<Mutex<T>>

其实很好理解,共享所有权的场景下,如果任意一方能随意修改被包裹的值,那就会影响其他所有权的持有者,整个就乱套了。所以要修改的话必须引入锁的机制。

Arc<T> 与不可变引用&的区别

首先,它们都是共享对象的行为,本质上都是指针。但 Arc<T> 是共享了所有权模型,而&只是共享借用模型。共享借用模型就得遵循借用检查器的规则——借用的有效性依赖于被借用资源的scope。对于这个的分析是非常复杂的。而所有权模型是由自己来管理资源的scope,所以处理起来比较方便。

小结

这节课我们一起学习了最常用的两个智能指针: Box<T>Arc<T>。其实Rust里还有很多智能指针,比如 Rc、Cell、RefCell 等等,每一种智能指针类型都有自己的特点。但是不管怎样,学习的方法都是一样的,那就是从所有权的视角去分析研究。你可以在后面遇到那些类型的时候,再根据这节课提供的方法去研究它们。

后面我们会看到,在智能指针的加持下,Rust代码写起来会非常流畅,可以和Java不相上下。再结合Rust强大的类型系统建模能力,等你写得熟练之后,在中大项目中,使用Rust甚至会有超越Python的开发效率。

思考题

你试着打开示例中的这两句,看看报错信息,然后分析一下是为什么?

    // arced.play_mutref();  // 不能用
    // arced.play_own();     // 不能用

欢迎你把自己的分析分享到评论区,也欢迎你把这节课的内容分享给其他朋友,我们下节课再见!