传递值给函数时,通过引用和通过封装(Box)的区别是什么?

56

"按引用传递"和"通过Box传递"将值传递给函数之间的区别是什么:

fn main() {
    let mut stack_a = 3;
    let mut heap_a = Box::new(3);

    foo(&mut stack_a);
    println!("{}", stack_a);

    let r = foo2(&mut stack_a);
    // compile error if the next line is uncommented
    // println!("{}", stack_a);

    bar(heap_a);
    // compile error if the next line is uncommented
    // println!("{}", heap_a);
}

fn foo(x: &mut i32) {
    *x = 5;
}

fn foo2(x: &mut i32) -> &mut i32 {
    *x = 5;
    x
}

fn bar(mut x: Box<i32>) {
    *x = 5;
}
为什么heap_a被移入函数,但stack_a没有被移入函数(在调用foo()后的println!语句中仍然可以使用stack_a)?
取消注释println!("{}", stack_a);会出现错误:
error[E0502]: cannot borrow `stack_a` as immutable because it is also borrowed as mutable
  --> src/main.rs:10:20
   |
8  |     let r = foo2(&mut stack_a);
   |                       ------- mutable borrow occurs here
9  |     // compile error if the next line is uncommented
10 |     println!("{}", stack_a);
   |                    ^^^^^^^ immutable borrow occurs here
...
15 | }
   | - mutable borrow ends here

我认为这个错误可以通过引用生命周期来解释。对于foo的情况,main函数中的stack_a被移动到函数foo中,但编译器发现函数foo的参数x:&mut i32的生命周期在foo的结尾处结束。因此,在foo返回后,它允许我们在main函数中使用变量stack_a。对于foo2的情况,stack_a也被移动到函数中,但我们还将其返回。

为什么heap_a的生命周期不会在bar的结尾处结束?

3个回答

56

按值传递始终是复制(如果涉及的类型是“平凡”的)或移动(如果不是)。Box<i32> 不可复制,因为它(或者至少它的某个数据成员)实现了 Drop。这通常是为了某种“清理”代码。一个 Box<i32> 是一个“拥有指针”。它是它所指向的东西的唯一所有者,这就是为什么它“感到有责任”在它的 drop 函数中释放 i32 的内存。想象一下如果你复制了一个 Box<i32> 会发生什么:现在,你将拥有两个指向同一内存位置的 Box<i32> 实例。这将是不好的,因为这将导致双重释放错误。这就是为什么 bar(heap_a) “移动” Box<i32> 实例到 bar() 中的原因。这样,堆分配的 i32 没有更多的所有者了。这使得内存管理变得非常简单:谁拥有它,最终释放它。

foo(&mut stack_a) 的区别在于,你不是按值传递 stack_a。你只是以一种让 foo() 能够改变它的方式“借给” foo() stack_afoo() 得到的是一个借用指针。当从 foo() 返回时,stack_a 仍然存在(并且可能通过 foo() 进行了修改)。你可以把它想象成 stack_a 回到了它所属的堆栈帧中,因为 foo() 只是暂时借用了它。

看起来让你困惑的部分是取消注释最后一行的部分。

let r = foo2(&mut stack_a);
// compile error if uncomment next line
// println!("{}", stack_a);

您实际上没有测试stack_a是否已经移动。 stack_a仍然存在。编译器只是不允许您通过名称访问它,因为您仍然拥有对它的可变借用引用:r。这是我们需要内存安全性的规则之一:如果我们被允许更改内存位置,则只能有一种访问内存位置的方式。在这个例子中,r是对stack_a的可变借用引用。因此,stack_a仍被视为可变借用。唯一访问它的方式是通过借用的引用r

通过一些额外的花括号,我们可以限制那个借用引用r的生命周期:

let mut stack_a = 3;
{
   let r = foo2(&mut stack_a);
   // println!("{}", stack_a); WOULD BE AN ERROR
   println!("{}", *r); // Fine!
} // <-- borrowing ends here, r ceases to exist
// No aliasing anymore => we're allowed to use the name stack_a again
println!("{}", stack_a);
在封闭括号之后,访问内存位置的唯一方法是使用名称 stack_a。这就是为什么编译器允许我们在println!中使用它的原因。
现在您可能会想知道,编译器如何知道r实际上是指stack_a? 它会分析foo2的实现吗?不需要。foo2的函数签名足以得出这个结论。它的
fn foo2(x: &mut i32) -> &mut i32

其实是缩写

fn foo2<'a>(x: &'a mut i32) -> &'a mut i32
根据所谓的“生命周期省略规则”,该签名的含义是:foo2()是一个函数,它接受对一些i32的借用指针,并返回一个对i32的借用指针,该i32与原始i32相同(或者至少是原始i32的“部分”),因为在返回类型中使用了相同的生命周期参数。只要您保持对该返回值(r)的控制,编译器就会认为stack_a被可变地借用了。

如果您想知道为什么我们需要禁止在某个内存位置上同时出现别名和(潜在的)变异,请查看Niko 的精彩演讲


24
当你传递一个被装箱的值时,你完全移动了这个值。你不再拥有它,你传递给的东西拥有它。对于任何非Copy类型(可以直接进行memcpy的普通旧数据类型,而堆分配肯定不是这样的)。这就是Rust所有权模型的工作方式:每个对象都只属于一个地方。
如果你想要改变箱子的内容,你应该传递一个&mut i32而不是整个Box<i32>
实际上,Box<T>仅对递归数据结构有用(这样它们可以被表示,而不是无限大),并且仅在大型类型的非常偶尔的性能优化中使用(在没有测量的情况下不应尝试这样做)。
要从Box<i32>中获取&mut i32,请对解引用的框取可变引用,即&mut *heap_a

15
通过引用传递和“按盒子传递”之间的区别在于,在引用情况下(“借出”),调用者负责释放对象,而在盒子情况下(“移动”),被调用方负责释放对象。
因此,Box<T> 对于传递需要负责释放的对象非常有用,而引用则对于传递不需要负责释放的对象非常有用。
下面是一个简单的例子来演示这些概念:
fn main() {
    let mut heap_a = Box::new(3);
    foo(&mut *heap_a);
    println!("{}", heap_a);

    let heap_b = Box::new(3);
    bar(heap_b);
    // can't use `heap_b`. `heap_b` has been deallocated at the end of `bar`
    // println!("{}", heap_b);
} // `heap_a` is destroyed here

fn foo(x: &mut i32) {
    *x = 5;
}

fn bar(mut x: Box<i32>) {
    *x = 5;
} // heap_b (now `x`) is deallocated here

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