03 Rust:为什么不能在同一个结构体中存储一个值和对该值的引用?

LIYI约 4076 字大约 14 分钟

03 Rust:为什么不能在同一个结构体中存储一个值和对该值的引用?

基本把下面问题这个搞明白,就能彻底明白Rust语言的生命周期是怎么回事了。简而言之,生命周期不会改变你的代码,是你的生命控制生命周期,而不是生命周期在控制你的代码。换言之,生命周期是描述性的,而不是规定性的。

原文:https://stackoverflow.com/questions/32300132/why-cant-i-store-a-value-and-a-reference-to-that-value-in-the-same-struct,作者:[kmdreko](https://kmdreko.github.io/)open in new window

问题

能否在同一个结构体中,同时存储一个值和对该值的引用?

示意图
示意图

这个问题很有意思,在一个含有自动GC(垃圾回收)功能的编程语言里,在一个数据结构内同时存储一块数据及该数据的引用,这是非常容易的事,举个例子:

// JS
let data = ["a", "b", "c"]
let obj = {data, ref: data}
console.assert(obj.data[0] === obj.ref["0"]) // 断言正常,值均是'a'

这是一个JS语言示例,这个例子很简单,obj.data[0]与obj.ref["0"]虽然访问方法不同,但异曲同工,指向了同一块内存地址。obj.data是一块数据,obj.ref是指向这块数据的引用。

C语言示例

由于JS中没有指针,演示这个问题可能不是很合适,下面我们看另一个C语言示例:

#include <stdio.h>

int main()
{
	struct 
	{
		char data[3];
		char *ref;
	} obj = {{'a','b','c'}};
	obj.ref = obj.data;
	
   printf("%s = %s\n", obj.data, obj.ref);// Output:abc = abc
   
   return 0;
}

在这个示例中,obj.data是一个字符数组,obj.ref是指向这块字符数组数据的指针,它们同时位于一个结构体内,满足了问题假设。

将一个值,和对该值的引用,同时存储于一个结构体内,这在C、C++等可操作指针的编程语言中没有任何问题。

然而,这在Rust中却成了问题。

Rust问题示例

示例代码1open in new window

struct Thing {
    count: u32,
}

struct Combined<'a>(Thing, &'a u32);

fn make_combined<'a>() -> Combined<'a> {
    let thing = Thing { count: 42 };
    Combined(thing, &thing.count)
}

第5行,这是一个元组结构体,它有两个成员,第一个是Thing类型,第二个是u32类型。代码的本意是,在Combined结构体内,同时存储数据Thing,及指向该Thing实例中u32真实数据的指针(Thing类型中的count是u32类型)。

编译这段代码,不出意外的话,会得到两个编译错误:

error[E0515]: cannot return value referencing local data `thing.count`
error[E0382]: borrow of moved value: `thing`

**为什么会报错?**你先想一下。

下面接着再看第二个代码示例2

struct Thing {
  count: u32,
}

impl Thing {
  fn new() -> Thing {
    Thing { count: 0 }
  }
}

struct Combined<'a>(Thing, &'a Thing);

fn make_combined<'a>() -> Combined<'a> {
  let thing = Thing::new();
  // error[E0515]: cannot return value referencing local variable `thing`
  // error[E0382]: borrow of moved value: `thing`
  Combined(thing, &thing)
}

fn main() {
  make_combined();
}

在该示例中,我们的要求退化了,不再存储数据的指针,改为存储数据实例对象的引用。第11行,在结构体Combined中,Thing是数据结构体,&'a Thing是结构体实例的引用。

但这样仍然不可以,它在编译时得到了两条同样的编译错误:

error[E0515]: cannot return value referencing local variable `thing`
error[E0382]: borrow of moved value: `thing`

Rust错误都有唯一的错误ID,只要方括号内以E开头的错误ID一致,错误便是一样的。

这个问题是由一位提问者提出者,他还贴了第三段代码,下面看第三个代码示例3:

struct Combined<'a>(Parent, Child<'a>);

fn make_combined<'a>() -> Combined<'a> {
    let parent = Parent::new();
    let child = parent.child();

    Combined(parent, child)
}

在这个示例中,提问者不试图在Combined中存储任何指针或引用了,但仍然得到了同样的错误。问为什么?

下在是来自kmdreko的回答。

引起错误的背后语法原理

让我们先看一个简单的实现open in new window

struct Parent {
    count: u32,
}

struct Child<'a> {
    parent: &'a Parent,
}

struct Combined<'a> {
    parent: Parent,
    child: Child<'a>,
}

impl<'a> Combined<'a> {
    fn new() -> Self {
        let parent = Parent { count: 42 };
        let child = Child { parent: &parent };

        Combined { parent, child }
    }
}

fn main() {}

该示例将编译失败,并主要展示如下错误:

error[E0515]: cannot return value referencing local variable `parent`
error[E0505]: cannot move out of `parent` because it is borrowed

