Rust第五天,认识所有权(一)

72 阅读18分钟

所有权是 Rust 最独特的特性,对 Rust 的其他部分有着深远的影响。它使 Rust 能够在无需垃圾收集器的情况下提供内存安全保障,因此理解所有权的工作原理至关重要。我们将要学习所有权的几个特性:借用、切片以及 Rust 如何在内存中布局数据。

1.什么是所有权

所有权是为了保证Rust安全,要理解所有权要了解如何让Rust运行安全或者不安全。

[安全就是没有未定义的行为]

我们看下面两段代码:

第一段

fn read(y: bool) {
    if y {
        println!("y is true!");
    }
}

fn main() {
    let x = true;
    read(x);
}

第二段

fn read(y: bool) {
    if y {
        println!("y is true!");
    }
}

fn main() {
    read(x); // oh no! x isn't defined!
    let x = true;
}

上面代码中,第二段运行会报错,所以我们可以说第二段代码是一个不安全的代码。

Rust 的一个基本目标是确保程序永远不会出现未定义行为,Rust 的第二个目标是在编译时(而不是运行时) 防止未定义行为

上面两个目标分别对应了两个动机:

  1. 在编译时捕获错误意味着避免生产中出现这些错误,从而提高软件的可靠性。
  2. 在编译时捕获错误意味着更少的运行时检查这些错误,从而提高软件的性能。

[所有权作为记忆安全的一门学科]

因为安全性意味着不存在未定义的行为并且所有权与安全性息息相关,所以我们需要从所有权的未定义行为来理解所有权。Rust 参考维护着一个庞大的“未定义行为”列表,目前我们就主要了解内存操作。

内存是程序运行占用的内存空间,当然我们也可以理解以下说法。

  • 如果您不熟悉系统编程,您可能会从高层次考虑内存,例如“内存是我计算机中的 RAM”或“如果我加载太多数据,内存就会耗尽”。
  • 如果您熟悉系统编程,您可能会从低层次考虑内存,例如“内存是一个字节数组”或“内存是我从中获取的指针malloc”。

Rust中不允许你将内存解释为字节数组,Rust 提供了一种思考内存的特殊方式。所有权是在这种思维方式下安全使用内存的规则.

[变量存在于堆栈中]

fn main() {
    let n = 5;
    let y = plus_one(n);
    println!("The value of y is: {y}");
}

fn plus_one(x: i32) -> i32 {
    x + 1
}

上面的内存示意图如下:

image.png

虽然此内存模型并不能完全描述 Rust 的实际工作方式!正如我们之前在汇编代码中看到的,Rust 编译器可能会将nx放入寄存器而不是堆栈框架中。但这种区别是一个实现细节。它不应该改变你对 Rust 安全性的理解,因此我们可以专注于更简单的仅限框架变量的情况。

表达式读取变量时,该变量的值会从堆栈框架中的对应位置复制过来

image.png

[盒子在堆中]

但是复制会占用大量内存。

image.png

为了在不复制数据的情况下传输对数据的访问,Rust 使用指针。指针是描述内存位置的值。指针指向的值称为其指针对象。 创建指针的一种常见方法是在堆中分配内存。堆是内存中一个独立的区域,数据可以无限期地存在于其中。堆数据不依赖于特定的堆栈框架。Rust 提供了一种称为 的结构,Box用于将数据放在堆上。例如,我们可以像这样包装百万元素数组Box::new

image.png

[Rust 不允许手动内存管理]

正如我们上面所见,调用 时会分配堆数据Box::new(..)。但是堆数据何时被释放呢?想象一下,Rust 有一个free()函数可以释放堆分配。想象一下,Rust 允许程序员free随时调用它。这种“手动”内存管理很容易导致 bug。例如,我们可以读取指向已释放内存的指针:

image.png

Rust 不允许程序手动释放内存。该策略避免了上述未定义的行为。

[盒子的主人管理释放]

Rust会自动释放 box 的堆内存。

