如何理解Rust的核心特性(所有权、借用、生命周期)

2022-09-05 17:19:23 浏览数 (1)

上一篇文章,我简单讲解了一下,我作为一个前端是如何看待Rust的,里面稍微提及了一下Rust的所有权机制和内存安全,说着的,Rust的所有权机制以及后续带来的生命周期问题确实不好理解,我一边看了TRPL的讲解,另一边又找了好几篇博文,最终写了这篇文章,这篇文章的布局和写作顺序可能有与其他人的文章不同,包含了我完全个人的理解和知识框架,因此也难免会有疏漏,如有疏漏,也请大家可以谅解,共同讨论与学习。

在这一篇文章当中,我大致讲解了一下rust的所有权机制,但是讲的比较简单,这一次分享,我会带大家一边写代码一边分享我对这些内容的了解,将从所有权、引用,一直讲解到生命周期。

基本的内存模型

首先先从基本内存模型讲解,这一部分大家应该都比较熟悉,几乎所有语言的内存模型都是大同小异的(我没见过特别奇怪的,当然也可能是我孤陋寡闻)。

对于占用内存大小固定的变量,会被保存在栈当中,栈先进后出,这符合编程语言作用域的特性,比如我们以以下这个简单的js代码为例;

代码语言:javascript复制
let a = Math.random() * 10      // a 入栈
​
if(a > 5) {
  let b = 100. // b 入栈
}  // b出栈
// 代码结束, a出栈。

栈显然是无法在中间插入数据的,这就导致,我们没办法把一个可变长度的数组、字符串、以及可以增加字段的object存放在栈当中。但是,对于一台计算机(或者一个运行时)来说,表示某一块内存的地址需要的空间是固定的,比如一个64bit的计算机,表示一块内存地址就是64bit,这样一来,我们就可以把真实数据保存在其他某个地方,然后我们在栈当中保存这个地方的地址,这样一来,程序执行过程中仍然可以调用到这个数据,这一个地方我们称之为堆。堆的空间可以由操作系统管理,也可以由运行时管理(最终也需要交由操作系统管理),当程序需要堆空间时,就向管理者申请,管理者开辟出空间之后,将空间的地址返回给程序,方便程序后续使用这块空间。

但是,堆当中的数据是无法和栈一样,靠入栈出栈来自动回收的,我们并不能在保存堆空间的那个地址出栈时,就将这个空间释放掉,仍然看下面这个简单的js代码:

代码语言:javascript复制
let b = getANewObject();  // b = p
​
function getANewObject {
    let a = {
      name: "altria"
    }   // a 入栈, a的值为某个存储这个对象的地址,设这个地址为p ,记为a = p
    return a;
} // a 出栈,
​
​
​
​

如果在a出栈之后就将p的数据清理掉,那么b拿到的空间指向了一个空内存,数据消失了,这显然不合理。实际工作中,我们写的代码比这个复杂几万倍,问题也复杂了许多。

程序是很难根据写的代码在静态检查阶段就判断出哪一个堆内存什么时候就可以不用了的,完全自动的堆内存回收自然也无法完成。由此,分化成两个流派:

  • 手动内存回收流派,由程序员自己判断某个内存空间什么时候没用了,自己去做回收,完全不做干涉,回收出错那就是程序员问题。
  • 自动内存回收流派,加入一个runtime,程序申请内存空间需要经过runtime,runtime记录了所有程序在用的内存空间地址,并且通过一系列复杂的算法,来计算每一个地址到底保存在栈的哪些个变量当中,当一个地址不保存在任何一个变量当中时,这一块地址就被认为是无法访问到了,那么就可以回收,这个算法叫引用计数算法。

这两种算法各自有各自的问题,算法一对程序员要求很高,稍有不慎就会内存泄漏。第二种办法则需要带一个runtime,增大程序体积。而rust则实现了与以上两个流派都不一样的第三个流派。

所有权机制

简单来说,任何一个值都只能归属于一个变量,我们称这个变量拥有这个值的所有权,首先,我们先看一段不包含栈的代码

代码语言:rust复制
fn main () {
  let a: i32 = 114514;
  let b = a;
  println!("a is {}", a);
  println!("b is {}", b)
}

这段代码可以正确执行,把a和b打印出来,类似的代码在绝大多数语言当中都可以被正确执行。但是我们尝试引入堆当中的变量之后:

代码语言:rust复制
fn main() {
  let person = String::from("altria");
  let another_person = person;
  println!("the person is {}", person);
  println!("the another person is {}",another_person);
}

