前端都能看懂的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. 所有者超出作用域时,值会被丢弃

每个值都有一个所有者

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

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

值只有一个所有者

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

rust 复制代码
 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)在程序中有效的范围。假设有这样一个变量:

rust 复制代码
let s = "hello";

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

rust 复制代码
    // 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 函数释放该值占用的内存

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

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

rust 复制代码
    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

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

2. Move

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

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

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

内存布局变化:

text 复制代码
之前:
s1 -> 堆上的 "hello"

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

3. 克隆(Clone)

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

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

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

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

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

函数与所有权

1. 参数传递移动所有权

rust 复制代码
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. 返回值转移所有权

rust 复制代码
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)与所有权

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

rust 复制代码
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. 不可变引用(&)

rust 复制代码
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)

rust 复制代码
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:引用必须总是有效的
rust 复制代码
    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)允许你借用值而不获取所有权。要访问引用指向的值,需要使用解引用操作符 *

*解引用

rust 复制代码
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 的定义

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

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

示例:Box<T> 的解引用

rust 复制代码
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

rust 复制代码
fn hello(name: &str) {
    println!("Hello, {}", name);
}

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

点操作符的自动解引用

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

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

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

DerefDerefMut

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

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

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

rust 复制代码
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. 任意时刻,一个值只能有一个可变引用 多个不可变引用
相关推荐
jump_jump1 小时前
RTK:给 AI 编码助手瘦身的 Rust 代理
性能优化·rust·claude
小杍随笔6 小时前
【Rust Exercism 练习详解:Anagram + Space Age + Sublist(附完整代码与深度解读)】
开发语言·rust·c#
Rust研习社8 小时前
Rust 字符串与切片实战
rust
朝阳5818 小时前
局域网聊天工具
javascript·rust
朝阳5818 小时前
我做了一个局域网传文件的小工具,记录一下
javascript·rust
Rust语言中文社区1 天前
【Rust日报】用 Rust 重写的 Turso 是一个更好的 SQLite 吗?
开发语言·数据库·后端·rust·sqlite
小杍随笔1 天前
【Rust 半小时速成(2024 Edition 更新版)】
开发语言·后端·rust
Source.Liu1 天前
【office2pdf】office2pdf 纯 Rust 实现的 Office 转 PDF 库
rust·pdf·office2pdf
洛依尘1 天前
深入浅出 Rust 生命周期:它不是语法负担,而是借用关系的说明书
后端·rust
Rust研习社1 天前
通过示例学习 Rust 模式匹配
rust