注意:本教程面向于有语言基础的人员,重点对一些陌生概念和一些差距大的语法进行解释。
关联知识介绍
堆和栈对比:
| 特性 | 堆(Heap) | 栈(Stack) |
|---|---|---|
| 内存分配方式 | 动态分配(在程序运行时分配),分配的内存通常较大且灵活,由操作系统控制分配大小 | 静态分配(在编译时分配),分配的内存通常较小且固定,受栈大小限制(操作系统/硬件规定) |
| 内存管理 | 需要手动管理,可能导致内存泄漏或碎片化 | 自动管理,调用结束时栈自动清理 |
| 性能 | 分配和释放速度较慢,因为需要复杂的内存管理机制。由于内存分散,访问速度较慢且可能导致缓存未命中。 | 分配和释放速度快,因为只需要移动栈指针。访问速度较快,由于内存布局连续,且通常使用栈指针进行管理。 |
| 安全性 | 内存需要程序员自己管理/依赖垃圾回收机制,内存分配和释放是随机的,可能产生内存碎片,管理不当容易出现内存泄漏、悬垂指针、双重释放等问题 | 内存安全由编译器保证,内存分配和释放是顺序的, 不会产生内存碎片。栈空间较小,容易出现栈溢出问题导致内存混乱、程序崩溃等问题。 |
| 适用场景 | 动态对象、需要灵活分配的内存 | 函数调用、局部变量存储 |
操作系统如何管理栈内存:
- 分配机制:栈内存由操作系统自动分配和管理(栈指针的移动),每个线程有独立的栈空间,大小固定且内存连续。
- 保护机制:操作系统通过栈溢出检测和保护页机制防止栈溢出,避免内存遭到破坏。
- 释放机制:栈内存自动释放,函数返回时栈帧释放,线程结束时操作系统回收栈内存。
操作系统如何管理堆内存:
- 分配机制:堆内存由程序员或运行时系统动态分配(
malloc、free),内存块不连续且大小灵活,可占用大部分可用内存。 - 保护机制:操作系统通过虚拟内存和页表机制管理堆内存,确保进程独立性和内存访问安全。
- 释放机制:堆内存需手动释放(如 C/C++)或通过垃圾回收自动释放(如 Java的分代垃圾回收、Go的三色标记清除算法)。
所有权系统
什么是所有权?
所有权是指 Rust 中每个值都有一个变量作为其所有者,所有者负责管理值的内存生命周期。所有权的规则确保内存的分配和释放是明确且安全的。
为什么会有所有权?
在C/C++中,内存管理依赖人为手动管理(如 malloc/free 或 new/delete)。如果稍微不注意,就容易出现内存方面的问题(如内存泄露、悬垂指针、空指针、数据竞争等)。
为了解决这些问题,一些语言(如 Java、Go)引入了垃圾回收机制,但垃圾回收会带来运行时开销和不可预测的停顿时间,性能较低。
为了同时保障性能和安全性,Rust在编译期检查内存是否正常使用,这就避免了在运行时进行垃圾回收的开销。
为了实现编译期检查这套逻辑,Rust 引入了所有权这一概念。所有权系统通过一系列严格的规则来进行约束,以便Rust进行编译时的静态分析,确保内存安全且无需垃圾回收。
所有权的三大规则(核心)
-
每个值都有一个所有者。当变量被创建时,它就拥有了分配给它的值,并且在变量的作用域结束时,值会被自动清理。
fn main() { let s = String::from("hello"); // s 是字符串 "hello" 的所有者 // 在这里,s 是有效的 println!("{}", s); // 可以使用 s } // s 的作用域结束,s 被丢弃 -
每个值同一时间只能有一个所有者(但是一个所有者能拥有多个值。Eg:数组)。当你将一个值赋给另一个变量时,所有权会从原来的变量转移到新的变量,原来的变量将不再有效。
fn main() { let s1 = String::from("hello"); // s1 是字符串 "hello" 的所有者 let s2 = s1; // 所有权从 s1 转移到 s2 // 此时 s1 不再有效,尝试使用 s1 会导致编译错误 println!("{}", s2); // 可以使用 s2 } // s2 的作用域结束,s2 被丢弃 -
当所有者离开作用域时,值将被丢弃。当所有者变量离开其作用域时,Rust 会自动调用
drop方法来清理资源。这意味着所有者变量所拥有的值将被丢弃,释放其占用的内存。fn main() { { // 创建一个作用域 let s = String::from("hello"); // s 是字符串 "hello" 的所有者 // 在这里,s 是有效的 println!("{}", s); // 可以使用 s } // s 的作用域结束,s 被丢弃 // s 在这里不再有效,尝试使用 s 会导致编译错误 // println!("{}", s); // 编译错误 }
所有权的转移机制
问题:在传统编程语言(如C/C++)中,手动管理内存和资源容易导致重复释放、悬空指针、资源管理混乱以及代码语义不明确等问题,如何解决这些内存安全和资源管理的挑战?
Rust的解决方案:通过所有权转移机制,确保每个值有且仅有一个所有者,当所有权转移后,原变量失效,资源在所有者离开作用域时自动释放。
首先记住一句话:同一时间只能有一个所有者。
其次记住转移的条件:值的类型 未实现 Copy trait, 且 有接收者 接收这个值。
trail:Rust 中定义共享行为的机制,类似于其他语言中的接口(Interface),用于为不同类型实现相同的方法集合。
Copytrait :它实现了Clone,是一个标记 trait ,用于表示一个类型的值可以通过简单的位复制来复制,而不会转移所有权。
值的接收者:变量、结构体、枚举、数组和元组、指针、函数、线程
未实现 Copy trait的类型:
-
堆分配的类型,字符串、集合、指针(
String、Vec<T>、HashMap、Box<T>…) -
文件句柄和网络资源(
File、TcpStream…) -
特别:复合类型(数组、元组)、自定义类型(枚举、结构体)中的值/类型不包含以上类型也属于未实现
Copytrait的类型// 正例 let mut arr = [1, 2, 3]; // [i32; 3] 实现了 Copy let arr2 = &mut arr; // arr 的值被复制到 arr2 println!("{:?}", arr); // 正确:arr 仍然有效 // 反例 struct MyStruct { value: String, // String 未实现 Copy } let s1 = MyStruct { value: String::from("hello") }; let s2 = s1; // 所有权从 s1 转移到 s2 // println!("{}", s1.value); // 错误:s1 的所有权已经转移
实现 Copy trait的类型:
- 基本类型(整型、浮点型、布尔型、字符型)
如何避免所有权转移?
- 使用借用机制(引用)
&T / &mut T - 使用
clone()创建副本 - 使用智能指针共享所有权
Rc<T>和Arc<T>(这个后面会讲)
所有权的借用机制
问题:在传统编程语言(如C/C++)中,共享访问数据时容易导致数据竞争、悬空指针以及复杂的资源管理问题,如何在不转移所有权的情况下安全地共享访问数据?
Rust的解决方案:通过借用机制,允许在不转移所有权的情况下共享访问数据。
所有权的借用机制可以允许你在不转移所有权的情况下使用值,它是基于引用实现的(可以理解为就是引用)。
不可变借用: 通过 &T 创建的引用,允许读取数据但不能修改数据。
可变借用: 通过 &mut T 创建的引用,允许读取和修改数据。
借用机制规则: 同一时间,要么只有一个可变引用,要么有多个不可变引用。
let mut s1 = String::from("hello");
let r1 = &mut s1;
// let r2 = &mut s1; // 错误:不能同时有两个可变引用
let mut s2 = String::from("world");
let r2 = &s2;
let r3 = &s2; // 合法:可以有多个不变引用
所有权的生命周期机制
问题:在传统编程语言(如C/C++)中,引用或指针的有效性难以保证,容易导致悬空指针、内存安全问题以及资源管理混乱,如何确保引用在其有效期内始终指向合法的数据?
Rust的解决方案:通过生命周期机制,显式或隐式地标注引用的有效期,确保引用在其生命周期内始终有效。
生命周期是 Rust 中用来描述引用有效作用域的概念。简单来说,生命周期决定了某个引用在程序的哪个部分是有效的。
在 Rust 中,引用(&)是一种指向数据的指针,但它并不拥有数据的所有权。为了避免引用指向的数据被释放后仍然被使用(即悬垂引用),Rust 引入了生命周期来确保引用的有效性。
fn main(){
let r;
{ // 创建一个作用域
let x = 5;
r = &x; // r 引用 x
} // x 在这里被释放
// println!("r: {}", r); // 错误:r 引用的 x 已经被释放
}
在这个例子中,r 引用了 x,但 x 在内部作用域结束后被释放,导致 r 成为一个悬垂引用。Rust 的编译器会检测到这种问题并报错。
生命周期的标注
在大多数情况下,Rust 编译器可以自动推导生命周期,但在某些情况下(函数返回引用时、结构体包含引用时…)编译器难以推导生命周期,这时候就需要程序员手动标注生命周期。
生命周期标注的语法以 ' 开头,通常使用小写字母表示,如 'a。生命周期标注不会改变引用的实际生命周期,它只是告诉编译器多个引用之间的关系。
例子:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
在这个例子中,函数返回一个引用,但编译器无法确定返回的引用是来自 x 还是 y,因此需要手动标注生命周期 'a,表示返回的引用至少和 x 和 y 中生命周期较短的那个一样长。
struct ImportantExcerpt<'a> {
part: &'a str,
}
在这个例子中,ImportantExcerpt 结构体包含一个引用字段 part,因此需要标注生命周期 'a,表示结构体实例的生命周期不能超过 part 的生命周期。
除此之外,Rust 中有一个特殊的生命周期 'static,表示引用在整个程序运行期间都有效。例如,字符串字面量的生命周期就是 'static,因为它们被硬编码到程序的二进制文件中,永远不会被释放。
let s: &'static str = "我是一个静态生命周期的字符串";
更加灵活的内存管理
由于篇幅限制,这里只会讲解基本的概念和用法,具体如何使用什么时候用可以自己去查。(或者等后面更新🌹)
智能指针
为什么需要智能指针?
Rust 的所有权和借用系统确保了内存安全性,但有些场景下,数据需要被多个部分共享或者需要动态分配内存,它们能够提供对内存的精细控制。
Box<T> 指针
用于在堆上分配内存。通过 Box,可以在堆上存储数据并确保该数据在使用结束后自动释放。一般用于需要动态存储大小的场景(动态数组、大数组…)
fn main() {
let s = String::from("Hello, world!");
let boxed_str: Box<str> = s.into_boxed_str(); // 转换为 Box<str>
println!("{}", boxed_str);
}
Rc<T> 指针
它是一个非线程安全的引用计数智能指针(不可变),用于在单线程环境中实现多个所有者共享同一个数据。
use std::rc::Rc;
fn main() {
let a = Rc::new(5); // 创建一个 Rc
let b = Rc::clone(&a); // 克隆了一份智能指针(a和b是一个指针),增加引用计数
println!("a = {}, b = {}", a, b); // 减少a和b的引用计数
} // a和b的引用计数为 0 时,内存释放
Arc<T> 指针
它是一个线程安全的引用计数智能指针(不可变),用于在多线程环境中共享数据。
use std::sync::Arc;
use std::thread;
fn main() {
let a = Arc::new(5); // 在堆上分配一个整数 5,并用 Arc 包装它
let b = Arc::clone(&a); // 克隆 Arc,增加引用计数
let handle = thread::spawn(move || { // 创建一个新线程
println!("a in thread = {}", a);
});
handle.join().unwrap(); // 等待线程结束
println!("b in main = {}", b);
}
Mutex<T> 指针
它是一个用于线程同步的智能指针(可变),通过互斥锁来确保一次只有一个线程可以访问其中的数据,通常配合Arc 一起使用,以便在多线程环境中共享数据。
use std::sync::{Arc, Mutex};
use std::thread;
let counter = Arc::new(Mutex::new(0));
let counter_clone = Arc::clone(&counter)
thread::spawn(move || {
let mut num = counter_clone.lock().unwrap();
*num += 1;
});
RefCell<T> 指针
它提供了内部可变性,允许在不可变上下文的情况下修改数据,通常配合Rc<T>指针一起使用(Rc<RefCell<T>>),以实现共享的可变数据(递归数据结构(树、链表)会用到)。
use std::rc::Rc;
use std::cell::RefCell;
fn main() {
let data = Rc::new(RefCell::new(5)); // 创建一个 Rc<RefCell<T>>,共享可变数据
let data_clone = Rc::clone(&data); // 克隆 Rc,增加引用计
*data.borrow_mut() += 1; // 修改数据
println!("data: {}", data.borrow()); // 输出 6
println!("data_clone: {}", data_clone.borrow()); // 输出 6
}
其他的还有:
Weak<T>(非强引用,用于解决循环引用)、RwLock<T>(读写锁)、Cell<T>(提供内部可变性)、NonNull<T>(表示一个非空指针)、Pin<P>(固定堆上的值)、Cow<T>(写时复制)。
Unsafe 代码块
Rust 语言中用于绕过编译器安全检查的机制。Rust 的所有权和借用规则在大多数情况下可以保证内存安全,但是由于这些规则的限制,在开发某些功能时会大大增加开发难度(双向链表就是个很好的例子)。
在Unsafe 代码块中,这些操作将被允许:
- 解引用裸指针(
*const T和*mut T)。 - 调用
unsafe函数。 - 访问或修改可变静态变量。
- 实现
unsafetrait。
use std::fmt;
unsafe trait MyUnsafeTrait { // 实现 unsafe trait
fn do_something(&self);
}
unsafe impl MyUnsafeTrait for i32 {
fn do_something(&self) {
println!("Doing something unsafe with {}", self);
}
}
unsafe fn dangerous_function(ptr: *mut i32) {
*ptr += 1; // 解引用裸指针并修改值
}
static mut COUNTER: i32 = 0;
fn main() {
unsafe {
dangerous_function(raw_ptr); // unsafe内调用unsafe函数
println!("x : {}", x); // 输出 44
COUNTER += 1; // 访问和修改可变静态变量
println!("COUNTER after modification: {}", COUNTER); // 输出 1
let value = 42;
value.do_something(); // 调用 unsafe trait 的方法
}
}
Allocator 内存管理器
用于管理内存分配和释放的组件。Rust的标准库提供了默认的全局内存管理器,但允许用户自定义内存管理器,以满足特定的内存管理需求。(一般用不上,给大佬用的)
定义在 std::alloc 模块中,提供了灵活的内存管理功能,包括:
allocate:分配内存。deallocate:释放内存。grow和shrink:调整已分配内存块的大小。
Rust如何解决内存安全问题
在了解了Rust强大的所有权系统过后,让我们看看Rust是如何利用所有权系统解决内存安全问题的。
野指针
指向不再有效的内存位置的指针。野指针通常指向非法内存地址或未初始化的内存区域(悬垂指针也是一种野指针),访问野指针可能导致未定义行为、程序崩溃或数据损坏。
悬垂指针是指指针指向的内存已经被释放,但指针仍然保留着该内存地址。访问悬垂指针会导致未定义行为。
Rust解决方法: Rust 通过所有权系统确保每个值都有一个明确的所有者,并在编译时通过借用检查器验证所有引用的生命周期和有效性,避免了野指针的产生。
fn main() {
let x: &i32; // 未初始化的引用
// println!("{}", x); // 编译错误:使用了未初始化的引用
let y = String::from("hello");
x = &y;
// x 被借用,不能再移动其所有权,否则会变得悬垂
// let z = x; // 这里会报错,因为 x 被借用中
println!("{}", y); // 安全地访问借用的值
}
数据竞争
数据竞争是指在多线程环境中,当两个或多个线程同时访问同一个内存位置,并且至少有一个线程在进行写操作时,访问的顺序是未定义的,从而导致程序行为不确定的情况。
Rust解决方法: Rust的所有权系统利用了借用机制,确保在任何给定时间,只有一个线程可以拥有对某个数据的可变引用。
use std::thread;
fn main() {
let mut data = vec![1, 2, 3]; // 创建一个可变的 Vec
// 尝试在多线程中修改 data
let handle = thread::spawn(move || {
data.push(4); // 在子线程中修改 data
});
// 主线程尝试访问 data
println!("{:?}", data); // 编译错误:data 的所有权已经转移到子线程
handle.join().unwrap(); // 等待子线程结束
}
如果需要多个线程共享数据,Rust也有解决方案:使用Arc<T>+Mutex (这里之后会讲,现在就不多说了)
缓冲区溢出
缓冲区溢出是指向缓冲区写入的数据超出了其分配的大小,覆盖了相邻的内存区域。
Rust解决方法: Rust 在编译时对数组和切片的访问做边界检查,确保不会发生缓冲区溢出。当你尝试访问一个超出范围的索引时,编译器会报错,或者运行时会发生 panic(动态索引的情况)。
fn main() {
let arr = [1, 2, 3];
println!("{}", arr[5]); // 运行时 panic: index out of bounds
}
内存泄露
内存泄漏是指程序分配的内存未被释放,导致内存占用不断增加,最终耗尽系统内存。
Rust解决方法: Rust 使用所有权和生命周期机制自动管理内存,确保每个对象的内存只有一个所有者,且在所有者离开作用域时自动释放内存。
fn main() {
let x = String::from("hello");
// x 的所有权在此函数结束时自动释放
} // 离开作用域,x 被自动销毁
双重释放
双重释放是指同一块内存被多次释放,导致内存管理数据结构被破坏。
Rust解决方法: Rust 的所有权规则确保每块内存只有一个所有者,且在所有者离开作用域时自动释放内存。这样,双重释放问题就无法发生,因为内存在转移所有权后不会被再次释放。
fn main() {
let s = String::from("hello");
let t = s; // 所有权转移
// println!("{}", s); // 错误:无法再使用 s,因为它的所有权已经转移
}
空指针
空指针是指指针的值为 NULL 或 nullptr,表示它没有指向任何有效的内存地址。访问空指针会导致程序崩溃。
Rust解决方法: 使用 Option<T> 类型来显式表示可能为空的值。Option<T> 是一个枚举,它有两个变体:Some(T) 和 None,它强制开发者在访问可能为空的值时做空值检查,避免了空指针解引用的问题。
fn main() {
let x: Option<i32> = Some(5);
let y: Option<i32> = None;
match x {
Some(v) => println!("x is {}", v),
None => println!("x is None"),
}
Result
// 访问空值时会报错
// println!("{}", y.unwrap()); //会 panic: called `Option::unwrap()` on a `None` value
}