每日一R「03」Borrow 语义与引用

979 阅读5分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第03天,点击查看活动详情

在上节课中,我们学习了变量的所有权原则。在所有权原则下,值有单一的所有者。所有权原则为 Rust 带来了非常多的好处,例如不需要设计 GC。但是,所有权转移也使得开发者在赋值、函数调用和返回时必须谨慎地处理值的所有权问题,否则将会导致失去所有权而无法访问原来的值。

为了解决所有权的问题,Rust 为所有定长的变量实现了 Copy trait,即当赋值、传参和返回时,使用 Copy 语义自动把值拷贝一份,而不使用 Move 语义;对于非定长类型的变量,没有实现 Copy trait,所以不能使用 Copy 语义复制。Rust 中设计了 Borrow 语义,在不获取变量所有权的情况下,通过“引用”访问变量的值。

01-只读借用/引用

Rust 中借用与引用是同义的,只不过与其他语言中的引用有所不同,所以 Rust 使用了新的概念借用。变量只读借用的语法是&x,它与 C/C++ 中的取引用语法类似。它对变量的作用我们通过一张图来理解:

Untitled.png 图中的变量 y 是 x 的只读引用,s1 是 s 的只读引用。要想访问借用变量的值,可以通过解引用*y

在其他语言中,例如 Java,函数调用时传参有传值和传引用的区别。Rust 中没有传引用的概念,传参与赋值一样,遵循变量所有权原则。传参时,对于实现了 Copy trait 的类型来说,Rust 会使用 Copy 语义将数据拷贝一份(浅拷贝)作为函数入参;对于未实现或不能实现 Copy trait 的类型来说,Rust 会使用 Move 语义,将变量所有权转移给函数入参。当函数结束并返回到调用点后,所有权转移到函数内部的变量随着函数的生命周期结束而被回收了,后续便无法再继续访问。

考虑如下的代码:

fn main() {
    let data = vec![1, 2, 3, 4];
    let data1 = data;  // data 的所有权转移给了 data1,后续不能再使用 data
    println!("sum of data1: {}", sum(data1)); // data1 的所有权转移到了函数 sum 中,后续不能再使用 data1
    println!("data1: {:?}", data1); // error1
    println!("sum of data: {}", sum(data)); // error2
}
fn sum(data: Vec<u32>) -> u32 {
    data.iter().fold(0, |acc, x| acc + x)
}

可以对 sum 方法修改下,将入参改为接收只读引用:

fn main() {
    let data = vec![1, 2, 3, 4];
    let data1 = &data; // data1 为 data 的只读引用
    println!("sum of data1: {}", sum(data1)); // 只读引用实现了 Copy trait
    println!("data1: {:?}", data1);  // 不会出错
    println!("sum of data: {}", sum(&data)); 
}
fn sum(data: &Vec<u32>) -> u32 {
    // 值的地址会改变么?引用的地址会改变么?
    println!("addr of value: {:p}, addr of ref: {:p}", data, &data);
    data.iter().fold(0, |acc, x| acc + x)
}

课程中有一张图,很好地解释了这个过程。Rust 中只读引用实现了 Copy trait,所以在调用 sum 方法时,会自动拷贝一份值。所以 sum 方法中 data 同样指向了栈中的胖指针。当 sum 方法结束后,回收的也只是 data 这个引用,而不会影响堆上的数据。

Untitled 1.png

仔细看下上图,如果 data 因为离开作用域,它拥有的堆上数据被回收(所有权原则),那 data1 和 data1’ 这种对 data 的引用岂不是会造成 Rust 极力避免的 use after free 问题吗?

所以,Rust 对变量引用做了严格地限制,即变量引用的生命周期不能超过(outlive)变量本身的生命周期

02-可变借用/引用

可变借用,顾名思义,是可以改变所引用变量值的引用。变量的可变引用语法是&mut x。在没有引入可变借用之前,修改值(内存中内容)只能通过拥有所有权的变量进行。引入了可变引用后,相当于同时存在了多个修改窗口,是非常危险的操作。所以,Rust 对可变引用进行了严格的限制:

  • 在一个作用域内,仅允许一个活跃的可变引用。
  • 在一个作用域内,活跃的可变引用(写)和只读引用(读)是互斥的,不能同时存在。

注:什么是活跃的可变引用?我们换个角度来理解,什么是不活跃的可变引用?不活跃是指虽然声明为可变引用,但却未当作可变引用来用。例如下面这个例子:

fn main() {
    let mut x: u32 = 10;
    let y = &mut x;
    println!("{}", *y);    // 1
    let z = &mut x;        // 2
    *z = 30;
    println!("{}", x);
}

y 和 z 都是 x 的可变借用,它们的作用域都在 main 方法中,但 Rust 编译器可通过也可正常运行。关于作用域其实还有另外一个说法:作用域从声明的地方开始一直持续到最后一次使用为止。上面的例子中,y 和 x 的作用域并不交叉,所以没问题。如果 1 和 2 两行代码交换位置,编译器就会报错。

对于变量 y 这种引用在作用域 } 之前就不再使用的代码位置,Rust 有一个专门的称呼,叫做 NLL(Non-Lexical Lifetimes)。这是一种编译器优化行为,在比较旧的编译器版本中,可能不可用。

对于引用生命周期限制规则中的第二条,是不是有点熟悉的感觉。仅从描述上看,非常类似于 Java 中的读写锁。其实我们从修改内存的角度看,可变引用和不变引用与读写锁有异曲同工之妙。

今天的课程链接:《08|所有权:值的借用是如何工作的?


历史文章推荐