为什么在 Rust 中作为参数传递的 &str 数组具有不同的生命周期?

6

我正在学习Rust,并测试通过函数进行一些数组复制。我确信在Rust中有内置的函数可以复制/克隆数组信息,但我认为自己实现一个函数能帮助我更好地理解如何通过函数传递引用。

fn copy_str_arr_original (a1: [&str; 60], a2: &mut [&str; 60]) {
    // copy 1 into 2
    for i in 0..60 {
        a2[i] = a1[i];
    } // change is reflected in a2 as it is passed as &mut
}

然而,这个问题导致了针对&str类型的错误信息:these two types are declared with different lifetimes...。在进一步研究后,我尝试声明自己的生命周期并将其分配给它们,问题得到了解决!
fn copy_str_arr_fix<'a> (a1: [&'a str; 60], a2: &mut [&'a str; 60]) {
    // copy 1 into 2
    for i in 0..60 {
        a2[i] = a1[i];
    } // change is reflected in a2 as it is passed as &mut
}

为什么会这样呢?为什么数组中的值类型需要分配生命周期,而不是参数本身?换句话说,为什么这根本不起作用呢?
fn copy_str_arr_bad<'a> (a1: &'a [&str; 60], a2: &'a mut [&str; 60]) {
    // does not work...           ^-----------------------^-------- different lifetimes
    for i in 0..60 {
        a2[i] = a1[i]; 
    } 
}

我仍在努力掌握生命周期在更复杂的对象(例如数组和结构体)中的工作方式,因此任何解释都将不胜感激!

3个回答

7
错误信息有点令人困惑,因为它提到了按照生命周期省略规则生成的生命周期。在您的情况下,生命周期省略意味着:
fn copy_str_arr_original(a1: [&str; 60], a2: &mut [&str; 60])

是语法糖,相当于:

fn copy_str_arr_original<'a1, 'a2_mut, 'a2>(a1: [&'a1 str; 60], a2: &'a2_mut mut [&'a2 str; 60])

换句话说,我们有三个完全不相关的生命周期。"不相关"意味着调用者可以选择与其关联的对象的存活时间。例如,a2 中的字符串可能是静态的,并且会一直存活到程序结束,而a1 中的字符串可能在copy_str_arr_original()返回后立即被删除。或者反过来。如果这种自由度似乎可能会引起问题,那么您就正确了,因为借用检查器也同意您的看法。
请注意,有些令人费解的是,'a2_mut 生命周期的长度完全无关紧要,它可以像调用者希望的那样长或短。我们的函数接收了引用,在函数的作用域内可以使用它。'a2_mut 生命周期告诉我们它将在函数作用域之外生存多久,但我们并不关心这个。

'a1'a2是另一回事。由于我们正在从a1复制引用到a2,因此我们实际上是将a1内部的引用(类型为&'a1 str)强制转换为存储在a2中的引用类型(即&'a2 str):

a2[i] = a1[i];  // implicitly casts &'a1 str to &'a2 str

为了使其有效,&'a1 str 必须是 &'a2 str子类型。虽然 Rust 没有 C++ 中的类和子类,但是在涉及生命周期的情况下,它确实有子类型。在这个意义上,如果 A 的值保证至少与 B 的值一样长,那么 A 就是 B 的子类型。换句话说,'a1 必须至少和 'a2 一样长,表示为 'a1: 'a2。因此,以下代码可以编译:

