Rust惑点启示系列(二):从函数中返回一些东西

发布时间:2024-10-14 08:16
最后更新:2024-10-14 13:31
所属分类:
Rust

从函数中返回一个值,看起来是非常简单的一个事情,不过在Rust里,就总是感觉不是那么简单。沿袭自其他语言的习惯,可能我们并没有意识到在一个函数里我们究竟返回了什么内容。但是在Rust这种“手动档”语言里,还就得认真的把它搞清楚。

本文是《Rust惑点启示笔记》系列文章中的第二篇。这个系列的文章主要计划对Rust语言使用过程中经常会出现的一些容易迷惑的惑点进行一个启发式的讨论和记录。

本系列专题还有以下文章:

  1. Rust惑点启示系列(一):避免随意使用Clone
  2. Rust惑点启示系列(二):从函数中返回一些东西
  3. Rust惑点启示系列(三):引用的生命期从来就不够长
  4. Rust惑点启示系列(四):到处都是的大括号
  5. Rust惑点启示系列(五):工具类型太多了
  6. Rust惑点启示系列(六):如何下手编写一个函数
  7. Rust惑点启示系列(七):使用全局变量和单例
  8. Rust惑点启示系列(八):奇形怪状的Rust闭包

又是生命期

从函数中返回一个值,最绕不开的事情就是生命期。这其实也是提现Rust生命期意义的一个最佳位置。我们从Rust编译器收到的报错信息,也往往是某一个值存活的时间不够长,也就是生命期不足。

首先要牢记的一点就是:Rust是不会自动的提升一个内容的生命期的。每一块内存的生命期在它被分配的时候,实际上就已经确定了,Rust不会因为你在函数里把这个内存块作为返回值传递出来就自动延长它的生命期。实际上这种对于自动延长生命期的期望是不对的。

根据Rust的生命期标注是可以发现的,函数中建立的资源所具有的生命期一般最长的都是'a,也就是函数调用时的生命期,一旦当函数执行结束,这个生命期即告终止。所以在函数中直接建立一个调用函数生命期'b或者全局生命期'static的内容是不可行的,也是不被Rust所支持的。

所以要解决这个问题,方法只能是想办法提升返回值的生命期长度。而能够延长一个值的生命期长度的方法,一般只有两个:

  1. 转移这个值的所有权。移动所有权会导致值在内存中的移动,新的内存区块自然会有新的生命期。
  2. 把这个值放在堆上,返回这个值在堆上的引用。

第一种方法其实是比较好理解的,返回值所在内存区域的所有权被移给调用方以后,内存区域发生了移动,从函数中返回的内容自然也就归调用方管理了。例如以下这个示例中,在函数运行结束后,其所有权会发生转移,移交给调用方。

1
2
3
4
5
6
7
8
fn create_tuple() -> (i32, i32) {
  let s = (0, 1);
  s // 返回在函数内部构建的元组,转移所有权
}

fn main() {
  let g = create_tuple(); // 这里g变量取得了从函数create_string返回的元组的所有权。
}

但是要理解第二种方法,就需要先了解一下栈内存和堆内存的区别了。

堆内存和栈内存的区别

栈内存是在函数被调用的时候自动分配的,在函数结束的时候会自动释放。所有函数中使用到的资源,例如函数参数、局部变量等,都是存储在栈内存里。这也是为什么函数会有一个独立的生命期的原因。栈内存的分配和释放非常快,采用LIFO(后进先出)形式的连续分配,没有非常复杂的内存管理,而且对于一个函数调用来说,栈内存的大小是有限的,一般在几MB到几十MB之间。如果在栈内存中存储了过大的内容或者分配非常多的局部变量,那么就非常容易导致栈溢出错误(Stack overflow)。

从函数调用扩展到多线程,每一个线程都是有自己独立的栈的,所以保存在栈上的数据都是线程安全的,在使用的时候无需考虑同步机制问题。

