前端都能看懂的rust入门教程(五)—— 所有权

0 阅读11分钟

本节介绍Rust中最复杂的内容之一——所有权。

多数语言只要掌握基本语法和语句,就可以流畅地写出代码,但Rust不是,只有掌握了所有权才算真正入门。

rust和其他语言难度对比 rust的学习曲线

前言

所有权(Ownership)是 Rust 最独特、最核心的特性,它使得 Rust 能够在没有垃圾回收(GC)的情况下保证内存安全。所有权系统基于三个核心规则,在编译时进行检查。

Rust的所有权规则基于三个基本原则

  1. 每个值都有一个所有者:变量绑定到一个值时,这个变量就是它的所有者。
  2. 值只有一个所有者:不能同时有多个所有者,这防止了数据竞争。
  3. 所有者超出作用域时,值会被丢弃:这自动释放内存。

为什么Rust引入了所有权系统

在 Rust 出现之前,许多系统级编程语言(如 C 和 C++)依赖手动内存管理,而 Java 或 JS 使用垃圾回收(GC)来自动管理内存,前者容易导致诸多内存错误:内存泄漏(忘记释放)、悬垂指针(dangling pointers,使用已释放的内存)、双重释放(double free)、缓冲区溢出(buffer overflows);而后者运行时会引入额外的开销,而且也可能造成内存泄漏(比如js中的闭包问题)。

既不需要开发者手动管理内存,又没有引入GC,那么如何确定一个值已经使用完成可以清理呢?最容易想到的两点就是

  1. 没有指针指向这个值——这引申出来的就是所有者和引用计数
  2. 值的作用域结束——这引申出来的就是Rust中的生命周期

前置知识:堆和栈

大多数编程语言使用时都不需要关心堆和栈的概念,但Rust中,理解堆和栈才能更容易掌握所有权。

在计算机内存管理中,堆(Heap)和栈(Stack)是两种常用的内存区域,用于存储程序运行时的数据。它们在内存分配、管理和使用方式上有很大的不同。

栈(Stack)

特点:

  1. 快速分配和释放:栈内存的分配和释放通过移动栈指针实现,速度非常快。
  2. 后进先出(LIFO) :栈是一种后进先出的数据结构,最后放入的数据最先被取出。
  3. 固定大小:栈的大小通常有限,由操作系统或编译器决定,可能会溢出。
  4. 局部变量和函数调用:栈用于存储局部变量、函数参数和返回地址等。
  5. 自动管理:栈内存的分配和释放由编译器自动管理,无需程序员干预。

堆(Heap)

特点:

  1. 动态分配:堆内存可以在运行时动态分配,大小可变。
  2. 手动管理:在有些语言(如C/C++)中需要手动管理堆内存的分配和释放,但Rust通过所有权系统自动管理。
  3. 较大空间:堆通常比栈大得多,受限于系统可用内存。
  4. 访问速度较慢:堆内存的分配和释放需要更复杂的管理,速度相对较慢。
  5. 无特定顺序:堆内存可以以任意顺序分配和释放,因此会产生内存碎片。

堆的工作原理:

  • 程序在运行时通过分配器(Allocator)请求堆内存。
  • 分配器在堆中找到一块足够大的未使用内存,将其标记为已使用,并返回指向该内存的指针。
  • 当不再需要时,内存必须被释放,以便重用。

Rust中存放在栈中的值,一定是能在编译器确定其大小,不涉及堆分配,并且通常都实现了Copy。常见的类型有数字,bool,char,单元,数组/元组(结构本身),而像String,Vec这些类型,其结构本身和指针存放在栈上,值存放在堆上。

Rust所有权

Rust所有权三原则

所有权的核心理念就是以下三个原则:

  1. 每个值都有一个所有者
  2. 值只有一个所有者
  3. 所有者超出作用域时,值会被丢弃

每个值都有一个所有者

可以把值(比如一个字符串、数字、结构体实例)想象成一个物品,这个物品在任何时刻都归且仅归一个变量(所有者)所有。

fn main() {
    // 变量 s 是字符串 "hello" 的所有者
    let s1 = String::from("hello"); 
    // 此时只有 s 能操作这个字符串,它是唯一的所有者
    println!("{}", s1);
}

值只有一个所有者

这个规则也叫 “移动(Move)语义”—— 如果把值从一个变量转移给另一个变量,原变量会立刻失去所有权,不能再使用(相当于物品从原主人手里转移给新主人,原主人再也无权支配)。

 let s1 = String::from("hello");
    // 此时只有 s 能操作这个字符串,它是唯一的所有者
    println!("{}", s1);

    // 将 s1 的所有权转移给 s2(移动)
    let s2 = s1;

    // 错误!s1 已经失去所有权,编译器会报 错borrow of moved value: `s1`
    // println!("{}", s1);
    // 正确:s2 是当前唯一的所有者
    println!("{}", s2);

