【Rust所有权机制】Rust所有权机制详细解析与应用实战

在这里插入图片描述

✨✨ 欢迎大家来到景天科技苑✨✨

🎈🎈 养成好习惯,先赞后看哦~🎈🎈

🏆 作者简介:景天科技苑
🏆《头衔》:大厂架构师,华为云开发者社区专家博主,阿里云开发者社区专家博主,CSDN全栈领域优质创作者,掘金优秀博主,51CTO博客专家等。
🏆《博客》:Rust开发,Python全栈,Golang开发,云原生开发,PyQt5和Tkinter桌面开发,小程序开发,人工智能,js逆向,App逆向,网络系统安全,数据分析,Django,fastapi,flask等框架,云原生K8S,linux,shell脚本等实操经验,网站搭建,数据库等分享。

所属的专栏:Rust语言通关之路
景天的主页:景天科技苑

在这里插入图片描述

Rust所有权

1、认识所有权

所有权(系统)是 Rust 最独特的功能,其令 Rust 无需垃圾回收(garbage collector)即可保障内存安全。
因此,理解Rust 中所有权如何工作是十分重要的。本文我们将讲到所有权以及相关功能:借用、slice 以及 Rust 如何在内存中布局数据。

1.1 什么是所有权

Rust 的核心功能(之一)是 所有权(ownership)。虽然这个功能说明起来很直观,不过它对语言的其余部分有着更深层的含义。
所有程序都必须管理其运行时使用计算机内存的方式。
一些语言中使用垃圾回收GC在程序运行过程中来时刻寻找不再被使用的内存,但是stw(stop the world)对性能的伤害极大;
在另一些语言中,程序员必须亲自分配和释放内存,比如C、C++。
Rust 则选择了第三种方式:内存被一个所有权系统管理,它拥有一系列的规则使编译器在编译时进行检查。任何所有权系统的功能都不会导致运行时开销。
因为所有权对很多程序员来说都是一个新概念,需要一些时间来适应。
好消息是随着你对 Rust 和所有权系统的规则越来越有经验,你就越能自然地编写出安全和高效的代码。持之以恒!
当你理解了所有权系统,你就会对这个使 Rust 如此独特的功能有一个坚实的基础。

总结:Rust 中的每一个值都有一个 所有者(owner)。值在任一时刻有且只有一个所有者。当所有者(变量)离开作用域,这个值将被丢弃。
Rust通过所有权机制来管理内存,编译器在编译时就会根据所有权规则对内存使用进行检查。

Rust内存管理模型:
所谓内存管理,就是对内存的分配和释放
Rust采用Ownership rules、semantics、Borrow Checker、Lifetime等在编译时期做这些检查,在编译期,如果发现内存有问题的话,直接不让编译通过
而且通过所有权机制,来限制内存错误的产生,直接将错误扼杀在摇篮之中,这一套整体的就是所有权机制。
Rust只能无限接近C/C++的性能,并不能超越。因为很多底层的还是C/C++

术语介绍:
STW(Stop the world)
“Stop the world"是与垃圾回收(Garbage Collection)相关的术语,它指的是在进行垃圾回收时系统暂停程序的运行。
这个术语主要用于描述一种全局性的暂停,即所有应用线程都被停止,以便垃圾回收器能够安全地进行工作。
这种全局性的停止会导致一些潜在的问题,特别是对于需要低延迟和高性能的应用程序。
需要注意的是,并非所有的垃圾回收算法都需要"stop the world”,有一些现代的垃圾回收器采用了一些技术来减小全局停顿的影响,比如并发垃圾回收和增量垃圾回收。
但是,编程语言只要使用了GC,就没办法和没有GC的语言进行性能比较。

1.2 栈(Stack)与堆(Heap)

在很多语言中并不经常需要考虑到栈与堆。不过在像 Rust 这样的系统编程语言中,值是位于栈上还是堆上在更大程度上影响了语言的行为以及为何必须做出这样的选择。
我们会在本文的稍后部分描述所有权与堆与栈相关的部分,所以这里只是一个用来预热的简要解释。
栈和堆都是代码在运行时可供使用的内存部分,不过他们以不同的结构组成。栈以放入值的顺序存储并以相反顺序取出值。这也被称作 后进先出(last in, first out)。

栈:即一个后进先出的模式。增加数据叫进栈, 移出数据叫出栈。栈中所有的数据必须暂用已知且固定大小。
编译的时候,数据的类型和大小是固定的,就分配在栈上
堆:即内存中的一块区域, 编译时大小未知或大小可能变化的数据, 存储在堆上。