盒子释放原则(几乎正确): 如果一个变量绑定到一个盒子,当 Rust 释放该变量的框架时,Rust 也会释放该盒子的堆内存。

image.png

盒子的堆内存已成功管理。但是如果我们滥用这个系统会怎么样呢?

let a = Box::new([0; 1_000_000]);
let b = a;

现在,装箱后的数组已同时绑定到a和。根据我们的“几乎正确”原则,Rust 会尝试为这两个变量释放两次b装箱的堆内存。这也是未定义的行为!为了避免这种情况,我们最终要讨论所有权。当a绑定到时Box::new([0; 1_000_000]),我们称a 拥有该盒子。该语句let b = a 盒子的所有权从a转移到b。基于这些概念,Rust 释放盒子的策略更准确地描述为:

盒子释放原则(完全正确): 如果一个变量拥有一个盒子,当 Rust 释放该变量的框架时,Rust 也会释放该盒子的堆内存。

当作用域结束时,Rust 只会为 释放一次该装箱b,而不是a

[收藏品使用盒]

Rust 数据结构1(例如VecString和 )使用盒子HashMap来保存可变数量的元素。例如,下面是一个创建、移动和修改字符串的程序:

fn main() {
    let first = String::from("Ferris"); // L1
    let full = add_suffix(first); // L4
    println!("{full}");
}

fn add_suffix(mut name: String) -> String {
    // L2
    name.push_str(" Jr.");
    // L3
    name
}

内存示意图:

image.png

[变量移动后无法使用]

字符串程序有助于说明所有权的一个关键安全原则。想象一下,在调用 之后,first在 中使用。我们可以模拟这样的程序,并观察由此导致的未定义行为:main``add_suffix

fn main() {
    let first = String::from("Ferris");
    let full = add_suffix(first);
    println!("{full}, originally {first}"); // 这里是L1 first is now used here
}

fn add_suffix(mut name: String) -> String {
    name.push_str(" Jr.");
    name
}

image.png

移动堆数据原则: 如果一个变量x将堆数据的所有权转移给另一个变量y,那么x移动后就无法使用。

[克隆避免移动]

避免移动数据的一种方法是使用该方法进行克隆.clone()

fn main() {
    let first = String::from("Ferris");
    let first_clone = first.clone(); // L1
    let full = add_suffix(first_clone); // L2
    println!("{full}, originally {first}");
}

fn add_suffix(mut name: String) -> String {
    name.push_str(" Jr.");
    name
}

image.png

观察一下,在 L1 处,first_clone并没有“浅”复制 中的指针first,而是将字符串数据“深”复制到新的堆分配中。因此,在 L2 处,虽然first_clone已被 移动并使其无效add_suffix,但原始first变量保持不变。可以安全地继续使用first

2.参考和借用

所有权、装箱和移动为安全地使用堆进行编程奠定了基础。然而,仅移动的 API 使用起来可能不太方便。例如,假设你想读取一些字符串两次:

fn main() {
    let m1 = String::from("Hello");
    let m2 = String::from("world");
    greet(m1, m2); // L2
    let s = format!("{} {}", m1, m2); // L3 Error: m1 and m2 are moved
}

fn greet(g1: String, g2: String) {
    println!("{} {}!", g1, g2); // L1
}

image.png

上面就会报错未定义m1,那我们该如何操作呢?

我们需要用greet方法添加返回值,返回字符串的所有权。如下:

fn main() {
    let m1 = String::from("Hello");
    let m2 = String::from("world"); // L1 
    let (m1_again, m2_again) = greet(m1, m2); 
    let s = format!("{} {}", m1_again, m2_again); // L2
}

fn greet(g1: String, g2: String) -> (String, String) {
    println!("{} {}!", g1, g2);
    (g1, g2)
}

image.png

[引用是非拥有指针]

fn main() {
    let m1 = String::from("Hello");
    let m2 = String::from("world"); // L1
    greet(&m1, &m2); // L3 note the ampersands
    let s = format!("{} {}", m1, m2);
}

