菜鸡前端的Rust学习笔记(四)—理解所有者(ownership)
写在前面
在rust中所有者是最独一无二的特性,他使得rust能保证内存中的GC是安全的。这里将介绍几个概念:借用(borrowing) 、切片(slices) 和rust在内存中的数据展开
4.1 什么是所有者
4.1.1 RUST所有者得模式
所有者是Rust核心的特性,让我们来看看在内存GC的管理上,RUST有何不同。
现有的GC模式
- 不断查询那些不再被使用的内存
- 让coder自己分配和释放内存
RUST的模式: 系统所有者指定规则,编译器再编译时检测规则,如果不指定所有者,运行时速度会变慢。
问题来了,作为一个新手我不知道应该怎么指定我所有者的规则该怎么办?
官方回答:你就得多学多练,越是有经验得RUST使用者,对所有者理解越深,你自然而然代码就会越安全和越有效,嘿嘿。听君一席话,的确听了一席话。
4.1.2 堆栈的介绍
这里因为RUST中在使用所有者的时候,部分可能需要通过堆和栈之间的关系才能做出合适的决定,因此简单介绍下。
堆和栈其实都是程序在运行过程中在内存中用于存储数据的地方。具体的区别可以看栈和堆的区别
-
栈的特点
- 后入先出(例子:等电梯)
- 长度固定,数据已直
- 存储是有序的
- 连续的内存区域
- push(存放)和access(查询)数据更快
-
堆的特点
- 长度可变或长度未知
- 存储是无序的,自由度比较大
- 不连续的内存区域
- push(存放)和access(查询)数据更慢
栈的存放更快是因为他是连续的区域,而堆需要分配器去找哪块空闲的区域可以存放
栈的读取更块是因为,不需要用指针去找到底是哪个碎片化的地址存储了相关的变量
4.1.3 所有者的规定
- 在rust中,每一个有值的变量都称为所有者(owner)
- 在同一个时间都只能有一个所有者
- 当所有者离开作用域,值也就被删除了
4.1.4 变量作用域
根据上面的所有者规定我们知道,一个变量在定义前和作用域外,其实是无效的,所以一下代码编译会报错:
fn main() {
{
// 这之前也无效
let s = "123";
}
// 无效的valid
// 在作用域外了
println!("s -> {}", s)
}
4.1.5 String type的例子
当数据变量存在栈中的时候,每个作用域当结束的时候,栈会被清空,这样如果我们在多个作用域中想要用到同一个变量,我们可能就需要copy自己的代码多次,如果我们的数据存在堆中,我们使用引用的时候,RUST会自己去判断,何时将不用的堆中的数据删除~
这里通过一个String类型的数据来做分析,扩展一个字符串的长度:
- 先通过
String::from
来实现字符串需要内存的请求(堆中) - 通过
push_str
来为增加一个string字面量到String上
fn main() {
let mut s = String::from("hello");
// 通过push_str的方式来扩展字符串的长度
s.push_str(", world");
println!("s -> {}", s);
// 没有增加字符串长度的办法
let a = "hello";
}
通过string字面量
的方式定义通常是放在栈里面,因为是连续的空间一开始的长度是固定的,所以不可变,但是通过String
来构造,其实是放在堆里面,扩展性更强,背后对应的是两种内存管理的方式。
4.1.6 内存和分配方式
从上面这个例子我们知道,由于栈分配的内存在编译时候其实是已知长度和内容,相当于是硬编码写死在代码里面的,所以其实效率更高,但是其实我们真正代码里面的场景不会是这个样子的,因此,我们需要String
类型来定义可变的可以扩展的文本内容,因此我们需要在堆中对其进行内存的分配:
- 在运行时,内存需要通过内存分配机制来进行分配
- 当我们使用
String
类型的时候内存分配器为我们分配内存(利用from
方法来申请内存) - RUST的GC策略,当变量不再所属于当前的作用域,该内存会被自动回收
4.1.7 数据变量交互方式—转移(move)
可能看这个标题有点迷糊,我们来看代码把,其实讲的意思就是一个相同的值,在不同的变量中间赋值来赋值去,如果两者都是标量类型的话,其实x
与y
都等于5,因为这个时候他们都是放在栈里面的
fn main() {
let x = 5;
let y = x;
// 这里会打印出 x -> 5, y -> 5
println!("x -> {}, y -> {}", x, y)
}
组合类型
接下来我们看一下组合类型的情况:
组合类型组成由三部分(以String为例):
- 起始指针(ptr)
- 长度(len)
- 容量(capacity)
当我将s
的值move到s2
的时候,操作为:
- 复制同样一份的
ptr
,len
和capacity
- 将
s2
的指针指向和s
同样的堆指针位置
【注意】
-
rust在这里这个
move
的操作不需要将堆中的内容重新复制一份 -
为了避免释放两次内存可能会造成的报错,当使用
move
将s
赋值给s2
的时候,这个时候s
就失效了 -
因为这种特性在这种场景下
s
的值赋值给了s2
其实值就转移
了,因为s
失效,行为看似是失效了s
浅拷贝的引用fn main() { let mut s = String::from("hello"); s.push_str(", world"); let s2 = s; // 这里会报错,目前这里的s是已经被借用了 // borrow of moved value: `s` value borrowed here after move println!("s -> {}", s); println!("s2 -> {}", s2); }
6.1.8 变量数据交互的方式—克隆(clone)
刚才说的场景,s2
将s1
做了一次浅拷贝,并将s1
的浅拷贝失效,如果这个时候我们想同时保留二者,这个时候需要做深拷贝,我们可以使用clone
方法
fn main() {
let mut s = String::from("hello");
let s2 = s;
let s3 = s2.clone();
// 这个时候不会报错
print!("s2 -> {}, {}", s2, s3);
}
这种方法其实是对堆的深拷贝,具体的示意图可以参考
4.1.9 栈数据赋值—拷贝
从4.1.7
中的第一个例子我们可以看到其实整型x
-> y
值也赋值了,但是也没有产生move
这是为什么呢?
理由主要是:RUST的Copy
特性,如果一个类实现了Copy
特性,老的变量在被赋值给人的变量时会变得不稳定。具体包括哪些呢?
- 所有的整型类型
- 布尔类型
- 浮点数类型
- 字符类型
- 元组
这些类型在被赋值的时候都不会发生类似s
赋值给s2
,导致s
失效这种问题
4.1.10 所有者和函数
在函数传递实参的时候,其实是对参数的赋值,所以也会满足上述说的拷贝时候的问题吗,就是基本上所有基础类型都不会存在move
但复杂类型会存在move
的问题
fn main() {
let s = String::from("hello");
takes_ownership(s);
// 这里编译会报错
// 因为这里其实s已经被函数借用了、
// 这个时候的s已经名存实亡了 哈哈哈哈
println!("x -> {}", s);
let x = 5;
makes_copy(x);
// 如果这里打印x不会报错,因为所有整型都不会move
}
fn takes_ownership(some_string: String) { // some_string comes into scope
println!("{}", some_string);
} // Here, some_string goes out of scope and `drop` is called. The backing
// memory is freed.
fn makes_copy(some_integer: i32) { // some_integer comes into scope
println!("{}", some_integer);
} // Her
4.1.11 作用域和返回值
如果是组合类型存在move
的但是我们,通过参数传入之后,原参数已失效,但是后续还要继续使用怎么办,这个时候可以返回一个元组,然后解构继续用(感觉这个操作好多余,不知道后面有没有其他的好办法)
fn main() {
let s1 = String::from("hello");
// 利用元组结构来继续使用这个s1
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
(s, length)
}
4.2 引用和借用
在4.1.10
中我们看到s
变量失效的这种场景,叫做借用,即这个变量从s
被赋予给了s1
引用类型允许我们在没有所有者的情况下拿到部分值(这句话其实我没从代码里面找到实际的例子,如果有大佬的话,可以给我指点一下)
如果我们想要s1
和s
同时有用呢?
- 第一种使用之前返回变量 + 元组解构的办法来继续拿到另一个值(借用需要将值通过函数返回值的方式还给变量)
- 使用引用,引用的使用方法是使用关键字
&
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()
}
4.2.1 引用的原理
将引用值得指针指向对应的引用变量,然后引用变量其实是指向对应堆中的值的,具体的示意图如下:
4.2.2 变量的引用
不仅函数可以通过引用的方式,普通变量原来move
的操作也可以改成引用,这个时候注意以下几点:
- 在move的变量前,增加
&
符号即可 - 这个时候
s2
其实就是类似s1
的copy
的结果,但是在内部代表的是同一个结果 - 变量的借用不需要“还”
fn main() {
let mut s1 = String::from("hello");
s1.push_str(", world");
let s2 = &s1;
println!("the str is {}", s2);
}
4.2.3 可变的引用
目前我们学习了引用,但是如果我们想为引用变量,进行操作,即可变的引用类型我们要怎么做的,来看个错误的例子:
fn main() {
let mut s1 = String::from("hello");
add_str(&s1);
println!("the str -> {}", s1);
}
fn add_str(s: &String) {
// 这里编译会报错
// 因为默认这里的s是个不可变的变量,默认都是不可变的
s.push_str("rust");
}
可变的引用只需要在&
后,增加mut
关键字即可,请看正确的示范:
- 在调用函数的地方增加
&mut
- 在函数定义时增加
&mut
- 同时只能被引用一次
fn main() { let mut s1 = String::from("hello"); add_str(&mut s1); println!("s1 -> {}", s1); } fn add_str(s: &mut String) { s.push_str(", rust"); }
4.2.4 只能有一次引用
这个地方其实是和变量的引用在文中是一章,但是比较容易出错,所以单独写一下,在使用引用的时候有一个重要的规则对一个数据在同一时间(作用域)只能有一个可变的引用,不然不符合这个规则,那么对不起,编译过不了。(目前我的理解是mut
的变量只能引用一次,借出去就没了)
这种策略是为了让可变性非常可控,为了避免一下几个行为:
- 超过两个指针在同时接入一个相同的数据(处理并发问题)
- 超过一个指针执行写操作
- 没有同步接入数据的机制
fn main() {
let mut s1 = String::from("hello");
s1.push_str(", world");
let mut s4 = &mut s1;
s4.push_str(", rust");
// 这里的s1会报错,报错的理由就是s1是不可变的变量,mut已经被出借了
// println!("The length of '{}' is {}", s1, len);
// 这里能看到hello world rust
println!("the str -> {}", s4);
}
如何处理这种需要多个mut的场景呢?
根据所有者的规则,只要在不同的几个作用域即可,所以我们可以这样安排代码
fn main() {
let mut s1 = String::from("hello");
// 如果写在这里,mut s4的地方就会报错
// 因为&mut s1这里已经被出借了
// let mut s2 = &mut s1;
{
// 写在独立的作用域里面就不会报错
let s2 = &mut s1;
println!("s2 -> {}", s2);
}
let mut s4 = &mut s1;
// println!("the str is {}, {}", s2, s4);
println!("the str -> {}", s4);
}
另外,如果是非mut
的多个引用,在rust中是可以被接受的,但是要注意是在哪里用的,如果最后一次调用完,对应的引用被回收了,那么再去赋值其实是不会报错的,这个很重要我们结合例子看下:
我理解的规则:
如果mut
引用创建时,还有引用没被收回不允许出借有了mut
引用不允许其他出借- 使用局部作用域,利用
所有者规则
可以额外出借
fn main() {
let mut s1 = String::from("hello");
let s2 = &s1;
let s3 = &s1;
// 如果print在这里是不会报错的
// 因为这里print是s2和s3最后被使用的
// 所以使用完s2, s3会被回收,后面
// 再出借mut的s1是可以的
println!("the str is {}, {}", s2, s3);
let mut s4 = &mut s1;
// 如果print在这里是不允许出借的
// 因为s2和s3还是出借状态,s1被s2和s3指向,这个时候不允许出借
// println!("the str is {}, {}", s2, s3);
println!("the str -> {}", s4);
}
4.2.5 悬摆的引用
函数没有必要返回一个无效的引用值,这个地方其实我理解官方的意思就是,因为函数执行完毕,我们的作用域内的变量会销毁,但是这个时候,我们如果使用引用的话,我们必须保证引用是有效的,如果这个原引用销毁了,那就不是有效的,所以编译时候会报错:
fn dangle() -> &String {
let s = String::from("hello");
// 这里会报错
&s
}
// 正确写法
fn dangle() -> String {
let s = String::from("hello");
s
}
官方的引用规则
- 任何时候只能有一个
mut
的引用和若干个immutable
的引用 - 引用必须总是有效的
4.3 切片类型
切片类型是另一种不需要所有者的数据类型,其目的是为了让我们能够在连续内存的一段连续的数据中,读取一部分数据所用的。
我们先在来看一个例子,如果需要获取一句话中第一个单词结尾的位置,我们需要怎么操作呢,很简单,可以通过遍历的方法,我们来看下遍历的代码:
【代码思路梳理】:
- 遍历所有字符串
- 字符串为" "的时候输出对应的索引值
【关键代码梳理】:
-
first_word
这个函数不需要所有者,所以直接使用引用类型
就好 -
as_bytes
其用法的意思就是,将一个字符串,变为字符的切片,即这里会循环一个数组Returns a byte slice of this
String
's contents.
-
遍历数组的方法,利用迭代器来实现,迭代器的使用方法是
.iter().enumerate()
即可实现迭代器,一个一个输出对应的值,类似next
fn main() { let mut s = String::from("hello world"); let word = first_word(&s); // word will get the value 5 println!("word -> {}", word); // 使用完成之后,将对应的字符串置为空 s.clear(); } fn first_word(s: &String) -> usize { let bytes = s.as_bytes(); for (i, &item) in bytes.iter().enumerate() { if item == b' ' { return i; } } s.len() }
思考:通过上述方法来看,如果我们这个时候需要获取第二第三个单词的索引,那其实这个方法会越来越冗长和复杂,其实想想在JS中如果我们要找到第二第三个单词怎么办,通过" "
来对字符串进行切割即可~,所以其实RUST也有这种切片类型的概念
4.3.1 字符串切片
回忆下之前,做猜数字游戏的时候的Range的使用方法[1..3]
代表从1~3的区间,其实这就是切片,我们来看看代码,通过..
这种方式,来获取区域中的[0, 5)
这个区间内的所有字符,具体可以翻译成JS中的a.slice(0, 5)
fn main() {
let mut s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];
println!("hello -> {}, world -> {}", hello, world);
}
注意点
-
使用切片类型,目前简写从第一个元素开始,和切片到末尾可以通过简写的方法
fn main() { let mut s = String::from("hello world"); let hello = &s[..5]; let world = &s[6..]; println!("hello -> {}, world -> {}", hello, world); }
-
字符串类型切片之后的类型变为了
&str
这个地方我们需要注意下,所以我们修改之前的first_word
函数如下// 这里函数的返回值需要是&str 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 }
-
引用类型在被使用之前其所有者必须存在
fn main() { let mut s = String::from("hello world"); // 这个first_word是2中的方法 let word = first_word(&s); // word will get the value 5 // 如果clear在这里,那么因为后面word使用的是s的引用 // 所以在这里清空后s就没有值了 // 因此编译阶段就会报错 // s.clear(); println!("word -> {}", word); s.clear(); }
【思考】
注意点3
中,我观察到报错是:mut类型不能出借给immutable类型,所以我就在想,难道我改了一波类型,这个地方这种奇怪的问题就能被绕过了吗~所以我进行了类型的魔改
fn main() {
let mut s = String::from("hello world");
let word = first_word(&mut s); // word will get the value 5
//
s.clear();
println!("word -> {}", word);
}
fn first_word(s: &mut String) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
s
}
但是其实我们发现这里还是报错的,cannot borrow
sas mutable more than once at a time
因为其实这个地方看到clear定义的时候,是&self
是一个mut
类型,所以这个地方我们其实明白了,一个变量最多创造一个mut
类型的引用,其实还是被规则拦住了!
4.3.2 String字面量切片
刚才4.3.1
中的切片指的是通过String
类来进行构造的,如果我们是通过String
字面量来进行构造的呢?会发生什么事呢?我们接下去来看~
字符串字面量,现在其实类型是&str
的引用,所以其实,对字面量定义的切片,是对其引用的切片,当然字面量的切片也可以作为函数的参数,可以看get_word
这个方法,但是如果作为切片接受参数,参数的类型为&str
fn main() {
let mut s2 = "Hello World";
s2 = "Rust Hello World";
let s3 = &s2[..5];
let s4 = get_word(&s2[..11]);
println!("s3 -> {}, s4 -> {}", s3, s4); // s3 -> Rust , s4 -> Rust
}
fn get_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[..i];
}
}
s
}
4.3.3 其他切片
其他切片例如数组的切片其实也一样,我们只需要对其进行引用切片即可~
fn main () {
let a = [1, 2, 3, 4, 5];
let slice = &a[1..3];
}
4.4 总结
本章学习了所有者,借用切片的概念,确保了在rust编译过程中的安全性。同时也梳理了一下rust的内存管理控制和使用,以及与其他语言在内存管理上的区别。所有者的影响rust代码的运行,是贯穿遍及整个rust学习者使用过程中的,所以我们必须好好理解
转载自:https://juejin.cn/post/7042935996324773902