Rust 所有权:值的生与死,由谁来掌控?

发布时间 2023-03-30 22:40:25作者: 古明地盆

楔子

所有权可以说是 Rust 里面非常独特的一个功能了,正是所有权概念和相关工具的引入,Rust 才能在没有垃圾回收机制的前提下保障内存安全。因此正确地了解所有权概念、以及它在 Rust 中的实现方式,对于所有 Rust 开发者来讲都是十分重要的。

所有权概念本身的含义并不复杂,但作为 Rust 语言的核心功能,它对语言的其他部分产生了十分深远的影响。

一般来讲,所有程序都需要管理自己在运行时使用的内存空间,某些带有垃圾回收机制的语言会在运行时定期检查并回收那些不再使用的内存;而在另外一些语言中,程序员需要手动地分配和释放内存。Rust 采用了与众不同的第三种方式:它使用包含特定规则的所有权系统来管理内存,这套规则允许编译器在编译过程中执行检查工作,而不会产生任何的运行时开销

因此所有权是 Rust 能将高效和安全两方面同时兼顾的原因,而本次我们会通过一些示例来学习所有权,这些示例将聚焦于一个十分常用的数据结构:字符串。

栈与堆

在许多编程语言中,程序员不需要频繁地考虑栈空间和堆空间的区别。但对于 Rust 这样的系统级编程语言来说,一个值被存储在栈上还是被存储在堆上,会极大地影响到语言的行为,进而影响到我们编写代码时的设计抉择。由于所有权的某些内容会涉及栈与堆,所以让我们再来复习一下它们。

栈和堆都是代码在运行时可以使用的内存空间,不过它们通常以不同的结构组织而成。栈会以我们放入值时的顺序来存储它们,并以相反的顺序将值取出,这也就是所谓的后进先出(Last In First Out,LIFO)策略。

你可以把栈上的操作想象成堆放盘子:当你需要放置盘子时,你只能将它们放置在最上面,而当你需要取出盘子时,你也只能从最上面取出。换句话说,你没有办法从中间或底部插入、移除盘子。用术语来讲,添加数据这一操作被称作入栈,移除数据则被称作出栈。

所有存储在栈中的数据都必须拥有一个已知且固定的大小,对于那些在编译期无法确定大小的数据,只能将它们存储在堆中(在栈上是不安全的)。

而堆空间的管理较为松散:当你希望将数据放入堆中时,你就可以请求特定大小的空间。操作系统会根据你的请求在堆中找到一块足够大的可用空间,将它标记为已使用,并把指向这片空间的指针返回。这一过程就是所谓的堆分配,它也常常被简称为分配,至于将值压入栈中则不叫分配。

由于指针的大小是固定的,且可以在编译期确定(64位系统固定 8 字节),所以会将指针存储在栈中,也就是栈区的指针指向堆区的数据。

可以把堆上的操作想象成到餐厅聚餐,当你到达餐厅表明自己需要的座位数后,服务员会找到一张足够大的空桌子,并将你们领过去入座。即便这时有小伙伴来迟了,他们也可以通过询问你们就座的位置来找到你们。

向栈上压入数据要远比在堆上进行分配更有效率,因为如果是堆的话,操作系统还要搜索新数据的存储位置,需要额外开销;但栈不用,对于栈而言这个位置永远处于栈的顶端。除此之外,操作系统在堆上分配空间时还必须首先找到足够放下对应数据的空间,并进行某些记录,来协调随后的其余分配操作。

访问数据也是同理,由于指针存在栈上,数据存在堆上,所以要通过指针存储的地址来访问数据。而这会多一步指针跳转的环节,因此访问堆上的数据要慢于访问栈上的数据。一般来说,现代处理器在进行计算的过程中,由于缓存的缘故,指令在内存中跳转的次数越多,性能就越差。

继续使用上面的餐厅来作类比,假设现在同时有许多桌的顾客正在等待服务员的处理。那么最高效的处理方式自然是报完一张桌子所有的订单之后再接着服务下一张桌子的顾客。而一旦服务员每次在单个桌子前只处理单个订单,那么他就不得不浪费较多的时间往返于不同的桌子之间。

出于同样的原因,处理器操作排布紧密的数据(在栈上)要比操作排布稀疏的数据(在堆上)有效率得多。另外,分配命令本身也可能消耗不少时钟周期。

