为什么我们需要Rc<T>,当不可变引用可以完成任务?

8
为了说明 Rc<T> 的必要性,该书 提供了以下片段(提示:它不会编译)来展示未使用 Rc<T> 时无法启用多重所有权。
enum List {
    Cons(i32, Box<List>),
    Nil,
}

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

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

它接着声称(重点在于)

我们可以将 Cons 的定义更改为保持引用,但那样我们就必须指定生命周期参数。通过指定生命周期参数,我们将指定列表中的每个元素至少与整个列表一样长寿。例如,借用检查器不会让我们编译 let a = Cons(10, &Nil);,因为临时的 Nil 值将在 a 取得对它的引用之前被丢弃。

嗯,不完全是这样的。以下代码片段在 rustc 1.52.1 下编译。

enum List<'a> {
    Cons(i32, &'a List<'a>),
    Nil,
}

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

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

注意,通过获取引用,我们不再需要 Box<T> 间接持有嵌套的 List。此外,我可以将 bc 都指向 a,这使得 a 具有多个概念所有者(实际上是借入者)。
问题:当不可变引用能够完成任务时,为什么还需要 Rc<T>

3
你确实可以这样做,但由于 List 只是借用其值,所以在函数中返回已填充的 List 会有麻烦。 - kmdreko
@kmdreko 那很有道理。现在让我试试是否可以像这样拥有Cons(i32, &'a Box<List<'a>>)... - nalzok
3个回答

9
使用“普通”借用,你可以非常粗略地认为是一种静态证明的顺序关系,其中编译器需要证明某个所有者 始终 在任何借用之前启用并且始终在所有借用死亡后退出(a 拥有 String,它在借用 ab 之前启用,然后 b 死亡,最后 a 死亡; 合法)。对于许多用例,可以实现这一点,这也是 Rust 实现借用系统的洞见所在。
在某些情况下,无法在静态情况下完成此操作。在您给出的示例中,您有点作弊,因为所有借用都具有 'static 生命周期;由于此原因,'static 项可以在无限期内在任何时间被“排序”,因此实际上根本没有约束。当考虑到不同生命周期(例如许多 List<'a>List<'b> 等)时,示例变得更加复杂。当您尝试将值传递到函数中并且这些函数尝试添加项目时,此问题将变得明显。这是因为在函数内部创建的值将在离开其范围时死亡(即当封闭函数返回时),因此我们不能在之后保留对它们的引用,否则会出现悬空引用。
当无法静态地证明谁是原始所有者、其生存期开始于任何其他所有者之前并且在任何其他所有者之后结束时,Rc 就派上用场了。一个典型的例子是从用户输入中导出的图形结构,其中多个节点可以引用另一个节点。它们需要在运行时与它们引用的节点形成“生于其后,在其前死亡”的关系,以确保它们永远不引用无效数据。这时,Rc 就是一个非常简单的解决方案,因为一个简单的计数器可以表示这些关系。只要计数器不为零,就仍然存在 某些“生于其后,在其前死亡”的关系。这里的关键洞见是,无论节点创建和死亡的顺序如何都没有关系,因为任何顺序都是有效的。只有两端的点非常重要——计数器达到 0 的位置——任何增加或减少都是相同的(例如:0=+1+1+1-1-1-1=0 等同于 0=+1+1-1+1-1-1=0)。当计数器达到零时,Rc 就会被销毁。在图形示例中,这是当节点不再被引用时。这告诉那个 Rc 的所有者(最后一个节点引用),“哦,原来是基础节点的所有者——没人知道!——然后我可以将其销毁”。

你所说的“因为所有的借用都有 'static 生命周期”,是指 Cons(10, &Nil)a 都有 'static 生命周期吗?我不太明白:它们是函数的返回值,怎么可能在编译时就知道呢? - nalzok
是的,这被称为“静态提升”。如果你执行 let x = &42,那么 x 的类型将是 &'static {integer}。同样,a 携带着 'static 生命周期,因为在 &Nil 中借用是使用一个静态分配的 Nil 进行的;所以 Rc 解决的问题是无声的。如果你考虑从外部输入创建描述结构,这一点就变得更加明显了。 - user2722968
有趣。 “静态推广”是否意味着所有字面上的struct/enum值,其字段也是字面值,都存储在.TEXT段中,并赋予“静态”生命周期? - nalzok
不一定,因为&'static在实际意义上类似于const,所以实际上编译器会将其值进行常量折叠和/或直接放入指令流中。但总的来说,如果你这样做 let x = &Some(42),你可以认为可执行文件中有一个Some(42),而x是指向它的&'static - user2722968

1

用户2722968的答案帮助我理解了问题,并创建了一个超级简单的示例来解释逻辑。

如果a的所有者超出范围,nalzok的原始参考解决方案将无法工作:

#[derive(Debug)]
enum List<'a> {
    Cons(i32, &'a List<'a>),
    Nil,
}

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

fn main() {
    let b;
    {
        let a = Cons(5, &Cons(10, &Nil));
        b = Cons(3, &a);
//                  ^^ borrowed value does not live long enough
    }
//  - `a` dropped here while still borrowed

    println!("{:?}", b);
//                   - borrow later used here
}

当使用Rc时,您将获得完全相同的代码运行,因为您不必关心所有者和借用的生命周期:

use std::rc::Rc;

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

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

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

    println!("{:?}", b);
}

0
即使是单线程,有时仍然会动态确定销毁顺序,而为了让借用检查器正常工作,必须有一个确定的生命周期树(堆栈)。
fn run() {
    let writer = Rc::new(std::io::sink());
    let mut counters = vec![
        (7, Rc::clone(&writer)),
        (7, writer),
    ];
    while !counters.is_empty() {
        let idx = read_counter_index();
        counters[idx].0 -= 1;
        if counters[idx].0 == 0 {
            counters.remove(idx);
        }
    }
}

fn read_counter_index() -> usize {
    unimplemented!()
}

正如您在此示例中所看到的,销毁顺序是由用户输入确定的。

使用智能指针的另一个原因是简单性。借用检查器确实会带来一些代码复杂性。例如,使用智能指针,您可以通过微小的开销绕过自引用结构问题。

struct SelfRefButDynamic {
    a: Rc<u32>,
    b: Rc<u32>,
}

impl SelfRefButDynamic {
    pub fn new() -> Self {
        let a = Rc::new(0);
        let b = Rc::clone(&a);
        Self { a, b }
    }
}

使用静态(编译时)引用不可能实现此功能:

struct WontDo {
    a: u32,
    b: &u32,
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接