前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >Rust学习笔记之所有权

Rust学习笔记之所有权

作者头像
前端柒八九
发布2023-03-23 19:49:10
发布2023-03-23 19:49:10
66300
代码可运行
举报
运行总次数:0
代码可运行

❝我们的不快乐,是不是来源于自己对自己的苛刻,我们的人生要努力到什么程度,才可以不努力?❞

大家好,我是「柒八九」

今天,我们继续「Rust学习笔记」的探索。我们来谈谈关于「所有权」的相关知识点。

如果,想了解该系列的文章,可以参考我们已经发布的文章。如下是往期文章。

文章list

  1. Rust学习笔记之Rust环境配置和入门指南
  2. Rust学习笔记之基础概念

你能所学到的知识点

所有权的概念 「推荐阅读指数」 ⭐️⭐️⭐️⭐️⭐️

引用与借用 「推荐阅读指数」 ⭐️⭐️⭐️⭐️⭐️

切片 「推荐阅读指数」 ⭐️⭐️⭐️⭐️⭐️❞

好了,天不早了,干点正事哇。


所有权ownership可以说Rust中最为独特的一个功能,正是所有权概念和相关工具的引入,Rust才能够「在没有垃圾回收机制的前提下保障内存安全」

什么是所有权

一般来讲,所有的程序都需要管理自己在「运行时」使用的计算机内存空间。

  • 某些使用垃圾回收机制的语言会在「运行时」定期检查并回收那些没有被继续使用的内存
  • 而在另外一些语言中,程序员需要「手动」地分配和释放内存。
  • Rust采用了第三种方式:它使用特定规则的「所有权系统」来管理内存。
    • 这套规则允许「编译器在编译过程中执行检查工作」,而不会产生任何的「运行时开销」

所有权规则

  • Rust「每一个值都有一个对应的变量作为它的所有者」
  • 「同一时间」内,值「有且仅有」一个所有者
  • 当所有者离开「自己的作用域」时,它持有的值就会被释放

变量作用域

简单来讲,「作用域是一个对象在程序中有效的范围」

假设有这样一个变量:

代码语言:javascript
代码运行次数:0
运行
复制
let s = "hello";

这里的变量s指向了一个字符串字面量,它的值被硬编码到了当前的程序中。「变量从声明的位置开始直到当前作用域结束都是有效的」

下面是针对一个变量s作用域的说明

代码语言:javascript
代码运行次数:0
运行
复制
fn main() {// 变量s还未被声明,所以它在这里是不可用的
   let s ="hello"; // 从这里开始变量s变得可用
   // 执行与s相关的操作
}  // 作用域到这里结束,变量s再次不可用

这里有两个重点:

  • s在进入作用域后变得有效
  • 它会保持自己的有效性直到自己离开作用域为止

String 类型

之前接触的那些数据类型会将数据存储在「栈」上,并在离开自己的作用域时将数据「弹出栈空间」

我们需要一个存储在「堆」上的数据类型来研究Rust是如何自动回收这些数据结构的。我们将以String类型为例,并将注意力集中到String类型与所有权概念相关的部分。

Rust提供了一种字符串类型String。这个类型「在堆上分配到自己需要的存储空间」,所以它能够处理「在编译时未知大小」的文本。可以调用from函数根据字符串字面量来创建一个String实例:

代码语言:javascript
代码运行次数:0
运行
复制
let s = String::from("hello");

这里的双冒号(::)运算符允许我们调用置于String命令空间下的特定方法from函数。

上面定义的字符串对象能够被声明为「可变的」

代码语言:javascript
代码运行次数:0
运行
复制
fn main() {
  let mut s = String::from("hello");
  s.push_str(", world");
  println!("{}",s)
}  

输出结果为hello, world


内存和分配

对于字符串字面量而言,由于我们在「编译时」就知道其内容,所有这部分「硬编码」的文本被「直接嵌入」到了「最终的可执行文件中」。这就是访问字符串字面量异常高效的原因,而这些性质「完全得益于字符串字面量的不可变性」。不幸的是,我们没有办法将那些「未知大小的文本」在编译期统统放入二进制文件中。

对于String类型而言,为了支持一个可变的、可增长的文本类型,我们需要在堆上分配一块在「编译时」未知大小的内存来存放数据。这就意味着:

  1. 使用的内存由「操作系统」「运行时动态分配」出来
  2. 「使用完」String时,需要通过某种方式将这些内存归还给操作系统