报错了。这里我使用String::from创建了一个长度不固定的字符串,从而将其保存在堆当中,person这个变量实际拥有的是一个地址。

而当我们令another_person = person;的时候,这一串地址的所有权发生了转移,它转移到了新的变量身上,person不再具备这个变量的所有权,person就空了,当我们再次使用person的时候,person就直接报错了。这就是所说的,任何一个值都只能归属于一个变量,所有权是对值的独占。

那第一段代码为什么a没有独占114514呢?实际上独占了,只不过当我们令b=a时,b获得了一个全新的114514,所以这不妨碍a继续独占它原本的值。但是地址呢?绝对不存在两个地址一模一样,但是指向不同内存空间的可能性,所以这个情况就无从发生了。

以下这个情况也会导致所有权转移:

代码语言:rust复制
fn main() { 
  let message = String::from("hello world");
  echo(message);
  println!("{}", message);
}
​
fn echo(message: String) {
  println!("{}", message);
}

当我们将message传入echo方法的时候,message的所有权就被转移到了方法内的message上了。

那rust为什么要引入所有权机制呢?原因在于,方便内存回收。

如果一个堆空间的地址,只能保存在一个变量里面,那么当这个变量出栈,无法再使用,那么不就代表这个堆空间就无法在程序内使用了吗?那么不就代表这个空间可以被回收了吗?而出栈、入栈的代码分析,那就比引用计数啥的简单了无数倍,完完全全可以放在静态检查阶段就能实现了啊。

这就是rust的内存回收策略了,它的编译器与开发者约定了这么一个规则——一个堆空间的地址,只能保存在一个值当中——如果我们不遵守这个规则,编译器就给我们报错,不给我们编译但如果我们遵守这个规则,编译器就很简单的判断出来了每一个空间要在什么时候回收,并且在在代码特定位置当中插入一些drop语句进行内存回收,然后再编译,这样编译出来的代码就可以在运行阶段自动gc且不需要运行时。

借用

上述这个想法确实很天才,但是我们发现,如果只是存在所有权机制,那么我们代码就很难写,比如说,我这有一个option变量,保存了一些设定信息,这个变量需要在多个方法内使用,比如下面这个代码:

代码语言:rust复制
fn do_something(option: String) {
  println!("do something by {}", option)
}
​
fn do_another_thing(option: String) {
  println!("do another thing by{}", option)
}
​
fn main() {
  let my_option = String::from("some option");
  do_something(my_option);
  do_another_thing(my_option);
}

根据上面讲到的原理来看,这段代码必定不通过,因此想要实现这么简单的逻辑,我们还得把我们的选项copy一份,防止被回收:

代码语言:rust复制
fn do_something(option: String) {
  println!("do something by {}", option)
}
​
fn do_another_thing(option: String) {
  println!("do another thing by{}", option)
}
​
fn main() {
  let my_option = String::from("some option");
  
  let my_option_copy =  my_option.clone();
  
  do_something(my_option);
  do_another_thing(my_option_copy);
}

这样代码才可以跑起来,可这么一搞,简单的事情变得非常麻烦,这显然不是一个好的设计,于是,rust还有另一个特性——借用。

简单来说,既然堆内存只能属于一个变量是不能改的,那么我们能不能考虑让这个变量,把这个内存空间借出去,这样不会改变所有权,后续我还能继续用,而其他借来的变量也能使用,一个变量的借用可以通过在变量之前加and符号来实现,上面的代码就可以改成下面这个样子:

代码语言:rust复制
  fn do_something(option: &String) {
    println!("do something by {}", option)
  }
​
  fn do_another_thing(option: &String) {
    println!("do another thing by{}", option)
  }
​
  fn main() {
    let my_option = String::from("some option");
    do_something(&my_option);
    do_another_thing(&my_option);
  }

那借用破坏了所有权机制吗?答案是没有。我们上文提到了,所有权机制的核心就是,让一个内存块的回收和唯一一个变量绑定,这个变量出栈,那么对应的堆内存也要回收,引入借用之后,所有权没有发生转移,所以堆内存回收的时机仍然和之前一样。

比如,我们看看以下这个代码:

代码语言:rust复制
fn main() {
  let a = String::from("hello");
  let mut b: &String = &a;
  if a.as_str() == "hello" {
    let c = String::from("world");
    b = &c;
  }
  println!("{}", b);
}

如果是在C 当中采取类似的写法,那么我们会得到b = world,但在rust当中不行,因为b只是拿到了c的借用,而c在大括号内被回收了,b就无法去拿到world这个值。换句话说,有没有借用、有多少个借用、都不影响内存回收的时机,内存回收只看一个时机,那就是所有权变量出栈的时机。

