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 防止了悬垂指针、数据竞争等问题的发生。
- 一个值在任意时刻,要么有一个可变引用,要么有任意多个不可变引用,但不能同时存在:
- 可变引用:保证独占性(mutability 和 exclusivity)。
- 不可变引用:允许并发访问(immutability 和 concurrency)。
同时存在会导致数据竞争,Rust 编译器会拒绝这样的代码。
- 引用必须总是有效:不能返回指向局部变量的引用,因为局部变量在函数结束时会被销毁。 引用的生命周期不能超过被引用值的生命周期。
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;
}
编译期计算x
,y
在栈帧中的的偏移量0
,4
,并写死到汇编代码中,运行期时通过栈指针加偏移量来访问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 # 返回
解释:
- 栈空间分配:
subl $8, %esp
:为两个 4 字节的局部变量(x
和y
)分配 8 字节的空间。
- 初始化变量:
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。- 栈恢复:
movl %ebp, %esp
:恢复栈指针,清理栈空间。popl %ebp
:恢复基指针%ebp
。- 返回:
movl $0, %eax
:将返回值 0 存储到eax
寄存器。ret
:返回。
栈布局:
x
存储在栈上 -4(%ebp
) 位置。y
存储在栈上 -8(%ebp
) 位置。
在 x86 架构中,
%esp
和%ebp
是两个非常重要的寄存器,它们用于管理栈(stack)的操作。它们分别有不同的作用和指向的内容。
%esp
(Stack Pointer)
- 作用:
%esp
是 栈指针,指向当前栈的顶部(栈顶)。- 指向内容:它始终指向栈的 当前活动位置,即最后一个入栈的值的地址。栈按照 “后进先出”(LIFO)原则工作,每次压入栈时,
%esp
会减小(因为栈在内存中是向下生长的),每次弹出栈时,%esp
会增大。
入栈:通过push
指令,将数据压入栈时,%esp
会向下移动(减小)。
出栈:通过pop
指令,移除栈顶数据时,%esp
会向上移动(增大)。- 示例: 如果栈原本在
0x1000
地址,执行push
操作后,%esp
会指向0x0ffc
地址(假设每次压栈 4 字节)。执行pop
后,%esp
会回到0x1000
地址。%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:基指针,指向当前栈帧的开始位置,通常在函数调用时保存并用于访问局部变量和参数。