栈比堆分配内存要快, 因为入栈时无需为新存储的数据查找合适空间, 因为其位置总是在栈顶。
相反, 堆分配要做更多的工作,要找一个足够的空间,并记录。
访问堆上的数据比访问栈上的数据慢,因为必须通过指针来访问。
当你的代码调用一个函数时,传递给函数的值(包括可能指向堆上数据的指针)和函数的局部变量被压入栈中。当函数结束时,这些值被移出栈。

想象一下一叠盘子:当增加更多盘子时,把他们放在盘子堆的顶部,当需要盘子时,也从顶部拿走。
不能从中间也不能从底部增加或拿走盘子!增加数据叫做 进栈(pushing ontothe stack),而移出数据叫做 出栈(popping off the stack)。
操作栈是非常快的,因为它访问数据的方式:永远也不需要寻找一个位置放入新数据或者取出数据因为这个位置总是在栈顶。
另一个使得栈快速的性质是栈中的所有数据都必须有一个已知且固定的大小。

对于在编译时未知大小或大小可能变化的数据,可以把他们储存在堆上。堆是缺乏组织的:当向堆放入数据时,我们请求一定大小的空间。
操作系统在堆的某处找到一块足够大的空位,把它标记为已使用,并返回给我们一个其位置的 指针(pointer)。
这个过程称作 在堆上分配内存(allocating on the heap),并且有时这个过程就简称为“分配”(allocating)。
向栈中放入数据并不被认为是分配。因为指针是已知的固定大小的,我们可以将指针储存在栈上,不过当需要实际数据时,必须访问指针。
想象一下去餐馆就坐吃饭。当进入时,你说明有几个人,餐馆员工会找到一个够大的空桌子并领你们过去。
如果有人来迟了,他们也可以通过询问来找到你们坐在哪。
访问堆上的数据要比访问栈上的数据要慢因为必须通过指针来访问。现代处理器在内存中跳转越少就越快(缓存)。
继续类比,假设有一个服务员在餐厅里处理多个桌子的点菜。在一个桌子报完所有菜后再移动到下一个桌子是最有效率的。
从桌子 A 听一个菜,接着桌子 B 听一个菜,然后再桌子 A,然后再桌子 B 这样的流程会更加缓慢。
出于同样原因,处理器在处理的数据之间彼此较近的时候(比如在栈上)比较远的时候(比如可能在堆上)能更好的工作。在堆上分配大量的空间也可能消耗时间。
当调用一个函数,传递给函数的值(包括可能指向堆上数据的指针)和函数的局部变量被压入栈中。
当函数结束时,这些值被移出栈。
记录何处的代码在使用堆上的什么数据,最小化堆上的冗余数据的数量以及清理堆上不再使用的数据以致不至于耗尽空间,这些所有的问题正是所有权系统要处理的。
一旦理解了所有权,你就不需要经常考虑栈和堆了,不过理解如何管理堆内存可以帮助我们理解所有权为何存在以及为什么要以这种方式工作。

1.3 所有权规则

首先,让我们看一下所有权的规则。请记住它们,我们将讲解一些它们的例子:

  1. Rust 中每一个值都有一个称之为其 所有者(owner)的变量。
  2. 值有且只能有一个所有者。
  3. 当所有者(变量)离开作用域,这个值将被丢弃。

1.4 变量作用域

作为所有权的第一个例子,我们看看一些变量的 作用域(scope)。作用域是一个项(原文:item) 在程序中有效的范围。
假设有这样一个变量:
let s = “hello”;
变量 s 绑定到了一个字符串字面值,这个字符串值是硬编码进程序代码中的。
这个变量从声明的点开始直到当前 作用域 结束时都是有效的。如下示例的注释标明了变量 s 在何处是有效的:

{                              // s is not valid here, it’s not yet declared
    let s = "hello";    // s is valid from this point forward
    // do stuff with s
}                            // this scope is now over, and s is no longer valid

换句话说,这里有两个重要的点:

  1. 当 s 进入作用域 时,它就是有效的。
  2. 这一直持续到它 离开作用域 为止。
    目前为止,变量是否有效与作用域的关系跟其他编程语言是类似的。
    如下,变量y只在花括号中有效,在花括号外面无效,因此打印y会报错
    在这里插入图片描述

上面的x和y就分配在栈上

1.5 String 与 &str

Rust中表示字符串的两种类型:String和&str
String是一个堆分配的可变字符串类型,可以动态增长或缩小。拥有所有权
String类型。编译器在编译时,是不知道它的大小的,因此,String类型的数据分配在堆上
String源码

pub struct String {
  vec: Vec<u8>
}