堆内存就不一样了,堆内存里的内容是在程序运行时手动分配的,其中的内容是按照所有权机制释放的。堆内存上存放的内容要复杂的多,大小也是不固定的,但堆内存中内容的一个显著特点就是生命期长,甚至可以在多个函数之间共享,直到被释放为止。而且堆内存的这种共享特性,也决定堆内存是可以在多个线程之间共享的,这就导致访问堆内存的时候往往需要增加额外的同步机制来保证线程安全。

跟栈内存的另一个显著的区别就是堆内存上内容的访问方式。栈内存是可以通过变量名快速访问的,但是堆内存不行。储存在堆内存上的内容只能通过指针进行间接的访问。也就是说需要先访问栈上的指针,然后再通过栈上指针保存的内存地址,跳转到堆上的实际数据地址。

利用堆内存

在了解了堆内存的特点以后,那就能理解解决函数返回值生命期的第二种方法了。只要把返回值放到堆上,那就能给返回值一个超长的生命期。

Rust里常用来控制堆内存的数据结构是BoxRcArc

要记得ArcRc的线程安全版本。

在一个函数中对一个分配在栈上的内容调用Box::new或者Rc::new的时候,原本位于栈上的这个内容就会被移动到堆上。然后在栈上留下指向堆上内容的地址。BoxRc都是Rust中提供的智能指针类型,它们都可以自行管理堆上内容的生命期。

在上面那个示例中,如果使用Box来改写就是下面这样的。

1
2
3
4
5
6
7
8
fn create_tuple() -> Box<(i32, i32)> {
  let s = (0, 1);
  Box::new(s) // 通过调用Box::new,原本在栈上的元组被移动到了堆上,返回的内容实际上是Box这个智能指针。
}

fn main() {
  let g = create_tuple(); // g变量取得智能指针Box的所有权,同时也就获得了堆上元组的访问能力。
}

这个简单的示例其实并看不出来转移所有权和利用堆内存有什么区别,但是在实际程序中如果处理比较大的结构体或者对象,甚至是处理多线程任务,区别就开始显现出来了。

不需要在使用Box类型的时候再在外面套上一层Rc,例如Rc<Box<T>>,这样是没有意义的。因为RcBox都会把其中包裹的内容放在堆上,嵌套使用只是多了一层解引用操作,并没有什么实际的意义。

返回Cow也是可以的

Rust中的智能类型Cow是一个常常被忽略的类型。Cow类型是一个枚举类型,提供了自动的所有权管理功能。在使用Cow的时候,可以使用Cow::Borrowed来承载一个引用,也可以使用Cow::Owned来承载一个具备所有权的内容。而且根据Cow写时复制的特性,在需要修改和所有权的时候,Cow::Borrowed会自动转换成Cow::Owned

Cow::Owned的特性与BoxRc一样,也会将拥有所有权的内容放置在堆内存中。所以在函数中返回一个使用Cow::Owned包装的返回值也是可以的。

Cow::Borrowed是不能确定其包装的内容存放位置的,也可能是存放在栈上,也可能是存放在堆上,这需要根据所引用的内容来确定。

尽量不要在函数中返回Cow::Borrowed包装的返回值。首先Cow::Borrowed承载的是一个内容的引用,这在使用Cow::Borrowed作为返回值的时候就产生了很大的局限性。一个引用如果能够被从函数中返回,那么它的生命期一定要长于返回它的函数才可以,这个限制对于Cow::Borrowed的使用同样有效。所以也就是说,如果要从一个函数中返回一个引用或者Cow::Borrowed,那么这个引用必须是函数从它的外部获得的。

从一个函数中返回引用这件事,需要在代码中使用生命期标注,因为Rust无法推断获取到的引用生命期到底有多长。这种费力不讨好的事情,如果不是确实有把握,还是尽量不要做了吧。

索引标签
Rust
所有权
函数返回值
引用
生命期