了解Rust:所有权,借用,生命周期

  这是我对这些事情的描述。 一旦掌握了它,所有这些在直观上都是显而易见的且美丽的,并且您不知道之前缺少了哪一部分。

  我不会从头开始教您,也不会重复《Rust教程书》所说的内容(尽管有时会)-如果您还没有的话,现在应该阅读其中的相应章节。 这篇文章旨在补充《Rust教程书》,而不是取代它。

  我也可以建议您阅读这篇出色的文章。 它实际上是在谈论类似的话题,但主要关注它们的其他方面。

  让我们来谈谈资源。 资源是有价值的东西,是“沉重的”东西,可以被获取和释放(或销毁)的东西-像一个套接字,一个打开的文件,一个信号量,一个锁,一个堆内存区域。 传统上,所有这些事情都是通过调用一个函数来创建的,该函数返回对资源本身的某种引用-"内存指针,文件描述符"-当程序认为自己对资源完成操作时需要显式关闭。

  这种方法存在问题。 首先,很容易忘记释放一些资源,从而导致所谓的泄漏。 更糟糕的是,人们可能试图访问已经释放的资源(*使用)。 如果幸运的话,他们会收到一条错误消息,希望可以帮助他们识别并修复错误。 否则,它们所具有的引用(尽管就逻辑而言是无效的)可能仍指向其他资源已经占用的某个"位置":已经存储了其他内容的内存,其他打开的文件使用的文件描述符。 试图通过无效的引用访问旧资源可能会破坏其他资源或使程序完全崩溃。

  我正在谈论的这些问题不是虚构的。 他们一直在发生。 例如,在Google Chrome浏览器发布博客中查看:存在许多由于使用空引用引起的漏洞和崩溃,并且花大量时间和精力(和金钱)来识别和修复这些漏洞和崩溃 。

  并不是说开发人员是愚蠢的,没有遗忘的。 逻辑流程本身容易出错:它要求您释放资源,但不强制执行。 此外,您通常不会注意到您忘记释放资源,因为很少会产生明显的影响。

  有时要实现简单的目标就需要发明复杂的解决方案,而这些解决方案会带来复杂的逻辑。 很难不会在庞大的代码库中迷失方向,并且漏洞总是四处出没也就不足为奇了。 其中大多数很容易发现。 但是,这些与资源相关的错误很难发现,但是如果在野外被利用,则非常危险。

  当然,像Rust这样的新语言无法为您解决错误。 但是,它可以做的-并成功地完成了-会影响您的思维方式,使您的思想结构化,从而使这类错误的发生几率大大降低。

  Rust为您提供了一种安全清晰的方法来管理资源。 而且,它不允许您以其他任何方式对其进行管理。 很好,这很严格,但这就是我们的目标。

  这些限制之所以令人敬畏,有几个原因:

  · 它们使您以正确的方式思考。 经过一些Rust的经验之后,即使在语法中没有内置这些概念时,也经常会发现自己尝试使用其他语言来应用相同的概念。

  · 它们使您的代码安全。 除了极少数情况下,所有"安全" Rust代码都保证不会受到我们所谈论的错误的影响。

  · Rust感觉像使用垃圾回收的高级语言一样令人愉悦(我是在开玩笑说JavaScript是令人愉悦的?),却与其他低级编译语言一样快且原生。

  考虑到这一点,让我们看一下Rust的一些优点。

  所有权

  在Rust中,关于哪个代码段拥有资源有非常明确的规则。 在最简单的情况下,是代码块创建了代表资源的对象。 在该块的末尾,对象被销毁并释放资源。 这里的重要区别是对象不是易于"忘记"的某种"弱引用"。 在内部,对象只是用于完全相同引用的包装,而从外部看,它似乎是它表示的资源。 删除它(即到达拥有它的代码的末尾)会自动且可预测地释放资源。 没有办法"忘了做" —由可预测的,完全指定的方式自动为您完成。

  (这时您可能会问自己,为什么我要描述这些琐碎的,显而易见的事情,而不是仅仅告诉您聪明的人将其称为RAII。好的,您是对的。让我们继续。)

  此概念适用于临时对象。 说,我们需要将一些文本写入文件。 专用代码块(例如,一个函数)将打开一个文件-得到一个文件对象(包装一个文件描述符)-然后对其进行一些处理,然后在该块末尾将得到文件对象 删除并且文件描述符关闭。

  但是在很多情况下,这个概念行不通。 您可能希望将资源传递给其他人,在几个"用户"之间甚至在线程之间共享它。

  让我们来看看这些。 首先,您可能希望将资源传递给其他人(转移所有权),以便现在是他们拥有资源,随心所欲,甚至更重要的是负责释放资源的人。

  Rust很好地支持了这一点-实际上,当您将资源提供给其他人时,默认情况下会发生这种情况。

  fn print_sum(v: Vec) {

  println!("{}", v[0] + v[1]);

  // v is dropped and deallocated here

  }

  fn main() {

  let mut v=Vec::new(); // creating the resource

  for i in 1..1000 {

  v.push(i);

  }

  // at this point, v is using

  // no less than 4000 bytes of memory

  // -------------------

  // transfer ownership to print_sum:

  print_sum(v);

  // we no longer own nor anyhow control v

  // it would be a compile-time error to try to access v here

  println!("We're done");

  // no deallocation happening here,

  // because print_sum is responsible for everything

  }

  所有权转移的过程也称为移动,因为资源是从旧位置(例如本地变量)移动到新位置(函数参数)的。 从性能角度来看,这只是"弱引用"的原因,因此一切仍在快速发展。 但是对于代码来说,好像我们实际上将整个资源都移到了新地方。

  移动与复制不同。 在后台,它们都意味着复制数据(如果Rust允许复制资源,则在这种情况下将是"弱引用"),但是在移动之后,原始变量的内容将被视为不再有效或不重要。 Rust实际上假装该变量是"逻辑上未初始化的",即充满了一些垃圾,就像刚刚创建的那些变量一样。 禁止使用此类变量(除非您使用新值重新初始化它)。 删除资源后,便不会进行资源重新分配:现在拥有资源的人都有责任在完成后进行清理。

  移动不仅限于传递参数。 您可以移动到变量。 为此,您可以移至"返回值"或从返回值移至变量或函数自变量。 基本上,到处都是显式或隐式分配。

  尽管移动语义可以是处理资源的完全合理的方式-我将在稍后演示-对于普通的旧原始(数字)变量,它们将是一场灾难(设想无法复制一个int值 到另一个!)。 幸运的是,Rust具有"复制"特征。 实现它的类型(所有原始类型都使用)在分配时使用复制语义,所有其他类型都使用移动语义。 非常简单。 如果您希望复制自己的类型,则可以实现复制特征(copy trait)。

  fn print_sum(a: i32, b: i32) {

  println!("{}", a + b);

  // the copied a and b are dropped and deallocated here

  }

  fn main() {

  let a=35;

  let b=42;

  // copy the values and transfer

  // ownership over the copies to print_sum:

  print_sum(a, b);

  // we still retain full control over

  // the original a and b variables here

  println!("We still have {} and {}", a, b);

  // the original a and b are dropped and deallocated here

  }

  现在,为什么移动语义会有用呢? 没有他们,一切都那么完美。 好吧,不完全是。 有时候这是最合乎逻辑的事情。 考虑一个函数(像这样),该函数分配一个字符串缓冲区,然后将其返回给调用方。 所有权已转移,该功能不再关心缓冲区的命运,而调用者可以完全控制缓冲区,包括负责缓冲区的释放。

  (在C语言中是相同的。诸如strdup之类的函数会分配内存,将其交给您,并希望您进行管理并最终对其进行分配。区别在于它只是一个指针,而他们所能做的最多就是要求/提醒您释放 ()完成后-上面的链接文档几乎无法做到-而在Rust中,它是该语言不可分割的一部分。)

  另一个示例是像这样的迭代器适配器,它消耗它获取的迭代器,因此以后再访问该迭代器是没有意义的。

  相反的问题是,在哪种情况下我们需要对同一资源有多个引用。 最明显的用例是进行多线程处理时。 否则,如果所有操作都按顺序执行,则移动语义可能几乎总是起作用。 尽管如此,始终不停地来回移动东西还是很不方便的。

  有时,尽管代码严格按顺序运行,但仍然感觉好像同时发生了几件事。 想象一下在向量上进行迭代。 循环完成后,迭代器可以将您对所涉及向量的所有权转移给您,但是您将无法在循环内获得对向量的任何访问权限-也就是说,除非您在代码与 每次迭代中都有迭代器,这将是一团糟。 似乎也没有办法在不将其破坏到堆栈上的情况下遍历一棵树,然后再构造回去,前提是您以后想要对它进行其他操作。

  而且我们将无法执行多线程。 而且不方便。 甚至丑陋。 值得庆幸的是,还有一个很酷的Rust概念可以为我们提供帮助。 输入借用!

  借用

  借用有多种推理方法:

  · 它使我们可以在对资源的多种引用的同时仍坚持"单一所有者,单一责任"的概念。

  · 引用类似于C中的指针。

  · 引用也是一个对象。 可变引用被移动,不可变引用被复制。 删除引用后,借用将终止(根据生命周期规则,请参见下一节)。

  · 在最简单的情况下,引用的行为"就像"来回移动所有权而无需明确地进行。

  这就是我的最后一个意思:

  // without borrowing

  fn print_sum1(v: Vec) -> Vec {

  println!("{}", v[0] + v[1]);

  // returning v as a means of transferring ownership back

  // by the way, there's no need to use "return" if it's the last line

  // because Rust is expression-based

  v

  }

  // with borrowing, explicit references

  fn print_sum2(vr: &Vec) {

  println!("{}", (*vr)[0] + (*vr)[1]);

  // vr, the reference, is dropped here

  // thus the borrow ends

  }

  // this is how you should actually do it

  fn print_sum3(v: &Vec) {

  println!("{}", v[0] + v[1]);

  // same as in print_sum2

  }

  fn main() {

  let mut v=Vec::new(); // creating the resource

  for i in 1..1000 {

  v.push(i);

  }

  // at this point, v is using

  // no less than 4000 bytes of memory

  // transfer ownership to print_sum and get it back after they're done

  v=print_sum1(v);

  // now we again own and control v

  println!("(1) We still have v: {}, {}, ...", v[0], v[1]);

  // take a reference to v (borrow it) and pass this reference to print_sum2

  print_sum2(&v);

  // v is still completely ours

  println!("(2) We still have v: {}, {}, ...", v[0], v[1]);

  // exacly the same here

  print_sum3(&v);

  println!("(3) We still have v: {}, {}, ...", v[0], v[1]);

  // v is dropped and deallocated here

  }

  让我们看看这里发生了什么。 首先,我们可以始终转移所有权,但是我们已经确信这不是我们想要的。

  第二个更有趣。 我们对向量进行引用,然后将其传递给函数。 就像在C中一样,我们显式地取消引用它以到达它后面的对象。 由于没有复杂的生命周期资料,因此一旦删除引用,借用便会终止。 虽然它看起来像第一个示例,但是有一个重要的区别。 主要功能始终负责向量-在借用向量时只能对向量做些限制。 在此示例中,主函数在借用向量时甚至没有机会观察向量,因此这没什么大不了的。

  第三个功能结合了第一个功能(不需要取消引用)和第二个功能(不弄乱所有权)的漂亮部分。 由于Rust自动引用规则,它可以工作。 这些有点复杂,但是在大多数情况下,它们使您可以编写代码,几乎就像引用只是它们指向的对象一样,因此类似于C ++引用。

  出乎意料的是,这是另一个示例:

  // takes v by (immutable) reference

  fn count_occurences(v: &Vec, val: i32) -> usize {

  v.into_iter().filter(|&&x| x==val).count()

  }

  fn main() {

  let v=vec![2, 9, 3, 1, 3, 2, 5, 5, 2];

  // borrowing v for the iteration

  for &item in &v {

  // the first borrow is still active

  // we borrow it the second time here!

  let res=count_occurences(&v, item);

  println!("{} is repeated {} times", item, res);

  }

  }

  您无需担心count_occurrences函数内部发生了什么,只需说它借用了向量即可(再次,无需移动它)。 循环也借用了向量,因此我们有两个借用同时处于活动状态。 循环结束后,main函数删除向量。

  (我有点恶心。我提到多线程是拥有引用的主要原因,但是我展示的所有示例都是单线程的。如果您真的感兴趣,则可以在此处和此处获得有关Rust中多线程的一些详细信息。 )

  获取和删除引用似乎很有效,好像涉及到垃圾回收一样。 不是这种情况。 一切都在编译时完成。 为此,Rust需要另一个神奇的概念。 让我们考虑以下示例代码:

  fn middle_name(full_name: &str) -> &str {

  full_name.split_whitespace().nth(1).unwrap()

  }

  fn main() {

  let name=String::from("Harry James Potter");

  let res=middle_name(&name);

  assert_eq!(res, "James");

  }

  上面可以,下面的代码不能工作:

  // this does not compile

  fn middle_name(full_name: &str) -> &str {

  full_name.split_whitespace().nth(1).unwrap()

  }

  fn main() {

  let res;

  {

  let name=String::from("Harry James Potter");

  res=middle_name(&name);

  }

  assert_eq!(res, "James");

  }

  首先,让我澄清一下字符串类型的混淆。 字符串是拥有的字符串缓冲区,而&str(字符串切片)是其他人的字符串或其他内存的"视图"(在这里并不重要)。

  为了使它更加明显,让我用纯C编写类似的内容:

  (不相关的说明:在C语言中,您不能对字符串的中间部分有"查看",因为标记其结尾将需要更改字符串,因此我们仅限于在此处查找姓氏。)

  #include

  #include

  #include

  const char *last_name(const char *full_name)

  {

  return strrchr(full_name, ' ') + 1;

  }

  int main() {

  const char *buffer=strcpy(malloc(80), "Harry Potter");

  const char *res=last_name(buffer);

  free(buffer);

  printf("%s

  ", res);

  return 0;

  }

  你现在看到了吗? 在使用结果之前,将缓冲区丢弃并释放。 这是无用后使用的一个简单例子。 如果printf实现不会立即将内存用于其他用途,则此C代码可以编译并正常运行。 不过,在一个不那么琐碎的示例中,它仍然是崩溃,错误和安全漏洞的来源。 正是在介绍所有权之前我们所说的。

  您甚至无法在Rust中编译它(我的意思是上面的Rust代码)。 这种静态分析机制已内置在语言中,并且可以终生使用。

  生命周期

  Rust中的资源具有生命周期。 它们从创建的那一刻起一直生活到被丢弃的那一刻。 通常将生命周期视为作用域或块,但这实际上不是准确的表示,因为资源可以在块之间移动,正如我们已经看到的那样。 无法引用尚未创建或已删除的对象,我们将很快看到如何执行此要求。 否则,这一切都是显而易见的,与所有权概念并没有什么不同。

  所以这是困难的部分。 引用以及其他对象也具有生命周期,并且这些生命周期可能不同于它们表示的借用生命周期(称为关联生命周期)。

  让我改一下。 借用的持续时间可能比其控制的引用时间更长。 通常,这是因为可能有另一个引用取决于借用是否处于活动状态-借用相同的对象或其一部分,例如上例中的字符串切片。

  实际上,每个引用都记住它代表的借用的生命周期,也就是说,每个引用都有附加的生命周期。 像所有与"借阅检查"相关的事情一样,这是在编译时完成的,并且运行时开销恰好为零。 与其他情况不同,您有时必须明确指定生存期详细信息。

  综上所述,让我们深入探讨:

  fn middle_name<'a>(full_name: &'a str) -> &'a str {

  full_name.split_whitespace().nth(1).unwrap()

  }

  fn main() {

  let name=String::from("Harry James Potter");

  let res=middle_name(&name);

  assert_eq!(res, "James");

  // won't compile:

  /*

  let res;

  {

  let name=String::from("Harry James Potter");

  res=middle_name(&name);

  }

  assert_eq!(res, "James");

  */

  }

  在前面的示例中,我们不必显式地指定生存期,因为生存期很短,Rust编译器可以自动找出生命周期(有关详细信息,请参见生存期省略)。 无论如何,我们在这里都做了一下,以演示它们的工作原理。

  <>表示函数在我们称为a的生存期内是通用的,也就是说,对于具有相关生存期的任何输入引用,它将返回具有相同相关生存期的另一个引用。 (让我再次提醒您,关联的生存期是指借阅的生存期,而不是参考的生存期。)

  实际上,它的含义可能尚不十分明显,所以让我们从相反的角度来看它。 返回的引用存储在res变量中,该变量在main()的整个作用域中都有效。 那就是参考的生命周期,因此借用(相关的生命周期)至少寿命一样长。 这意味着函数输入参数的关联生存期必须相同,因此我们可以得出结论,必须为整个函数借用名称。 而这正是发生的情况。

  在"无用后使用"示例中(此处已注释),res的寿命仍然是整个功能,而名称只是"寿命不长"而无法借用整个功能。 如果您尝试编译此代码,这将是确切的错误。

  因此,发生的事情是Rust编译器尝试使借用生存期尽可能短,最好是在删除引用后就结束(这是我在"借阅"部分开头提到的"最简单的情况")。 从结果的有效期到原始借用的寿命相反的约束(例如"此借用的寿命与那个借用的寿命一样长")使寿命变得越来越长。 只要满足所有约束条件,此过程就会停止,如果无法实现,则会出错。

  哦,您不能说出您的函数返回的借用值与完全不相关的生命周期来欺骗Rust,因为那样您会在函数内收到相同的"寿命不足"错误,因为不相关的生命周期可能很多 比输入的长。 (好的,我在撒谎。实际上,错误会有所不同,但很高兴能认为是同一错误。)

  让我们来看这个例子:

  fn search<'a, 'b>(needle: &'a str, haystack: &'b str) -> Option<&'b str> {

  // imagine some clever algorithm here

  // that returns a slice of the original string

  let len=needle.len();

  if haystack.chars().nth(0)==needle.chars().nth(0) {

  Some(&haystack[..len])

  } else if haystack.chars().nth(1)==needle.chars().nth(0) {

  Some(&haystack[1..len+1])

  } else {

  None

  }

  }

  fn main() {

  let haystack="hello little girl";

  let res;

  {

  let needle=String::from("ello");

  res=search(&needle, haystack);

  }

  match res {

  Some(x)=> println!("found {}", x),

  None=> println!("nothing found")

  }

  // outputs "found ello"

  }

  搜索功能接受两个引用,这些引用具有完全不相关的关联生存期。 尽管大海捞针上有一个约束,但我们唯一需要做的就是在函数本身执行时借用必须有效。 完成后,借用立即结束,我们可以安全地重新分配关联的内存,同时仍保持函数结果不变。

  haystack用字符串文字初始化。 这些是类型为&'static str的字符串片段-始终是"活动"的"借阅"。 因此,我们能够在需要时保持res变量不变。 这是例外,因为借款可以持续的时间尽可能短。 您可以将其视为对"借用的字符串"的另一个限制-字符串文字借用必须在程序的整个执行时间中持续存在。

  最后,我们返回的不是引用本身,而是一个内部对象的复合对象。 这是完全支持的,不会影响我们的生命周期的逻辑。

  因此,在此示例中,该函数接受两个参数,并且在两个生存期内都是通用的。 让我们看看如果我们强制生命周期相同会发生什么:

  fn the_longest<'a>(s1: &'a str, s2: &'a str) -> &'a str {

  if s1.len() > s2.len() { s1 } else { s2 }

  }

  fn main() {

  let s1=String::from("Python");

  // explicitly borrowing to ensure that

  // the borrow lasts longer than s2 exists

  let s1_b=&s1;

  {

  let s2=String::from("C");

  let res=the_longest(s1_b, &s2);

  println!("{} is the longest if you judge by name", res);

  }

  }

  我在内部代码块之外进行了明确的借用,因此借用必须持续到main()的其余部分。 这显然不同于&s2。 如果只接受两个具有相同关联生存期的参数,为什么可以调用该函数?

  事实证明,关联的生命周期是强制类型的主题。 与大多数语言(至少是我所熟知的语言)不同,Rust中的原始(整数)值不会强制转换-您必须始终将其强制转换。 您仍然可以在一些不太明显的地方找到强制,例如这些关联的生存期和带有类型擦除的动态调度。

  我将把这段C ++代码进行比较:

  struct A {

  int x;

  };

  struct B: A {

  int y;

  };

  struct C: B {

  int z;

  };

  B func(B arg)

  {

  return arg;

  }

  int main() {

  A a;

  B b;

  /* this works fine:

  * a B value is a valid A value

  * to put it another way, you can use a B value

  * whenever an A value is expected

  */

  a=b;

  /* on the other hand,

  * this would be an error:

  */

  // b=a;

  // this works just fine

  C arg;

  A res=func(arg);

  return 0;

  }

  派生类型强制为其基本类型。 当我们传递C的实例时,它强制转换为B,然后返回,强制转换为A,然后存储在res变量中。

  同样,在Rust中,更长的借用可能会变短。 它不会影响借用本身,而只会在需要较短借入的地方被接受。 因此,您可以为函数传递寿命比预期更长的借用(它将被强制执行),并且可以强制将借还的返回的时间更短。

  再考虑一下这个示例:

  fn middle_name<'a>(full_name: &'a str) -> &'a str {

  full_name.split_whitespace().nth(1).unwrap()

  }

  fn main() {

  let name=String::from("Harry James Potter");

  let res=middle_name(&name);

  assert_eq!(res, "James");

  // won't compile:

  /*

  let res;

  {

  let name=String::from("Harry James Potter");

  res=middle_name(&name);

  }

  assert_eq!(res, "James");

  */

  }

  人们通常会想知道这样的函数声明是否意味着参数的关联生存期必须(至少)与返回值一样长,反之亦然。

  答案现在应该很明显。 在功能上,两个寿命完全相同。 但是由于强制,您可以通过较长的借用时间,甚至有可能在获得结果后缩短结果的关联寿命。 因此正确的答案是-参数必须至少与返回值一样长。

  而且,如果您创建一个通过引用接受多个参数的函数,并声明它们必须具有相等的关联生命周期(如在我们之前的示例中一样),则将给出该函数的实际参数将被强制为其中最短的生命周期。 这只是意味着结果不能超过借用的任何参数。

  这与我们之前讨论的反向约束规则很好地配合。 被呼叫者不在乎-它只是获得并返回相同生命周期的借贷。 另一方面,调用者确保参数的关联生存期永远不会比结果的生存期短,可以通过扩展它们来实现。

  随机附加说明

  · 您不能移出借用的值,因为借用结束后该值必须保持有效。 即使您在下一行移回某些内容,也无法将其移出。 但是有mem :: replace可以让您同时执行两项操作。

  · 如果您要拥有一个指针(类似于C ++中的unique_ptr),则可以使用Box类型。

  · 如果您希望进行一些基本的引用计数(例如C ++中的shared_ptr和weak_ptr),则可以使用此标准模块。

  · 如果您确实确实需要解决Rust所施加的限制,则始终可以使用不安全的代码。

  (本文翻译自Sergey Bugaev的文章《Understanding Rust: ownership, borrowing, lifetimes》

上一篇:Rust语言圣经12 - 字符串与切片


下一篇:EF6数据迁移