所有权规则

现在让我们来具体看一看所有权规则,先将这些规则记下来,我们随后会通过示例来解释它们:

  • Rust 中的每一个值都有一个对应的变量作为它的所有者;
  • 在同一时间内,值有且仅有一个所有者;
  • 当所有者离开自己的作用域时,它持有的值就会被释放掉;

作为所有权的第一个示例,我们先来了解一下变量的作用域。简单来讲,作用域是一个对象在程序中有效的范围,假设有这样一个变量:

fn f() {
    {
        println!("你好 世界");
        // 从这里开始,变量 s 被声明
        // 我们可以使用这个变量了,之前都是不可用的
        let s = "hello world";  
        println!("{}", s);
    }  // 出了这个大括号,就不在变量 s 的作用域内了
    // 此时再使用 s 这个变量就会报错,s 的值也会被释放掉
    // println!("{}", s);
}

因此要注意两个关键点:

  • 1)变量在进入作用域后变得有效;
  • 2)它会保持自己的有效性直到自己离开作用域为止;

所以 Rust 语言中变量的有效性与作用域之间的关系跟其它编程语言非常类似,现在让我们继续在作用域的基础上学习 String 类型。

String 类型

为了演示所有权的相关规则,我们需要一个特别的数据类型,它比之前介绍的数据类型(比如整型、浮点型等等)都要复杂。因为之前接触的数据都存储在栈上,并在变量离开作用域时自动从栈空间弹出。

但 String 则不同,它的数据存在堆上。而之所以选择 String,是因为我们现在需要通过一个数据存储在堆上的类型,来研究 Rust 如何自动回收这些数据。

我们将以 String 类型为例,并将注意力集中到 String 类型与所有权概念相关的部分,这些部分同样适用于标准库中提供的或者我们自己创建的其它复杂数据类型,另外后续还会更加深入地讲解 String 类型。

首先在最开始的时候我们就接触过字符串,比如 println!("hello world"),这个宏里面就是一个字符串。只不过这种字符串也称作字符串字面量,也就是被硬编码进程序的字符串值。

字符串字面量的确是很方便,但它并不能满足所有需要使用文本的场景。原因之一在于字符串字面量是不可变的,而另一个原因则在于并不是所有字符串的值都能够在编写代码时确定。假如我们想要获取用户的输入并保存,但是我们不知道用户事先会输入多少个字符,这时应该怎么办呢?

为了应对这种情况,Rust 提供了第二种字符串类型 String,来弥补字符串字面量的不足。而 String 类型的字符串会在堆上分配到自己需要的存储空间,所以它能够处理编译时大小未知的文本,我们可以调用 from 函数根据字符串字面量来创建一个 String 实例:

fn main() {
    let mut s = String::from("hello");
}

这里的双冒号运算符允许我们调用置于 String 命名空间下面的特定函数 from,而不需要使用类似于 string_from 这样的名字。我们会在后续着重讲解这个语法,并讨论基于模块的命名空间。

上面定义的字符串对象是可以动态变化的:

fn main() {
    // 我们前面介绍过元组,无论是赋值一个新的元组
    // 还是修改元组内部的某个值,都涉及到值的改变
    // 因此变量都要声明为可变,而字符串也是同理
    let mut s = String::from("hello");
    // 在尾部增加字符串字面量,因为 String 类型的值是可变的
    // 所以变量 s 也要声明为可变,否则调用 push_str 方法报错
    // 因为我们是调用变量 s 来修改 String 类型的值
    s.push_str(" world");
    println!("{}", s);  // hello world
}

所以 String 是可变的,但是字符串字面量不允许改变:

fn main() {
    // s 的值是字符串字面量,它的值无法改变
    let mut s = "hello";
    // 如果想打印 hello world
    // 我们只能给 s 赋值为一个新的字符串字面量
    // 但是我们无法修改原来的字符串字面量
    // 同理整数、浮点数也是如此,它们都无法改变
    // 如果想改只能给变量赋一个新的值
    s = "hello world";
    println!("{}", s);  // hello world
    
    // 而 String 则不同,String 类型的值是可以进行修改的
    // 因为它申请在堆区,大小可变,我们无需重新赋值
    // 而是可以通过 s.push_str 在原本的值上进行修改
}