要理解这些错误,你必须思考这些值(例如parent)在内存是如何展示的,以及当我们移动它们时又发生了什么。如下所示,我们假设这些值的内存地址是这样的,我们以此注释我们的ombined::new代码,看看这里面内存发生了什么变化:

// 下面假设Parent与Child均没有实现Copy主义,它们会发生Move移动
let parent = Parent { count: 42 }; // 0x1000
// `parent`变量初始位于内存地址0x1000处
// `parent`的真实值是42 

let child = Child { parent: &parent }; // 0x1010
// `child`变量位于内存地址0x1010处,注意它的地址与parent不同
// `child`的真实值是一个地址,是0x1000
         
Combined { parent, child } // 0x2000
// 返回值的内存地址位于0x2000处
// 现在`parent`被移到了内存地址0x2000这个地方
// 那么此时`child`的内存地址是什么 ?

对于child这个变量,它发生了什么?开始它位于0x1010这个地址,它的数据指向0x1000这个地址,但是在最后当返回值发生返回时,即在parent发生了移动以后,child变量所指向的内存地址已经不能保证含有正确的值了。任何其它代码都将被允许在内存地址0x1000存储新值,这时候如果假想原来那块内存地址(0x1000)仍然是整型数字并勇敢地访问它,将引发崩溃或安全Bug,这是Rust禁止的主要错误类别之一。

这个问题正是生命周期(lifetimes)要解决的问题。**生命周期是一个充许你和编译器知道,一个值在它当前的内存存储序列里( current memory location)能够存活多久的一个元数据信息。**这里有一点特别重要,Rust新手经常在这里犯错误。要注意,Rust的生命周期并不是简单的指在一个对象被创建和被销毁之间的时间周期。

作者注:上面这一段不是很好理解,大概意思是讲,生命周期并不是简单的可以理解为,是在变量被创建和被销毁之间的这段时间。有时候从代码上看,一个变量应该被销毁(结束)了,但其实它的生命周期仍然有效。例如'static生命周期,它是贯穿整个应用程序运行时的。

打个比方,我们可以这样想:在人们的一生中,他们会在许多不同的地方驻足,每一个地方都是一个完全不同的地址。想象你是代码中的一个变量,Rust的生命周期只会关心你当前在哪一个地址,而不会关心将来你在什么地方什么时候会死(尽管死亡也会改变你驻足的地址)。每一次你搬家都意味着你的地址不再有效。

有一点非常重要:生命周期不会改变你的代码,是你的生命控制生命周期,而不是生命周期在控制你的代码。换言之,生命周期是描述性的,而不是规定性的。

下面我们用一组行号数字,标注一下Combined::new代码,稍后这些行号将帮助我们更好地理解生命周期:

{                                          // 0
    let parent = Parent { count: 42 };     // 1
    let child = Child { parent: &parent }; // 2
                                           // 3
    Combined { parent, child }             // 4
}                                          // 5

parent变量的实际生命周期是1至4,包含1和4,用数学集合符号表示是[1,4]。child变量的实际生命周期是[2,4],返回值的实际生命周期是[4,5]。这里也有可能存在一个从0开始的生命周期,它代表整个代码块之外的某个函数参数或其它什么的生命周期,这不重要,我们现在可以不管它。

请注意,child的生命周期是[2,4],但是它指向了生命周期是[1,4]的值(即parent)。一般只要引用值(child)在被引用值(parent)变成生命周期无效之前变成无效,就没事。(作者注:换言之,引用值的生命周期总是会小于被引用值的生命周期长度的。)编译错误发生在当我们想从代码块返回child变量时,这会撑爆生命周期的自然长度。

到这里为止,以上内容可以解释前两个不工作的示例代码了。第三个示例还要看一下Parent::child代码的实现,它包含的变化如下所示:

impl Parent {
    fn child(&self) -> Child { /* ... */ }
}

这段代码自动应用了生命周期省略(lifetime elision,Rust的语法特性),从而避免了严格的一般的生命周期参数标注的繁琐劳动。上面的代码实际等同于下面这个非省略版本:

impl Parent {
    fn child<'a>(&'a self) -> Child<'a> { /* ... */ }
}

(作者注:这里只有self一个参数,根据省略三原则,因为Rust编译器可以推断出正确的生命周期标注,所以编译器就帮助开发者省略了。)

对于这段代码,可以分两种方式解释:一种解释是,child方法表明它会返回一个由self参数的实际生命周期参数化的Child结构体实例;另一种解释是,Child实例包含一个创建它的Parent实例的引用(该引用指向Child实例外部一个拥有更大生命周期的实倒),Child实例不能比Parent实例存活的周期长。

这让我们意识到,我们(提问者)的创建代码:

fn make_combined<'a>() -> Combined<'a> { /* ... */ }

有时候你更有可能看到下面这种另一种形式的不同写法(作用相同):

impl<'a> Combined<'a> {
    fn new() -> Combined<'a> { /* ... */ }
}

