- borrow语义、deref、Copy语义
- Rc和Arc, RefCell和Mutex/RwLock
- 生命周期、内存管理
borrow语义
- Borrow 语义通过引用语法(
&
或者&mut
)来实现 - Box 是 Rust 下的智能指针,它可以强制把任何数据结构创建在堆上,然后在栈上放一个指针指向这个数据结构
- 引用是一个受控的指针,指向某个特定的类型
- Rust 没有传引用的概念,Rust 所有的参数传递都是传值,不管是 Copy 还是 Move。所以在 Rust 中,你必须显式地把某个数据的引用,传给另一个函数。
- Rust 的引用实现了 Copy trait,所以按照 Copy 语义,这个引用会被复制一份交给要调用的函数
- 堆变量的生命周期不具备任意长短的灵活性,因为堆上内存的生死存亡,跟栈上的所有者牢牢绑定。而栈上内存的生命周期,又跟栈的生命周期相关,所以我们核心只需要关心调用栈的生命周期。
- 在同一个作用域下有多个可变引用,是不安全的
- Rust 对可变引用的使用做了严格的约束
- 在一个作用域内,仅允许一个活跃的可变引用
- 活跃,就是真正被使用来修改数据的可变引用,如果只是定义了,却没有使用或者当作只读引用使用,不算活跃。
- 在一个作用域内,活跃的可变引用(写)和只读引用(读)是互斥的,不能同时存在
deref
deref 的用途不是模拟继承。它最根源的需求是为智能指针提供对内部数据的方便的访问:https://doc.rust-lang.org/std/ops/trait.Deref.html。当你需要你的数据结构在使用时用起来可以感觉和内部的数据类似时,可以使用 Deref trait。比如我可以构建一个 Memmap 结构,把文件 mmap 到内存中,但如果我提供一系列额外的接口,会让使用者很不方便,但我把它 deref 到 &[u8],让用户操作起来像一个内存 buffer,用起来就很舒服
我的建议:
-
简单的数据结构的封装。像我 DataSet 的使用那样。
-
智能指针。比如你要实现一个 SmartString,在 < 24 字节时使用栈上的内存,更大的字符串才使用 String。这样的场合,如果不用 Deref,使用起来会非常不友好。
Copy特性
按位复制,等同于 C 语言里的 memcpy。
C 语言中的 memcpy 会从源所指的内存地址的起始位置开始拷贝 n 个字节,直到目标所指的内存地址的结束位置。但如果要拷贝的数据中包含指针,该函数并不会连同指针指向的数据一起拷贝。
Rust 在设计时就已经保证了你无法为一个在堆上分配内存的结构实现 Copy。所以 Vec / String 等结构是不能实现 Copy 的。因为这条路已经被堵死了:Copy trait 和 Drop trait 不能共存。一旦你实现了 Copy trait,就无法实现 Drop trait。反之亦然。
Rc/Arc和RefCell/Mutex/RwLock
Rc类型的模型:
Box::leak机制
- Rust中,堆的生命期由创建它的栈的生命期决定
Box::leak()
让内存可以像 C/C++ 那样,创建不受栈内存控制的堆内存,从而绕过编译时的所有权规则-
Box::leak(),创建的对象,从堆内存上泄漏出去,不受栈内存控制,是一个自由的、生命周期可以大到和整个进程的生命周期一致的对象
RefCell
用 let mut 显式地声明一个可变的值,或者,用 &mut 声明一个可变引用时,编译器可以在编译时进行严格地检查,保证只有可变的值或者可变的引用,才能修改值内部的数据,这被称作外部可变性(exterior mutability),外部可变性通过 mut 关键字声明
要绕开这个编译时的检查,对并未声明成 mut 的值或者引用,也想进行修改。也就是说,在编译器的眼里,值是只读的,但是在运行时,这个值可以得到可变借用,从而修改内部的数据,这就是 RefCell 的用武之地
Arc和Mutex/RwLock
- 数据在多线程环境中的共享,需要用
Arc
和Mutex/RwLock
来包装 - Arc 内部的引用计数使用了 Atomic Usize ,而非普通的 usize。
- Atomic Usize 是 usize 的原子类型,它使用了 CPU 的特殊指令,来保证多线程下的安全
- Rust 实现两套不同的引用计数数据结构,完全是为了性能考虑
- 如果不用跨线程访问,可以用效率非常高的 Rc
- 如果要跨线程访问,那么必须用 Arc
- RefCell 也不是线程安全的,如果我们要在多线程中,使用内部可变性,Rust 提供了 Mutex 和 RwLock
- Mutex 是互斥量,获得互斥量的线程对数据独占访问
- RwLock 是读写锁,获得写锁的线程对数据独占访问,但当没有写锁的时候,允许有多个读锁。
- 总线
- 如果想绕过“一个值只有一个所有者”的限制,我们可以使用 Rc / Arc 这样带引用计数的智能指针
- 如果想要修改内部的数据,需要引入内部可变性,在单线程环境下,可以在 Rc 内部使用 RefCell;在多线程环境下,可以使用 Arc 嵌套 Mutex 或者 RwLock 的方法
所有权 | 访问方式 | 数据 | 不可变借用 | 可变借用 |
---|---|---|---|---|
单一所有权 | 单线程 | T | &T | &mut T |
Rc<T> |
&Rc<T> |
无法得到可变借用 | ||
Rc<RefCell<T>> |
v.borrow() | v.borrow_mut() | ||
共享所有权 | 多线程 | Arc<T> |
&Arc<T> |
无法得到可变借用 |
Arc<Mutex<T>> |
v.lock() | v.lock() | ||
Arc<RwLock<T>> |
v.read() | v.write() |
内存管理
- 栈内存“分配”和“释放”都很高效,在编译期就确定好了,因而它无法安全承载动态大小或者生命周期超出帧存活范围外的值。
- 所以,我们需要运行时可以自由操控的内存,也就是堆内存,来弥补栈的缺点
- 但堆内存的灵活也导致了各种问题
- Rust 的创造者们,重新审视了堆内存的生命周期,发现
- 大部分堆内存的需求在于动态大小,
- 小部分需求是更长的生命周期
- 所以它默认将堆内存的生命周期和使用它的栈内存的生命周期绑在一起,并留了个小口子 leaked 机制,让堆内存在需要的时候,可以有超出帧存活期的生命周期。
值的创建
- 编译时可以确定大小的值都会放在栈上
- 堆
- 大小无法确定
- 或者它的大小确定但是在使用时需要更长的生命周期,就最好放在堆上
struct 数据结构
-
Rust 在内存中排布数据时,会根据每个域的对齐(aligment)对数据进行重排,使其内存大小和访问效率最好。使用
#[repr]
宏可以不作优化
enum 数据结构
enum
是一个标签联合体(tagged union),它的大小是标签的大小,加上最大类型的长度- 对于
Option<u8>
,其长度是 1 + 1 = 2 字节,而
Option`,长度是 8 + 8 =16 字节 - 虽然 tag 只占 1 个 bit,但 64 位 CPU 下,引用结构的对齐是 8,所以它自己加上额外的 padding,会占据 8 个字节,一共 16 字节
- Option 配合带有引用类型的数据结构,比如 &u8、Box、Vec、HashMap ,没有额外占用空间
- 因为引用类型的第一个域是个指针,而指针是不可能等于 0 的
- 我们可以复用这个指针:
- 当其为 0 时,表示 None
- 否则是 Some,减少了内存占用
vec 和 String
- String 和 Vec
占用相同的大小,都是 24 个字节
值的使用
- 一个值如果没有实现 Copy,在赋值、传参以及函数返回时会被 Move
- 都是浅层的按位做栈内存复制(浅拷贝),对应按位做堆内存复制(深拷贝)
-
Copy和Move的效率高,而Clone效率低
值的销毁
- 所有者离开作用域,它拥有的值会被丢弃,使用 Drop 特性
-
drop释放顺序
- 先释放 HashMap 下的 key
- 然后释放 HashMap 堆上的表结构
- 最后释放栈上的内存
- rust 编译器保证当一个值离开作用域的时候,这个值不会有任何人引用,它占用的任何资源,包括内存资源、文件资源、socket连接资源,都可以立即释放,而不会导致问题
总结
真正需要管理的内存可以分为两种: 数据都在栈上, 部分数据在堆上部分数据在栈上.比如i32都在栈上, string则部分在堆上部分在栈上.
对于数据都在栈上的内存对象,我们可以实现copy Trait,这样用起来很方便.类似其他语言的值拷贝.在传递的时候,内存对象会拷贝一份.标准提供的很多基本类型都实现了copy Trait,比如i32, usize, &str
当然自定义的数据结构,比如结构体,你也可以不实现copy Trait,那么这里就牵扯到内存对象所有权move的问题.无论内存对象是仅在栈上还是混合的,在转移对象所有权时,栈上的内容是完整复制过去的,指向堆的指针也会复制过去.同时,旧的栈对象无法再使用.
然后rust是依赖仿射类型控制资源最多只能被使用一次,实现了Copy
特性的类型,可以复制自己而不被消耗,否则只要使用了就被消耗。
- 每个借用都不能超出其引用对象的作用域范围
这里还有另一个问题,有一些比较大的内存对象,我们不希望经常拷贝来拷贝去,那么就需要实现类似引用的功能. rust为了避免悬垂指针,就引入了生命周期的概念.
每个对象和每个借用都有其生命周期标注. 在大多数情况下,该标注都是编译器自动添加和检查的.
但是还是有部分场景是编译器无法在编译期确定的,这就需要开发者手动添加生命周期标注,来指明各借用及其对象间的关系.
编译器则会在函数调用方和实现方两侧进行检查,只要能通过检查,至少是内存安全的.
为什么需要生命周期标注?
我想可能还有种原因是为了编译的速度,rust是按各函数单元编译的.因此无法按照调用链做全局分析,所以有些从上下文很容易看出来的生命周期标注,rust依然需要开发者去标注.
在标注的时候,还是要牢记: 可读不可写,可写不可读.可变引用有且只能有一个;
所有权问题
数组简单易用,在实现上使用的是连续的内存空间,可以借助CPU的缓存机制,预读数组中的数据,所以访问效率越高。而链表在内存中并不是连续存储,所以对CPU缓存不友好,没有办法预读
变量的移动
- 先关断是不是真的要把变量移动到新的作用域?
- 如果不需要,可以用借用
- 如果需要
- 是不是多个所有者共享同一个数据
- Rc/Arc/Cell/RefCell/Mutex/RwLock
- 不需要共享
- Clone/Copy
- 是不是多个所有者共享同一个数据
生命周期
而在 Rust 中,除非显式地做 Box::leak() / Box::into_raw() / ManualDrop 等动作,一般来说,堆内存的生命周期,会默认和其栈内存的生命周期绑定在一起。
动态与静态生命期
let ans = "42";
let no_ans = ans;
生成的MIR如下:
let _1: &str;
scope 1 {
debug ans => _1;
let _2: &str;
scope 2 {
debug no_ans => _2;
}
}
这里的scope1
和scope2
就是生命期。
- 生命期的作用
- 生命期往往在函数声明中使用,因为函数本身携带的信息,就是编译器在编译时使用的全部信息。
- 我们在函数签名中提供生命周期的信息,也就是生命周期标注(lifetime specifier)。
- 在生命周期标注时,使用的参数叫生命周期参数(lifetime parameter)。
- 通过生命周期标注,我们告诉编译器这些引用间生命周期的约束。
- 生命周期标注的目的是,在参数和返回值之间建立联系或者约束。
- 调用函数时,传入的参数的生命周期需要大于等于(outlive)标注的生命周期。
- 当每个函数都添加好生命周期标注后,编译器就可以从函数调用的上下文中分析出,在传参时引用的生命周期是否和函数签名中要求的生命周期匹配。如果不匹配,就违背了“引用的生命周期不能超出值的生命周期”,编译器就会报错。
fn max<'a>(s1: &'a str, s2: &'a str) -> &'a str;
对于这个函数签名,只要保证调用max(arg1, arg2)
时,arg1和arg2的生命期大于'a
的生命期就可以编译通过
生命期
- 函数返回引用时
- 返回的引用一定是和输入有关的(除非是返回静态引用)
- 只要弄清输入与返回的关系
- 这只有函数签名有关,也函数内部无关
-
比如HashMap的get
pub fn get<Q: ?Sized>(&self, K: &Q) -> Option<&V> where K: Borrow<Q>, Q: Hash + Eq
- 返回的
&V
与函数内部无关,只需要考虑它和&self
和K: &Q
的关系 - 因为
K: &Q
只是用来查询的,所以这个&V
一定是和self
有关,因为它是属于HashMap的一部分
- 返回的
- 返回的数据如果与输入参数无关,那么它一定是在函数内部创建的,这时要返回带所有权的数据
- 即使是返回引用,也要是
clone()
或to_owned()
,从引用中获取所有权 - 一般是 clone(),得到有所有权的数据。比如 &str -> String
- 即使是返回引用,也要是
- 返回的引用一定是和输入有关的(除非是返回静态引用)
早绑定与晚绑定
fn f<'a>() {}
fn g<'a: 'a>() {}
fn main() {
let pf = f::<'static> as fn(); // late bound
let pg = g::<'static> as fn(); // early bound
print!("{}", pf == pg);
}
晚绑定的pf
要用编译器自己计算出生命周期,不能手动指定
struct Buffer {
buf: Vec<u8>,
pos: usize,
}
impl Buffer {
fn new() -> Buffer {
Buffer {
buf: vec![1,2,3, 4, 5,6],
pos: 0,
}
}
// 这里声明生命周期参数,是晚绑定
fn read_bytes<'a>(&'a mut self) -> &'a [u8] {
self.pos += 3;
&self.buf[self.pos-3..self.pos]
}
}
fn print(b1 :&[u8], b2: &[u8]) {
println!("{:#?} {:#?}", b1, b2)
}
fn main() {
let mut buf = Buffer::new();
let b1 = buf.read_bytes(); // don't work
//let b1 = &(buf.read_bytes().to_owned());
let b2 = buf.read_bytes();
print(b1,b2)
}
这里let b1 = buf.read_bytes()
的错误提示是:
cannot borrow `buf` as mutable more than once at a time
second mutable borrow occurs hererustcE0499
main.rs(26, 14): first mutable borrow occurs here
main.rs(29, 11): first borrow later used here
第1次可变借用在let b1 = buf.read_bytes()
这里,第1次的只读借用在print(b1, b2)
的b1
这里。
因为
fn read_bytes<'a>(&'a mut self) -> &'a [u8] {
...
}
是晚绑定,由let b1 = buf.read_bytes()
来确定read_bytes<'a>
中'a
的值,而这个值又给了&'a mut self
,所以导致b1
的生命周期和b1
的生命周期一致,这样在
let b1 = buf.read_bytes(); // don't work
let b2 = buf.read_bytes();
print(b1,b2)
这个范围内,有1次可变借用buf.read_bytes() = Buffer::read_bytes(&mut buf)
和1次只读借用print(b1, .) = print(&buf, ·)
要解决这个问题,就要把晚绑定修改成早绑定
fn main() {
let v = vec![1,2,3, 4, 5,6];
let mut buf = Buffer::new(&v);
let b1 = buf.read_bytes();
let b2 = buf.read_bytes();
print(b1,b2)
}
fn print(b1 :&[u8], b2: &[u8]) {
println!("{:#?} {:#?}", b1, b2)
}
struct Buffer<'a> {
buf: &'a [u8],
pos: usize,
}
// 这里声明了生命周期参数,这就是早绑定
impl<'b, 'a: 'b> Buffer<'a> {
fn new(b: &'a [u8]) -> Buffer {
Buffer {
buf: b,
pos: 0,
}
}
fn read_bytes(&'b mut self) -> &'a [u8] {
self.pos += 3;
&self.buf[self.pos-3..self.pos]
}
}
因为是早绑定,所以在
let v = vec![1,2,3, 4, 5,6];
let mut buf = Buffer::new(&v);
这里,就已经把’a
的生命周期参数确定了,而'a: 'b
又确定'b
的长度比'a
的要短,所以可以让
let b1 = buf.read_bytes(); // = Buffer::read_bytes(&mut buf)
let b2 = buf.read_bytes();
print(b1,b2) // print(&b1, ·)
这里的 &'b mut buf
的借用只到这句话就可以结束,不会延长到下面的print(b1, ·)
处,相当于:
let b1 = Buffer::read_bytes(&'b mut buf); // 'b的作用域不到print
print(&'a b1, ·)
这样在'a
的作用域就不会有&mut
和&
共存了
泛型的生命周期
trait Trait {
fn f(self);
}
impl<T> Trait for fn(T) {
fn f(self) {
print!("1");
}
}
impl<T> Trait for fn(&T) {
fn f(self) {
print!("2");
}
}
fn main() {
let a: fn(_) = |_: u8| {};
let b: fn(_) = |_: &u8| {};
let c: fn(&_) = |_: &u8| {};
a.f(); // 1
b.f(); // 1
c.f(); // 2
}
- 因为
fn(_)
的类型就是fn(T)
|_: u8|
和|_: &u8|
都是T
impl<T> Trait for fn(&T)
本质是
impl<'a, T> Trait for fn(&'a T)
而
let c: fn(&_) = |_: &u8| {};
的本质是
let c: fn(&'a _) = |_: &'a u8| {}a
这样
let b: fn(_) = |_: &u8| {};
相当于
let b: fn(_) = |_: &'x u8| {}; // 'x是晚绑定,由编译器计算出
因为类型fn(_)
不是fn(&'a _)
所以它仍然属于fn(T)
unwrap()
-
使用
unwrap()
表示确定不会出现panic
if v.is_empty() { return None; } // 前面已经确保不会出现panic了 let a = v.pop().unwrap();
-
不然要用
match
或?
处理
全局变量
use lazy_static::lazy_static;
use std::collections::HashMap;
use std::sync::{Mutex, Arc};
lazy_static! {
static ref HASHMAP: Arc<Mutex<HashMap<u32, &'static str>>> = {
let mut m = HashMap::new();
m.insert(0, "a");
m.insert(1, "b");
m.insert(2, "c");
Arc::new(Mutex::new(m))
}
}
fn main() {
let mut map = HASHMAP.lock().unwrap();
map.insert(3, "d");
}
*的解引用
智能指针有个特点,*
解到原型,&*
就是获取数据的引用,单&
栈上结构体的地址
*
因为会解出原型,所以原数据是否实现copy trait,否则会move,智能指针就没有所有权了
所以 *
不能直接使用在诸如 String / Vec 这样数据结构的引用上
fn main() {
let s = "hello".to_string();
let r1 = &s;
// 把
let s1 = *r1;
println!("{:?}", s1);
}
*s
相当于C语言的*(T_TYPE *)s