&str 字符串字面量,是指字符串切片引用,是借用的字符串切片,不拥有数据。是在栈上分配的。
是不可变引用,指向存储在其他地方的UTF-8编码的字符串数据,由指针和长度构成
可以指向堆上的 String 数据(如 &String 可强转为 &str)。
也可以是静态字符串字面量(如 “hello”,存储在程序的只读内存中)。

如何相互转换?
&str → String(获取所有权)

let s: &str = "hello";
let s_string: String = s.to_string(); // 或 String::from(s)
let s1 = "景天".to_owned();
let s2 = String::from("Hello World")

String → &str(借用)

let s: String = String::from("hello");
let s_slice: &str = &s; // 自动解引用为 &str

在这里插入图片描述

String与&str如何选择
注意String是具有所有权的,而&str并没有
Struct中属性使用String
如果不使用显式声明生命周期无法使用&str
不只是麻烦,还有更多的隐患

函数参数推荐使用&str(如果不想交出所有权)
&str为参数,可以传递&str和&String
&String为参数,只能传递&String不能传递&str

用 String:
需要修改字符串(如 push_str、replace)。
需要拥有字符串(如返回字符串、存储到结构体)。

用 &str:
只需要读取字符串(如函数参数)。
使用字符串字面量(如 “hello”)。

&String 和 &str 区别
&String 和 &str 都是字符串的引用,但它们在类型、灵活性、自动转换等方面有重要区别。以下是它们的详细对比:

  1. 类型与本质
    类型 本质 存储方式
    &String 对 String 的不可变引用 指向堆上的 String 数据
    &str 字符串切片(任意字符串的只读视图) 可以指向堆或静态内存(如 “abc”)
    关键区别
    &String 只能引用 String 类型的数据。
    &str 可以引用:
    String 的数据(如 &some_string[…])
    静态字符串字面量(如 “hello”)
    其他任意合法的字符串切片。

  2. 灵活性与兼容性
    &str 更通用
    函数参数通常使用 &str 而不是 &String,因为:
    &String 可以自动隐式转换为 &str(通过 Deref Coercion)。
    &str 可以接受 String 或字面量,而 &String 只能接受 String。

  3. 内存布局
    &String
    是一个指向 String 结构的指针(String 本身包含堆上的数据指针、长度和容量)。
    内存布局:ptr → (data_ptr, len, capacity)。

&str
是一个胖指针(包含数据指针和长度)。
内存布局:(data_ptr, len)。

let s = String::from("Hello");
let string_ref: &String = &s;    // 指向 String 结构
let str_slice: &str = &s[..];    // 指向实际字符串数据
  1. 使用场景
    用 &String
    需要明确限制参数必须是 String 的引用(罕见情况)。
    需要访问 String 的方法(如 .capacity())。

用 &str(更常见)
函数参数(兼容性更好)。
只读操作(如字符串查找、切片)。
避免不必要的所有权转移。

  1. 自动转换(Deref Coercion)
    Rust 会自动将 &String 转换为 &str,因此几乎不需要手动写 &String:
fn print_str(s: &str) { /* ... */ }

let s = String::from("Rust");
print_str(&s); // &String 自动转为 &str

这些方面也同样适用于其他标准库提供的或你自己创建的复杂数据类型。
我们已经见过字符串字面值了,它被硬编码进程序里。字符串字面值是很方便的,不过他们并不总是适合所有需要使用文本的场景。
原因之一就是他们是不可变的。另一个原因是不是所有字符串的值都能在编写代码时就知道:例如,如果想要获取用户输入并储存该怎么办呢?
为此,Rust 有第二个字符串类型, String 。这个类型储存在堆上所以能够储存在编译时未知大小的文本。可以用 from 函数从字符串字面值来创建 String ,如下:
let s = String::from(“hello”);
这两个冒号( :: )运算符允许将特定的 from 函数置于 String 类型的命名空间(namespace)下而不需要使用类似string_from 这样的名字。

这类字符串 可以 使用mut 被修改:

let mut s = String::from("hello");
s.push_str(", world!"); // push_str() appends a literal to a String
println!("{}", s); // This will print `hello, world!`

原先s的指针指向hello的内存地址。
当执行s.push_str(“, world”)时。操作系统将重新开辟一块新的内存,用来存放追加后的字符串
因此String类型的大小是不固定的,分配在堆上
在这里插入图片描述

1.6 内存与分配