所有者超出作用域时,值会被丢弃(销毁)

作用域是一个项(item)在程序中有效的范围。假设有这样一个变量:

let s = "hello";

变量 s 绑定到了一个字符串字面值,这个字符串值是硬编码进程序代码中的。这个变量从声明的点开始直到当前作用域结束时都是有效的。下面代码标明了变量 s 在何处是有效的。

    // s 在这里无效,它尚未声明
    // print!("{}", s); //cannot find value `s` in this scope
    {
        let s = "hello"; // 从此处起,s 是有效的
        // 使用 s
        println!("{}", s);
    } // 此作用域已结束,s 不再有效
    // print!("{}", s) //cannot find value `s` in this scope
  • 当 s 进入作用域时,它就是有效的。
  • 这一直持续到它离开作用域为止。
  • 当离开作用域时,Rust 会自动调用 drop 函数释放该值占用的内存

反例:一个值能有两个所有者?

下面这个例子中似乎违背了第二条原则——值只有一个所有者。

    let x = 5;
    let y = x; // move?
    println!("x = {}, y = {}", x, y); // 两者都有效

这就要展开说起所有权的操作了。

所有权操作

在Rust中,所有权(Ownership)密切相关的主要操作有:copy(复制)、move(移动)、和clone(克隆)。

1. Copy

Copy是一个trait,表示类型可以通过简单的位复制(bitwise copy)来创建新值,而不会使原变量失效。如果一个类型实现了Copy trait,那么赋值操作或传参时就会复制值,而不是移动。这意味着原变量仍然有效。

基本标量类型(如整数、浮点数、布尔值、字符)以及由这些类型组成的元组和数组通常实现了Copy

let x = 5;
let y = x;  // 复制值,因为 i32 实现了 Copy trait
println!("x = {}, y = {}", x, y);  // 两者都有效

2. Move

当将一个值赋给另一个变量时,或者将值传递给函数时,如果该类型没有实现Copy trait,那么默认会发生移动(move)。移动意味着原变量不再拥有该值的所有权,所有权转移到新的变量或函数中。移动后,原变量将无法再被使用,因为所有权已经转移。

let s1 = String::from("hello");
let s2 = s1;  // 所有权从 s1 移动到 s2

// println!("{}", s1);  // 编译错误!s1 已失效
println!("{}", s2);     // 正确:hello

内存布局变化:

之前:
s1 -> 堆上的 "hello"

之后:
s2 -> 堆上的 "hello"
s1 -> 无效(已被移动)

3. 克隆(Clone)

Clone trait允许我们显式地创建一个值的深拷贝(deep copy)。对于实现了Clone的类型,我们可以调用clone方法来创建一个完全独立的新值,原变量和新变量彼此独立,修改一个不会影响另一个。

例如,String类型实现了Clone,所以我们可以克隆一个字符串:

let s1 = String::from("hello");
let s2 = s1.clone();  // 深度复制,创建新的堆分配

println!("s1 = {}, s2 = {}", s1, s2);  // 两者都有效

如果能理解以上内容,那么就能理解函数中的所有权操作

函数与所有权

1. 参数传递移动所有权

fn main() {
    let s = String::from("hello");
    takes_ownership(s);  // s 的所有权移动到函数内
    // println!("{}", s);  // 错误!s 不再有效
    
    let x = 5;
    makes_copy(x);  // x 被复制,仍然有效
    println!("x = {}", x);  // 正确
}

fn takes_ownership(some_string: String) {
    println!("{}", some_string);
}  // some_string 离开作用域,调用 drop,内存被释放

fn makes_copy(some_integer: i32) {
    println!("{}", some_integer);
}  // some_integer 离开作用域,无事发生

2. 返回值转移所有权

fn test2() {
    let s1 = String::from("hello");
    let s2 = takes_and_gives_back(s1);
    // print!("{}", s1);//错误:borrow of moved value: `s1
    print!("{}", s2);
}

fn takes_and_gives_back(a_string: String) -> String {
    a_string // 返回,所有权转移给调用者
}

附:切片(Slices)与所有权

切片是对集合中一段连续元素的引用,不获取所有权。

let s = String::from("hello world");

let hello = &s[0..5];    // 对 s 的一部分的引用
let world = &s[6..11];   // 另一个引用
let whole = &s[..];      // 对整个字符串的引用

