Skip to content

所有权(重点知识)

它可以让 Rust 不需要 GC(Garbage Collector 垃圾回收器),就能保证内存安全,Rust 中最独特的特点(核心特性)。其它语言像 JS、Java 都有自动垃圾回收器,C 和 C++则需要手动去回收内存。

Stack 栈和 Heap 堆

存储在 Stack 的数据必须拥有已知的固定大小,编译时大小未知或者大小可能发生变化的数据放在 Heap 堆里,普通数组和 Vec 就是其中的一个例子。

堆内存组织性要差一点,数据放到堆中时,操作系统会找到一个足够大的空间,标记为在用,并返回一个指针,指针指向这个地址的空间,称为内存分配,其中的指针放在栈中,想要访问实际数据,必须要用指针来进行定位。

访问堆的数据要比访问栈慢得多,因为需要通过指针才能找到堆中的数据,多了一个指针跳转的环节,传达命令需要时间。数据存放地址越近,寻址速度就会快一些(栈就是这样的,栈是连续的地址空间),地址之间距离越远,访问速度就越慢,指针寻址要时间(堆中存放的数据的地址不一定连续)。

所有权解决的问题

所有权本质上就是用来管理堆中数据的。

  • 跟踪代码哪些部分正在使用堆中的数据
  • 最小化堆上的重复数据量
  • 清理堆中未使用的数据

所有权的三条规则

  • 每个值都有一个变量,这个变量就是这个值的所有者,比如说let x = 0;,x 就是 0 的所有者。
  • 每个值同时只能有一个所有者
  • 当所有者超出作用域的时候,值就会被删除

作用域

类似于 JS 的 let 声明变量的作用域,当前作用域里的范围,不使用闭包的话其它作用域没法访问。

rust
fn main() {
    // s不可用
    let s = "hello"; // s可用
    // 此后可以对s进行相关操作
} // 超出作用域,s不可用

String 类型

String 类型比其它的基础类型更复杂,其它的基础数据类型的数据都保存在栈中,而 String 的数据存放在堆中。

  • 字符串字面值,比如let x = "hello";,这个字符串字面值是不可变的。
  • String 在堆中分配内存,可以存储动态长度的字符串数量,是可变的。
  • 对于 String 类型的内存回收:当拥有它的变量走出作用域范围时,内存就会自动回收给操作系统。即调用 drop 函数去回收内存。
rust
// 创建一个 String 类型的字符串,::表示 from 是 String 命名空间下的函数,这类字符串可以修改。
let mut s = String::from("hello");

s.push_str(", world!");

println!("{}", s); // hello, world!

变量和数据使用(移动 Move)进行交互

rust
fn main() {
    let x = 5;
	let y = x;
}

这里的 x 和 y 都是简单数据类型,都被压到了栈中。

而对于 String 类型

rust
fn main() {
    let s1 = String::from("hello");
	let s2 = s1;
}

存在问题(这里不是 Rust 所使用的方式,只是说常规的方式):

  • 此时 s1 赋值给 s2,String 的数据就被复制了一份,实际上是在栈中复制了一份 s1 的指针(指向存放 s1 数据的堆地址),但长度和容量并没有复制指针所指向的堆中的数据。

  • 当变量离开作用域时,Rust 会自动调用 drop 函数,并将变量使用的堆内存释放

  • s1 和 s2 离开作用域的时候,都会尝试释放相同的内存:会引起二次释放的 bug。

Rust 的解决方式,为了保证内存安全:

  • Rust 不尝试复制被分配的内存
  • s1 赋值给 s2 后,s1 就失效了,s1 离开作用域时,Rust 不释放任何东西,如果在 s2 之后再访问 s1,就会报错。

克隆

想对堆中的 String 数据进行深度拷贝而不仅仅只是浅拷贝栈中的(指针、长度、容量),可以使用 clone 方法。

rust
fn main() {
	let s1 = String::from("hello");
	let s2 = s1.clone();
	println!("{}, {}",s1, s2);
}