fn greet(g1: &String, g2: &String) { // note the ampersands
    // L2
    println!("{} {}!", g1, g2);
}

image.png

引用是非拥有指针,因为它们不拥有它们指向的数据

[取消引用指针访问其数据]

 fn main() {
    let mut x: Box<i32> = Box::new(1);
    let a: i32 = *x;         // *x reads the heap value, so a = 1
    *x += 1;                 // *x on the left-side modifies the heap value,
                         //     so x points to the value 2

    let r1: &Box<i32> = &x;  // r1 points to x on the stack
    let b: i32 = **r1;       // two dereferences get us to the heap value

    let r2: &i32 = &*x;      // r2 points to the heap value directly
    let c: i32 = *r2;    // L1 so only one dereference is needed to read it
 }

image.png

Rust 在某些情况下会隐式插入解引用和引用,例如使用点运算符调用方法。例如,此程序展示了调用i32::abs(absolute value) 和str::len(string length) 函数的两种等效方法:

let x: Box<i32> = Box::new(-1);
let x_abs1 = i32::abs(*x); // explicit dereference
let x_abs2 = x.abs();      // implicit dereference
assert_eq!(x_abs1, x_abs2);

let r: &Box<i32> = &x;
let r_abs1 = i32::abs(**r); // explicit dereference (twice)
let r_abs2 = r.abs();       // implicit dereference (twice)
assert_eq!(r_abs1, r_abs2);

let s = String::from("Hello");
let s_len1 = str::len(&s); // explicit reference
let s_len2 = s.len();      // implicit reference
assert_eq!(s_len1, s_len2);

此示例以三种方式显示了隐式转换:

  1. i32::abs函数需要 类型的输入i32。要abs使用调用Box<i32>,您可以像 一样显式地取消引用该框i32::abs(*x)。您也可以使用 这样的方法调用语法隐式地取消引用该框x.abs()。点语法是函数调用语法的语法糖。
  2. 这种隐式转换适用于多层指针。例如,调用abs一个 box 的引用r: &Box<i32>将插入两次取消引用。
  3. 这种转换反过来也成立。函数str::len需要一个引用&str。如果你调用len一个owned类型的引用String,Rust会插入一个借用运算符。(实际上,还有一个从String到 的转换str!)

[Rust 避免同时发生别名和变异]

指针是一个强大而危险的特性,因为它支持aliasing(别名) 。 aliasing 指的是通过不同的变量访问相同的数据。 aliasing 本身无害,但与mutation(变量改变) 结合起来,就酿成了一场灾难。 一个变量可以通过多种方式“夺走”另一个变量的利益,例如:

  • 通过释放别名数据,让另一个变量指向已释放的内存。
  • 通过改变别名数据,使其他变量所期望的运行时属性无效。
  • 通过同时改变别名数据,导致另一个变量具有不确定行为的数据竞争。

作为一个示例,我们将研究使用向量数据结构的程序。Vec与具有固定长度的数组不同,向量通过将元素存储在堆中而具有可变长度。例如,[Vec::push]将一个元素添加到向量的末尾,如下所示:

fn main() {
    let mut v: Vec<i32> = vec![1, 2, 3];  // L1
    v.push(4);  // L2
}

image.png

该宏vec!创建一个向量,其元素位于括号之间。该向量v的类型为Vec<i32>。语法<i32>意味着向量的元素类型为i32

一个重要的实现细节是v分配一个特定容量的堆数组。我们可以深入Vec了解 的内部机制,亲眼看看这个细节:

fn main() {
  let mut v: Vec<i32> = vec![1, 2, 3];
}

image.png

请注意,该向量的长度 ( len) 为 3,容量 ( cap) 也为 3。向量已达到容量上限。因此,当我们执行 时push,向量必须创建一个容量更大的新分配空间,将所有元素复制过去,并释放原始堆数组。在上图中,该数组1 2 3 4与原始数组位于(可能)不同的内存位置1 2 3