对于字符串字面值的情况,我们在编译时就知道其内容所以它直接被硬编码进最终的可执行文件中,这使得字符串字面值快速且高效。
不过这些属性都只来源于其不可变性。
不幸的是,我们不能为了每一个在编译时未知大小的文本而将一块内存放入二进制文件中而它的大小还可能随着程序运行而改变。
对于 String 类型,为了支持一个可变,可增长的文本片段,需要在堆上分配一块在编译时未知大小的内存来存放内容。这意味着:

  1. 内存必须在运行时向操作系统请求。
  2. 需要一个当我们处理完 String 时将内存返回给操作系统的方法。

第一部分由我们完成:当调用 String::from 时,它的实现 (implementation) 请求其所需的内存。这在编程语言中是非常通用的。
然而,第二部分实现起来就各有区别了。在有 垃圾回收(garbage collector,GC)的语言中, GC 记录并清除不再使用的内存,而我们作为程序员,并不需要关心他们。
没有 GC 的话,识别出不再使用的内存并调用代码显式释放就是我们程序员的责任了,正如请求内存的时候一样。
从历史的角度上说正确处理内存回收曾经是一个困难的编程问题。
如果忘记回收了会浪费内存。如果过早回收了,将会出现无效变量。
如果重复回收,这也是个 bug。我们需要 allocate 和free 一一对应。

Rust 采取了一个不同的策略:内存在拥有它的变量离开作用域后就被自动释放。
下面是示例 中作用域例子的一个使用 String 而不是字符串字面值的版本:

{
    let s = String::from("hello"); // s is valid from this point forward
    // do stuff with s
}                                              // this scope is now over, and s is no
                                               // longer valid

这里是一个将 String 需要的内存返回给操作系统的很自然的位置:当 s 离开作用域的时候。
当变量离开作用域,Rust为其调用一个特殊的函数。这个函数叫做 drop ,在这里 String 的作者可以放置释放内存的代码。
Rust 在结尾的 } 处自动调用 drop 。

注意:在 C++ 中,这种 item 在生命周期结束时释放资源的方法有时被称作 资源获取 即初始化(Resource Acquisition Is Initialization (RAII))。
如果你使用过 RAII 模式的话应该对 Rust 的 drop 函数并不陌生。

这个模式对编写 Rust 代码的方式有着深远的影响。现在它看起来很简单,不过在更复杂的场景下代码的行为可能是不可预测的,比如当有多个变量使用在堆上分配的内存时。
现在让我们探索一些这样的场景。

1.7 变量与数据交互的方式(一):移动(move)

Rust 中的多个变量可以采用一种独特的方式与同一数据交互。让我们看看如下示例中一个使用整型的例子:

let x = 5;
let y = x;

将变量 x 赋值给 y
根据其他语言的经验我们大致可以猜到这在干什么:“将 5 绑定到 x ;接着生成一个值 x 的拷贝并绑定到 y ”。
现在有了两个变量, x 和 y ,都等于 5 。这也正是事实上发生了的,因为正数是有已知固定大小的简单值,所以这两个5 被放入了栈中。

现在看看这个 String 版本:

let s1 = String::from("hello");
let s2 = s1;

这看起来与上面的代码非常类似,所以我们可能会假设他们的运行方式也是类似的:也就是说,第二行可能会生成一个s1 的拷贝并绑定到 s2 上。
不过,事实上并不完全是这样。
为了更全面的解释这个问题,让我们看看如下图 中 String 真正是什么样的。
String 由三部分组成,如图所示:一个指向存放字符串内容内存的指针,一个长度,和一个容量。
这一组数据储存在栈上。右侧则是堆上存放内容的内存部分。
在这里插入图片描述

一个绑定到 s1 的拥有值 “hello” 的 String 的内存表现
长度代表当前 String 的内容使用了多少字节的内存。容量是 String 从操作系统总共获取了多少字节的内存。
长度与容量的区别是很重要的,不过这在目前为止的场景中并不重要,所以可以暂时忽略容量。
当我们把 s1 赋值给 s2 , String 的数据被复制了,这意味着我们从栈上拷贝了它的指针、长度和容量。
我们并没有复制堆上指针所指向的数据。换句话说,内存中数据的表现如图所示。
在这里插入图片描述

这个表现形式看起来 并不像 如下图中的那样,但是如果 Rust 也拷贝了堆上的数据后内存看起来会是如何呢。
如果 Rust这么做了,那么操作 s2 = s1 在堆上数据比较大的时候可能会对运行时性能造成非常大的影响。
在这里插入图片描述