fn copy_str_arr<'a1: 'a2, 'a2, 'a2_mut>(a1: [&'a1 str; 60], a2: &'a2_mut mut [&'a2 str; 60]) {
    for i in 0..60 {
        a2[i] = a1[i];
    }
}

另一种使转换成功的方式是只要求生命周期保持相同,就像您在copy_str_arr_fix()中所做的那样。(您还省略了'a2_mut生命周期,编译器正确地解释为请求一个不相关的匿名生命周期。)

2
假设你可以用两个不相关的生命周期定义copy_str_arr,例如:
fn copy_str_arr<'a, 'b>(a1: [&'a str; 60], a2: &mut [&'b str; 60]) {
    // ...
}

那么考虑这个例子:
let mut outer: [&str; 60] = [""; 60];

{
    let temp_string = String::from("temporary string");
    
    let inner: [&str; 60] = [&temp_string; 60];

    // this compiles because our bad `copy_str_arr` function allows
    // `inner` and `outer` to have unrelated lifetimes
    copy_str_array(&inner, &mut outer); 

}   // <-- `temp_string` destroyed here

// now `outer` contains references to `temp_string` here, which is invalid
// because it has already been destroyed!

println!("{:?}", outer); // undefined behavior! may print garbage, crash your
                         // program, make your computer catch fire or anything else

正如您所看到的,如果允许a1a2具有完全不相关的生命周期,那么我们可能会陷入一种情况,其中一个数组保存对无效数据的引用,这是非常糟糕的。
然而,生命周期并不需要相同。相反,您可以要求您从中复制的生命周期超过您要复制到的生命周期(从而确保您没有非法地延长引用的生命周期):
fn copy_str_arr<'a, 'b>(a1: &[&'a str; 60], a2: &mut [&'b str; 60])
where
    'a: 'b, // 'a (source) outlives 'b (destination)
{
    for i in 0..60 {
        a2[i] = a1[i];
    }
}

谢谢!这非常有帮助!只是为了确认 - 我们不想要 where 'b: 'a,因为这意味着我们的目标超出了源的生命周期,这意味着我们的数组指向已释放的内存,对吗? - jts
@jts 是的,完全正确。 - Frxstrem
我很好奇:将str引用的生命周期要求相同是否有任何不利影响?我试图构造一个例子,其中否则正确的代码由于绑定过于严格而无法调用copy_str_arr()函数,但我未能想出一个例子。例如,如果您使源字符串静态并将目标字符串堆栈分配,则使用单个生命周期定义的copy_str_arr()的调用仍会编译。大概编译器会自动找到较短的公共生命周期,并将其作为函数所需的寿命。 - user4815162342
C++最大的问题在于迷失在其极其复杂的语义细节中。Rust似乎也存在这个问题,但程度要小得多。我曾经质疑为什么一个函数会涉及超过一个生命周期,但是引用依赖于另一个引用的想法使这一点变得清晰明了。 - ATL_DEV

0
简单的答案是编译器不太聪明。
事实上,你不必每次定义一个处理引用的函数时都指定一堆生命周期,这只是因为编译器会进行一些有根据的猜测。所以它有点聪明,但并不是非常聪明。
假设你正在编写一个函数,该函数接受一个结构体的引用,并返回该结构体中一个字段的引用:
struct Book {
  pages: u16,
  title: String,
}

fn borrow_title(book: &Book) -> &str {
  &book.title
}

九次中的九次,它确实是指你传递的参数的引用。但有时候不是这样的:
fn borrow_title(book: &Book) -> &'static str {
  if book.pages > 10 {
    "Too long..."
  } else {
    "Not long enough"
  }
}

正如您所看到的,您需要指定返回的&str具有不同的生命周期(在本例中为特殊的'static)。

因此,既然您说fn copy_str_arr_original(a1:[&str; 60],a2:&mut [&str; 60]),编译器实际上并不会推理您的实现,并且不知道a1中引用的生命周期应该至少与a2中任何引用的生命周期一样长。

至于第二部分,您需要考虑引用只是指向某些数据的指针。这些数据可以包含其他引用。在这种情况下,重要的是这些其他引用。

您在这里有两个字符串引用数组。假设您将第一个数组的引用复制到第二个数组中。无论您是否通过引用将这些数组传递到函数中都不重要。重要的是,如果持有第一个数组所有权的任何内容被删除,则字符串也将被删除。如果第二个数组仍然持有任何引用,则会导致不安全的内存处理。

简化一下,我们假设只有一个字符串,我们将从中借用值到一个数组中,然后将这些借用的值复制到另一个数组中,丢弃第一个数组,然后丢弃该字符串。你会期望发生什么?
编译器会抛出异常以确保没有对该字符串的引用保留。

2
编译器并不是很聪明这句话并不是不正确的(借用检查器正在积极开发中),但它并不能准确地描述情况。编译器有意地不从实现中推断生命周期。这是为了确保函数签名仍然是一个合同,即使实现发生变化,其调用者也可以依赖它。当然,编译器还会检查实际实现是否符合声明的签名。 - user4815162342
我知道,但它听起来真的很不错... - Kendas
在许多情况下,这是完全正确的 - 只是在这种情况下不是。 - user4815162342
我第一次听说在 Rust 中定义一个函数契约。 - ATL_DEV

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接