Rust学习笔记之所有权

简介: 所有权的概念 推荐阅读指数 ⭐️⭐️⭐️⭐️⭐️引用与借用 推荐阅读指数 ⭐️⭐️⭐️⭐️⭐️切片 推荐阅读指数 ⭐️⭐️⭐️⭐️⭐️


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

大家好,我是柒八九

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

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

文章list

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

你能所学到的知识点

  1. 所有权的概念  推荐阅读指数 ⭐️⭐️⭐️⭐️⭐️
  2. 引用与借用  推荐阅读指数 ⭐️⭐️⭐️⭐️⭐️
  3. 切片  推荐阅读指数 ⭐️⭐️⭐️⭐️⭐️

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


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

什么是所有权

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

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

所有权规则

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

变量作用域

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

假设有这样一个变量:

let s = "hello";
复制代码

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

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

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

这里有两个重点:

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

String 类型

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

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

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

let s = String::from("hello");
复制代码

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

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

fn main() {
  let mut s = String::from("hello");
  s.push_str(", world");
  println!("{}",s)
}  
复制代码

输出结果为hello, world


内存和分配

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

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

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

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

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

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

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


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

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

let x = 5;
let y = x;
复制代码

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

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

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

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 会发生什么;这段代码不能运行

let s1 = String::from("hello");
let s2 = s1;
println!("{}, world!", s1);
复制代码

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

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

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


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

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

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();
    println!("s1 = {}, s2 = {}", s1, s2);
}
复制代码

只在栈上的数据:拷贝

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) 就没有。

所有权与函数

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

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 会抛出一个编译时错误


返回值与作用域

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

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 函数,它以一个对象的引用作为参数而不是获取值的所有权:

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()
}
复制代码

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

仔细看看这个函数调用:

let s1 = String::from("hello");
let len = calculate_length(&s1);
复制代码

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

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

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

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

创建一个引用的行为称为 {借用|Borrowing}

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

fn main() {
    let s = String::from("hello");
    change(&s);
}
fn change(some_string: &String) {
    some_string.push_str(", world");
}
复制代码

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


可变引用

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 函数将改变它所借用的值。

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

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 避免了这种情况的发生,因为它甚至不会编译期间存在数据竞争的代码

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

fn main() {
    let mut s = String::from("hello");
    {
        let r1 = &mut s;
    } // r1 在这里离开了作用域,所以我们完全可以创建一个新的引用
    let r2 = &mut s;
}
复制代码

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

fn main() {
    let mut s = String::from("hello");
    let r1 = &s; // 没问题
    let r2 = &s; // 没问题
    let r3 = &mut s; // 大问题
    println!("{}, {}, and {}", r1, r2, r3);
}
复制代码

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

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

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 会通过一个编译时错误来避免:

fn main() {
    let reference_to_nothing = dangle();
}
fn dangle() -> &String {
    let s = String::from("hello");
    &s
}
复制代码

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

fn dangle() -> &String { // dangle 返回一个字符串的引用
    let s = String::from("hello"); // s 是一个新字符串
    &s // 返回字符串 s 的引用
} // 这里 s 离开作用域并被丢弃。其内存被释放。
  // 危险!
复制代码

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

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

fn no_dangle() -> String {
    let s = String::from("hello");
    s
}
复制代码

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


引用的规则

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

切片 slice

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

字符串 slice

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

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 开始,可以不写两个点号之前的值。换句话说,如下两个语句是相同的:

fn main() {
  let s = String::from("hello");
  let slice = &s[0..2];
  let slice = &s[..2];
}
复制代码

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

fn main() {
  let s = String::from("hello");
  let len = s.len();
  let slice = &s[3..len];
  let slice = &s[3..];
}
复制代码

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

fn main() {
  let s = String::from("hello");
  let len = s.len();
  let slice = &s[0..len];
  let slice = &s[..];
}
复制代码

字符串字面量就是 slice

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

fn main() {
  let s = "Hello, world!";
}
复制代码

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


其他类型的 slice

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

fn main() {
  let a = [1, 2, 3, 4, 5];
}
复制代码

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

fn main() {
  let a = [1, 2, 3, 4, 5];
  let slice = &a[1..3];
}
复制代码

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


后记

分享是一种态度

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

全文完,既然看到这里了,如果觉得不错,随手点个赞和“在看”吧。



相关文章
|
8天前
|
存储 Rust
【Rust】——所有权规则、内存分配
【Rust】——所有权规则、内存分配
29 0
|
8天前
|
存储 Rust 安全
Rust 笔记:Rust 语言中的 所有权 与 生命周期
Rust 笔记:Rust 语言中的 所有权 与 生命周期
138 0
|
8天前
|
Rust 算法 安全
【Rust中的所有权系统深入解析】A Deep Dive into Rust‘s Ownership System
【Rust中的所有权系统深入解析】A Deep Dive into Rust‘s Ownership System
35 0
|
8天前
|
Rust 编译器
【Rust】——函数(所有权)以及借用或引用
【Rust】——函数(所有权)以及借用或引用
23 0
|
8天前
|
存储 缓存 Rust
【Rust】——所有权:Stack(栈内存)vs Heap(堆内存)(重点)
【Rust】——所有权:Stack(栈内存)vs Heap(堆内存)(重点)
26 0
|
8天前
|
Rust 安全 编译器
深入Rust的所有权系统:理解变量的所有权
本文详细探讨了Rust编程语言中所有权系统的核心概念,包括变量的所有权、生命周期、借用规则和内存安全。通过理解这些概念,我们能够编写出更加高效、安全和可维护的Rust代码。
|
8天前
|
存储 Rust 安全
Rust核心功能之一(所有权)
Rust核心功能之一(所有权)
|
6月前
|
Rust 安全 编译器
Rust 基础入门 —— 2.3.所有权和借用 (二)
同一时刻,你只能拥有要么一个可变引用, 要么任意多个不可变引用 引用必须总是有效的 贴一个体验不错的学习链接恰饭:学习链接
46 0
|
6月前
|
Rust 安全 算法
Rust 基础入门 ——所有权 引言 :垃圾自动回收机制的缺陷。
能有这些问题的部分发生场景: 游戏开发:在游戏开发中,需要保持稳定的帧率和低延迟,以提供流畅的游戏体验。如果GC频繁触发或停顿时间过长,会导致游戏卡顿或掉帧,影响游戏的流畅度和响应性能。
55 0
|
6月前
|
存储 Rust 安全
Rust 基础入门 —— 2.3.所有权和借用
写在前面的序言 因为我们这里实际讲述的内容是关于 内存安全的,所以我们最好先复习一下内存的知识。
27 0