生命周期

这大概是Rust当中最折磨人的概念了,生命周期这个特性,也可以说是引入所有权机制之后带来的effect。首先让我们回到上一节报错的那一段代码,我们看看它的报错信息。

代码语言:shell复制
38 |     b = &c;
   |         ^^ borrowed value does not live long enough

变量活的不够久,这个错误就算触及到生命周期了。

然后,让我们回到源代码,来具体分析一下原因。

根据代码逻辑我们知道,b是一个借用,它有可能是a的借用,也有可能是c的借用,虽然我们根据逻辑来看,b最终一定是c的引用,但这是我们脑内debug的结果,静态检查阶段是无法得知b最终到底是什么的。

上面这句话,我们做一个简单的概括,概括为——a和c是b的依赖。基于这一个概括,那么可以再得到一个结论——只有依赖有效,那么结果才能有效,任何一个依赖无效,结果就无效。

那么,什么可以导致依赖无效呢?所有权机制导致的内存回收。我们把一个变量的有效区间,称之为它的生命周期,它的生命周期从它被声明开始,到它结束为止。在它生命周期内,它就是有效的,否则就是无效的。

再推一层:我们想要使用结果的时候,结果有效,那么就意味着,结果的生命周期不长于依赖的生命周期,用集合符号表示就是:结果的生命周期A⊆依赖的生命周期。即,结果有效时,依赖必须有效,结果无效时,依赖的有效性不做要求。而上文代码当中,c的有效期显然短于b,那自然就不合理了。

这里我们看到了生命周期带来的问题,那么有办法解决或者绕过这些问题吗?答案是,没有。生命周期是所有权机制带来的副作用,我们要做的是,保证依赖的有效性,而不是在依赖可能无效的前提下,保证结果的有效性。这两者是存在很大区别的。

不过幸好,Rust的编译器会忠实的把一切生命周期问题在静态检查阶段全部抛出来,甚至于那些我们一眼就能脑内debug没问题的代码它也会抛出来,比如我们看以下这段代码:

代码语言:rust复制
fn get_longest_string(str1: &String, str2: &String) -> &String {
  if str1.len() > str2.len() {
    return str1;
  } else {
    return str2;
  }
}
​
fn main() {
  let a = String::from("Hello, How are you?");
  let b = String::from("I'm fine. Thank you, and you?");
  let longest_str = get_longest_string(&a, &b);
  println!("{}", longest_str);
}

a和b是longest_str的依赖,而a和b的生命周期也长于longest_str,这显然是符合生命周期原则的,但是我们跑一下代码发现还是报错。原因在于,对于get_longest_string方法来说,它是不确定自己的调用时机和每次调用会传进来哪些借用的,对于函数内部来说,它只能得知str1是借用,str2也是借用,他们理应存在生命周期。在调用longest_str之前,定义str1和str2的的地方,是可以根据上下文追溯其生命周期的,但是在函数定义的地方,str1和str2只是外部传入的借用,其生命周期,是未知。而rust本身就需要检验任何一个变量的生命周期是否合理,对于未知的生命周期,如何检验其合理呢?

上面例子里传进去的两个变量,它们确实有相同的生命周期,但是其他调用场景下,我传入两个不同生命周期的变量,那么返回值的生命周期到底是什么呢?

在静态检查阶段,编译器判断不出来,所以我们就要手动告诉编译器,帮助编译器判断。换句话说,我们的标注,仅仅只是帮助rust的编译器,在函数定义的地方(非调用初),在函数内部,检验我们的代码是否合理。对于函数外部,函数调用的地方,生命周期本身就是已知的,就不需要我们的帮助。

那有什么解决办法呢,也很简单,我们需要手动指定结果和依赖的生命周期,来保证函数内部可以判断,同时我们再函数外部,确认函数调用时传进去的借用符合程序声明时候的生命周期。这样问题就得以解决:

代码语言:rust复制
fn get_longest_string<'a>(str1: &'a String, str2: &'a String) -> &String {
  if str1.len() > str2.len() {
    return str1;
  } else {
    return str2;
  }
}
​
fn main() {
  let a = String::from("Hello, How are you?");
  let b = String::from("I'm fine. Thank you, and you?");
  let longest_str = get_longest_string(&a, &b);
  println!("{}", longest_str);
}

这个写法有点类似于泛型,先是定义了一个生命周期为'a,然后规定了输入值的生命周期都为‘a,而a是一个不可短于函数执行结果的生命周期的生命周期。

