Ownership

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 迫使你主动思考,这里需要怎么处理,习惯了也没有那么吓人。

更多的信息可以看这里,大佬写的更清晰

相关推荐
Hello.Reader12 小时前
为什么选择 Rust 和 WebAssembly?
开发语言·rust·wasm
yinhezhanshen12 小时前
rust 的Clone
开发语言·rust
Kapaseker17 小时前
Bevy 时间
rust·游戏开发
Ho1aAs18 小时前
『Rust』Rust运行环境搭建
开发语言·后端·rust
CS semi19 小时前
Rust从入门到实战
开发语言·后端·rust
Aspirin_Slash2 天前
【Tauri2.0教程(四)】对话框插件的使用
rust
bruce541102 天前
Rust学习之实现命令行小工具minigrep(一)
rust
__Benco2 天前
OpenHarmony子系统开发 - Rust编译构建指导
开发语言·人工智能·后端·rust·harmonyos
Yeauty2 天前
三分钟掌握视频剪辑 | 在 Rust 中优雅地集成 FFmpeg
rust·ffmpeg·音视频·音频·视频
Source.Liu2 天前
【CXX】6.7 SharedPtr<T> — std::shared_ptr<T>
c++·rust·cxx