这里的第一步由程序的编写者,在调用String::from时完成,这个函数会请求自己需要的内存空间。也就是说「程序员来发起堆内存的分配请求」

针对与第二步,Rust提供了和其余GC机制不同的解决方案:「内存会自动地在拥有它的变量离开作用域后进行释放」

代码语言:javascript
代码运行次数:0
运行
复制
fn main() {// 变量s还未被声明,所以它在这里是不可用的
   let s ="hello"; // 从这里开始变量s变得可用
   // 执行与s相关的操作
}  // 作用域到这里结束,变量s再次不可用

观察上面的代码,有一个很合适用来回收内存给操作系统的地方:变量s离开作用域的地方。Rust在变量离开作用域时,会调用一个叫做drop的特殊函数。「Rust会在作用域结束的地方自动调用drop函数」


变量和数据交互的方式:移动

Rust多个变量可以采用一种独特的方式与同一数据进行交互。

代码语言:javascript
代码运行次数:0
运行
复制
let x = 5;
let y = x;

将变量x的绑定的值重新绑定到变量y上。

上面的代码中,将整数值5绑定到变量x上;然后创建一个x值的「拷贝」,并将它绑定到y上。结果我们有了两个变量xy,它们的值都是5。 因为整数是「已知固定大小」的简单值,「两个值会同时被推入当前的栈中」

我们请上面的程序改造,变成String版本的

代码语言:javascript
代码运行次数:0
运行
复制
let s1 = String::from("hello");
let s2 = s2;

String三部分组成,如图左侧所示:

  • 一个「指向存放字符串内容内存的指针」
  • 一个「长度」:
    • 长度表示 String 的内容当前使用了多少字节的内存
  • 一个「容量」
    • 容量是 String分配器总共获取了多少字节的内存

这一组数据存储在「栈」上。「右侧则是堆上存放内容的内存部分」

当我们将 s1 赋值给 s2String 的数据被复制了,这意味着我们「从栈上拷贝了它的指针、长度和容量」。我们「并没有复制指针指向的堆上数据」

之前我们提到过当变量离开作用域后,Rust 「自动调用」 drop 函数并清理变量的堆内存。不过上图展示了「两个数据指针指向了同一位置」。这就有了一个问题:当 s2s1 离开作用域,他们都会尝试释放相同的内存。这是一个叫做 二次释放double free的错误。两次释放(相同)内存会导致内存污染,它可能会导致潜在的安全漏洞。

为了确保内存安全,这种场景下 Rust 的处理有另一个细节值得注意。在 let s2 = s1 之后,Rust 认为 s1 不再有效,「因此 Rust 不需要在 s1 离开作用域后清理任何东西」

s2 被创建之后尝试使用 s1 会发生什么;「这段代码不能运行」

代码语言:javascript
代码运行次数:0
运行
复制
let s1 = String::from("hello");
let s2 = s1;

println!("{}, world!", s1);

如果你在其他语言中听说过术语 浅拷贝shallow copy和 深拷贝deep copy,那么拷贝指针、长度和容量而不拷贝数据可能听起来像浅拷贝。不过因为 Rust 同时「使第一个变量无效了」,这个操作被称为 移动move,而不是浅拷贝。

上面的例子可以解读为 s1「移动」 到了 s2 中。那么具体发生了什么,如下图所示。

Rust 永远也不会「自动创建」数据的 “深拷贝” ❞


变量与数据交互的方式:克隆

如果我们确实需要「深度复制」 String 中堆上的数据,而不仅仅是栈上的数据,可以使用一个叫做 clone 的通用函数。

代码语言:javascript
代码运行次数:0
运行
复制
fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();

    println!("s1 = {}, s2 = {}", s1, s2);
}

只在栈上的数据:拷贝

代码语言:javascript
代码运行次数:0
运行
复制
fn main() {
    let x = 5;
    let y = x;

    println!("x = {}, y = {}", x, y);
}

没有调用 clone,不过 x 依然有效且没有被移动到 y 中。

原因是像整型这样的在「编译时已知大小的类型被整个存储在栈上」,所以拷贝其实际的值是快速的。这意味着没有理由在创建变量 y 后使 x 无效。换句话说,这里没有深浅拷贝的区别,所以这里调用 clone 并不会与通常的浅拷贝有什么不同。

