rust权威指南笔记(二)

第4章 认识所有权

Rust是一个注重内存安全和性能优化的语言。为了在不依赖垃圾回收的情况下保证内存安全,Rust引入了所有权借用的概念。

  • 所有权(ownership):每个值在 Rust 中都有一个明确的“所有者”。每当所有者(变量)超出作用域时,Rust 会自动清理内存,避免内存泄漏。
  • 借用(borrowing):借用允许一个值在不转移所有权的情况下被多个变量访问,但借用会有一些限制(比如不可变借用和可变借用)。

1 栈和堆

ChatGPT原文
栈(Stack)的特点

  • 静态分配:栈的内存分配通常在编译期完成。函数调用时,编译器已经为栈上的局部变量分配好了固定大小的空间。
    因此,栈无法动态地适应编译期未知大小的数据,因为其大小和布局需要在编译时确定。只能存在堆中
  • 连续性和空间限制:栈是为高效的函数调用和返回设计的,要求内存连续且大小固定。栈的大小一般是由操作系统决定的,通常较小(例如几MB),且过大的栈空间分配可能会导致栈溢出。且分配和访问速度比堆快。
  • 生命周期受限:栈上的数据生命周期严格受限于作用域,超出作用域后内存会立即被回收。

堆(Heap)的特点

  • 动态分配:堆允许在运行时动态分配和释放内存。它为编译期大小未知的数据提供灵活性。
  • 灵活性:堆内存分布不要求连续,并且没有严格的大小限制(受限于系统内存),可以容纳大对象。分配和访问速度比栈慢。
  • 管理复杂性:堆上的数据生命周期由程序员控制(或垃圾回收器管理),更适合需要长时间保留的动态数据。

2 所有权规则

  • Rust中的每一个值都有一个对应的变量作为它的所有者
  • 在同一时间内,值有且仅有一个所有者
  • 当所有者离开自己的作用域时,它持有的值就会被释放掉

3 内存与分配

程序员发起堆内存的分配请求,操作系统在运行时动态分配,我们需要考虑通过某种方式将内存归还给操作系统,有些语言会通过垃圾回收机制释放堆内存,有些需要程序员手动释放。如果忘记释放内存,就会造成内存泄漏。如果过早释放内存,就会产生一个非法变量(悬垂指针(Dangling Pointer))。如果重复释放一块内存,会产生无法预料的后果双重释放(Double Free)。rust如何避免这些问题呢?所有权移动的概念保证了自动释放内存,引用的概念保证了多个指针可以指向同一块内存。 Rust会在作用域结束的地方自动调用drop函数,类似于C++的RAII。看起来很简单,但是当多个指针指向同一处内存呢,可能会有多重释放问题。

变量与数据交互的方式:移动(move)
let s1 = String::from("hello");
let s2 = s1;
浅拷贝,只复制栈上指针不复制堆上的数据,存在二次释放问题。 为了确保内存安全,同时也避免复制分配的内存,Rust在这种场景下会简单地将s1废弃,不再视其为一个有效的变量

fn main(){
    let s1 = String::from("hello");
    let s2 = s1;
    println!("{}",s1)//编译时错误
}

移动:浅拷贝+前一个变量无效 -> 解决二次释放

变量与数据交互方式:克隆(clone)
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1={},s2={}",s1,s2);//合法
克隆:深拷贝

栈上数据的复制
整数类型、浮点类型、布尔类型、字符类型、只包含以上类型数据的元组类型如(i32,i32)
这类完全存储在栈上的数据类型,那么它的变量就可以在赋值给其他变量之后保持可用性

4 所有权与函数

将变量传递给函数将会触发移动或复制,就像赋值语句一样

fn main(){
    let s = String::from("hello"); // 变量s进入了作用域
    takes_ownership(s);            // s的值被移动进了函数
                                   // 所以它从这里开始不再有效
    let x = 5;                     // 变量x进入作用域
    makes_copy(x);                 // 变量x同样被传递进了函数
    // 但由于i32是Copy的,所以我们依然可以在这之后使用x
}// x首先离开作用域,随后是s。
//但由于s的值已经发生了移动,所以没有什么特别的事会发生

fn takes_ownership(some_string: String){ // some_string进入作用域
    println!("{}",some_string);
} // some_string在这里离开作用域,drop函数被自动调用,
// some_string所占用的内存也就随之释放了

fn makes_copy(some_integer: i32) {
    println!("{}", some_integer);
}// some_interger在这里离开了作用域,没有什么特别的事情发生

5 返回值与作用域

变量所有权的转移总是遵循相同的模式:将一个值赋值给另一个变量时就会转移所有权。当一个持有堆数据的变量离开作用域时,它 的数据就会被drop清理回收,除非这些数据的所有权移动到了另一个变量上。

6 引用与借用

fn main() {  
    let s1 = String::from("hello");  
    let (s2, len) = calculate_length(s1);  
    println!("The length of '{}' is {}.", s2, len);  
}  
fn calculate_length(s: String) -> (String, usize) {  
    let length = s.len(); // len()会返回当前字符串的长度  
    (s, length)  
}