之前,我们提到过当变量离开作用域后 Rust 自动调用 drop 函数并清理变量的堆内存。不过如下图 展示了两个数据指针指向了同一位置。
这就有了一个问题:当 s2 和 s1 离开作用域,他们都会尝试释放相同的内存。这是一个叫做 二次释放(double free)的错误,也是之前提到过的内存安全性 bug 之一。
两次释放(相同)内存会导致内存污染,它可能会导致潜在的安全漏洞。
为了确保内存安全,这种场景下 Rust 的处理有另一个细节值得注意。与其尝试拷贝被分配的内存,Rust 则认为 s1 不再有效,因此 Rust 不需要在 s1 离开作用域后清理任何东西。
看看在 s2 被创建之后尝试使用 s1 会发生什么:

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

你会得到一个类似如下的错误,因为 Rust 禁止你使用无效的引用。
在这里插入图片描述

如果你在其他语言中听说过术语 “浅拷贝”(“shallow copy”)和 “深拷贝”(“deep copy”),那么拷贝指针、长度和容量而不拷贝数据可能听起来像浅拷贝。
不过因为 Rust 同时使第一个变量无效化了,这个操作被称为 移动(move),而不是浅拷贝。上面的例子可以解读为 s1 被 移动 到了 s2 中。那么具体发生了什么,如图 所示。
在这里插入图片描述

这样就解决了我们的麻烦!因为只有 s2 是有效的,当其离开作用域,它就释放自己的内存,完毕。
另外,这里还隐含了一个设计选择:Rust 永远也不会自动创建数据的 “深拷贝”。因此,任何 自动 的复制可以被认为对运行时性能影响较小。
Rust针对move的赋值方式
在move的赋值方式下,它的所有权ownership会发生改变
比如struct,String等复杂的数据类型的赋值都是move的。因为复杂的数据类型,在赋值时,将所有权交出去,会节省很大的空间,提高性能,防止数据竞争的出现
在这里插入图片描述

因此,对于move类型的数据操作,你必须非常清楚值的所有权在什么地方,如果一个变量,将其值的所有权交给另一个变量后,继续去使用,编译器将会报错

1.8 变量与数据交互的方式(二):克隆

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

let s1 = String::from("hello");
let s2 = s1.clone();
println!("{}, world!", s1);
println!("{}, world!", s2);

在这里插入图片描述

这段代码能正常运行,这里堆上的数据 确实 被复制了。
当出现 clone 调用时,你知道一些特定的代码被执行而且这些代码可能相当消耗资源。你很容易察觉到一些不寻常的事情正在发生。

1.9 只在栈上的数据:拷贝

这里还有一个没有提到的小窍门。这些代码使用了整型并且是有效的

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

在这里插入图片描述

他们似乎与我们刚刚学到的内容相抵触:没有调用 clone ,不过 x 依然有效且没有被移动到 y 中。
原因是像整型这样的在编译时已知大小的类型被整个储存在栈上,所以拷贝其实际的值是快速的。
这意味着没有理由在创建变量 y 后使 x 无效。换句话说,这里没有深浅拷贝的区别,所以这里调用 clone 并不会与通常的浅拷贝有什么不同,我们可以不用管它。

Rust 有一个叫做 Copy trait 的特殊注解,可以用在类似整型这样的储存在栈上的类型。
如果一个类型拥有 Copy trait,一个旧的变量在将其赋值给其他变量后仍然可用。
Rust 不允许自身或其任何部分实现了 Droptrait 的类型使用 Copy trait。
如果我们对其值离开作用域时需要特殊处理的类型使用 Copy 注解,将会出现一个编译时错误。

那么什么类型是 Copy 的呢?可以查看给定类型的文档来确认,
不过作为一个通用的规则,任何简单标量值的组合可以是 Copy 的,任何需要分配内存,或者本身就是某种形式资源的类型不会是 Copy 的。

如下是一些 Copy 的类型:
所有整数类型,比如 u32 。
布尔类型, bool ,它的值是 true 和 false 。
所有浮点数类型,比如 f64 。
字符类型,char
元组,当且仅当其包含的类型也都是 Copy 的时候。 (i32, i32) 是 Copy 的,不过 (i32, String) 就不是。
在这里插入图片描述

1.10 所有权与函数

将值传递给函数在语义上与给变量赋值相似。向函数传递值可能会移动或者复制,就像赋值语句一样。
如下示例是一个展示变量何时进入和离开作用域的例子:

fn main() {
    //所有权与函数
    let s = String::from("hello");
    takes_ownership(s);

    let x = 5;
    makes_copy(x);

}

fn takes_ownership(s:String){
  println!("s= {}",s)
}

fn makes_copy(i:i32){
  println!("i= {}",i)
  
}

运行,能拿到数据
在这里插入图片描述

我们知道,变量s的作用域就在takes_ownership()函数之中,当执行完这个函数,s就被回收了
我们在函数执行完,在打印s看看