克隆之后,s1 和 s2 指针指向的堆内存地址就不是一样的了。

复制(Trait)

而对于栈中的数据,我们一般称为复制而不叫克隆。下面的例子就是复制:

rust
fn main() {
	let x = 5;
	let y = x;
}
  • Copy Trait(复制特性),可以像整数这样完全存放在 stack 上面的类型
  • 如果一个类型实现了 Copy 这个 Trait,那么旧变量可以在赋值给其它变量后继续使用,而不会被回收。
  • 如果一个类型或者该类型的一部分使用了 Drop Trait,那么 Rust 就不允许它继续实现 Copy Trait 了。

拥有 Copy Trait 的类型:

  • 任何简单的组合数据类型都可以实现 Copy Trait
  • 需要分配内存或资源的类型都不可以实现 Copy Trait,而是 Drop Trait
  • 拥有 Copy Trait 的类型:所有整数类型,比如 u32、bool 类型、char 类型、浮点类型、Tuple(前提是里面的所有数据都是可以实现 Copy trait 的,并且元组数量不能超过 12)

小结:

  • 原生类型,包括函数、不可变引用和裸指针实现了 Copy;
  • 数组和元组,如果其内部的数据结构实现了 Copy,那么它们也实现了 Copy;
  • 可变引用没有实现 Copy;
  • 非固定大小的数据结构,没有实现 Copy。

所有权和函数

下面看两个例子,为引用的概念做铺垫。

rust
fn main() {
    let s = String::from("hello world");

    take_string(s);

    // 这里 s 会报错,因为 s 的所有权被转移到了 take_string 函数里,然后函数执行完,就会调用 drop 函数,把 s 的内存释放掉。
    println!("s:{}", s);

    let x = 5;

    // 这里不会发生什么特殊的事情,因为传递进去的 x 是通过复制进去的。
    take_var(x);
}

fn take_string(test_string: String) {
    println!("{}", test_string);
}

fn take_var(test_var: i32) {
    println!("{}", test_var)
}
rust
fn main() {
    let s1 = gives_ownership();

    let s2 = String::from("hello");

    // 函数执行的结果返回到 s3 上了
    let s3 = take_and_give_back(s2);

    // 这里的 s2 会报错。因为 s2 已经被移动到函数里,最后函数执行完,s2 就会被 drop 掉。
    println!("s1:{}, s2:{}, s3:{}", s1, s2, s3)
}

fn gives_ownership() -> String {
    let some_string = String::from("hello1");
    some_string
}

fn take_and_give_back(test_string: String) -> String {
    // 这里取得 s2 的 String 的所有权,并将它作为结果进行返回
    test_string
}

返回值和作用域

  • 函数在返回的时候也会发生所有权的转移
  • 把一个值赋值给其它变量的时候所有权会发生移动
  • 当一个包含堆数据的变量离开作用域时,它的值会被 drop 函数清理掉,除非所有权移动到了另一个变量上。

如何让函数使用某个值,但是又不获得所有权? ->使用引用

这里就提出了引用(reference)的概念。引用允许你使用某个值而不获得它的所有权。

rust
fn main() {
    let s1 = String::from("hello");

    // 这里calc_length函数传递进去的s1,只是s1的引用,所以所有权不会进行移动。外面的s1的所有权依旧存在。
    let len = calc_length(&s1);

    println!("{}的长度是{}", s1, len); // hello的长度是5
}

fn calc_length(test_string: &String) -> usize {
    test_string.len()
}

借用

把引用作为函数参数的这种行为就称为借用,可以看上面的例子,把 s1 借用到了 calc_length 函数里,但是没获得它的所有权。借用到函数里面的变量是不可以修改的,和变量一样,引用默认也是不可修改的(imutable),但是如果声明可变引用的话则引用是可以修改的

rust
fn main() {
    let mut s1 = String::from("hello");

    let len = calc_length(&mut s1);

    println!("{}的长度是{}", s1, len); // hello,world的长度是11
}

