Rust学习笔记(三)|所有权机制 Ownership
本篇文章包含的内容
- 1 重新从堆和栈开始考虑
- 2 所有权规则
- 3 变量和数据(值)的交互方式
- 3.1 移动 Move
- 3.2 克隆 Clone
- 3.3 复制 Copy
- 4 函数与所有权
- 4.1 参数传递时的所有权转移
- 4.2 函数返回时的所有权转移
- 5 引用和借用
- 6 切片
前面两篇仅仅介绍了一些Rust的语法以及一些程序书写特点。如果是其他语言,其实已经可以说完成了六成以上的学习,可以开始着手项目,以实践驱动学习了。但所有权和生命周期才是Rust的魅力所在,真正的难点现在才刚刚开始(噔噔咚)。
1 重新从堆和栈开始考虑
所有权是Rust最独特的特性之一,使得它与Java、C#等语言相比不需要GC(Garbage Collector,垃圾收集器)就可以保证内存安全,同时也不需要像C/C++一样手动释放内存。为了理解所有权,我们必须了解Rust的内存分配机制,这是在之前学习的语言中基本不会注意的点。
无论哪种语言编写的程序,都必须考虑他们运行时对计算机内存的操作方式。Rust并不相信程序员,但是也摒弃了GC算法这种低效的方式,取而代之的是引入所有权的概念,使程序中的内存操作错误在编译时就基本解决,并且这种做法不会造成任何的运行时开销。
在程序运行时,堆(Heap)和栈(Stack)都是程序可用的内存,它们的本质区别是内存组织的方式不同。栈内存先入后出,永远有一个指针指向栈顶,内存的存储是连续的,所有存储在栈中的数据必须有已知的或者固定的大小;而堆内存相对比较混乱,程序使用的内存是碎片化的,一般在运行时申请的动态内存都属于堆内存,操作系统在申请Heap时,需要申请一个足够大的空间,并返回一个额外的指针变量记录变量的存储位置(并且需要做好记录和管理方便下次分配),这导致程序运行时的指针可能存在大范围的跳转。总之,栈内存效率更高,堆内存以牺牲效率为代价换取了更多的灵活性。
所有权解决了以下问题:
- 跟踪代码的哪些部分正在使用Heap的哪些数据;
- 最小化Heap上的重复数据量;
- 及时清理Heap上未使用的数据以避免空间不足。
2 所有权规则
Rust中所有权有以下三条规则(它很重要,先记下来再慢慢理解):
- 每个值都有一个变量,这个变量就是这个值的所有者;
- 每个值同时只能有一个所有者;
- 当所有者超出作用域(Scope)时,该值将被删除。
下面是一个关于作用域(Scope)的简单例子。作用域的概念在其他编程语言中也有,这里需要理解的是,s
是变量,“hello”
就是这个变量的值(一个字符串字面值)。
// s 无效
fn main() {// s 无效let s = "hello"; // s 可用// s 继续有效
} // s 的作用域从这里结束
通过第一部分的解释,这里就比较好理解变量s
的存储方式了。它的值在编译时就已经全部确定,并且不会随之变化(如果需要变化则需要引入String类型),所以这个变量和它的值在编译时就会被全部写入可执行文件中。
与之相比,String
类型在堆上分配,这使得它可以存储在编译时未知数量的文本。下面的例子中,s
超出作用域时会自动调用一个特殊的名为drop
的函数来释放内存。所以String类型是一个实现了Drop trait(trait,接口)的类型。
fn main() {let mut s = String::from("Hello");s.push_str(", world!");println!("{}", s);
} // s 会自动调用一个drop函数
看到这里你可能依然一头雾水(这家伙在说什么呢.jpg),这些概念和C/C++以及其他语言难道做不到吗?超出作用域释放内存难道不是理所当然的吗?既然如此我还为什么要学Rust?Rust究竟好在哪?所谓的内存安全就这?
别急,这个Drop方法看似人畜无害,但是它会导致一个非常严重的bug。
3 变量和数据(值)的交互方式
3.1 移动 Move
首先看下面这个例子,创建了两个简单的整数变量,由于它们的大小是确定的,所以两个变量都将被压入栈中,值发生了复制。像整数这样完全存放在栈上的数据实现了Copy trait。
let x = 5;
let y = x; // value copied here
但是下面这个例子不同,s1
在内存中的索引信息存储在栈中,s1
所对应的内容需要被存放在堆中(出于值的长度可变的需要)。栈中包含一个指向字符串存储位置的指针,一个字符串实际长度,一个从操作系统中获得的内存的总字节数。
let s1 = String::from("hello");
如果接下来接着执行这一语句,那么栈中s1的信息会被复制一份,但是堆中字符串的值不会复制(有点像浅拷贝),s1
的所有权将会直接被递交给s2
,同时s1
会直接失效,这时我们说值的所有权发生了移动(Move)。这样做的目的是避免两个字符串离开作用域时调用两次drop函数,从而导致严重的Double Free错误。
let s2 = s1; // value moved here
println!("{}", s1); // 编译直接报错
3.2 克隆 Clone
对于上面的s1
和s2
的例子,如果想同时拷贝栈和堆中的信息,可以使用clone()
方法。这样的操作明显是比较浪费资源的。
fn main() {let s1 = String::from("hello");let s2 = s1.clone();println!("{} {}", s1, s2);
}
3.3 复制 Copy
总之,如果一个变量存在Copy trait,那么旧变量在“移动”后依然可用;如果一个类型或者该类型的一部分实现了Drop triait(例如定义的元组的一部分是String的情况),那么Rust就不允许它再实现Copy trait了,编译时就会进行检查,在移动后旧变量就不再可用,除非使用了clone()
方法。
4 函数与所有权
Rust中的变量总是遵循下面的规则:
- 把一个变量赋值给其他变量就会发生移动(除非变量存在Copy trait);
- 当变量超出其作用域后,存储在Heap上的数据就会被销毁(Drop trait),除非它的所有权已经被转移。
4.1 参数传递时的所有权转移
在Rust中,如果函数参数的类型是一个实现了Drop trait的类型(例如String类型),把值传递给函数中往往伴随着所有权的转移,也就是说旧变量对值的所有权会发生丢失,这里发生的事情和把变量赋值给另一个变量是类似的。看下面这个例子:
fn main() {let s1 = String::from("hello");take_ownership(s1);// println!("{}", s1); // 编译报错let x = 1;makes_copy(x);println!("the x is {}", x);
}fn take_ownership(some_string: String) {println!("{}", some_string);
}fn makes_copy(some_integer: i32) {println!("{}", some_integer);
}
对于String这种类型的变量,直接将其作为函数参数时,传入参数时hello
String的所有权会从s1
转换到函数内部的some_string
,程序运行到take_ownership
函数之外时会自动调用Drop trait,字符串的值的内存会被释放。但是对于实现了Copy trait的类型,例如i32
,参数传递时会发生copy,而不是move,这样在函数调用后x
变量依然是可用的。
4.2 函数返回时的所有权转移
这个比较好理解,看下面一个例子:
fn main() {let s1 = gives_ownership();let s2 = String::from("hello");let s3 = takes_and_gives_back(s2);
}fn gives_ownership() -> String {let s = String::from("hello");s
}fn takes_and_gives_back(a_string: String) -> String {a_string
}
对于gives_ownership
函数,在函数内部创建了一个新的String,函数返回时不会将其销毁,而是把它的所有权交给主函数的s1
;而takes_and_gives_back
函数获取到s2
到的所有权,s2
之后会失效,返回时将String的所有权交还给主函数的s3
。
5 引用和借用
但有些时候,我们只想获得变量的值,而不想它的所有权发生转移(甚至丢失),这时候就可以使用引用(Reference)。
fn main() {let s1 = String::from("hello");let lenth = calculate_length(&s1);println!("The length of '{}' is {}.", s1, lenth);
}fn calculate_length(s: &String) -> usize {s.len()
}
在上面的例子中,calculate_length
函数使用了String的引用作为参数,函数计算返回字符串长度后s1
仍然是可用的。引用相当于一个指针,它可以获取到变量对应的值,但是不拥有它,所以当其离开作用域时也无法销毁它。像这样,把引用作为函数参数这个行为称为借用(Borrow)。
在Rust中,引用和变量类似,也分为可变的引用和不可变的引用,创建的引用默认同样是不可变的。下面是一个使用可变引用的例子。
fn main() {let mut s1 = String::from("hello");let lenth = calculate_length(&mut s1);println!("The length of '{}' is {}.", s1, lenth);
}fn calculate_length(s: &mut String) -> usize {s.push_str(", world!");s.len()
}
需要注意引用的特殊限制:在特定的作用域内,一个变量只能同时拥有一个可变的引用;并且不能同时存在可变的引用和不可变的引用。一个变量可以拥有多个不可变的引用。Rust从编译层面解决了数据竞争的问题。
let mut s = String::from("hello");let s1 = &mut s;
let s2 = &mut s; // 非法
let mut s = String::from("hello");
{let s1 = &mut s;
}
let s2 = &mut s; // 合法
这样的做法还带来了另一个好处,即永远不会存在“悬空引用”(Dangling Reference,一个引用或者指针指向一块内存,但是这一块内存可能已经被释放或者被其他人使用了)或者“野指针”。
总之,引用一定满足下面的规则:
- 引用一定有效;
- 引用一定满足下列条件之一,不可能同时满足:
- 存在一个可变引用;
- 存在任意数量的不可变引用。
6 切片
切片(Slice)是指一段数据的引用。这里的一段数据可以是String类型,也可以是数组。字符串切片的写法如下所示,类型名在程序中是&str
。
let s = String::from("hello world");let hello = &s[0..5]; // 左闭右开,此时相当于 &s[..5]
let world = &s[6..11] // 此时相当于 &s[6..]let whole = &s[..] // 整个字符串的切片
需要注意,字符串切片的索引必须发生在有效的UTF-8字符边界内(就是不能把字符切“坏”了),否则程序就会报错退出。
为什么要使用切片?看下面这个例子:获取字符串中的各个单词,如果字符串中没有空格,则返回整个字符串。
fn main() {let s = String::from("hello");let word_index = first_word(&s);println!("{}", word_index);
}fn first_word(s: &String) -> usize {let bytes = s.as_bytes(); // 将String转换为字符数组for (i, &item) in bytes.iter().enumerate() {if item == b' ' {return i;}}s.len()
}
上面这个程序虽然能完成一部分功能(获取第一个空格的位置),但是这个程序存在一个重要的结构性缺陷:变量word_index
和Strings
之间没有任何联系,即使s
被释放,或者被修改,word_index
也无法感知。
使用字符串切片重写上面的例子:
fn main() {let s = String::from("hello world");let word = first_word(&s); // 把s作为不可变的引用发生借用,之后s都不可变// s.clear(); // s不可变println!("{}", word);
}fn first_word(s: &String) -> &str {let bytes = s.as_bytes(); // 将String转换为字符数组for (i, &item) in bytes.iter().enumerate() {if item == b' ' {return &s[..i];}}&s[..]
}
字符串子面值也是切片。利用这一特点,我们可以将函数的参数类型改为字符串切片&str
,使得函数可以直接接收字符串子面值作为参数,这样函数就可以同时接收String和字符串切片两种类型的变量作为参数了。
fn main() {let word = first_word("hello world"); println!("{}", word);
}fn first_word(s: &str) -> &str {let bytes = s.as_bytes(); // 将String转换为字符数组for (i, &item) in bytes.iter().enumerate() {if item == b' ' {return &s[..i];}}&s[..]
}
其他数组类型也存在切片,例如使用下面的方法创建一个i32
类型的切片,程序中用&[i32]
表示该类型。
let a: [i32; 5] = [1, 2, 3, 4, 5];
let slice = &a[1..3]; // slice类型是&[i32]
原创笔记,码字不易,欢迎点赞,收藏~ 如有谬误敬请在评论区不吝告知,感激不尽!博主将持续更新有关嵌入式开发、FPGA方面的学习笔记。