fn main() {
    //所有权与函数
    let s = String::from("hello");
    takes_ownership(s);
    println!("执行完函数的s={}",s);

    let x = 5;
    makes_copy(x);

}

fn takes_ownership(s:String){
  println!("s= {}",s)
}


fn makes_copy(i:i32){
  println!("i= {}",i)
  
}

可以看到s已经被回收了
所有权规则,会阻止我们在作用域之外使用变量
在这里插入图片描述

如果想要使用s,可以在定义函数时,将字符串返回。然后在main函数中创建个变量接收

但是x在执行完函数后还是可以使用的
x是整形,声明在栈上的。
因为整形值的组合可以是 Copy 的,不需要分配内存
在这里插入图片描述

1.11 返回值与作用域

上面的两个函数,字符串类型的参数,执行完函数之后,变量就被回收了。如果想要延长该变量的作用域,可以将该变量返回。
返回值也可以转移作用域。

fn main() {
    //所有权与函数
    let s = String::from("hello");
    let y= takes_ownership(s);
    println!("执行完函数的y={}",y);

    let x = 5;
    makes_copy(x);
    //我们试用下x
    println!("执行完函数之后的x={}",x)

}

fn takes_ownership(s:String) ->String{
  println!("s= {}",s);
  s
}


fn makes_copy(i:i32){
  println!("i= {}",i)
  
}

在这里插入图片描述

变量的所有权总是遵循相同的模式:将值赋值给另一个变量时移动它。当持有堆中数据值的变量离开作用域时,其值将通过 drop 被清理掉,除非数据被移动为另一个变量所有。
在每一个函数中都获取并接着返回所有权可能有些冗余。
如果我们想要函数使用一个值但不获取所有权该怎么办呢?如果我们还要接着使用它的话,每次都传递出去再传回来就有点烦人了,
另外我们也可能想要返回函数体产生的任何(不止一个)数据。
可以使用元组来返回多个值,像这样:

fn main() {
    let s1 = String::from("hello");
    let (s2, len) = calculate_length(s1);
    println!("The length of '{}' is {}.", s2, len);
}
fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() returns the length of a String.
    (s, length)
}

但是这未免有些形式主义,而且这种场景应该很常见。幸运的是,Rust 对此提供了一个功能,叫做 引用(references)。

在 Rust 中,字符串(String 类型)是可变且拥有所有权的,而字符串字面值(&str 类型)是不可变的引用。
如果你想通过函数修改字符串并返回新的字符串,有几种常见的方法:
方法 1:传入 String,修改后返回

fn main() {
    //
    let a = String::from("hello");
    let b = modyfy_string(a);
    // println!("{:?}", a); // 这里会报错,因为a的所有权已经被转移到b
    println!("{:?}", b); // 这里可以使用b,因为b是一个新的字符串
}

//函数传个字符串,修改后返回字符串
fn modyfy_string(s: String) -> String {
    let mut s = s;
    s.push_str(" world");
    s
}

在这里插入图片描述

2、引用与借用

上面结尾的元组代码有这样一个问题:我们不得不将 String 返回给调用函数,以便仍能在调用 calculate_length后使用 String ,因为 String 被移动到了 calculate_length 内。
下面是如何定义并使用一个(新的) calculate_length 函数,它以一个对象的 引用 作为参数而不是获取值的所有权:

2.1 引用语法

引用的用法:就是取地址&,将地址作为参数传进去。创建一个指向值的引用,但不拥有它,因为不拥有这个值,所以当引用离开其值指向的作用域后也不会被丢弃。

fn main() {
    //为了防止变量每次用一次,就不能再使用了,Rust可以借助引用来实现
    let s1 = String::from("hello");
    //注意,这里根据函数的定义,参数是内存地址,所以把s1的地址传进去
    let len = calc_length(&s1);
    //如果按照之前的方法,到这里s1就不能使用了,但是我们这里传的是引用。因此s1可以继续使用
    println!("s1={}, len={}",s1,len);
    
}   

//定义一个返回字符串函数
//将字符串的引用作为传参类型,就不必考虑作用域问题了
fn calc_length(s:&String)->usize{
    s.len()
}

我们看到s1在函数调用后,可以继续使用
在这里插入图片描述

首先,注意变量声明和函数返回值中的所有元组代码都消失了。其次,注意我们传递 &s1 给 calculate_length ,同时在函数定义中,我们使用的是 &String 而不是 String 。
这些 & 符号就是 引用,他们允许你使用值但不获取其所有权。
在这里插入图片描述

注意:与使用 & 引用相对的操作是 解引用(dereferencing),解引用运算符是 *