这样,在函数内部,就能准确知道,这是符合 生命周期规则的了,输出结果的生命周期不可长于依赖的生命周期,问题得以解决。生命周期的命名是随意的,唯一一个限制是必须以一个单独的单引号开头。

还有一点是,对于变量生命周期的指定,并不是要求了变量的生命周期就必须是a,而是说变量的生命周期应包含a,可以更长,但不能更短,比如下面这个例子:

代码语言:rust复制
fn get_longest_string<'a>(str1: &'a String, str2: &'a String) -> &String {  
  if str1.len() > str2.len() {
    return str1;
  } else {
    return str2;
  }
}
​
fn main() {
  let a = String::from("Hello, How are you?");
  {
    let b = String::from("I'm fine. Thank you, and you?");
    let longest_str = get_longest_string(&a, &b);
    println!("{}", longest_str);
  }
}

可以看到a和b两个变量的生命周期显而易见不同,但是这代码是正确的。

当一个函数需要输入借用输出借用时,就必须显式的声明生命周期。如果厌倦使用声明周期,上述代码也可以稍作调整:

代码语言:javascript复制
fn get_longest_string(str1: &String, str2: &String) -> &String {
  if str1.len() > str2.len() {
    return str1.clone();
  } else {
    return str2.clone();
  }
}
​
fn main() {
  let a = String::from("Hello, How are you?");
  {
    let b = String::from("I'm fine. Thank you, and you?");
    let longest_str = get_longest_string(&a, &b);
    println!("{}", longest_str);
  }
}

这里返回的不再是一个引用,而是原值的复制,这样就可以不再关心生命周期,但同时,这也会带来一些性能损失——毕竟做了一次内存拷贝,速度和占用内存两方面的性能都会受到影响。

但是这么说还是无法解释一个问题,比如我们上面说的,如果一个函数的输入和输出都是一个引用,那么就需要标注生命周期,那下面这一段代码需要标注吗?

代码语言:rust复制
fn first_word(s: &str) -> &str {
  let bytes = s.as_bytes();
​
  for (i, &item) in bytes.iter().enumerate() {
      if item == b' ' {
          return &s[0..i];
      }
  }
​
  &s[..]
}
​
fn main() {
  let a = "hello world";
  let first_word = first_word(a);
  println!("{}", first_word);
}
​

实际上,这段代码是能跑的。为什么呢?因为这个函数,只有一个输入引用,一个输出引用,那么就必然只存在两个情况:

  • 返回值的引用,是函数内创建了一个变量,返回了这个变量的借用。
  • 返回的借用来自输入值本身,而非来自函数内部创建的变量。

而第一种写法是显而易见会报错的,因为函数结束时内部变量被释放掉了。那么只剩下最后一个可能了,那就是返回值从输入值那里拿到了借用,那么返回值的生命周期就一定等于输入值的生命周期,那么就一定是合法的。

换句话说,rust的编译器尽可能遵循【非必要、不标注】的原则,只要不标注生命周期,也不会产生关于生命周期的不明确和疑问,那就可以不标注生命周期。这个现象可以称之为生命周期消除。

生命周期参数也未必需要一个,可以有任意个,比如以下这段代码也是合法的:

代码语言:rust复制
fn get_longest_string<'a, 'b>(str1: &'a String, str2: &'b String) -> &String 

这意味着,a和b两个生命周期都必须长于结果的生命周期。

注意,生命周期的标注,和生命周期本身并没有关系,我们无论标注不标注生命周期,生命周期都是实际存在的,我们的标注也没办法改变生命周期,或者影响代码逻辑,我们的标注只是帮助编译器做静态检查用的。

比如说,我们把上面使用生命周期标注的代码改一下,用另一种方式做标注,在看代码之前,我们先简单介绍另一个生命周期标注的用法。生命周期参数不仅写起来像泛型,它实际上也确实和泛型一样是一个类型。而有了类型就有了子类型,生命周期长的类型,是生命周期短的类型的子类型,这么听起来很奇怪,为什么长的是短的子类型,原因在于,只要是更长的类型,都能完美表述这个短的类型,而反过来就不行,那既然短类型有多种表达方式,那么这些表达方式,都可以看作是短类型的子类型,两个生命周期长度可以记为:'l: 's,意为l不短于s。

基于此,我们把返回最长字符串的代码的生命周期标注改一下:

