Rust Borrow借用详解:不转移所有权访问数据

97 阅读10分钟

《Rust编程实战》系列第35篇
在上一篇文章中,我们学习了Rust的Copy,了解了哪些轻量数据类型可以在赋值或传参时自动复制。本篇继续进入Rust所有权体系中非常重要的概念:

Borrow

中文通常称为“借用”。
前面我们已经知道,StringVec等非Copy类型传递给另一个变量或函数时,通常会发生所有权移动:

fn print_name(name: String) {
    println!("{}", name);
}

fn main() {
    let name = String::from("Tom");
    print_name(name);
}

调用print_name(name)后,name的所有权进入函数,后面不能继续使用:

println!("{}", name);

如果函数只是想读取字符串,并不需要真正拥有它,那么直接转移所有权显得非常浪费。
Rust为此提供了借用机制:

&T

例如:

fn print_name(name: &String) {
    println!("{}", name);
}

fn main() {
    let name = String::from("Tom");
    print_name(&name);
    println!("{}", name);
}

这次函数只是临时借用了name,所有权仍然属于main中的变量。
本文将介绍:

  • 什么是Borrow

  • 为什么需要借用

  • 引用&T

  • 借用与Move的区别

  • 多个不可变引用

  • 函数参数中的借用

  • &String&str

  • Vec和切片借用

  • 结构体借用

  • 解引用*

  • 借用作用域

  • Borrow Checker

  • 常见借用错误

  • Borrow最佳实践

什么是Borrow

Borrow表示:

临时访问一个值,但不取得这个值的所有权。
例如:

fn main() {
    let text = String::from("Rust");
    let reference = &text;
    println!("{}", reference);
    println!("{}", text);
}

这里:

&text

创建了一个指向text的引用。
变量:

reference

只是借用了text,并没有拥有字符串数据。
因此:

text

仍然可以继续使用。

什么是引用

Rust中使用:

&T

表示某个类型T的不可变引用。
例如:

&i32
&String
&Vec<i32>
&User

如果:

let number = 100;

那么:

let reference = &number;

reference的类型就是:

&i32

示例:

fn main() {
    let number = 100;
    let reference: &i32 = &number;
    println!("{}", reference);
}

引用可以理解为:

一个指向原数据的安全地址,但它受到Rust借用规则约束。

为什么需要借用

假设有一个函数用于计算字符串长度:

fn get_length(text: String) -> usize {
    text.len()
}

调用:

fn main() {
    let text = String::from("Hello Rust");
    let length = get_length(text);
    println!("长度:{}", length);
}

函数调用之后,text已经被移动,不能继续使用。
get_length()实际上只需要读取字符串长度,并不需要取得所有权。
更合理的写法:

fn get_length(text: &String) -> usize {
    text.len()
}

fn main() {
    let text = String::from("Hello Rust");
    let length = get_length(&text);
    println!("长度:{}", length);
    println!("内容:{}", text);
}

这里:

get_length(&text)

只是把text借给函数使用。

Borrow与Move的区别

Move:

let first = String::from("Rust");
let second = first;

结果:

first失效
second取得所有权

Borrow:

let first = String::from("Rust");
let second = &first;

结果:

first仍然拥有数据
second只是借用数据

可以简单记住:

Move是“把东西给你”。
Borrow是“借给你看看”。

函数参数中的Borrow

实际开发中,借用最常见的地方就是函数参数。
例如:

fn print_user(name: &String) {
    println!("用户名:{}", name);
}

调用:

fn main() {
    let name = String::from("Alice");
    print_user(&name);
    print_user(&name);
    println!("{}", name);
}

同一个变量可以被反复借用,因为所有权没有离开原变量。

&String与&str

如果函数只需要读取字符串,一般更推荐:

&str

而不是:

&String

例如:

fn print_name(name: &str) {
    println!("用户名:{}", name);
}

fn main() {
    let first = String::from("Tom");
    let second = "Alice";
    print_name(&first);
    print_name(second);
}

&str更加通用,它既能接受:

String

的借用,也能接受字符串字面量。
所以只读字符串参数通常推荐:

fn function(text: &str)

多个不可变引用

Rust允许同时存在多个不可变引用。
例如:

fn main() {
    let text = String::from("Rust");
    let first = &text;
    let second = &text;
    let third = &text;
    println!("{}", first);
    println!("{}", second);
    println!("{}", third);
}

这是安全的。
因为多个不可变引用只能读取数据,不会修改数据,因此不会产生数据冲突。
可以简单表示为:

      ┌─ first
