Rust学习笔记(三)|所有权机制 Ownership

本篇文章包含的内容

  • [1 重新从堆和栈开始考虑](#1 重新从堆和栈开始考虑)
  • [2 所有权规则](#2 所有权规则)
  • [3 变量和数据(值)的交互方式](#3 变量和数据(值)的交互方式)
    • [3.1 移动 Move](#3.1 移动 Move)
    • [3.2 克隆 Clone](#3.2 克隆 Clone)
    • [3.3 复制 Copy](#3.3 复制 Copy)
  • [4 函数与所有权](#4 函数与所有权)
    • [4.1 参数传递时的所有权转移](#4.1 参数传递时的所有权转移)
    • [4.2 函数返回时的所有权转移](#4.2 函数返回时的所有权转移)
  • [5 引用和借用](#5 引用和借用)
  • [6 切片](#6 切片)

前面两篇仅仅介绍了一些Rust的语法以及一些程序书写特点。如果是其他语言,其实已经可以说完成了六成以上的学习,可以开始着手项目,以实践驱动学习了。但所有权和生命周期才是Rust的魅力所在,真正的难点现在才刚刚开始(噔噔咚)。


1 重新从堆和栈开始考虑

所有权是Rust最独特的特性之一,使得它与Java、C#等语言相比不需要GC(Garbage Collector,垃圾收集器)就可以保证内存安全,同时也不需要像C/C++一样手动释放内存。为了理解所有权,我们必须了解Rust的内存分配机制,这是在之前学习的语言中基本不会注意的点。

无论哪种语言编写的程序,都必须考虑他们运行时对计算机内存的操作方式。Rust并不相信程序员,但是也摒弃了GC算法这种低效的方式,取而代之的是引入所有权的概念,使程序中的内存操作错误在编译时就基本解决,并且这种做法不会造成任何的运行时开销。

在程序运行时,堆(Heap)和栈(Stack)都是程序可用的内存,它们的本质区别是内存组织的方式不同。栈内存先入后出,永远有一个指针指向栈顶,内存的存储是连续的,所有存储在栈中的数据必须有已知的或者固定的大小;而堆内存相对比较混乱,程序使用的内存是碎片化的,一般在运行时申请的动态内存都属于堆内存,操作系统在申请Heap时,需要申请一个足够大的空间,并返回一个额外的指针变量记录变量的存储位置(并且需要做好记录和管理方便下次分配),这导致程序运行时的指针可能存在大范围的跳转。总之,栈内存效率更高,堆内存以牺牲效率为代价换取了更多的灵活性。

所有权解决了以下问题:

  • 跟踪代码的哪些部分正在使用Heap的哪些数据;
  • 最小化Heap上的重复数据量;
  • 及时清理Heap上未使用的数据以避免空间不足。

2 所有权规则

Rust中所有权有以下三条规则(它很重要,先记下来再慢慢理解):

  1. 每个值都有一个变量,这个变量就是这个值的所有者;
  2. 每个值同时只能有一个所有者;
  3. 当所有者超出作用域(Scope)时,该值将被删除。

下面是一个关于作用域(Scope)的简单例子。作用域的概念在其他编程语言中也有,这里需要理解的是,s是变量,"hello"就是这个变量的值(一个字符串字面值)。

rust 复制代码
// s 无效
fn main() {
	// s 无效
	let s = "hello";	// s 可用
	// s 继续有效
}	// s 的作用域从这里结束

通过第一部分的解释,这里就比较好理解变量s的存储方式了。它的值在编译时就已经全部确定,并且不会随之变化(如果需要变化则需要引入String类型),所以这个变量和它的值在编译时就会被全部写入可执行文件中。

与之相比,String类型在堆上分配,这使得它可以存储在编译时未知数量的文本。下面的例子中,s超出作用域时会自动调用一个特殊的名为drop的函数来释放内存。所以String类型是一个实现了Drop trait(trait,接口)的类型。

rust 复制代码
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。

rust 复制代码
let x = 5;
let y = x;		// value copied here

但是下面这个例子不同,s1在内存中的索引信息存储在栈中,s1所对应的内容需要被存放在堆中(出于值的长度可变的需要)。栈中包含一个指向字符串存储位置的指针,一个字符串实际长度,一个从操作系统中获得的内存的总字节数。

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

如果接下来接着执行这一语句,那么栈中s1的信息会被复制一份,但是堆中字符串的值不会复制 (有点像浅拷贝),s1的所有权将会直接被递交给s2,同时s1会直接失效,这时我们说值的所有权发生了移动(Move)。这样做的目的是避免两个字符串离开作用域时调用两次drop函数,从而导致严重的Double Free错误。

rust 复制代码
let s2 = s1;			// value moved here
println!("{}", s1);		// 编译直接报错

3.2 克隆 Clone

对于上面的s1s2的例子,如果想同时拷贝栈和堆中的信息,可以使用clone()方法。这样的操作明显是比较浪费资源的。

rust 复制代码
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类型),把值传递给函数中往往伴随着所有权的转移,也就是说旧变量对值的所有权会发生丢失,这里发生的事情和把变量赋值给另一个变量是类似的。看下面这个例子:

rust 复制代码
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这种类型的变量,直接将其作为函数参数时,传入参数时helloString的所有权会从s1转换到函数内部的some_string,程序运行到take_ownership函数之外时会自动调用Drop trait,字符串的值的内存会被释放。但是对于实现了Copy trait的类型,例如i32,参数传递时会发生copy,而不是move,这样在函数调用后x变量依然是可用的。

4.2 函数返回时的所有权转移

这个比较好理解,看下面一个例子:

rust 复制代码
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)。

rust 复制代码
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中,引用和变量类似,也分为可变的引用和不可变的引用,创建的引用默认同样是不可变的。下面是一个使用可变引用的例子。

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从编译层面解决了数据竞争的问题。

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

let s1 = &mut s;
let s2 = &mut s;	// 非法
rust 复制代码
let mut s = String::from("hello");
{
	let s1 = &mut s;
}
let s2 = &mut s;	// 合法

这样的做法还带来了另一个好处,即永远不会存在"悬空引用"(Dangling Reference,一个引用或者指针指向一块内存,但是这一块内存可能已经被释放或者被其他人使用了)或者"野指针"。

总之,引用一定满足下面的规则

  • 引用一定有效;
  • 引用一定满足下列条件之一,不可能同时满足:
    • 存在一个可变引用;
    • 存在任意数量的不可变引用。

6 切片

切片(Slice)是指一段数据的引用。这里的一段数据可以是String类型,也可以是数组。字符串切片的写法如下所示,类型名在程序中是&str

rust 复制代码
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字符边界内(就是不能把字符切"坏"了),否则程序就会报错退出。

为什么要使用切片?看下面这个例子:获取字符串中的各个单词,如果字符串中没有空格,则返回整个字符串。

rust 复制代码
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也无法感知。

使用字符串切片重写上面的例子:

rust 复制代码
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和字符串切片两种类型的变量作为参数了

rust 复制代码
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]表示该类型。

rust 复制代码
let a: [i32; 5] = [1, 2, 3, 4, 5];
let slice = &a[1..3];		// slice类型是&[i32]

*  原创笔记,码字不易,欢迎点赞,收藏~ 如有谬误敬请在评论区不吝告知,感激不尽!博主将持续更新有关嵌入式开发、FPGA方面的学习笔记。*


相关推荐
siy23332 小时前
[c语言日记] 数组的一种死法和两种用法
c语言·开发语言·笔记·学习·链表
在路上`4 小时前
前端学习之后端java小白(三)-sql外键约束一对多
java·前端·学习
尚久龙5 小时前
安卓学习 之 用户登录界面的简单实现
android·运维·服务器·学习·手机·android studio·安卓
yb0os15 小时前
RPC实战和核心原理学习(一)----基础
java·开发语言·网络·数据结构·学习·计算机·rpc
乱飞的秋天5 小时前
网络编程学习
网络·学习·php
2202_755744306 小时前
开学季技术指南:构建高效知识管理系统与学习工作流
学习
不会聊天真君6478 小时前
ES(springcloud笔记第五期)
笔记·elasticsearch·spring cloud
时空自由民.8 小时前
repo 学习教程
大数据·学习·elasticsearch
汇能感知8 小时前
光谱相机在AI眼镜领域中的应用
经验分享·笔记·科技
汇能感知8 小时前
光谱相机的图像模式
经验分享·笔记·科技