为了将其与内存安全联系起来,让我们将引用引入其中。假设我们创建了一个指向 Vector 堆数据的引用。然后,该引用可以通过推送操作失效,如下所示:

fn main() {
  let mut v: Vec<i32> = vec![1, 2, 3];
  let num: &i32 = &v[2];
  v.push(4);
  println!("Third element is {}", *num);
}

image.png

数据可以别名化,也可以被修改。但数据不能同时被别名化修改。例如,Rust 通过禁止别名来强制执行此原则,适用于 box(拥有指针)。将 box 从一个变量赋值给另一个变量会转移所有权,使之前的变量无效。拥有的数据只能通过所有者访问——没有别名。

[引用更改地点的权限]

借用检查器背后的核心思想是变量对其数据具有三种权限:

  • 读取(R):数据可以被复制到另一个位置。
  • 写入(W):数据可以改变。
  • 拥有(O):数据可以移动或删除。

这些权限在运行时并不存在,只存在于编译器中。它们描述了编译器在程序执行之前如何“思考”你的程序。

默认情况下,变量对其数据具有读/拥有权限 ( R O )。如果变量带有 注释let mut,则它还具有写权限 ( W )。关键在于引用可以暂时移除这些权限。

为了说明这一点,让我们看一下上面程序的一个变体的权限,该变体实际上是安全的。push被移到了 之后println!。该程序中的权限用一种新的图表来可视化。该图显示了每一行权限的变化。

fn main() {
    let mut v: Vec<i32> = vec![1, 2, 3];
    let num: &i32 = &v[2];
    println!("Third element is {}", *num);
    v.push(4);
}

image.png

更一般地,权限定义在位置上,而不仅仅是变量上。位置是指可以放在赋值语句左侧的任何内容。位置包括:

  • 变量,例如a
  • 取消对地点的引用,例如*a
  • 位置的数组访问,例如a[0]
  • 位置的字段,例如a.0元组或a.field结构体(下一章讨论)。
  • 以上任意组合,如*((*a)[0].1)

其次,为什么位置在不使用时会失去权限?因为某些权限是互斥的。如果你写了num = &v[2],那么在使用v时就无法改变或删除num。但这并不意味着它num再次使用无效。例如,如果我们println!在上面的程序中添加另一个,那么num在下一行之后就会失去它的权限:

image.png

[借用检查器发现权限违规]

回想一下指针安全原则:数据不应被别名化和修改。这些权限的目标是确保数据在被别名化后不会被修改。创建对数据的引用(“借用”)会导致该数据暂时处于只读状态,直到该引用不再被使用为止。

Rust 在其借用检查器中使用这些权限。借用检查器会查找涉及引用的潜在不安全操作。让我们回到之前看到的使push引用无效的不安全程序。这次,我们将在权限图中添加另一个方面:

image.png

[可变引用提供对数据的唯一且非拥有的访问]

到目前为止,我们所见的引用都是只读的不可变引用(也称为共享引用)。不可变引用允许使用别名,但不允许修改。然而,在不移动数据的情况下,临时提供对数据的可变访问也很有用。

实现此目的的机制是可变引用(也称为唯一引用)。以下是可变引用及其伴随权限更改的简单示例:

image.png

可变引用也可以暂时“降级”为只读引用。例如:

image.png

在这个程序中,借用从中&*num删除了W*num权限,但没有删除R权限,因此println!(..)可以读取*num*num2

[权限在引用生命周期结束时返回]

我们上面提到,引用在“使用中”时会改变权限。“使用中”这个短语描述的是引用的生命周期,或者说是从其诞生(引用被创建)到消亡(引用最后一次被使用)的代码范围。

例如,在此程序中,的生命周期y以 开始let y = &x,以 结束let z = *y

image.png

就像我们之前看到的那样,的生命周期结束后,的W权限将x返回到。x``y