但是问题来了,为什么 String 是可变的,而字符串字面量不是?这是因为它们采用了不同的内存处理方式。

内存与分配

对于字符串字面量而言,由于我们在编译时就知道内容,所以这部分硬编码的文本被直接嵌入到了最终的可执行文件中。这就是访问字符串字面量异常高效的原因,而这些性质完全得益于字符串字面量的不可变性。但不幸的是,我们没有办法将那些未知大小的文本在编译期统统放入二进制文件中,更何况这些文本的大小还可能随着程序的运行而发生改变。

对于 String 类型而言,为了支持一个可变的、可增长的文本类型,我们需要在堆上分配一块在编译时未知大小的内存来存放数据。这同时也意味着:

  • 我们使用的内存是由操作系统在运行时动态分配出来的,并且内存是堆上的内存;
  • 当使用完 String 时,我们需要通过某种方式来将这些堆内存归还给操作系统;

这里的第一步由我们,也就是程序的编写者,在调用 String::from 时完成,这个函数会请求自己需要的内存空间。在大部分编程语言中都有类似的设计,即:由程序员来发起堆内存的分配请求。

然而对于不同的编程语言来说,第二步实现起来就各有区别了。在某些拥有垃圾回收机制的语言中,GC 会代替程序员来负责记录并清除那些不再使用的内存。而对于那些没有 GC 的语言来说,识别不再使用的内存并调用代码显式释放的工作就依然需要由程序员去完成,和请求分配的时候一样。

按照以往的经验来看,正确地完成这些任务往往是十分困难的,假如我们忘记释放内存,那么就会造成内存泄漏;假如我们过早地释放内存,那么就会产生一个非法变量;假如我们重复释放同一块内存,那么就会产生无法预知的后果。因此为了程序的稳定运行,我们必须严格地将分配和释放操作一一对应起来。

但 Rust 与这些语言都不同,Rust 提供了另一套解决方案:内存会自动地在拥有它的变量离开作用域后进行释放。

fn main() {
    {
        let mut s = String::from("hello");
    }  // 在此处 s 就失效了,因为离开了作用域
       // 并且它的值也会被回收
}

审视上面的代码,会发现有一个很适合用来回收内存的地方:也就是变量 s 离开作用域的地方。Rust 在变量离开作用域时,会调用一个叫作 drop 的特殊函数,来对堆内存进行释放。

这种模式极大地影响了 Rust 的许多设计抉择,并最终决定了我们现在编写 Rust 代码的方式。在上面的例子中,这套释放机制看起来也许还算简单,然而一旦把它放置在某些更加复杂的环境中,代码呈现出来的行为往往会出乎你的意料,特别是当我们拥有多个指向同一处堆内存的变量时。下面就来看一看。

变量和数据交互的方式:移动

Rust 中的多个变量可以采用一种独特的方式与同一数据进行交互。

fn main() {
    let x = 5;
    let y = x;
    println!("x = {}, y = {}", x, y);  
    // x = 5, y = 5
}

这段代码的执行效果很好理解:将整数值 5 绑定到变量 x 上;然后创建一个 x 值的拷贝,并将它绑定到 y 上。结果我们有了两个变量 x 和 y,它们的值都是 5。

这正是实际发生的情形,因为整数是已知固定大小的简单值,所以两个值 5 会同时被推入当前的栈中。但是这针对的也只是存放在栈上的数据,因为栈上的数据由操作系统来维护,函数结束时数据自动回收,不需要我们关心,非常方便,并且数据在栈上复制的效率也是非常高的。

栈上的数据在传递时永远都是拷贝一份,但栈上的内存分配是非常高效的,和堆是天壤之别。只需要改动栈指针(stack pointer),就可以预留相应的空间;把栈指针改动回来,预留的空间又会被释放掉。空间的申请和释放只是动动寄存器,不涉及额外计算、不涉及系统调用,因而效率很高。并且这个过程还是由操作系统自动维护,不需要我们关心。

如果是堆区的数据就不一定了,比如 String,我们将上面的代码改一下。

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
    println!("s1 = {}, s2 = {}", s1, s2); 
}

以上两段代码非常相似,你也许会假设它们的运行方式也是一致的。也就是说,第二行代码可能会生成一个 s1 值的拷贝,并将它绑定到 s2 上。不过,事实并非如此,如果执行上面这段代码,会报出如下错误:value borrowed here after move,至于这个错误是什么意思,我们一会说。