Rust 有一个叫做 Copy trait「特殊标注」,可以用在类似整型这样的存储在栈上的类型上「如果一个类型实现了 Copy trait,那么一个旧的变量在将其赋值给其他变量后仍然可用」

作为一个通用的规则,任何一组「简单标量值的组合」都可以实现 Copy,任何不需要分配内存或某种形式资源的类型都可以实现 Copy 。如下是一些 Copy 的类型:

  • 「所有整数类型」,比如 u32
  • 「布尔类型」bool,它的值是 truefalse
  • 「所有浮点数类型」,比如 f64
  • 「字符类型」char
  • 「元组」,当且仅当其包含的类型也都实现 Copy 的时候。比如,(i32, i32) 实现了 Copy,但 (i32, String) 就没有。

所有权与函数

❝将值传递给函数在语义上与给变量赋值相似。「向函数传递值可能会移动或者复制」,就像赋值语句一样。 ❞

代码语言:javascript
代码运行次数:0
运行
复制
fn main() {
  let s = String::from("hello");  // s 进入作用域

  takes_ownership(s);             // s 的值移动到函数里 ...
                                  // ... 所以到这里不再有效

  let x = 5;                      // x 进入作用域

  makes_copy(x);                  // x 应该移动函数里,
                                  // 但 i32 是 Copy 的,所以在后面可继续使用 x

} // 这里, x 先移出了作用域,然后是 s。但因为 s 的值已被移走,
  // 所以不会有特殊操作

fn takes_ownership(some_string: String) { // some_string 进入作用域
  println!("{}", some_string);
} // 这里,some_string 移出作用域并调用 `drop` 方法。占用的内存被释放

fn makes_copy(some_integer: i32) { // some_integer 进入作用域
  println!("{}", some_integer);
} // 这里,some_integer 移出作用域。不会有特殊操作

当尝试在调用 takes_ownership 后使用 s 时,Rust 会抛出一个编译时错误


返回值与作用域

❝返回值也可以转移所有权。 ❞

代码语言:javascript
代码运行次数:0
运行
复制
fn main() {
  let s1 = gives_ownership();         // gives_ownership 将返回值
                                      // 移给 s1

  let s2 = String::from("hello");     // s2 进入作用域

  let s3 = takes_and_gives_back(s2);  // s2 被移动到
                                      // takes_and_gives_back 中,
                                      // 它也将返回值移给 s3
} // 这里, s3 移出作用域并被丢弃。s2 也移出作用域,但已被移走,
  // 所以什么也不会发生。s1 移出作用域并被丢弃

fn gives_ownership() -> String {           // gives_ownership 将返回值移动给
                                           // 调用它的函数

  let some_string = String::from("yours"); // some_string 进入作用域

  some_string                              // 返回 some_string 并移出给调用的函数
}

// takes_and_gives_back 将传入字符串并返回该值
fn takes_and_gives_back(a_string: String) -> String { // a_string 进入作用域

  a_string  // 返回 a_string 并移出给调用的函数
}

❝变量的所有权总是遵循相同的模式:「将值赋给另一个变量时移动它」「持有堆中数据值的变量离开作用域时」,其值将通过 drop 被清理掉,除非数据被移动为另一个变量所有。 ❞


引用与借用

下面是如何定义并使用一个 calculate_length 函数,它以一个「对象的引用作为参数」而不是获取值的所有权:

代码语言:javascript
代码运行次数:0
运行
复制
fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{}' is {}.", s1, len);
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

❝这些 & 符号就是「引用」,它们「允许你使用值但不获取其所有权」。 ❞

仔细看看这个函数调用:

代码语言:javascript
代码运行次数:0
运行
复制
let s1 = String::from("hello");

let len = calculate_length(&s1);

&s1 语法让我们创建一个指向值 s1 的引用,但是并不拥有它」。因为并不拥有这个值,所以当引用停止使用时,它所指向的值也不会被丢弃。

同理,函数签名使用 & 来表明参数 s 的类型「是一个引用」。让我们增加一些解释性的注释:

代码语言:javascript
代码运行次数:0
运行
复制
fn calculate_length(s: &String) -> usize { // s 是对 String 的引用
    s.len()
} // 这里,s 离开了作用域。但因为它并不拥有引用值的所有权,
  // 所以什么也不会发生

变量 s 有效的作用域与函数参数的作用域一样,不过「当引用停止使用时并不丢弃它指向的数据,因为我们没有所有权」。当函数使用引用而不是实际值作为参数,无需返回值来交还所有权,因为就不曾拥有所有权。