我们仔细看看这个函数的调用
let len = calc_length(&s1);
&s1 语法允许我们创建一个 指向 值 s1 的引用,但是并不拥有它。因为并不拥有这个值,当引用离开作用域时其指向的值也不会被丢弃。

同理,函数定义使用了 & 来表明参数 s 的类型是一个引用。让我们增加一些解释性的注解:
fn calculate_length(s: &String) -> usize { // s is a reference to a String
s.len()
} // Here, s goes out of scope. But because it does not have ownership of what
// it refers to, nothing happens.

变量 s 有效的作用域与函数参数的作用域一样,不过当引用离开作用域后并不丢弃它指向的数据,因为我们没有所有权。
函数使用引用而不是实际值作为参数意味着无需返回值来交还所有权,因为就不曾拥有所有权。
我们将获取引用作为函数参数称为 借用(borrowing)。正如现实生活中,如果一个人拥有某样东西,你可以从他那里借来。当你使用完毕,必须还回去。

传参字符串,根据空格分隔返回第一个单词

fn main() {
    let a = String::from("hello world");
    let b = first_word(&a);
    println!("{:?}", a); // 这里可以使用a,因为a的所有权没有被转移
    println!("{:?}", b); // 这里可以使用b,因为b是一个字符串切片,引用了a的内存
}


//传字符串,根据空格返回第一个单词
fn first_word(s: &String) -> &str {
    //将字符串地址转换为字节数组
    let bytes = s.as_bytes();
    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }
    &s[..]
}

传入的是切片引用,传出的也是切片引用
这样做的前提是只有一个传入和一个传出,如果有多个,生命周期不可推算,就无法运行
在这里插入图片描述

2.2 可变引用

如果我们尝试修改借用的变量
按照常规思路,直接在函数中修改,是行不通的,因为正如变量默认是不可改变的,引用也一样,(默认)不允许修改引用的值。
我们需要在函数定义时,将引用改为可变引用,使用mut关键字来定义可变引用
并且,在调用时,使用&mut 来借用

fn main() {
    //为了防止变量每次用一次,就不能再使用了,Rust可以借助引用来实现
    let mut s1 = String::from("hello");
    //调用可变引用
    //借用 &mut
    change(&mut s1);
    //如果按照之前的方法,到这里s1就不能使用了,但是我们这里传的是引用。因此s1可以继续使用
    println!("s1={}",s1);
    
}

//可变引用
fn change(s :&mut String){
    s.push_str(", world!");
}

修改成功
在这里插入图片描述

对于某个可变引用,发生过借用之后,是不可以再使用的。因为借用之后,s1可能会发生变化,如果再使用s1没可能会发生数据竞争
在这里插入图片描述

借用之后,在使用被借用的引用的值,会报错
在这里插入图片描述

总结:在任意时间段,有了可变引用,被借用之后,就不能再有不可变引用了

2.3 悬垂引用(Dangling References)

在存在指针的语言中,容易通过释放内存时保留指向它的指针而错误地生成一个 悬垂指针(dangling pointer),在其他语言,比如C++里面叫做空指针或者野指针。
所谓悬垂指针是其指向的内存可能已经被分配给其它持有者。相比之下,在 Rust 中编译器确保引用永远也不会变成悬垂状态:
当我们拥有一些数据的引用,编译器确保数据不会在其引用之前离开作用域。
我们用个案例说明下:

fn main() {
    //悬垂引用
    //在这里试图接收引用
    let refs = drang();
    println!("refs = {}",refs);

}

//定义一个返回引用的函数
fn drang()-> &String{
    let s = String::from("hello");
    //返回s的引用,引用并不拥有这个对象的所有权,它只是指向这个对象,离开作用域后,引用就被drop了
    &s
}

编译
在这里插入图片描述

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

引用必须有效,不能在引用被释放之后使用。否则报错

2.4 slice

slice 允许你引用集合中一段连续的元素序列,而不用引用整个集合。

这里有一个小的编程问题:编写一个获取一个字符串并返回它在其中找到的第一个单词的函数。
如果函数没有在字符串中找到一个空格,就意味着整个字符串是一个单词,所以整个字符串都应返回。

2.4.1 字符串slice

字符串 slice(string slice)是 String 中一部分值的引用,字面值就是slice。
它看起来像这样:

let s = String::from("hello world");
let hello = &s[0..5];   //遵循前闭后开原则,包含起始位置,不包含结束位置
let world = &s[6..11];

在这里插入图片描述

运行
在这里插入图片描述

