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

这是我对描述这些事情的看法。 一旦掌握了它,这一切看起来都非常明显和美丽,你不知道你之前缺少了哪一部分。

我不打算从头开始教你,也不要重复书中所说的内容(虽然有时我会) - 如果你还没有,你应该现在阅读相应的章节。 这篇文章旨在补充本书,而不是取代它。

我也建议你阅读这篇优秀的文章。 它实际上涉及类似的主题,但侧重于它们的其他方面。

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

这种方法存在问题。 首先,忘记释放一些资源太容易了,导致所谓的泄漏。 更糟糕的是,人们可能会尝试访问已经发布的资源(免费使用后)。 如果幸运的话,他们会得到一条错误消息,希望能帮助他们识别和修复错误。 否则,它们具有的引用 - 尽管逻辑无效 - 可能仍然引用某些其他资源已经占用的“地点”:已存储其他内容的内存,其他一些打开文件使用的文件描述符。 尝试通过无效引用访问旧资源可能会破坏其他资源或完全崩溃程序。

我所说的这些问题并不是虚构的。 它们一直在发生。 例如,请查看Google Chrome发布博客:由于免费使用后导致许多漏洞和崩溃得到修复,并且需要花费大量时间和工作(和金钱)来识别和修复这些漏洞。。

并不是说开发人员是愚蠢而无视的。 逻辑流本身容易出错:它需要您释放资源,但不强制执行它。 此外,您通常不会注意到您忘记释放资源,因为很少有可观察到的影响。

有时实现简单的目标需要发明复杂的解决方案,而那些带来复杂的逻辑。 很难不让自己迷失在一个巨大的代码库中,而且这里和那里总是会出现错误也就不足为奇了。 他们中的大多数很容易被发现。 然而,这些与资源相关的错误很难被注意到,但如果它们在野外被利用则非常危险。

当然,像Rust这样的新语言无法为您修复错误。 它能做什么 - 它完全成功 - 它会影响你的思维方式,为你的思想带来一些结构,从而使这些错误不太可能出现。

Rust为您提供了一种安全,清晰的资源管理方式。 并且它不允许您以任何其他方式管理它们。 这是非常严格的限制,但这就是我们的目的。

由于以下几个原因,这些限制非常棒:

  • 它们会让你以正确的方式思考。经过一些Rust体验后,您经常会发现自己在使用其他语言进行开发时尝试应用相同的概念,即使它们没有内置于语法中。
  • 它们使您的代码安全。 除了几个非常罕见的角落案例之外,所有“安全”Rust代码都保证不会出现我们所讨论的错误。
  • Rust感觉像带有垃圾收集的高级语言一样愉快(我开玩笑说JavaScript是令人愉快的吗?),但是和其他低级编译语言一样快。
    考虑到这一点,让我们看看Rust的一些好东西。

所有权(Ownership)

在Rust中,关于哪一段代码拥有资源有非常明确的规则。 在最简单的情况下,它是创建表示资源的对象的代码块。 在块的末尾,对象被销毁并且资源被释放。 这里的重要区别在于,对象不是某种容易“忘记”的“弱引用”。 虽然在内部,对象只是完全相同引用的包装器,但从外部看,它似乎是它所代表的资源。 删除它 - 也就是说,到达拥有它的代码的末尾 - 自动且可预测地释放资源。 没有办法“忘记这样做” - 它是以可预测和完全指定的方式自动完成的。

(此时你可能会问自己为什么我要描述这些微不足道的,显而易见的事情,而不仅仅是告诉你聪明的人称之为RAII。好的,你是对的。让我们继续吧。)

这个概念适用于临时对象。 比如说,我们需要将一些文本写入文件。 专用的代码块(比如一个函数)会打开一个文件 - 得到一个文件对象(包装文件描述符)作为结果 - 然后用它做一些工作,然后在块的末尾,文件对象将获得 删除并关闭文件描述符。

但在许多情况下,这个概念不起作用。 您可能希望将资源传递给其他人,在几个“用户”之间或甚至在线程之间共享。