fn calc_length(test_string: &mut String) -> usize {
    // 因为传的是可变引用,所以这里可以修改
    test_string.push_str(",world");
    test_string.len()
}

可变引用的限制 : 在特定作用域内,对某一块数据,只能有一个可变的引用。这样做的好处是可以在编译时防止数据产生竞争。

rust
fn main() {
    let mut s = String::from("hello");

    let s1 = &mut s;
    // 这里就会报错cannot borrow `s` as mutable more than once at a time
    // 意思就是不能同时对s进行超过一次的可变引用。
    let s2 = &mut s;

    println!("s1:{}, s2:{}", s1, s2);
}

数据竞争会发生的情况,这些行为在运行时是很难发现的,所以就在编译时就防止这种现象出现。

  • 两个或多个指针同时访问同一个数据
  • 至少有一个指针用于写入数据
  • 没有使用任何机制来同步对数据的访问

可以通过创建新的作用域来允许非同时创建多个可变引用:

rust
fn main() {
    let mut s = String::from("hello");

    let s1 = &mut s;

    {
        let s2 = &mut s;
    }
}

另外的限制:

  • 不可以同时拥有一个可变引用和一个不可变的引用,多个不变的引用则是可以的
rust
fn main() {
    let mut s = String::from("hello");

    let s1 = &s;
    let s2 = &s;
    // 这里报错cannot borrow `s` as mutable because it is also borrowed as immutable。
    // 不能借用已经被不可变引用的s再进行可变引用。
    let s3 = &mut s;

    println!("s1:{}, s2:{}, s3:{}", s1, s2, s3)
}

悬空引用

概念:一个指针引用了内存中的某个地址,而这块内存可能已经被释放并分配给别人使用了。

而 Rust 编译器会保证引用永远都不会产生悬空引用

  • 如果引用了某些数据,编译器会保证引用离开作用域之前数据不会离开作用域。
rust
fn main() {
    let s = test();
}

// 这里报错:expected named lifetime parameter,期待声明一个生命周期的参数
// 生命周期后面具体再讲
fn test() -> &String {
    let s = String::from("hello");
    &s
}

引用的规则

任何给定时刻,只能满足以下两个条件之一:

  • 一个可变的引用
  • 任意数量不可变的引用

引用必须要一直有效。

切片(一种不持有所有权的数据类型,slice)

字符串切片是指向字符串中一部分内容的引用。使用方式[开始索引..结束索引) ,左闭右开区间。

rust
fn main() {
    let s = String::from("hello world");

    let hello = &s[0..5]; // 语法糖&[..5]
    let world = &s[6..11]; // 语法糖&[6..]

    // 获取所有字符串,相当于[0..s.len()]
    let whole = &s[..];

    println!("{}, {}, whole: {}", hello, world, whole); // hello, world, whole: hello world
}

测试题:

  • 接收字符串作为参数
  • 返回它在这个字符串中找到的第一个单词
  • 如果函数没找到任何空格,那么将整个字符串返回
rust
fn main() {
    let s = String::from("hello world");
    let res = first_word(&s);

    println!("res: {}", res);
}

//返回类型 &str 是字符串切片的类型。
fn first_word(s: &String) -> &str {
    // as_bytes 方法会将 s 字符串转化为字符串数组。bytes 的类型是 &[u8]
    let bytes = s.as_bytes();

    // (i, &item) 是元组类型。iter 方法创建迭代器,依次返回数组中每个元素
    // 然后调用 enumerate 这个方法,把iter的结果进行包装,并把每个结果作为元组的一部分进行返回。
    // 元组的第一个元素就是 enumerate 遍历的索引,第二个元素就是数组里的元素。
    // 这里实际上用了模式匹配
    for (i, &item) in bytes.iter().enumerate() {
        // 判断遍历到的项是否等于空格,b' '(byte) 为空格的写法。
        if item == b' ' {
            return &s[..i];
        }
    }

    &s[..]
}

每天进步一丢丢