在前面的例子中,生命周期指的是一段连续的代码区域。然而,一旦引入控制流,情况就不再如此了。例如,下面是一个将 ASCII 字符向量中首字母大写的函数:

image.png

该变量c在 if 语句的每个分支中都有不同的生命周期。在 then 语句块中,c该变量在表达式中使用c.to_ascii_uppercase()。因此,直到该行之后*v才重新获得W权限。

但是在 else 块中,c不使用。在进入 else 块时*v立即重新获得W权限。

[数据必须比其所有引用都存活更久]

作为指针安全原则的一部分,借用检查器强制数据必须比任何对其的引用存活更久。Rust通过两种方式强制执行此属性。第一种方式处理在单个函数作用域内创建和删除的引用。例如,假设我们试图在持有一个字符串的引用时删除它:

image.png

为了捕获这类错误,Rust 使用了我们已经讨论过的权限。借用会从 中&s删除Os权限。然而,drop期望的是O权限,导致权限不匹配。

在这个例子中,关键思想在于 Rust 知道s_ref引用的生命周期。但是,当 Rust 不知道引用的生命周期时,它需要一种不同的强制机制。具体来说,当引用作为函数的输入或输出时。例如,这是一个返回向量中第一个元素的引用的安全函数:

image.png

这段代码引入了一种新的权限,即流权限F。每当表达式使用输入引用(如)或返回输出引用(如)时,都需要F权限。&strings[0]``return s_ref

与R W O权限不同,F在整个函数主体中不会发生变化。如果引用允许在特定表达式中使用(即流动),则该引用具有F权限。例如,假设我们切换到一个包含参数的新函数:**first``first_or``default

image.png

此函数无法编译,因为表达式&strings[0]和缺少返回default所需的F权限。但为什么呢?Rust 会报以下错误:

image.png

“缺少生命周期说明符”这条消息有点神秘,但帮助信息提供了一些有用的上下文。如果 Rust查看函数签名,它无法知道输出是否是对或 的&String引用。为了理解为什么这很重要,假设我们使用了如下代码:strings``default``first_or

fn main() {
    let strings = vec![];
    let default = String::from("default");
    let s = first_or(&strings, &default);
    drop(default);
    println!("{}", s);
}

first_or如果允许default流入返回值 则该程序不安全。与前面的示例类似,drop可能会使 无效。Rust 只有在确定不会流入返回值s时才会允许该程序编译。**default

为了指定是否default可以返回,Rust 提供了一种称为生命周期参数的机制。我们将在后面的第 10.3 章“使用生命周期验证引用”中解释该功能。目前,只需了解以下几点就足够了:(1) 输入/输出引用与函数体内的引用的处理方式不同,以及 (2) Rust 使用不同的机制(即F权限)来检查这些引用的安全性。

要在另一个上下文中查看F权限,假设您尝试返回对堆栈上变量的引用,如下所示:

image.png

这段程序是不安全的,因为返回时引用&s会失效return_a_string。Rust 会拒绝这段程序并抛出类似的missing lifetime specifier错误。现在你应该明白了,这个错误意味着s_ref缺少适当的流权限。

概括

引用提供了在不消耗数据所有权的情况下读写数据的能力。引用通常通过借用(&&mut)创建,并通过解除引用(*)使用,且通常是隐式的。

然而,引用很容易被滥用。Rust 的借用检查器强制执行一套权限系统,以确保引用的安全使用:

  • 所有变量都可以读取、拥有和(可选)写入其数据。
  • 创建引用将把权限从借用的地方转移到引用。
  • 一旦引用的生命周期结束,就会返回权限。
  • 数据必须比指向它的所有引用都存活得更久。

在本节中,您可能感觉我们描述的 Rust不能做的事情比能做的事情多。这是故意的!Rust 的核心特性之一是允许您使用指针而不受垃圾回收的影响,同时还能避免未定义的行为。现在理解这些安全规则将有助于您避免以后遇到编译器问题。