原文: Strategies for Returning References in Rust by Bryce Fisher-Fleig.
译者按: 这是 Fisher-Fleig 整理一篇关于从函数/方法中返回引用值的知识。
很显然,对于Rust初学者来说,很容易陷入无法返回函数内的本地变量的泥沼里,尤其是从其它编程语言如Java、Go转过来的程序员,在其它编程语言中很容易的编程方式却在Rust编程语言中行不通。这篇文章可以帮助你理解返回引用的各种方法,包括网友也提供的一些方法。
这次我会演示我在编程中经常和Borrow Checker
有冲突的一些场景,提供一些修改代码以便成功编译的编程模式。
假定我们使用一个数据库连接池去连接一个假想的Postgres数据库。这个假想的库的API需要我们首先使用一个字符串初始化一个连接池。一旦连接池初始化成功,我们就可以调用它的connect
方法得到一个可用的拥有所用权的链接对象进行查询。
为什么在Rust中返回引用这么困难?
让我们从一个单纯幼稚的不可编译的程序开始:
|
|
编译器会报告&Connection
的生命周期不够长(the lifetime of &Connection not lasting long enough)。 为什么呢?编译器想在这个函数的末尾释放connection
但是它又知道我们尝试返回&Connection
这个引用对象。
The book(特指Rust的编程圣经)中很好的介绍了owned value
、borrowing
、lifetime
;但是本文中我会提供我的版本。在接下来的几段章节中我进行了大量的概括和润色。如果你已经理解了为什么我的编码例子不能编译通过的原因,那么你可以跳过对应的章节。
脱离主题,先了解内存分配
计算机有固定大小的物理内存,我们通常称之为“内存”。当我们编写程序时,我们处理的所有数据在程序执行中的某个时刻都在内存中表示。我们将内存分配给特定进程的过程称为“分配内存”(allocating
),将释放内存的所有权称为“释放内存”(“deallocating
)。在最简单的情况下,新分配的内存存储我们变量指向的数据。对于其他类型的数据,我们存储另一个变量的内存地址。我们称第二种类型的变量为“指针”,因为它“指向”其他内存。(我接下来会将“引用”一词与“指针”互用)指针的存在主要是为了(1)避免在内存中重复的数据,(2)提供一个大小可预测的变量,该变量表示大小不可预测的数据(如用户输入或来自网络调用的数据)。
垃圾回收器
许多语言包括Java和Python使用“垃圾收集器”来决定进程何时应该释放内存。每隔一段时间,垃圾收集器就会(1)中断程序员的代码正在执行的任何操作,(2)寻找没有指针指向的内存,(3)释放内存。当程序运行时,垃圾收集器在“runtime”上运行。这意味着程序员可以让垃圾收集器知道何时自动释放内存,这使得使用垃圾收集的语言更容易、更安全地进行编写程序,但有时速度较慢,并且内存使用不可预知。
有些语言不使用垃圾收集器。在C语言中,程序员必须决定何时手动释放内存,这是很难正确执行的。如果程序员在内存释放后试图使用指针,可能会发生一些不好的事情,比如程序崩溃或允许黑客获得root访问权限。Rust的独特之处在于,尽管它不使用垃圾收集器,但它仍然保证指针可以安全使用。
不使用垃圾回收器 Rust是如何保障内存安全的?
Rust编译器使用“所有权”这个隐喻。如果内存是一栋房子,那么只有一个变量是该房子的所有者,而该所有者的死亡引发了一场房地产销售(又称 deallocation)。当程序员为变量分配数据时,变量拥有内存。当所有者超出范围(scope
)并死亡时,该内存将被释放。因为编译器总是知道某个变量何时超出范围,所以它总是知道在编译时何时释放内存,这样,rust程序在运行时就不需要暂停和垃圾收集。
在房地产交易中,所有权可以转让。就像卖房子一样,将一个变量赋值(assign)另一个变量就转移了所有权。我们说值已经被“转移”,就像卖掉我的房子也意味着转移我房子的东西一样,Rust中的转移数据也意味着数据到了内存中的一个新地方。这被称为“move”语义。
您可能会认为,如果移动了大量数据,那么我们可能会浪费内存,因为数据必须至少存在于内存中的两个位置。但是,rust编译器在使用--release
标志时会对代码进行大量优化,因此大多数时候编译器都会检查到我们将要浪费内存,然后简单地重用已有的内存位置,而不是沙雕地复制。
move
语义引入的另一个问题是,通常你希望很多变量能够从程序的不同位置访问相同的数据。所有权似乎使这变得不可能,因为同时只能有一个变量拥有一个内存位置。然而,Rust也允许“借用”内存。程序员通过创建指向内存的指针来借用内存。如果内存是一栋房子,而所有者是一个变量,那么指针就是临时使用该内存的租用者。如果所有者超出scope而死亡,指针就不能合法地使用该内存。Rust编译器就像一个物业管理机构,通过跟踪所有"所有权"的生命周期和借用来确保没有非法占用内存。这个Rust的功能被称为借Borrow Checker
,它的存在,使Rust显得与众不同。
回到代码例子
|
|
回到我们的例子,这里只有一个scope
:函数connect
的函数体。所以,在connect()
的尾部,所有在函数中声明的变量都会被释放。调用connect()
会生成一个在未命名的这个scope中的connection对象,但是因为这个对象在connect()
scope中生成,它也会在这个函数的尾部被释放。这意味着当我们把指向这个connection的指针时,实际connection已经不存在了。这就是编译器想告诉我们的:指针指向了一个函数结尾已经释放的内存。
返回引用的模式
模式一: 返回 Owned Value
这种模式就是方式使用引用,而是返回这个值的一个完全的副本。按照我们的使用场景,这可能时一个比较好的解决方案,这也是最容易安抚 Borrow Checker 的方式。
|
|
代码中又两处改变:
- 去掉函数签名中的返回值的
&..
,使用..
- 去掉函数体中的
&
一些类型,比如str
和Path
,只用作引用,它们又相应的兄弟类型,可以用做owned value
。对于这样的类型,如果我们移除代码中的&
,编译器会报错。下面时一个报错的例子:
|
|
对于只能用作引用的这些类型,请查找它的ToOwned
trait的实现。ToOwned
的工作方式使用共享引用,并将值复制到新的owned引用中。以下是利用ToOwned
特性的一个例子:
|
|
优点:
- 低修改量:通常,将返回值从共享引用转换为
owned value
非常容易。我通常可以用这种技术快速编译和重构一些东西。 - 应用广泛:几乎在任何我们可以返回引用的地方,我们都可以返回该值的owned副本
- 安全: 即使在线程之间move数据,该值的新副本也不会损坏其他位置的内存。
缺点
- 同步:如果更改原始值,则不会更改返回的值。
- 内存:我们可能会因为复制相同的数据而浪费内存(通常不会发生)
对我来说,使用这种技术通常感觉像是一种蹩脚的方案,因为我只想和Borrow Checker谈谈,而不是推翻我的核心代码逻辑。仔细想想有多少地方需要修改这个问题值,或者他们是否都可以简单地读取相同的值就好。如果它是一个不需要改变的值,返回owned value可能是一个很好的解决方案。
模式二: 返回 Boxed Value
让我们将connection的位置从函数栈移到堆中。对于我们自定义的类型,我们需要使用标准库中的 Box
struct 显式地在堆上分配数据。因此,重构上面的代码以使用堆,如下所示:
|
|
有两处改变:
- 函数的签名使用
Box<..>
作为返回值,而不再是&..
- 函数内实例化了一个Box, 通过
Box::new(..)
包装了connnection,而不再使用&..
boxed value 是一个owned struct(非引用),所以它可以从函数中返回,无需招惹 borrow checker。当这个box离开它的scope后,它的内存会被释放。
优点
- 内存:仅有的额外内存是指向堆中数据的指针,指针只用很少的内存啦
- 应用性:几乎所有使用 std的代码都可以使用这种方式
缺点
- 间接:我们需要在类型注解中编写更多的代码,并且我们可能需要了解如何利用Deref trait来处理 boxed value。
- 开销:在堆上分配内存更复杂,这可能会导致运行时有些性能的损失。
对于像usize
、bool
、f32
等其他原始类型的类型,如果我发现自己对这些值进行装箱,它可能是一种代码味道(code smell,指不好的编码方式)。相反,我通常会返回这些类型的副本。
对于动态增长的类型(如Vec
、String
和HashMap
),这些类型已经在内部使用了堆,因此通过装箱来获取不了太多的好处。对于性能或内存使用很在意的场景,请在实际环境下分析您自己的代码,以确定装箱返回值是否相对于其它模式是提高还是降低性能。
和前面的模式一样,您需要知道数据是否要被修改——有多少地方读取这个值?有这些地方你会修改这个值?如果需要的话,结合原子引用计数(Arc
类型)进行装箱可以使跨线程可变共享值成为可能。如果你不需要的话,Box可能只是减慢了你的程序或者浪费了你的内存。
模式三: 将 Owned Value
移动到上面的Scope
这种技术重新组织代码以帮助我们利用传入到函数中的引用。下面是一个例子:
|
|
这个模式利用下面的技术:
- 将 pool和 connection对象移动到函数之外
- 改变函数签名,使用一个connection的引用作为参数,这个引用时函数中我们想修改的
- 返回和传入参数借用的内存相同的引用
在setup_connection()
的尾部,并没有新的声明的新struct返回,这意味着没有内存释放。因为connection在for循环之外声明,它可以在一次迭代中存活。在下一次迭代中,一个新的连接又会被创建。 基本上我们只能得到函数传入参数带来的声明周期。
在这个特殊的例子中, 不管我们调用了setup_connection()
多少次,只有一个Pool对象会分配,相比较其它模式会分配多次pool对象。
优点
- 内存:这种模式避免了堆分配和编写样板代码,因此它的内存效率高,并且代码优雅
- 代码香味: 这种模式通常是良好代码组织的自然结果,可以减少不必要的工作;好的代码“香味”
缺点
- 复杂性:此模式需要深入了解应用程序和数据流,通常意味着需要重写代码的几个不同区域。
- 适用性:此模式多次不能使用
- 刚性:使用此模式可能会使重构代码更加困难
当我们拥有一个“emit”其他借用对象的owned value时,这个模式可能会起作用。如果我们可以将owned struct移动到更高的范围,那么我们可以将引用传给helper函数。当我想要重构和减少内存使用时,我会检查是否考虑使用这个模式。
模式四: 使用回调取代返回值
这个技术由reddit上的mmstick提供。如果你曾经使用JavaScript写过回调函数,或者使用Java中的依赖注入,你就会很熟悉了。最基本的想法时避免和Borrow Checker缠斗,使用一个 closure传入到函数中,例如:
|
|
和模式三的关键区别在于,我们传入一个匿名函数|connection| { .. }
到connect_and_attempt
,我们永远不会返回connection对象。这意味着我们不必和Borrow Checker打交道。
优点
- 优雅:避免与Borrow Checker发生冲突
- 解耦:有助于将应用程序逻辑与I/O或依赖项隔离开来
缺点
- Rust的闭包比JavaScript更复杂,在某些情况下可能需要装箱。
- 复杂度:需要更深入的Rust知识和对closure的掌握。
正如mmstick所指出的,这种模式可以使单元测试非常容易,因为我们可以在不设置完整运行环境的情况下分离出代码块并测试它们的逻辑。我们可以将假的回调传给正在测试的系统中。
补充材料
- Reddit Comments: 我对所有参与Reddit这篇文章的人深感荣幸——我学到了很多东西!
- Let’s Clone a Cow - New Rustacean Podcast: - Chris Krycho解释了如何在满足Borrow Checker的同时,利用Rust中的内存管理进行一些复杂的操作。
更多的模式?
请,如果您有使用超过我在这里列举的模式的经验,请分享它们!我希望将您的模式纳入本文中,作为对Borrow Checker上新手的技术参考。