这类似于获取整个 String 的引用不过带有额外的 [0…5] 部分。不同于整个 String 的引用,这是一个包含 String 内部的一个位置和所需元素数量的引用。
start…end 语法代表一个以 start 开头并一直持续到但不包含 end 的 range。
使用一个由中括号中的 [starting_index…ending_index] 指定的 range 创建一个 slice,其中 starting_index 是包含在 slice 的第一个位置, ending_index 则是 slice 最后一个位置的后一个值。
在其内部,slice 的数据结构储存了开始位置和 slice 的长度,长度对应 ending_index 减去 starting_index 的值。
所以对于 let world = &s[6…11]; 的情况,
world 将是一个包含指向 s 第 6 个字节的指针和长度值 5 的 slice。
如下图所示
在这里插入图片描述

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

let s = String::from("hello");
let slice = &s[0..2];
let slice = &s[..2];

由此类推,如果 slice 包含 String 的最后一个字节,也可以舍弃尾部的数字。这意味着如下也是相同的:

let s = String::from("hello");
let len = s.len();
let slice = &s[3..len];
let slice = &s[3..];

也可以同时舍弃这两个值来获取一个整个字符串的 slice。所以如下亦是相同的:

let s = String::from("hello");
let len = s.len();
let slice = &s[0..len];
let slice = &s[..];

注意:字符串 slice range 的索引必须位于有效的 UTF-8 字符边界内,如果尝试从一个多字节字符的中间位置创建字符串 slice,则程序将会因错误而退出。
出于介绍字符串 slice 的目的,本部分假设只使用 ASCII 字符集;“字符串” 部分会更加全面的讨论 UTF-8 处理问题。
例如如下例子:一个汉字占用三个字节,如果我们分界不在汉字的边界,编译将会报错

fn main() {
    //非ASCII字符集获取测试
    let s = String::from("我们都是中国人,你是谁");
    let y = &s[..5];
    println!("y={}",y);
}

在这里插入图片描述

如果边界找对了,就可以正常切分
在这里插入图片描述
在这里插入图片描述

字符串字面值就是 slice
还记得我们讲到过字符串字面值被储存在二进制文件中吗。现在知道 slice 了,我们就可以正确的理解字符串字面值了:

let s = "Hello, world!";

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

在 Rust 中,字符串(String 类型)是可变且拥有所有权的,而字符串字面值(&str 类型)是不可变的引用。如果你想通过函数修改字符串并返回新的字符串,有几种常见的方法:
方法 1:传入 String,修改后返回

fn modify_string(mut s: String) -> String {
    s.push_str(" World!"); // 修改字符串
    s
}

fn main() {
    let original = String::from("Hello");
    let modified = modify_string(original); // original 的所有权被转移
    println!("{}", modified); // 输出 "Hello World!"
}

方法 2:传入 &str,返回新的 String

fn modify_string(s: &str) -> String {
    let mut new_string = String::from(s);
    new_string.push_str(" World!");
    new_string
}

fn main() {
    let original = "Hello";
    let modified = modify_string(original); // original 不会被修改
    println!("{}", modified); // 输出 "Hello World!"
}

方法 3:传入 &mut String,直接修改

fn modify_string(s: &mut String) {
    s.push_str(" World!");
}

fn main() {
    let mut original = String::from("Hello");
    modify_string(&mut original); // 直接修改 original
    println!("{}", original); // 输出 "Hello World!"
}

方法 4:使用 clone() 避免所有权转移

fn modify_string(s: &String) -> String {
    let mut new_string = s.clone();
    new_string.push_str(" World!");
    new_string
}

fn main() {
    let original = String::from("Hello");
    let modified = modify_string(&original); // original 仍然可用
    println!("Original: {}", original); // "Hello"
    println!("Modified: {}", modified); // "Hello World!"
}

推荐方式
如果不需要保留原字符串,使用方法 1(直接传 String)。
如果需要保留原字符串,使用方法 2(传 &str 返回新 String)。
如果要在原字符串上直接修改,使用方法 3(传 &mut String)。
方法 4 会克隆字符串,可能带来额外性能开销,仅在必要时使用。

2.4.2 其他类型的slice

字符串 slice,正如你想象的那样,是针对字符串的。不过也有更通用的 slice 类型。考虑一下这个数组:
let a = [1, 2, 3, 4, 5];

就跟我们想要获取字符串的一部分那样,我们也会想要引用数组的一部分,而我们可以这样做:
let a = [1, 2, 3, 4, 5];
let slice = &a[1…3];

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

所有权、借用和 slice 这些概念是 Rust 可以在编译时保障内存安全的关键所在。
Rust 像其他系统编程语言那样给予你对内存使用的控制,但拥有数据所有者在离开作用域后自动清除其数据的功能意味着你无须额外编写和调试相关的控制代码。

评论 53
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

景天科技苑

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值