我们先来看一下 String 的内存布局:

String 实际上由 3 部分组成:指向存放具体字符串的指针(ptr)、长度(len)以及容量(capacity),这部分的数据存储在了栈中,图中的左半部分。然后 ptr 指向了字符串存储在堆上的文本内容,图中的右半部分。

长度字段用来记录当前 String 中的文本使用了多少字节的内存,而容量字段则用来记录 String 向操作系统总共申请到的内存字节数量。如果你用过 Go 的话,那么会发现这和 Go 里面切片的结构是一样的。

当把 s1 赋值给 s2 的时候,会把 s1 拷贝一份给 s2,因为 s1 和 s2 都是栈上的数据,所以会直接拷贝一份。我们说过栈上的数据拷贝的效率非常高,和堆根本不在一个层次,并且也不需要我们来维护,只不过大小固定,不能动态变化,毕竟速度摆在那里。但需要注意的是,这里的拷贝仅仅是针对栈上的数据,字符串里面的 ptr 指向的存储在堆区的文本并没有拷贝。

这么做完全可以理解,因为在堆上拷贝数据的效率远不如栈,所以不能像栈那样直接将数据拷贝一份。而且存在堆上的数据也可能会比较大,这样的话拷贝就更加消耗资源了。

然后当一个变量离开它所在的作用域时,它的值就会被释放,这里释放的不仅仅是栈上的值。比如这里的 s1,当离开了作用域之后,释放的不仅仅是栈上的字符串本身,字符串里面的 ptr 指向的堆区的内存同样会被释放,这是显然的。

但是问题来了,s1 和 s2 里面的 ptr 指向的是同一份堆内存,因为将 s1 拷贝给 s2 的时候只拷贝了字符串(结构体),堆内存并没有拷贝,所以如果 s1 和 s2 都离开作用域的时候,那么同一份堆内存不就被释放两次了吗?这就是臭名昭著的二次释放,而重复释放内存可能会导致某些正在使用的数据发生损坏,进而产生潜在的安全隐患。

fn main() {
    {
        let s1 = String::from("hello world");
        let s2 = s1;
    }  // 在此处会调用 drop 函数清理栈上的 s1 和 s2
       // 以及 ptr 指向的堆上的内存
}

但问题是堆上的内存真的会被释放两次吗?很明显不会的,而 Rust 的做法也很简单,为了确保内存安全,同时也避免复制分配堆内存,Rust 会直接将 s1 废弃,不再将其视为一个有效的变量,因此 Rust 也不需要在 s1 离开作用域后清理任何东西。

而以上便发生了所有权的转移,一开始 s1 对堆内存是持有所有权的,但当把 s1 赋值给 s2 的时候就会发生所有权的转移。也就是说 let s2 = s1 之后,s1 将不再具有操控这份堆内存的权利,这个权利被交给了 s2。而所有权一旦转移,那么之前的变量就不能再用了,我们举个例子:

fn main() {
    // s1 此时持有堆内存的所有权
    let s1 = String::from("hello world");
    {
        // 所有权发生转移,s1 不再具有操控堆内存的权利
        // 该权利交给了 s2,之后 s1 就不能再用了
        // 至于堆内存是否被释放则只取决于 s2,和 s1 无关
        // 因为当 let s2 = s1 的那一刻,s1 就已经失去生命了
        let s2 = s1;
    }  // 离开了作用域,在此处会调用 drop 函数清理栈上的 s2
       // 以及 ptr 指向的堆上的内存
       
   // 因此看似 s1 还没有离开自己所在作用域
   // 但实际上它早在 let s2 = s1 的时候就因为所有权的转移而不能再使用了
   // 所以接下来再打印 s1 是会报错的
}

因此一定要理解所有权这个概念。

  • Rust 中的每个值都有一个变量,被称为所有者;
  • 一个值同时只能有一个所有者,如果这个值里面维护了一个指针,该指针指向了一片堆内存,那么这片堆内存同时也只能有一个所有者;
  • 当所有者离开作用域时,该值(连同指向的堆内存)会被删除;
  • 既然变量是值的所有者,那么所有权显然就是变量操作值的权利;