我们希望在将变量s1作为参数传递给函数后,继续使用s1,不得不使用元组将其再次返回,但以上写法太笨拙

引用:调用函数时使用&s1作为参数,可以在不获取所有权的情况下使用该值

 fn main() {  
    let s1 = String::from("hello");  
    let len = calculate_length(&s1);  
    println!("The length of '{}' is {}.", s1, len);  
}  
fn calculate_length(s: &String) -> usize {  // s 是一个指向 String 的引用 
    s.len()  
}// 到这里,s离开作用域。但是由于它并不持有自己所指向值的所有权, 
//所以没有什么特殊的事情会发生

此处,变量s的有效作用域与其他任何函数参数一样,唯一不同的是,它不会在离开自己的作用域时销毁其指向的数据,因为它并不拥有该数据的所有权。当一个函数使用引用而不是值本身作为参数时,我们便不需要为了归还所有权而特意去返回值,毕竟在这种情况下,我们根本没有取得所有权。 这种通过引用传递参数给函数的方法也被称为借用。与变量类似,引用是默认不可变的,Rust不允许我们去修改引用指向的值。

注意:如果你借用某个值,那么你不能再将该值的所有权转移给另一个变量。

fn main() {
    let s1 = String::from("hello");
    let s2 = &s1;
    let s3 = s1;
    println!("{}", s2);
}

编译器会报错 你尝试打印 s2,但是因为 s2 是对 s1 的引用,而 s1 的所有权已经转移给 s3,此时 s1 不再有效。 因此,s2 变成了一个 悬垂引用,Rust 不允许悬垂引用的存在,这会导致编译错误。

7 可变引用

fn main() {  
    let mut s = String::from("hello");  
    change(&mut s);  
}  
fn change(some_string: &mut String) {  
    some_string.push_str(", world");  
}

限制: 一次只能声明一个可变引用 -> 避免数据竞争
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s;
不能同时声明不可变引用和可变引用 -> 避免不可变引用的用户的值突然发生变化

8 垂悬引用(Dangling Reference)

fn main() 
{  
    let reference_to_nothing = dangle();  
}  
fn dangle() -> &String {  // dangle会返回一个指向String的引用 
    let s = String::from("hello");  // s被绑定到新的String上 
    &s  // 我们将指向s的引用返回给调用者
} // 变量s在这里离开作用域并随之被销毁,它指向的内存自然也不再有效。
// 危险!

以上代码无法通过编译
Rust 的借用规则禁止这种情况: 函数不能返回指向局部变量的引用,因为局部变量在函数返回时会被销毁

悬垂引用是指引用指向了一个已经被释放的内存位置 “垂悬引用”在 Rust 语言里不允许出现,如果有,编译器会发现它。

fn main() {
    let s1 = String::from("hello");
    let s2 = &s1;
    let s3 = s1;
    println!("{}", s2);
}

编译器会报错 你尝试打印s2,但是因为s2是对s1的引用,而s1的所有权已经转移给s3,此时s1不再有效。
因此,s2 变成了一个 悬垂引用,Rust 不允许悬垂引用的存在,这会导致编译错误。

9 借用规则(引用规则)

Rust 的借用规则是 Rust 所有权系统的核心之一,它用于确保程序在编译时的内存安全。通过借用规则,Rust 防止了悬垂指针、数据竞争等问题的发生。

借用规则的核心内容

  1. 一个值在任意时刻,要么有一个可变引用,要么有任意多个不可变引用,但不能同时存在:
    • 可变引用:保证独占性(mutability 和 exclusivity)。
    • 不可变引用:允许并发访问(immutability 和 concurrency)。
      同时存在会导致数据竞争,Rust 编译器会拒绝这样的代码。
  2. 引用必须总是有效:不能返回指向局部变量的引用,因为局部变量在函数结束时会被销毁。 引用的生命周期不能超过被引用值的生命周期。

10 切片

字符串切片

字符串切片是指向String对象中某个连续部分的引用,字符串切片的类型写作&str,它的使用方式如下所示:

let s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];
//使用切片使程序更加健壮
 fn first_word(s: &String) -> &str { 
    let bytes = s.as_bytes(); 
    for (i, &item) in bytes.iter().enumerate() { 
        if item == b' ' { 
        return &s[0..i]; 
        } 
    } 
    &s[..] 
} 
fn main() { 
    let mut s = String::from("hello world"); 
    let word = first_word(&s); 
    s.clear(); // 错误
    println!("the first word is : {}", word); 
} 

回忆一下借用规则,当我们拥有了某个变量的不可变引用时,我们就无法同时取得该变量的可变引用。由于clear需要截断当前的String 实例,所以调用clear需要传入一个可变引用。

字符串字面量就是切片

数组切片

数组切片允许你引用数组的一部分。切片类型是&[T],如&[i32]
示例:
let arr = [1, 2, 3, 4, 5];
let slice = &arr[1..4]; // 获取数组的第 2 到 4 个元素
切片在 Rust 中非常常见,它们提供了高效且灵活的方式来处理集合的一部分数据。