text ─┼─ second
      └─ third

所有引用都只是读取同一个值。

不可变引用不能修改数据

下面的代码会报错:

fn main() {
    let text = String::from("Rust");
    let reference = &text;
    reference.push_str(" Language");
}

原因是:

reference

类型为:

&String

它只是不可变借用。
不可变借用的规则是:

可以读取,不能修改。
如果需要通过引用修改数据,需要使用:

&mut T

这就是下一篇要重点讲解的“可变借用”。

借用Vec

Vec也可以通过引用借用:

fn print_numbers(numbers: &Vec<i32>) {
    println!("{:?}", numbers);
}

fn main() {
    let numbers = vec![10, 20, 30];
    print_numbers(&numbers);
    println!("{:?}", numbers);
}

不过函数只需要读取元素时,更推荐切片:

fn print_numbers(numbers: &[i32]) {
    for number in numbers {
        println!("{}", number);
    }
}

这样既可以传入Vec:

let numbers = vec![1, 2, 3];
print_numbers(&numbers);

也可以传入数组:

let numbers = [1, 2, 3];
print_numbers(&numbers);

借用结构体

结构体同样可以通过引用传入函数。

#[derive(Debug)]
struct User {
    name: String,
    age: u32,
}

fn print_user(user: &User) {
    println!("姓名:{}", user.name);
    println!("年龄:{}", user.age);
}

fn main() {
    let user = User {
        name: String::from("Tom"),
        age: 20,
    };
    print_user(&user);
    println!("{:?}", user);
}

print_user()不会取得User的所有权,因此调用后user仍然有效。
在业务项目中,大型结构体通常不会为了读取而频繁Move或Clone,而是通过:

&User

借用。

引用不会拥有数据

看下面代码:

fn main() {
    let text = String::from("Rust");
    let reference = &text;
    println!("{}", reference);
}

reference离开作用域时,不会释放字符串数据。
真正负责释放数据的是:

text

因为它才是所有者。
可以理解为:

text
  |
  └──拥有──> "Rust"
      ↑
      |
reference只是指向

解引用*

引用保存的是对数据的访问方式,如果想获得引用指向的值,可以使用:

*

称为解引用。
示例:

fn main() {
    let number = 100;
    let reference = &number;
    println!("{}", *reference);
}

这里:

*reference

得到引用指向的i32值。
例如:

fn main() {
    let number = 10;
    let reference = &number;
    assert_eq!(number, *reference);
}

number是:

i32

reference是:

&i32

*reference再次得到:

i32

为什么println!经常不需要*

可能会发现:

println!("{}", reference);

也能正常输出。
这是因为Rust很多场景会自动进行解引用或格式化处理,不需要每次显式写:

*reference

类似地:

user.name

即使user&User,Rust也通常能够自动处理字段访问。

借用的作用域

引用不能比它指向的数据活得更久。
错误示例:

fn create_reference() -> &String {
    let text = String::from("Rust");
    &text
}

这是不允许的。
因为函数结束时:

text

会被释放。
如果允许返回:

&text

那么返回的引用将指向已经被释放的内存。
这种引用叫:

悬空引用 Dangling Reference

Rust编译器会直接阻止这种代码。

悬空引用

在C/C++中可能出现类似:

创建数据
↓
取得指针
↓
释放数据
↓
继续访问指针

这可能造成严重内存错误。
Rust通过借用检查确保:

引用永远不能比被引用的数据活得更久。
因此Rust从编译阶段避免大量悬空指针问题。

借用作用域可以提前结束

看下面代码:

fn main() {
    let text = String::from("Rust");
    let reference = &text;
    println!("{}", reference);
    println!("{}", text);
}

现代Rust借用检查器通常会判断:

reference

最后一次使用的位置。
当:

println!("{}", reference);

结束后,如果后面不再使用reference,这次借用也可以认为已经结束,而不一定要等整个代码块结束。
这让Rust的借用规则更加灵活。

Borrow Checker是什么

Rust编译器中负责检查借用是否合法的机制通常称为:

Borrow Checker

借用检查器主要保证:

  • 引用指向的数据仍然有效

  • 不会出现悬空引用

  • 不会发生不安全的同时读写

  • 可变访问不会产生数据竞争
    例如下面的代码无法通过编译:

fn main() {
    let reference;
    {
        let text = String::from("Rust");
        reference = &text;
    }
    println!("{}", reference);
}

因为text在内部代码块结束时已经被释放,但reference还想继续访问它。

借用与所有权的关系

假设:

let text = String::from("Rust");
let reference = &text;