在这两种写法下,没有生命周期参数作为参数提供了,这意味着Combined类型将不受来自调用者的任何约束。这太荒谬了,调用者只能适配'static静态全局生命周期,这根本无法满足它的调用条件。

怎么解决此类问题?

最简单的解决方案是不将数据和引用放在同一个结构体中。为此,可以使用嵌套的结构体模拟代码的生命周期。将包含自身数据的类型一起放在结构体中,如有必要,提供访问引用或包含引用的对象的方法。

有一种特别情况,当把一些数据放在堆上的时候,生命周期会超出预想范围。举个例子,在使用Box<T>的情况下,结构体会变成一个包含指向堆上数据指针的容器,指针指向的数据会保持稳定,但是指针本身的地址却会变化。在实践中,这其实没有关系,因为作为开发者的你可以追随指针编程。

翻译结束,以下是作者的补充内容。

如何返回局部变量?

在Rust中,对于如何返回局部变量,有人总结了以下三种方法:

Box<T> instead of &T
Vec<T> instead of &[T]
String instead of &str

稍微解释一下,遇到&T类型用Box<T>类型返回。后面类似。

第1个出错示例改写

对于出错的示例代码1,可以这样改写open in new window

struct Thing {
  count: u32,
}

struct Combined<'a>(&'a Thing, &'a u32);

fn make_combined<'a>() -> Combined<'a> {
  let thing = &Thing { count: 42 };

  // error[E0515]: cannot return value referencing local data `thing.count`
  // error[E0382]: borrow of moved value: `thing`
  Combined(thing, &thing.count)
}

fn main() {
  make_combined();
}

改动只有两处:

  • 第5行,将第一个元组类型Ting,修改为了&'a Thing,由值类型改用了引用类型;
  • 第8行,在实例前加了&符号,代表取引用。

然后,代码就编译通过了!

为什么这样就可以了?

在 Rust 中,默认情况下所有的结构体和枚举类型都是存储在堆上的,这是因为它们可以具有不定长度,并且在函数调用结束后仍然需要存在。这是一般情况下,即使它具有确定长度的域也是如此,也是被分配在堆上:

struct Thing {
	count: u32,
}

但是这只是一般情况,如果如下所示,结构体实现了Copy语义,那么它便又被分配在栈上了:

#[derive(Copy,Clone)]
struct Thing {
  count: u32,
}

是不是有点神奇?其实很简单,规则只有一条,凡是能在编译期确定大小的类型,默认都是分配在栈上的,不能确定大小的类型,也无法在编译器分配在栈上,只能分配在堆上。

对于原示例代码1,Thing类型并没有实现Copy语义,所以它是分配在堆上的。既然是在堆上分配的,就没有必要使用Box<T>进行装箱了,所以在第8行,我们只需要取出Thing实例的地址就可以了,这个地址已经是堆上的地址了,它在函数make_combined退出后,并不会变成无效的地址。换言之,它的生命周期是'static,是远大于返回值Combined实例的生命周期的。

至于第5行,修改元组类型及生命周期标注,只是配合第8行而做出的改变。这里的生命周期标注,其实不一定非得是'a,它也可以是'static,不信你自己可以改改看:

struct Combined<'a>(&'static Thing, &'a u32);

它一样可以正常运行。

小结

怎么样,看完kmdreko的回答感觉如何,是不是觉得Rust没有那么简单?Rust语言并不难,只是学习曲线有些陡峭,有些概念与其它语言相悖,开始理解时有些吃力,看得多了会慢慢改变。

这两年低代码比较火,这个东西其实十几年前就有,主要就是辅助程序员生成低级代码的,以前基本上大家都写过,只不过现在有人专门拿出来炒它而已。编程其实一向是向更广、更深、更多样化发展的,当有人看到低代码觉得编程越来越简单的时候,认为以后程序员都会失业,都没有用了,那是因为他无知,至少他还没有看到Rust。

下面附一段作者刚看到Rust所有权时写下的一段话,分享给你:

Rust这个语言很是强大,10年后它或成为地表最强语言,没有之一,主要强大在思想上:

  • Rust所有权的本质是数据权责清晰,谁拥有数据,谁担负维护数据一致性的责任。这条规则在数据库实践中是显而易见的真理,但当它被引入到Rust语言设计中的时侯,反而引起了程序员的不适应。可以说,Rust是地表对数据最负责任的编程语言。
  • 所有权、移动、不可变引用、可变引用、Copy Trait、Drop Trait等这些非常规概念,其实拥有着同一个内核,它们都是为了完成同一个Rust设计哲学:权责清晰,谁的数据谁负责,不是你的数据你别动。

基础软件设施是不断进化的,以后Rust在操作系统、嵌入式、通讯协议等领域应用会越来越普遍。

如果你是一名程序员,有时间一定要学习一下Rust这门语言。

新人从0到1编程自学入门经典《微信小游戏开发》open in new window全套书籍已经在京东、当当上架,需要签名版及1v1辅导的读者请与作者联系。作者博客:艺述论open in new window

Loading...