// s 仍然有效,因为切片只是引用

 let a = "hello";
let b = a; // copy,Shared references (`&T`)都实现了copy
println!("{},{}", a, b)

借用(Borrowing)

为了避免频繁的所有权转移,Rust 提供了引用(references)的概念,称为"借用"。

1. 不可变引用(&)

fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1);  // 借用,不获取所有权
    println!("'{}' 的长度是 {}", s1, len);  // s1 仍然有效
}

fn calculate_length(s: &String) -> usize {  // s 是对 String 的引用
    s.len()
}  // s 离开作用域,但因为它不拥有所有权,所以不会丢弃任何东西

2. 可变引用(&mut)

fn main() {
    let mut s = String::from("hello");
    change(&mut s);  // 可变引用
    println!("{}", s);  // hello, world!
}

fn change(some_string: &mut String) {
    some_string.push_str(", world!");
}

 借用规则(编译时检查)

  • 规则 1:任意时刻,只能有一个可变引用  多个不可变引用
  • 规则 2:引用必须总是有效的
    let mut s = String::from("hello");

    let r1 = &s;      // ✓ 不可变引用
    let r2 = &s;      // ✓ 另一个不可变引用
    // let r3 = &mut s; // ✗ 不能同时有可变和不可变引用 cannot borrow `s` as mutable because it is also borrowed as immutable

    println!("{}, {}", r1, r2);  // 使用完毕(后续代码不再使用r1,r2)

    let r3 = &mut s;  // ✓ 现在可以创建可变引用

    r3.push_str(", world");

解引用

在 Rust 中,引用(&T)允许你借用值而不获取所有权。要访问引用指向的值,需要使用解引用操作符 *

*解引用

let x = 5;
let y = &x;        // y 是对 x 的引用

assert_eq!(5, x);
assert_eq!(5, *y); // 解引用 y 得到值 5

这里 *y 就是解引用操作,它返回 y 所指向的值。

 智能指针与 Deref Trait

Rust 中的智能指针(如 Box<T>Rc<T>Arc<T>)通过实现 Deref trait 来获得类似引用的行为。

Deref Trait 的定义

pub trait Deref {
    type Target: ?Sized;
    fn deref(&self) -> &Self::Target;
}

实现 Deref 后,智能指针可以被自动解引用为内部类型。

示例:Box<T> 的解引用

let x = Box::new(5);
println!("{}", *x); // 解引用 Box,得到内部的 5

*x 实际上是 *(x.deref()) 的语法糖,编译器会自动插入 deref() 调用。

隐式解引用

Deref Coercion

当类型 T 实现了 Deref<Target = U> 时:

  • &T 可以自动转换为 &U
  • &mut T 可以自动转换为 &mut U(如果实现了 DerefMut
  • 转换可以连续进行,直到目标类型匹配

因此下面代码中&Box<String>类型可以转为&str

fn hello(name: &str) {
    println!("Hello, {}", name);
}

let m = Box::new(String::from("Rust"));
hello(&m); // 自动将 &Box<String> 转换为 &String,再转换为 &str

点操作符的自动解引用

Rust 的方法调用语法(点操作符)会自动执行解引用和引用,使得调用方法非常方便。

let s = Box::new(String::from("hello"));
// len 是 String 的方法,不是 Box 的方法
println!("{}", s.len()); // 自动解引用为 &String

编译器会在必要时插入 * 和 &,直到找到匹配的方法。这个过程称为“自动引用和解引用”。

Deref 与 DerefMut

在 Rust 中,可变引用(&mut T)解引用出来的值可以修改,而不可变引用(&T)解引用出来的值不可以修改。这是Rust解引用的一个基本规则,它还有两个细节:

  • 当对智能指针使用 * 时,实际调用的是 deref() 方法,返回一个引用,然后编译器自动取该引用的值(实际上 * 的结果是 deref 返回的引用的目标)。

  • 如果智能指针实现了 DerefMut,并且位于可变上下文中,则会调用 deref_mut()

let mut x = 10;
let r = &x;           // 不可变引用
// *r = 20;           // ❌ 编译错误:不能对不可变引用赋值

let r_mut = &mut x;   // 可变引用
*r_mut = 20;          // ✅ 可以修改
println!("{}", x);    // 输出 20

总结

Rust的所有权看上去很复杂,但总结下来也只有很简单的四句话:

  1. 每个值都有且只有一个所有者
  2. 所有者离开作用域,值会被回收
  3. 引用(&T)可以借用值而不获取所有权
  4. 任意时刻,一个值只能有一个可变引用  多个不可变引用