对于 String 类型的变量来说,是值里面有一个指针,这个指针指向了一份堆内存。而一旦发生所有权的转移,那么该变量就没有权利再操作这片堆内存了,因为一片堆内存同时只能有一个所有者。至于后续这片堆内存是否释放、何时释放都和该变量无关。并且发生所有权转移之后,该变量也不能再使用了,当该变量离开自己的作用域时,也不会二次释放堆内存,因为它已经失去对之前这片堆内存的所有权。

所以一旦涉及到变量的所有权时,这些变量的值基本上都是内部会有一个指向堆内存的指针。因为像整数、浮点数、字符串字面量等等这些只会分配在栈上的值,变量传递的时候都是直接把值拷贝一份,既然是拷贝,那么每个变量都拥有不同的值,此时也就不涉及所有权转移啥的。

而 String 类型的变量则不同,因为它们的值虽然在栈上,但是值里面的指针指向的内存在堆上,而该类型的变量在传递的时候不会拷贝堆内存,所以为避免二次释放,此时才会有所有权的转移,因为值和值里面的指针指向的堆内存同时都只能有一个所有者。

因此这一语义完美地解决了我们的问题,既然只有 s2 有效,那么也就只有它会在离开自己的作用域时释放空间,所以再也没有二次释放的可能性了。此外这里还隐含了另一个设计原则:Rust 永远不会自动地创建数据的深度拷贝(堆上数据),那么在 Rust 中,任何自动的赋值操作都可以被视为高效的。

变量和数据交互的方式:克隆

只拷贝栈上数据、不拷贝堆上数据,我们称之为浅拷贝(shallow copy);栈上数据和堆上数据都拷贝,我们称之为深拷贝(shallow copy)。但有时我们确实需要去深度拷贝 String 在堆上的数据,而不仅仅是栈数据时,可以使用一个名为 clone 的方法。我们将在后续讨论该内容,但很明显我们已经在其它语言中见过类似的东西。

fn main() {
    let mut s1 = String::from("hello world");
    // 调用 s1.clone(),那么不仅拷贝栈上的字符串
    // 字符串内的指针指向的真正用来存储文本的堆内存也会拷贝
    let mut s2 = s1.clone();
    // 如果是 let s2 = s1 的话,那么这里打印 s1 就会出错
    // value borrowed here after move
    // 提示的错误信息涉及到了引用和借用,这两个概念后续再聊
    // 暂时可以理解为 Rust 编译器告诉我们:所有权转移之后就不能再使用了
    // 但我们这里是把堆内存也拷贝了一份,所以此时使用 s1 没有问题
    println!("s1 = {}", s1);  // s1 = hello world
    println!("s2 = {}", s2);  // s2 = hello world

    // 修改 s1,不会影响 s2
    s1.push_str("......");
    println!("s1 = {}", s1);  // s1 = hello world......
    println!("s2 = {}", s2);  // s2 = hello world
}

当你看到某处调用了 clone 时,你就应该知道某些特定的代码将会被执行,而且这些代码可能会相当消耗资源。

栈上数据的复制

这些概念上面已经说过了,但是提到了深浅拷贝,所以我们将两者结合起来再说一下。

fn main() {
    let x = 5;
    let y = x;
    println!("x = {}, y = {}", x, y);  
    // x = 5, y = 5
}

这与我们刚刚学到的内容似乎有些矛盾:即便代码没有调用 clone,x 在被赋值给 y 后也依然有效,且没有发生移动现象。这是因为整数可以在编译时确定自己的大小,并且能够将自己的数据完整地存储在栈中(不涉及到堆),而栈上的数据在传递的时候会直接拷贝一份。

这也同样意味着,在创建变量 y 后,我们没有任何理由去阻止变量 x 继续保持有效,因为 x 和 y 拥有的是不同的数据。换言之,如果不涉及到堆,只是栈上的数据,那么深拷贝和浅拷贝没有任何区别。

因此还是之前说的,对于整数、浮点数、字符串字面量这种,它们的数据只会存在栈上,而栈上的数据在传递的时候会直接拷贝一份,所以你的是你的,我的是我的,根本不需要担心所有权的问题。而当涉及到所有权转移时,一定也会涉及到堆,因为堆内存默认不会拷贝,那么为了防止变量失效,需要调用 clone 方法进行深度拷贝(除了拷贝栈上的数据、还拷贝堆上的数据)。

