Ownership 是 Rust 中一个好玩的概念,很神奇的一种想法。
Ownership 是一套有关内存管理的规则,理解这套规则,首先需要回顾下栈和堆的概念。
栈和堆
栈(stack)
栈是一个特殊的结构,基本的特点是,先进后出(first in, last out)。此外,语言层面,还会做一些额外限制。例如,存储在栈内的数据,必须有确定的大小。
先进后出,保证执行顺序靠前的数据,不会在后续代码执行中,提前被删除。一定是后续代码出栈后,才会轮到前面的出栈。进出操作,只涉及到栈顶,新入栈数据的地址分配也会简单。
例如,下面代码(概念性的就使用 js 举例了)。代码从上到下执行,执行到 let s = 't'
,会在栈上分配空间,存储下 s 这个变量。随后,出现一个新的代码块,此时会执行进栈操作,推入新的代码块。在新的代码块执行完成前,外层代码块不能执行出栈操作,也就保证了 s 在内层代码块中,一定可以访问。
rust
{
let s = 't'; // s 进栈
{
println!("{}", s); // 代码执行时,一定能访问到 s
}
}
栈内数据大小确定,可以提升性能。如果每个数据都有确定大小,每个数据对应地址就非常好计算。读取操作,就会很快。同时可以保证,栈中间部分数据,存储空间大小不变,不会在改变存储大小后,导致数据越界,修改覆盖掉后面的数据。
堆(heap)
相比之下,堆的限制就小一些。栈中数据,需要具有确定的大小。代码编写过程中,常遇到需要不确定大小的数据,或者大小可变的数据,这些数据可以存储堆上。
堆存储数据,有一个内存分配的过程。拿到一个数据后,分配器,需要在堆上查找一个可以放下数据的空间,存入数据。分配完成后,返回一个指针。指针的大小是确定的,意味着指针可以存储在栈上。需要数据时,根据指针查找真实的数据。
存储的灵活,意味着牺牲了一部分性能。每次操作堆,需要查找合适大小的空间。也可能进行一些处理,优化下次分配,例如移动一些数据位置,减少碎片化的存储空间。数据大小可变,意味着存储空间上可能不连续,这样查找的消耗也会比较大。
栈上的数据存储和释放,是代码执行中自动操作的,无需过多关心。容易出现问题的就是指针,也就是堆上的数据。
堆没有自动出栈的操作,也就意味着数据可能不被释放,造成内存泄漏。不同的语言有不同的要求,一些要求程序员必须手动释放内存。还有一些语言拥有垃圾回收(garbage collection),会定期查找不再使用的数据,释放掉对应空间。
Rust 想出一个很神奇的折中方案。
Ownership Rules
代码块是一个很重要的概念,有了代码块,我们就可以保证一部分代码的独立性。更重要的是,代码块中可以命名重复的变量名。
例如,下述代码:
rust
{
let a = 1;
println!("{}", a); // 1
{
let a = 2;
println!("{}", a); // 2
}
println!("{}", a); // 1
}
考察内层代码块中变量 a,会发现它只在内部代码块中有效,出了代码块就会被释放,正是栈所做的。如果 a 指向堆上的数据,当然也可以自动释放。
也就是说 Rust 希望通过一套规则,使得堆上数据可以像栈上一样,可以自动释放,就是 Ownership Rules。规则包括三条:
- 所有的数据都有 owner。
- 同一时间,一个数据只能有一个 owner。
- 当数据的 owner 离开作用域时,数据也会被 drop(清空)。
上述的例子,并不需要完全用到这些限制,如果涉及到数据复制,就能体现 Ownership Rules 的作用了。
针对栈和堆上的数据,复制时通常采取两种不同的策略。
栈上的数据复制时,会在栈上开辟空间,复制一份数据。这样两个变量各自对应不同的地址,销毁一者,并不会影响另一个变量的有效性。
堆上数据则不然,性能考虑,一般会复制指针,也即浅拷贝(shallow copy),Rust 中叫 move,此时就会有多个变量指向同一个地址。
例如,下述代码:
ini
let s = String::from("anything");
{
let s1 = s;
println!("{}", s1)
}
代码执行过程中,先在堆上分配了一片空间,假设为 a,a 中存储了 anything 字符串。随后将一个指向 a 的指针,赋值给 s。然后将 s 复制给 s1,s 是指针,s1 同样也是一个指针,均指向 a。当内部代码块执行完毕时,无法确定,a 空间是否需要释放。s1 虽然已经离开作用域,外层还有一个 s 同样指向 a。
Ownership Rules 要求同一时间,只能有一个 owner,let s1 = s;
就会将 a 空间的 ownership 转移到 s1。这样,你就不用考虑,何时手动释放空间,或者等待 GC 去回收。当 s1 离开作用域,a 空间的数据就会被释放,无法访问。
如果,写了下述代码,编译器就会报错提示:
rust
let s = String::from("anything");
{
let s1 = s;
println!("{}", s1)
}
println!("{}", s) // 报错 borrow of moved value: `s`
Ownership 转移后,s1 离开 scope 对应数据 drop 后,s 指向的数据源已经被销毁,不能访问。
如果希望,深拷贝一份数据,Rust 也提供方法。如果希望 s 不失去 ownership,可以直接深拷贝一份。
rust
let s = String::from("anything");
{
let s1 = s.clone(); // 深拷贝
println!("{}", s1)
}
println!("{}", s) // 报错 borrow of moved value: `s`
对于函数来说也是一样的,Ownership 要解决的是堆上数据何时释放的问题。指针被当作参数传入函数,函数执行完成,参数的作用域也就结束了,根据 ownership rules 此时堆上数据需要释放。
需要注意的是返回值,一个值被返回,意味着我们希望外部去获取它的 ownership。函数执行结束,内部的变量已经失效,只有外部接收的变量拥有数据的 ownership,并不违反 ownership rules。
rust
fn main() {
let s = String::from("anything");
let s1 = take_and_give(s);
println!("{}", s1);
println!("{}", s); // 报错,此时 s 指向数据已经 drop
}
fn take_and_give(s: String) -> String {
println!("{}", s);
let s1 = String::from("new one");
s1 // s1 被返回,ownership 被转移
}
简单粗暴的处理方式,同时很有效。习惯了GC,可能会对这种要求不适应。我个人觉得就是, Rust 把深拷贝,浅拷贝的思考过程显化了。通常写代码,只有遇到部分需要深拷贝时候,才会考虑两者的区别。Ownership 迫使你主动思考,这里需要怎么处理,习惯了也没有那么吓人。
更多的信息可以看这里,大佬写的更清晰