让我们来看看这些。 首先,您可能希望将资源传递给其他人 - 转移所有权 - 这样他们现在拥有资源,随心所欲地做任何事情,或许更重要的是,负责释放资源。

Rust非常支持这一点 - 事实上,当你将资源提供给其他人时,默认情况下会发生这种情况。

fn print_sum(v: Vec<i32>) {
    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具有复制特性。 实现它的类型(所有原始类型)在分配时使用复制语义,所有其他类型都使用移动语义。 很简单。 如果要复制特征,可以为自己的类型实现复制特征 - 这是一个选择加入。

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这样的函数会分配内存,交给你,并期望你管理并最终解除分配。不同之处在于它只是一个指针而且它们能做的最多就是提醒/提醒你free()它 - 并且上面的链接文档几乎没有做到 - 而在Rust中它是语言中不可分割的一部分。)

另一个例子是像这样的迭代器适配器,它使用它获得的迭代器,所以无论如何都不需要访问迭代器。

相反的问题是在哪种情况下我们需要对同一资源进行多次引用。 最明显的用例是当你进行多线程处理时。 否则,如果所有操作都是按顺序执行的,那么移动语义几乎总是有效。 但是,一直来回移动都会非常不方便。

有时,尽管代码严格按顺序运行,但仍然感觉有几件事情同时发生。 想象一下迭代矢量。 循环结束后,迭代器可以转移你对所讨论的向量的所有权,但是你无法获得对循环中向量的任何访问权限 - 也就是说,除非你在代码和代码之间取得所有权。 迭代器在每次迭代时,这将是一个可怕的混乱。 它似乎也没有办法遍历树而不将其解构为堆栈 - 然后将其构建回来,前提是你想在之后用它做其他事情。

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

借用(Borrowing)

有多种方法来推理借用:

  • 它允许我们对资源进行多次引用,同时仍然遵循“单一所有者,单一责任场所”的概念。
    这与C中的指针类似。

引用也是一个对象。 移动可变引用,复制不可变引用。 删除引用时,借用结束(根据生命周期规则,请参阅下一节)。

在最简单的情况下,引用表现为“就像”来回移动所有权而不明确地进行。

这是我最后一个意思:

// without borrowing
fn print_sum1(v: Vec<i32>) -> Vec<i32> {
    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<i32>) {
    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<i32>) {
    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
}

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

第三个功能结合了第一个的好部分(不需要解除引用)和第二个功能(没有弄乱所有权)。 它的工作原理是Rust自动解除引用规则。 这些有点复杂,但在大多数情况下,它们允许您编写代码,就像引用只是它们指向的对象一样 - 因此类似于C ++引用。

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

(我会有点邪恶。我提到多线程作为引用的主要原因,但我展示的所有示例都是单线程的。如果你真的很感兴趣,你可以在这里和这里获得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");
}

为了使它更加明显,让我在纯C中写一些类似的东西:

(不相关的注释:在C中,你不能在字符串的中间有一个“视图”,因为标记它的结尾将需要更改字符串,因此我们仅限于在此处查找姓氏。)

#include <stdlib.h>
#include <stdio.h>
#include <string.h>

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\n", res);

    return 0;
}

你现在看到了吗? 在使用结果之前,缓冲区被删除并释放。 这是免费使用后的一个简单例子。 这个C代码编译并运行得很好,前提是printf实现不会立即重用内存。 尽管如此,在一个不那么简单的例子中,它将成为崩溃,错误和安全漏洞的来源。 这正是我们在介绍所有权之前所谈到的。

你甚至不能在Rust中编译它(我的意思是上面的Rust代码)。 这种静态分析机制直接构建在语言中,并在整个生命周期内工作。

生命周期(Lifetimes)

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编译器来说是足够微不足以自动计算出来(详见生命周期省略)。 无论如何,我们已经完成了它,以展示它们的工作原理。

关于它在实践中意味着什么可能不是很明显,所以让我们以相反的方式看待它。 返回的引用存储在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给你的限制,你可以随时使用不安全的代码。

猜你喜欢

转载自blog.csdn.net/m0_37696990/article/details/82818982