而 Rust 提供了一个名为 copy 的 trait(什么是 trait 后续会详细说),一旦某个类型拥有了 copy 这种 trait,那么它的变量就可以在赋值给其它变量之后仍然保持可用性,显然完全存储在栈上的数据类型都实现了 copy。

而除了 copy,还有一种 trait 叫 drop,我们之前说过,当值涉及到堆的变量在离开作用域的时候会调用 drop 释放堆内存。而一旦某种类型实现了 drop,那么 Rust 就不允许其再实现 copy,因为实现了 copy 就表示变量传递之后仍然可用(数据完全在栈上,不能涉及到堆),实现了 drop 就表示变量离开作用域之后释放堆内存(数据涉及到堆),所以两者是矛盾的。

那么究竟哪些类型是 copy 的呢?我们可以查看特定类型的文档来确定,不过一般来说,任何简单标量类型都是 copy 的,任何需要运行时动态分配内存的类型都不是 copy 的。下面是一些拥有 copy 这种 trait 的类型:

  • 所有的整数类型,诸如 u32;
  • 仅拥有两种值(true 和 false)的布尔类型:bool;
  • 字符类型:char;
  • 所有的浮点类型,诸如 f64;
  • 如果元组包含的所有字段的类型都是 copy 的,那么这个元组也是 copy 的。例如 (i32, i32) 是 copy 的,但 (i32, String) 则不是;

所有权与函数

将值传递给函数在语义上类似于对变量进行赋值,而将变量传递给函数等价于变量的传递,因此同样会触发移动或复制。

fn main() {
    // 变量 s 进入作用域
    let s = String::from("hello");  
    // s 的值作为实参被传递给了函数
    // 等价于变量传递,此时会发生所有权的转移
    takes_ownership(s);  
                        // 所以变量 s 从这里开始将不再有效
                        // 后续不可以再使用 s 这个变量

    let x = 5;  // 变量 x 进入作用域

    // x 的值作为实参被传递给了函数,但 i32 是可 copy 的
    // 或者说它完全是栈上的数据,因此 x 在拷贝之后不受影响
    makes_copy(x);  
                     // 所以接下来我们仍然可以使用这个 x

}  // x 离开作用域,但 x 是栈上的数据,操作系统负责,无需我们关心
   // s 离开作用域,但由于 s 的所有权发生了转移
   // 它不再具有操作堆内存的权利,所以它离开作用域时不会有任何事情发生


                   // some_string 进入作用域     
fn takes_ownership(some_string: String) {  
    println!("{}", some_string);
}  // some_string 离开作用域,drop 函数自动调用
   // some_string 内部的指针指向的堆内存也就被释放掉了
   // 至于 some_string 的值本身(一个结构体),它是位于栈上的
   // 而栈上的数据在函数结束后操作系统会处理它,我们只需要关注堆内存即可


              // some_integer 进入作用域
fn makes_copy(some_integer: i32) {  
    println!("{}", some_integer);
}  // some_integer 离开作用域,但 i32 是栈上数据
   // 操作系统会处理,所以此时不会有任何事情发生
   // 当然这些栈上数据(可 copy)也没有实现 drop
   // 因为不会涉及到堆,而 drop 释放的内存指的是堆内存

总而言之,函数里面的参数也是一个变量,所以把变量传到函数里面,和把变量赋值给另一个变量是等价的。既然等价,那么表现出的行为也是一致的。

返回值与作用域

函数在返回的过程中也会发生所有权的转移,我们举个栗子:

// 该函数会将它的返回值的所有权转移给调用方
fn gives_ownership() -> String {
    // some_string 进入作用域
    let some_string = String::from("hello");  
    // some_string 作为返回值,会将所有权转移给调用方
    some_string   
}

// 该函数会取得一个 String 的所有权并将它作为结果返回
                        // some_string 进入作用域
fn takes_and_gives_back(some_string: String) -> String {  
    // some_string 作为返回值,会将所有权转移给调用方
    // 等于说是先剥夺了所有权,然后又还回去了
    some_string  

}