关系是:

text
拥有String
|
└── reference借用text

借用不会改变真正的所有者。
所以:

drop(reference);

不会释放String
最终仍然由text负责释放内存。
需要注意,对于Copy类型的引用,显式drop(reference)通常没有实际意义,因为引用本身可以直接复制。

借用与Clone的选择

假设函数只需要打印字符串。
方案一:

fn print_text(text: String) {
    println!("{}", text);
}

fn main() {
    let text = String::from("Rust");
    print_text(text.clone());
}

虽然可以工作,但为了打印一次就复制整个字符串并不划算。
更好的方式:

fn print_text(text: &str) {
    println!("{}", text);
}

fn main() {
    let text = String::from("Rust");
    print_text(&text);
}

原则:

只读取数据时优先Borrow。
真正需要独立副本时再Clone。

实战:计算订单总价

#[derive(Debug)]
struct Order {
    product: String,
    price: f64,
    count: u32,
}

fn calculate_total(order: &Order) -> f64 {
    order.price * order.count as f64
}

fn main() {
    let order = Order {
        product: String::from("Rust Book"),
        price: 99.0,
        count: 3,
    };
    let total = calculate_total(&order);
    println!("订单:{:?}", order);
    println!("总价:{:.2}", total);
}

函数只读取订单,所以使用:

&Order

而不是取得整个Order的所有权。

实战:查找最大值

fn find_max(numbers: &[i32]) -> Option<&i32> {
    numbers.iter().max()
}

fn main() {
    let numbers = vec![10, 88, 35, 99, 20];
    match find_max(&numbers) {
        Some(max) => println!("最大值:{}", max),
        None => println!("没有数据"),
    }
    println!("{:?}", numbers);
}

函数接收:

&[i32]

并返回:

Option<&i32>

整个过程中不需要复制Vec中的数据。

常见错误

函数只读却取得所有权

不推荐:

fn print_user(user: User)

如果只查看用户信息,更推荐:

fn print_user(user: &User)

为了继续使用原变量到处clone

不推荐:

process(data.clone());

如果process()只是读取数据,应改成接受引用。

返回局部变量的引用

错误:

fn get_name() -> &String {
    let name = String::from("Tom");
    &name
}

局部变量离开函数后会被释放,不能返回指向它的引用。

试图通过不可变引用修改

错误:

let text = String::from("Rust");
let reference = &text;
reference.push('!');

需要修改时必须使用可变借用。

把引用误认为新的所有者

let second = &first;

并不会转移或复制底层数据,只是借用。

Borrow最佳实践

只读函数参数优先引用

字符串:

fn print(text: &str)

集合:

fn sum(values: &[i32])

结构体:

fn show(user: &User)

不需要独立数据时不要Clone

Borrow通常比复制大型String、Vec、HashMap更高效。

业务实体负责拥有数据

例如:

struct User {
    name: String,
}

User拥有用户名,而临时处理函数使用:

&User

或:

&str

访问。

API通过类型表达意图

fn save(user: User)

通常表达“函数接管User”。

fn show(user: &User)

表达“函数只临时访问User”。
良好的Rust API应该让所有权意图一眼就能看出来。

本章小结

Borrow让Rust可以在不转移所有权的情况下安全访问数据。
本文学习了:

  • Borrow表示临时借用数据

  • 使用&T创建不可变引用

  • 借用不会改变数据所有者

  • 多个不可变引用可以同时存在

  • 不可变引用只能读取数据

  • 函数参数可以通过引用避免Move

  • 字符串参数通常推荐&str

  • 集合参数通常推荐切片&[T]

  • 结构体可以通过&Struct借用

  • 使用*进行解引用

  • 引用不能比原数据活得更久

  • Rust通过Borrow Checker防止悬空引用

  • Borrow通常比无意义的Clone更加高效
    可以记住:

Ownership决定谁拥有数据。
Move负责转移所有权。
Borrow允许临时访问数据而不取得所有权。
如果函数只是读取数据,优先考虑借用而不是Move或Clone。

下一篇预告

下一篇我们将继续学习:

Rust可变借用详解:&mut T与安全修改数据

内容包括:

  • 什么是可变借用

  • &mut T基本语法

  • 通过可变引用修改数据

  • 为什么同一时间只能有一个可变引用

  • 不可变借用与可变借用冲突

  • 可变借用作用域

  • 函数参数中的&mut

  • String、Vec和Struct的可变借用

  • Reborrow重新借用

  • Borrow Checker常见报错

  • 可变借用实战与最佳实践