附录

  int main() {
    int x = 0;
    int y = x;
    return 0;
  }

编译期计算xy在栈帧中的的偏移量04,并写死到汇编代码中,运行期时通过栈指针加偏移量来访问x,y
对应的汇编代码:

    .global _start        # 标记程序入口
_start:
    # 保存基指针
    pushl %ebp            # 保存旧的栈帧指针
    movl %esp, %ebp       # 设置新的栈帧指针

    # 为局部变量x和y分配栈空间
    subl $8, %esp         # 为x和y各分配4字节,总共分配8字节

    # 初始化变量 x = 0
    movl $0, -4(%ebp)     # 将0存储到x的栈位置,x位于-4(%ebp)

    # 将x的值赋给y
    movl -4(%ebp), %eax   # 将x的值从栈中加载到eax寄存器
    movl %eax, -8(%ebp)   # 将eax的值存储到y的栈位置,y位于-8(%ebp)

    # 清理栈空间并恢复栈指针
    movl %ebp, %esp       # 恢复栈指针
    popl %ebp             # 恢复旧的栈帧指针

    # 返回
    movl $0, %eax         # 将返回值0加载到eax寄存器
    ret                   # 返回

解释:

  1. 栈空间分配:
    • subl $8, %esp:为两个 4 字节的局部变量(xy)分配 8 字节的空间。
  2. 初始化变量:
  • movl $0, -4(%ebp):将 0 存储到 x 变量的位置。x 的位置是相对于栈基指针 %ebp 的偏移 -4。
  • movl -4(%ebp), %eax:将 x 的值从栈中加载到 eax 寄存器。
  • movl %eax, -8(%ebp):将 eax 寄存器的值(即 x 的值)存储到 y 变量的位置。y 的位置是相对于 %ebp 的偏移 -8。
    1. 栈恢复:
  • movl %ebp, %esp:恢复栈指针,清理栈空间。
  • popl %ebp:恢复基指针 %ebp
    1. 返回:
  • movl $0, %eax:将返回值 0 存储到 eax 寄存器。
  • ret:返回。

栈布局:

  • x 存储在栈上 -4(%ebp) 位置。
  • y 存储在栈上 -8(%ebp) 位置。

在 x86 架构中,%esp%ebp 是两个非常重要的寄存器,它们用于管理栈(stack)的操作。它们分别有不同的作用和指向的内容。

  1. %esp (Stack Pointer)
    • 作用:%esp 是 栈指针,指向当前栈的顶部(栈顶)。
    • 指向内容:它始终指向栈的 当前活动位置,即最后一个入栈的值的地址。栈按照 “后进先出”(LIFO)原则工作,每次压入栈时,%esp 会减小(因为栈在内存中是向下生长的),每次弹出栈时,%esp 会增大。
      入栈:通过 push 指令,将数据压入栈时,%esp 会向下移动(减小)。
      出栈:通过 pop 指令,移除栈顶数据时,%esp 会向上移动(增大)。
    • 示例: 如果栈原本在 0x1000 地址,执行 push 操作后,%esp 会指向 0x0ffc 地址(假设每次压栈 4 字节)。执行 pop 后,%esp 会回到 0x1000 地址。
  2. %ebp (Base Pointer)
    • 作用:%ebp 是 基指针,通常用于指向 当前栈帧的起始位置。
    • 指向内容:%ebp 保存了栈帧的基地址,也就是当前函数调用时栈的 固定起点。栈帧是为了管理函数的局部变量、参数和返回地址等而分配的内存区域。每个函数调用都会建立一个新的栈帧,%ebp 保持指向栈帧的开始位置。
      函数调用时,通常会将 %esp 的值保存到 %ebp,这样在函数内部就可以通过相对偏移来访问局部变量和函数参数。例如,%ebp + 8 通常指向函数的第一个参数,%ebp - 4 指向函数的第一个局部变量。
      返回时,%ebp 被用来恢复调用者的栈帧,从而可以继续执行函数调用后的代码。
    • 示例: 在函数开始时,%ebp 被保存,然后 sub 操作将 %esp 向下调整,分配空间给局部变量。%ebp 仍指向栈帧的基地址,局部变量和参数通过相对偏移访问。 栈帧结构: 典型的函数栈帧结构如下:

|----------------------| <- %ebp + 4 (函数返回地址)
| 返回地址 (return address) |
|----------------------|
| 参数 1               |
|----------------------|
| 参数 2               |
|----------------------| <- %ebp (函数的基指针,栈帧的开始位置)
| 局部变量 1           |
|----------------------|
| 局部变量 2           |
|----------------------| <- %esp (栈顶,当前函数的栈顶位置)
总结:
%esp:栈指针,指向栈的当前顶部(栈的活动部分),每次压栈或弹栈时会发生变化。
%ebp:基指针,指向当前栈帧的开始位置,通常在函数调用时保存并用于访问局部变量和参数。