fn main() {
    // gives_ownership 将它返回值的所有权转移给 s1
    let s1 = gives_ownership();  
    
    // s2 进入作用域
    let s2 = String::from("hello");  

    // s2 进入函数,它的所有权被交给了 takes_and_gives_back 中的 some_string 参数
    // 所以 s2 之后无法再使用,然后该函数将值返回,所有权又交给了 s3
    let s3 = takes_and_gives_back(s2);
                               
}  // s3 离开作用域时会销毁堆内存,但 s2 的所有权已经移动了 
   // 所以它离开作用域时不会发生任何事情
   // s1 最后离开作用域时也会释放堆内存,并且 s1 的所有权从始至终都没有发生转移

变量所有权的转移总是遵循相同的模式:将一个变量赋值给另一个变量时就会转移所有权;当一个持有堆数据的变量离开作用域时,它的数据就会被 drop 清理回收,除非这些数据的所有权移动到了另一个变量上面。

但是在所有的函数中都要先获取所有权、再返回所有权似乎显得有些烦琐,假如你希望在调用函数时保留参数的所有权,那么就不得不将传入的值作为结果返回。除了这些需要保留所有权的值,函数还可能会返回它们本身的结果。我们举个栗子:

// 该函数计算一个字符串的长度
fn get_length(s: String) -> (String, usize) {
    // 因为这里的 s 会获取变量的所有权
    // 而一旦获取,那么调用方就不能再使用了
    // 所以我们除了要返回计算的长度之外
    // 还要返回这个字符串本身,也就是将所有权再交回去
    let length = s.len();
    (s, length)
    // Rust 对类型的要求很严格,计算的长度(以及索引)是一个 usize
    // 所以函数返回值签名里面也要是 usize,不能是 int32
}


fn main() {
    let s = String::from("古明地觉");

    // 接收长度的同时,还要接收字符串本身,将所有权重新 "夺" 回来
    // 当然,如果后续不再使用这个 s,那么也可以放弃所有权
    let (s, length) = get_length(s);
    println!("s = {}, length = {}", s, length); 
    /*
    s = 古明地觉, length = 12
    */
    // 从返回的结果也可以看出,Rust 采用了 utf-8 编码,一个汉字 3 个字节
}

但这种写法未免太过笨拙了,因为类似的概念在编程工作中相当常见,所以 Rust 针对这类场景提供了一个名为引用的功能。

关于引用我们下一篇文章介绍。

小结

所有权这个概念本身不难理解,就把它当成是操作堆内存的权利。在 Python 里面,堆内存可以有很多个所有者,并通过引用计数维护所有者的数量,当没有所有者的时候(也就是没有变量引用的时候),那么释放堆内存。但维护引用计数需要额外的开销,并且由于引用计数机制无法解决循环引用,还要有垃圾回收来负责兜底。

而 Rust 就简单了,它让每个堆内存只能有一个所有者,换言之就是只能有一个变量持有对堆内存的所有权。而一旦赋值给新的变量,Rust 就会让所有权发生转移,而不是像 Python 那样让多个变量都持有所有权。这样 Rust 只需要关注持有所有权的那个变量即可,堆内存是否释放,就看持有所有权的变量所在的作用域是否已经结束。

fn main() {
    let s1;
    {   
        // s2 持有对堆内存的所有权
        let s2 =  String::from("古明地觉");
        // 所有权交给 s1,s2 不再具备操作堆内存的权利
        s1 = s2;
    } // 到此 s2 所在的作用域已经结束,s2 会被销毁
      // 但销毁的只是 s2 本身,它内部指针指向的堆内存则不会销毁
      // 因为 s2 不是这片堆内存的所有者,s1 才是
      // 所以堆内存是否销毁只和 s1 有关
    
    // 此处打印 s1,没有问题
    print!("{}", s1);  // 古明地觉
}

所以对于那些已经将所有权交出去的变量,等到所在的作用域结束后,它们在栈上的数据会被自动清理掉,至于内部指针指向的堆区数据则与之无关。并且变量的所有权一旦转移,我们就不能再使用了,当然也不需要再关注了,等到作用域一结束,由操作系统自动将栈上数据清理掉即可。

因此 Rust 保证一份堆内存只能有一个所有者,便可以在不使用垃圾回收的情况下保证内存安全,这是一个非常有意思的设计。而如果确实需要同时存在两个所有者,那么就通过 s.clone() 将堆上数据也拷贝一份,让每个变量持有不同的堆区数据。