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方面的学习笔记。*


相关推荐
tingshuo29179 小时前
D006 【模板】并查集
笔记
DongLi0113 小时前
rustlings 学习笔记 -- exercises/06_move_semantics
rust
ssshooter17 小时前
Tauri 踩坑 appLink 修改后闪退
前端·ios·rust
布列瑟农的星空18 小时前
前端都能看懂的rust入门教程(二)——函数和闭包
前端·后端·rust
tingshuo29171 天前
S001 【模板】从前缀函数到KMP应用 字符串匹配 字符串周期
笔记
蚂蚁背大象2 天前
Rust 所有权系统是为了解决什么问题
后端·rust
布列瑟农的星空2 天前
前端都能看懂的rust入门教程(五)—— 所有权
rust
Java水解3 天前
Rust嵌入式开发实战——从ARM裸机编程到RTOS应用
后端·rust
Pomelo_刘金3 天前
Rust:所有权系统
rust
Ranger09293 天前
鸿蒙开发新范式:Gpui
rust·harmonyos