❝将创建一个引用的行为称为 借用Borrowing。 ❞

如果我们尝试修改借用的变量呢?结果是:「这行不通」

代码语言:javascript
代码运行次数:0
运行
复制
fn main() {
    let s = String::from("hello");

    change(&s);
}

fn change(some_string: &String) {
    some_string.push_str(", world");
}

❝正如「变量默认是不可变」的,引用也一样。(默认)不允许修改引用的值。 ❞


可变引用

代码语言:javascript
代码运行次数:0
运行
复制
fn main() {
    let mut s = String::from("hello");

    change(&mut s);
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

首先,我们必须将 s 改为 mut。然后必须在调用 change 函数的地方创建一个可变引用 &mut s,并更新函数签名以接受一个可变引用 some_string: &mut String。这就非常清楚地表明,change 函数将改变它所借用的值。

❝不过可变引用有一个很大的限制:在「同一时间,只能有一个对某一特定数据的可变引用」。尝试创建两个可变引用的代码将会失败: ❞

代码语言:javascript
代码运行次数:0
运行
复制
fn main() {
    let mut s = String::from("hello");

    let r1 = &mut s;
    let r2 = &mut s;

    println!("{}, {}", r1, r2);
}

这个报错说这段代码是无效的,因为我们不能在「同一时间多次」s 作为可变变量借用。第一个「可变的借用」r1 中,并且必须持续到在 println! 中使用它,但是在那个可变引用的创建和它的使用之间,我们又尝试在 r2 中创建另一个可变引用,它借用了与 r1 相同的数据。

这个限制的好处是 Rust 可以在编译时就「避免数据竞争」

❝数据竞争Data Race类似于竞态条件,它可由这三个行为造成:

  • 两个或更多指针「同时访问同一数据」
  • 至少有一个指针被用来写入数据。
  • 没有同步数据访问的机制。

数据竞争会导致未定义行为,难以在运行时追踪,并且难以诊断和修复;Rust 避免了这种情况的发生,因为它甚至「不会编译期间存在数据竞争的代码」

可以使用大括号来「创建一个新的作用域」,以允许拥有多个可变引用,只是不能「同时」 拥有:

代码语言:javascript
代码运行次数:0
运行
复制
fn main() {
    let mut s = String::from("hello");

    {
        let r1 = &mut s;
    } // r1 在这里离开了作用域,所以我们完全可以创建一个新的引用

    let r2 = &mut s;
}

rust「不能在拥有不可变引用的同时拥有可变引用」

代码语言:javascript
代码运行次数:0
运行
复制
fn main() {
    let mut s = String::from("hello");

    let r1 = &s; // 没问题
    let r2 = &s; // 没问题
    let r3 = &mut s; // 大问题

    println!("{}, {}, and {}", r1, r2, r3);
}

不可变引用的用户可不希望在他们的眼皮底下值就被意外的改变了!然而,多个不可变引用是可以的,因为没有哪个只能读取数据的人有能力影响其他人读取到的数据。

❝一个引用的作用域从「声明的地方开始」一直持续到「最后一次使用为止」

代码语言:javascript
代码运行次数:0
运行
复制
fn main() {
    let mut s = String::from("hello");

    let r1 = &s; // 没问题
    let r2 = &s; // 没问题
    println!("{} and {}", r1, r2);
    // 此位置之后 r1 和 r2 不再使用

    let r3 = &mut s; // 没问题
    println!("{}", r3);
}

不可变引用 r1r2 的作用域在 println! 最后一次使用之后结束,这也是创建可变引用 r3 的地方。「它们的作用域没有重叠」,所以代码是可以编译的。


悬垂引用Dangling References

在具有指针的语言中,很容易通过释放内存时保留指向它的指针而错误地生成一个悬垂引用Dangling References,所谓悬垂指针是「其指向的内存可能已经被分配给其它持有者」。相比之下,在 Rust 中编译器确保「引用永远也不会变成悬垂状态」:当你拥有一些数据的引用,编译器确保数据不会在其引用之前离开作用域。

尝试创建一个悬垂引用,Rust 会通过一个编译时错误来避免:

代码语言:javascript
代码运行次数:0
运行
复制
fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String {
    let s = String::from("hello");

    &s
}

让我们仔细看看我们的 dangle 代码的每一步到底发生了什么:

代码语言:javascript
代码运行次数:0
运行
复制
fn dangle() -> &String { // dangle 返回一个字符串的引用

    let s = String::from("hello"); // s 是一个新字符串

    &s // 返回字符串 s 的引用
} // 这里 s 离开作用域并被丢弃。其内存被释放。
  // 危险!

因为 s 是在 dangle 函数内创建的,当 dangle 的代码执行完毕后,s 将被释放。不过我们尝试返回它的引用。这意味着这个引用会指向一个「无效的 String」Rust 不会允许我们这么做。

这里的解决方法是直接返回 String

代码语言:javascript
代码运行次数:0
运行
复制
fn no_dangle() -> String {
    let s = String::from("hello");

    s
}

所有权被移动出去,所以没有值被释放。


引用的规则

  • 在任意给定时间,要么「只能有一个可变引用」,要么「只能有多个不可变引用」
  • 引用必须总是有效的。

切片 slice

另一个没有所有权的数据类型是 sliceslice 允许你「引用集合中一段连续的元素序列,而不用引用整个集合」

字符串 slice

字符串 slicestring slice)是 String 中一部分值的引用。