代码语言:rust复制
fn get_longest_string<'s, 'l:'s>(str1: &'l String, str2: &'s String) -> &'s String {  
  if str1.len() > str2.len() {
    return str1;
  } else {
    return str2;
  }
}
​
fn main() {
  let a = String::from("Hello, How are you?");
  {
    let b = String::from("I'm fine. Thank you, and you?");
    let longest_str = get_longest_string(&a, &b);
    println!("{}", longest_str);
  }
}

这里,我换了一种标记方式,我将str1的生命周期标记为更长的l,str2为较短的s,返回值也是取最小值s,代码仍然是可以运行,且效果完全一致。这也能看出来,标注对代码运行结果是没有影响的,它只是为了让编译器更好判断函数内部,返回值的生命周期到底是什么而已。

对于同一段代码,生命周期完全可以有不同的标注,使用过程中尽可能不要把问题复杂化,比如下面这个写法,就比上面那个写法差劲,如果返回值。

对于生命周期标注的【并不影响代码实际执行,只是帮助函数定义时,判断函数内部逻辑是否合理】这一规则,我们可以尝试一个检验办法来理解,那就是:标注的生命周期,在函数内部合理,符合依赖的生命周期必须不短于结果的生命周期这一规则,但是和函数调用时传入的实际变量的真实生命周期不一致,如果编译器抛出错误,那就意味着我们的说法不合理,但如果编译器仍然正确执行,就可以断定,函数标注的生命周期,仅仅只是校验函数内部逻辑的,对其他地方无影响。我们尝试构建这样的代码:

代码语言:rust复制
fn get_longest_string<'s, 'l:'s>(str1: &'l String, str2: &'s String) -> &'s String {  
  if str1.len() > str2.len() {
    return str1;
  } else {
    return str2;
  }
}
​
fn main() {
  let a = String::from("Hello, How are you?");
  {
    let b = String::from("I'm fine. Thank you, and you?");
    let longest_str = get_longest_string(&b, &a);
    println!("{}", longest_str);
  }
}

这里,函数定义时的形参分别是str1和str2,调用时的实参是b和a,定义时,str1的生命周期更长,调用时,b的生命周期却更短。生命周期标注和调用时候的实参生命周期不一致,但是代码是完全可以运行的,没有抛出错误。原因可以归结为以下两点:

  • 定义时,str1和str2的生命周期都不短于结果的生命周期,定义时遵守了生命周期原则。
  • 使用时,a和b的生命周期都不短于longest_str的生命周期,调用时遵守了生命周期原则。
  • 尽管双方不一致,但是前者不会影响后者,前者仅仅只是帮编译器在定义时做校验,所以不会报错。

除了函数之外,其他任何涉及到【先定义、后调用,定义时允许传入借用】的场景,都需要使用生命周期标注,比如结构体,看下面这个例子:

先定义一个结构体类型,其中存在字段的类型为借用,然后使用这个类型,定义出一个实际的变量。

代码语言:rust复制
// 错误代码,缺少生命周期标注
#[derive(Debug)]
struct Person {
  name: &String,
  profession: &String,
}
​
​
fn main() {
  let name = String::from("altria");
  let profession = String::from("programmer");
  let altria_yu = Person {
    name: &name,
    profession: &&profession,
  };
  println!("{:?}", altria_yu)
}
​
​
​
// 正确代码,必须加上生命周期标注
#[derive(Debug)]
struct Person <'a> {
  name: &'a String,
  profession: &'a String,
}
​
​
fn main() {
  let name = String::from("altria");
  let profession = String::from("programmer");
  let altria_yu = Person {
    name: &name,
    profession: &&profession,
  };
  println!("{:?}", altria_yu)
}

这里的报错信息和函数缺少生命周期是一样的。在上述结构体当中,结构体Person可以看作是结果,name和profession是其依赖,这里实际上和函数的生命周期一模一样,能理解函数生命周期标注,就能理解结构体的生命周期标注。

所以,我们在这里,对生命周期标注做一个尽可能简洁的归纳总结:

生命周期标注,仅仅只是帮助编译器确定多个引用的生命周期之间的关系,它不会影响我们的逻辑,也不会影响编译结果(只要标注的生命周期关系,符合生命周期关系的规则)。因此,也绝对不是说,定义函数的时候做了合适的标注,就可以万事大吉了。我们仍然需要在调用函数的时候,依靠我们自己来保证,传入的引用的生命周期是合规的,当然,如果我们自己没发现是否合规,编译器仍然可以帮助我们发现。

生命周期最最难以理解的地方就是我该如何去标注生命周期,以及为什么这么标注,这一点非常令人头疼,但是一旦我们理解了生命周期标注的本质,那我们就不需要过于纠结生命周期的标注了。

0 人点赞