我们的不快乐,是不是来源于自己对自己的苛刻,我们的人生要努力到什么程度,才可以不努力?
大家好,我是柒八九。
今天,我们继续Rust学习笔记的探索。我们来谈谈关于所有权的相关知识点。
如果,想了解该系列的文章,可以参考我们已经发布的文章。如下是往期文章。
文章list
你能所学到的知识点
- 所有权的概念 推荐阅读指数 ⭐️⭐️⭐️⭐️⭐️
- 引用与借用 推荐阅读指数 ⭐️⭐️⭐️⭐️⭐️
- 切片 推荐阅读指数 ⭐️⭐️⭐️⭐️⭐️
好了,天不早了,干点正事哇。
{所有权|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
类型而言,为了支持一个可变的、可增长
的文本类型,我们需要在堆上分配一块在编译时未知大小的内存来存放数据。这就意味着:
- 使用的内存由操作系统在运行时动态分配出来
- 当使用完
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
上。结果我们有了两个变量x
和y
,它们的值都是5
。 因为整数是已知固定大小的简单值,两个值会同时被推入当前的栈中。
我们请上面的程序改造,变成String
版本的
let s1 = String::from("hello"); let s2 = s2; 复制代码
String
由三部分组成
,如图左侧所示:
- 一个指向存放字符串内容内存的指针
- 一个长度:
- 长度表示
String
的内容当前使用了多少字节的内存
- 一个容量
- 容量是
String
从分配器总共获取了多少字节的内存
这一组数据存储在栈上。右侧则是堆上存放内容的内存部分。
当我们将 s1
赋值给 s2
,String
的数据被复制了,这意味着我们从栈上拷贝了它的指针、长度和容量。我们并没有复制指针指向的堆上数据。
之前我们提到过当变量离开作用域后,Rust
自动调用drop
函数并清理变量的堆内存
。不过上图展示了两个数据指针指向了同一位置。这就有了一个问题:当 s2
和 s1
离开作用域,他们都会尝试释放相同的内存。这是一个叫做 {二次释放|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
,它的值是true
和false
。 - 所有浮点数类型,比如
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); } 复制代码
不可变引用 r1
和 r2
的作用域在 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
将被释放。不过我们尝试返回它的引用。这意味着这个引用会指向一个无效的 String,Rust
不会允许我们这么做。
这里的解决方法是直接返回 String
:
fn no_dangle() -> String { let s = String::from("hello"); s } 复制代码
所有权被移动出去,所以没有值被释放。
引用的规则
- 在任意给定时间,要么只能有一个可变引用,要么只能有多个不可变引用。
- 引用必须总是有效的。
切片 slice
另一个没有所有权的数据类型是 slice
。slice
允许你引用集合中一段连续的元素序列,而不用引用整个集合。
字符串 slice
字符串 slice
(string 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_index
是 slice
的第一个位置,ending_index
则是 slice
最后一个位置的后一个值。在其内部,slice
的数据结构存储了 slice
的开始位置和长度,长度对应于 ending_index
减去 starting_index
的值。
所以对于 let world = &s[6..11]
; 的情况,world
将是一个包含指向 s
索引 6
的指针和长度值 5
的 slice
。
对于 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权威指南》
全文完,既然看到这里了,如果觉得不错,随手点个赞和“在看”吧。