代码语言:javascript
代码运行次数:0
运行
复制
fn main() {
    let s = String::from("hello world");

    let hello = &s[0..5];
    let world = &s[6..11];
}

这类似于引用整个 String 不过带有额外的 [0..5] 部分。它不是对整个 String 的引用,而是对「部分 String 的引用」

可以使用一个由中括号中的 [starting_index..ending_index] 指定的 range 创建一个 slice,其中 starting_indexslice「第一个位置」ending_index 则是 slice 「最后一个位置的后一个值」。在其内部,slice 的数据结构存储了 slice「开始位置和长度」,长度对应于 ending_index 减去 starting_index 的值。

所以对于 let world = &s[6..11]; 的情况,world 将是一个包含指向 s 索引 6 的指针和长度值 5slice

对于 Rust.. range 语法,如果想要从「索引 0 开始,可以不写两个点号之前的值」。换句话说,如下两个语句是相同的:

代码语言:javascript
代码运行次数:0
运行
复制
fn main() {
  let s = String::from("hello");

  let slice = &s[0..2];
  let slice = &s[..2];
}

如果 slice 包含 String「最后一个字节」,也可以舍弃尾部的数字。

代码语言:javascript
代码运行次数:0
运行
复制
fn main() {
  let s = String::from("hello");

  let len = s.len();

  let slice = &s[3..len];
  let slice = &s[3..];
}

也可以同时舍弃这两个值来获取「整个字符串」slice

代码语言:javascript
代码运行次数:0
运行
复制
fn main() {
  let s = String::from("hello");

  let len = s.len();

  let slice = &s[0..len];
  let slice = &s[..];
}

字符串字面量就是 slice

「字符串字面量」被储存在二进制文件中。

代码语言:javascript
代码运行次数:0
运行
复制
fn main() {
  let s = "Hello, world!";
}

这里 s 的类型是 &str:它是一个指向「二进制程序特定位置」slice。这也就是为什么字符串字面量是不可变的;&str 是一个不可变引用。


其他类型的 slice

字符串 slice,正如你想象的那样,是针对字符串的。不过也有更通用的 slice 类型。考虑一下这个数组:

代码语言:javascript
代码运行次数:0
运行
复制
fn main() {
  let a = [1, 2, 3, 4, 5];
}

就跟我们想要获取字符串的一部分那样,我们也会想要引用数组的一部分。

代码语言:javascript
代码运行次数:0
运行
复制
fn main() {
  let a = [1, 2, 3, 4, 5];

  let slice = &a[1..3];
}

这个 slice 的类型是 &[i32]。它跟字符串 slice 的工作方式一样,通过存储第一个集合元素的引用和一个集合总长度。


后记

「分享是一种态度」

参考资料:《Rust权威指南》

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2023-02-07,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 前端柒八九 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 文章list
  • 你能所学到的知识点
  • 什么是所有权
    • 所有权规则
    • 变量作用域
    • String 类型
    • 内存和分配
    • 变量和数据交互的方式:移动
    • 变量与数据交互的方式:克隆
      • 只在栈上的数据:拷贝
    • 所有权与函数
      • 返回值与作用域
  • 引用与借用
    • 可变引用
    • 悬垂引用Dangling References
    • 引用的规则
  • 切片 slice
    • 字符串 slice
      • 字符串字面量就是 slice
    • 其他类型的 slice
  • 后记
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档