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

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

相关推荐
咸甜适中5 小时前
rust语言 (1.88) egui (0.32.1) 学习笔记(逐行注释)(十四)垂直滚动条
笔记·学习·rust·egui
张志鹏PHP全栈9 小时前
Rust第四天,Rust中常见编程概念
后端·rust
咸甜适中13 小时前
rust语言 (1.88) egui (0.32.1) 学习笔记(逐行注释)(十五)网格布局
笔记·学习·rust·egui
susnm1 天前
最后的最后
rust·全栈
bruce541102 天前
深入理解 Rust Axum:两种依赖注入模式的实践与对比(二)
rust
该用户已不存在3 天前
这几款Rust工具,开发体验直线上升
前端·后端·rust
m0_480502645 天前
Rust 入门 生命周期-next2 (十九)
开发语言·后端·rust
寻月隐君6 天前
Rust Web 开发实战:使用 SQLx 连接 PostgreSQL 数据库
后端·rust·github
Moonbit6 天前
MoonBit Pearls Vol.05: 函数式里的依赖注入:Reader Monad
后端·rust·编程语言
Vallelonga7 天前
Rust 异步中的 Waker
经验分享·rust·异步·底层