详解Rust中的所有权机制

Rust
411
0
0
2023-03-21
目录
  • Rust中的所有权机制
  • 什么是所有权
  • 所有权规则
  • String类型
  • 内存与分配
  • 移动
  • 克隆
  • 所有权与函数
  • 引用与借用
  • 可变引用
  • 悬垂引用
  • 引用的规则
  • Slice类型(切片)
  • 字符串切片
  • 其他类型的 slice
  • 总结

Rust中的所有权机制

什么是所有权

Rust 的核心功能(之一)是 所有权(ownership)。虽然该功能很容易解释,但它对语言的其他部分有着深刻的影响。

所有程序都必须管理其运行时使用计算机内存的方式。

一些语言中具有垃圾回收机制,在程序运行时有规律地寻找不再使用的内存——例如Java、Python等;在另一些语言中,程序员必须亲自分配和释放内存——例如C、C++等。

Rust 则选择了第三种方式:通过所有权系统管理内存,编译器在编译时会根据一系列的规则进行检查。如果违反了任何这些规则,程序都不能编译。在运行时,所有权系统的任何功能都不会减慢程序。

所以Rust具有安全性的原因之一是Rust的程序把大部分因为内存方面的安全问题在编译时给予扼杀。

所有权规则

  • Rust 中的每一个值都有一个 所有者(owner)。
  • 值在任一时刻有且只有一个所有者。
  • 当所有者(变量)离开作用域,这个值将被丢弃。

String类型

其实这个已经在数据类型那一节的时候就应该简单介绍一下它,但是也没多大关系。String是一种结构体,其原型如下:

pub struct String {
    vec: Vec<u8>,
}

结构体这个概念对于有C语言基础的就不用多说了,前面还介绍过元组类型,元组其实就是更简单一点的结构体。

我们已经见过字符串字面值,即被硬编码进程序里的字符串值。

字符串字面值是很方便的,不过它们并不适合使用文本的每一种场景。原因之一就是它们是不可变的。另一个原因是并非所有字符串的值都能在编写代码时就知道:例如,要是想获取用户输入并存储该怎么办呢?

为此,Rust 有第二个字符串类型,String。这个类型管理被分配到上的数据,所以能够存储在编译时未知大小的文本。可以使用 from 函数基于字符串字面值来创建 String,如下:

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

这两个冒号 :: 是运算符,允许将特定的 from 函数置于 String 类型的命名空间(namespace)下,而不需要使用类似 string_from 这样的名字。

关于String的一些简单的操作:

往String类型的变量尾部插入字符和字符串分别可以使用push和push_str函数完成。

fn main() {
    let mut s = String::from("hello");
    s.push(',');
    s.push_str(" world!");
    println!("s = {}", s);
}

结果:

s = hello, world!

因为String对 ‘+’ 做了运算符重载,所以上面的操作也可使用 '+'完成:

fn main() {
    let mut s = String::from("hello");
    s = s + ",";	//这里是双引号
    s = s + " world!";
    println!("s = {}", s);
}

String和&str的区别在于两个类型对内存的处理上。

内存与分配

字符串字面值在编译时就知道其内容,所以文本被直接硬编码进最终的可执行文件中。这使得字符串字面值快速且高效。不过这些特性都只得益于字符串字面值的不可变性。不幸的是,我们不能为了每一个在编译时大小未知的文本而将一块内存放入二进制文件中,并且它的大小还可能随着程序运行而改变。

对于 String 类型,为了支持一个可变,可增长的文本片段,需要在堆上分配一块在编译时未知大小的内存来存放内容。这意味着:

  • 必须在运行时向内存分配器(memory allocator)请求内存。
  • 需要一个当我们处理完 String 时将内存返回给分配器的方法。

就是字符串字面量的内存是在栈上,而String类型的内存是在堆上。

在有 垃圾回收(garbage collector,GC)的语言中, GC 记录并清除不再使用的内存,而我们并不需要关心它。在大部分没有 GC 的语言中,识别出不再使用的内存并调用代码显式释放就是我们的责任了,跟请求内存的时候一样。从历史的角度上说正确处理内存回收曾经是一个困难的编程问题。如果忘记回收了会浪费内存。如果过早回收了,将会出现无效变量。如果重复回收,这也是个 bug。我们需要精确的为一个 allocate 配对一个 free

  • 有GC的语言:不需要关心不再使用的内存,因为会自动GC.
  • 没有GC的语言:一次申请内存对应一次释放内存。

Rust的处理方式:内存在拥有它的变量离开作用域后就被自动释放。

 {
        let s = String::from("hello"); // 从此处起,s 是有效的

        // 使用 s
    }                                  // 此作用域已结束,
                                       // s 不再有效

这是一个将 String 需要的内存返回给分配器的很自然的位置:当 s 离开作用域的时候。当变量离开作用域,Rust 为我们调用一个特殊的函数。这个函数叫做 drop,在这里 String 的作者可以放置释放内存的代码。Rust 在结尾的 } 处自动调用 drop

说白了Rust的机制就是对应着C++的智能指针中unique_ptr的机制。

移动

在Rust 中,多个变量可以采取不同的方式与同一数据进行交互。

例如:

  • 标量的版本
let x = 5;
let y = x;

因为整数是有已知固定大小的简单值,所以这两个 5 被放入了栈中。

原因是像整型这样的在编译时已知大小的类型被整个存储在栈上,所以拷贝其实际的值是快速的。这意味着没有理由在创建变量 y 后使 x 无效。换句话说,这里没有深浅拷贝的区别,所以这里调用 clone 并不会与通常的浅拷贝有什么不同,我们可以不用管它。
  • String类型的版本
let s1 = String::from("hello");
let s2 = s1;

String 由三部分组成:

  • 指向存放字符串内容内存的指针
  • 长度
  • 容量。

这一组数据存储在栈上。String通过其内部的成员指针,访问到实际字符串的位置。如下图所示:

在Rust中如果出现以下的情况时:

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

那么Rust采取的方式是,把s1的所有权移交给s2,那么此后s1不不可再对原来的内存进行操作(保证Rust中的所有权的规则)。

在这里插入图片描述

另外,这里还隐含了一个设计选择:Rust 永远也不会自动创建数据的 “深拷贝”。因此,任何 自动 的复制可以被认为对运行时性能影响较小。

克隆

因为Rust不会自动复制变量中的具体内容,而有些场景中,我们有希望拷贝原来的内容,那Rust也给我们提供了一种克隆的方式,这样就符合我们原来的编码习惯。

let s1 = String::from("hello");
let s2 = s1.clone();

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

这样的话就没有将s1的所有权移交给s2,而是s2拷贝了一份新的s1的内容。

Rust 有一个叫做 Copy trait 的特殊注解,可以用在类似整型这样的存储在栈上的类型上(第十章将会详细讲解 trait)。如果一个类型实现了 Copy trait,那么一个旧的变量在将其赋值给其他变量后仍然可用。

任何一组简单标量值的组合都可以实现 Copy,任何不需要分配内存或某种形式资源的类型都可以实现 Copy 。如下是一些 Copy 的类型:

  • 所有整数类型,比如 u32
  • 布尔类型,bool,它的值是 true 和 false
  • 所有浮点数类型,比如 f64
  • 字符类型,char
  • 元组,当且仅当其包含的类型也都实现 Copy 的时候。比如,(i32, i32) 实现了 Copy,但 (i32, String) 就没有。

所有权与函数

将值传递给函数与给变量赋值的原理相似。向函数传递值可能会移动或者复制,就像赋值语句一样。

例如:

fn main() {
    let i = 9;
    fun(i);
    //程序走到这里时i依然有效
    let s = String::from("hello");
    fun2(s);
    //程序走到这里时s不再有效
}
//在参数传进来的时候实际上是发生了 形参 = 实参 的事情
fn fun(x: i32) {	
    ...
}
fn fun2(y: String) {
    ...
}

上述例子就相当于发生了以下的操作:

let i = 9;
let x = i;	//这里是发生了Copy,不会发生所有权的转移
let s = String::from("hello");
let y = s;	//这里因为String类型是在堆上申请内存,所以发生了所有权的转移

如果想要使s调用完函数(移交所有权后),还能再次使用则需要把所有权移交回来,例如以下例子:

fn main() {
    let mut s = String::from("hello");
    s = show_string(s);
}
fn show_string(x: String) -> String{
    println!("{}", x);
    x
}

引用与借用

因为移交所有权后再移交回来这种方式太笨了,所以Rust提供了一种引用的方式方便我们操作。

引用(reference)像一个指针,因为它是一个地址,我们可以由此访问储存于该地址的属于其他变量的数据。 与指针不同,引用确保指向某个特定类型的有效值。

简单来讲就是引用就是有一种不好的感觉,我让你帮我办一件事,你办事的工具从头到尾都是你的,我从来就没碰过,你只需要帮我把事情办了就好。

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()
}

注意我们传递 &s1calculate_length,同时在函数定义中,我们获取 &String 而不是 String。这些 & 符号就是 引用,它们允许你使用值但不获取其所有权。

把上述calculate_length函数所表达的翻译成“不好的”的方式表达:

calculate_length时期,s是一个办事不露面的人,他计划着要办len这件事,于是他让s1帮他完成他愿望,因为s1擅长处理len这件事。

在这里插入图片描述

变量 s 有效的作用域与函数参数的作用域一样,不过当 s 停止使用时并不丢弃引用指向的数据,因为 s 并没有所有权。当函数使用引用而不是实际值作为参数,无需返回值来交还所有权,因为就不曾拥有所有权。

我们将创建一个引用的行为称为 借用(borrowing)。正如现实生活中,如果一个人拥有某样东西,你可以从他那里借来。当你使用完毕,必须还回去。我们并不拥有它。

可变引用

因为在Rust中,变量默认都是不可变的,引用也是变量,所以当我们需要修改内存中的内容的话需要加上mut关键字才可以进行修改。

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

    change(&mut s);
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

可变引用有一个很大的限制:如果你有一个对该变量的可变引用,你就不能再创建对该变量的引用。这些尝试创建两个 s 的可变引用的代码会失败:

let mut s = String::from("hello");

let r1 = &mut s;
let r2 = &mut s;

println!("{}, {}", r1, r2);
$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0499]: cannot borrow `s` as mutable more than once at a time
 --> src/main.rs:5:14
  |
4 |     let r1 = &mut s;
  |              ------ first mutable borrow occurs here
5 |     let r2 = &mut s;
  |              ^^^^^^ second mutable borrow occurs here
6 | 
7 |     println!("{}, {}", r1, r2);
  |                        -- first borrow later used here

For more information about this error, try `rustc --explain E0499`.
error: could not compile `ownership` due to previous error

这个报错说这段代码是无效的,因为我们不能在同一时间多次将 s 作为可变变量借用。

这一限制以一种非常小心谨慎的方式允许可变性,防止同一时间对同一数据存在多个可变引用。新 Rustacean 们经常难以适应这一点,因为大部分语言中变量任何时候都是可变的。这个限制的好处是 Rust 可以在编译时就避免数据竞争。数据竞争(data race)类似于竞态条件,它可由这三个行为造成:

  • 两个或更多指针同时访问同一数据。
  • 至少有一个指针被用来写入数据。
  • 没有同步数据访问的机制。

数据竞争会导致未定义行为,难以在运行时追踪,并且难以诊断和修复;

Rust 避免了这种情况的发生,因为它甚至不会编译存在数据竞争的代码!

允许拥有多个可变引用,只是不能 同时 拥有:

 let mut s = String::from("hello");
    {
        let r1 = &mut s;
    } // r1 在这里离开了作用域,所以我们完全可以创建一个新的引用

    let r2 = &mut s;
fn main() {
    let mut s = String::from("hello");
    test(&mut s);

    let r2 = &mut s;
    println!("r2 = {}", r2);
}

fn test(x: &mut String) {
    x.push_str("world");
}
r2 = helloworld

Rust 在同时使用可变与不可变引用时也采用的类似的规则。

 let mut s = String::from("hello");

    let r1 = &s; // 没问题
    let r2 = &s; // 没问题
    let r3 = &mut s; // 大问题

    println!("{}, {}, and {}", r1, r2, r3);
$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:14
  |
4 |     let r1 = &s; // no problem
  |              -- immutable borrow occurs here
5 |     let r2 = &s; // no problem
6 |     let r3 = &mut s; // BIG PROBLEM
  |              ^^^^^^ mutable borrow occurs here
7 | 
8 |     println!("{}, {}, and {}", r1, r2, r3);
  |                                -- immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` due to previous error

不能在拥有不可变引用的同时拥有可变引用。多个不可变引用是可以的,因为没有哪个只能读取数据的人有能力影响其他人读取到的数据。

错误版本:

fn main() {
    let mut s = String::from("hello");
    let r1 = &s;
    let r2 = &s;
    let r3 = &mut s; //有问题,因为下面还在使用r1,r2
    println!("{},{}", r1, r2); 
    println!("{}", r3);
}

正确版本:

fn main() {
    let mut s = String::from("hello");
    let r1 = &s;
    let r2 = &s;
    println!("{},{}", r1, r2);
    // 此位置之后 r1 和 r2 不再使用
    let r3 = &mut s;	//没问题,因为后面不再使用r1,r2
    println!("{}", r3);
}

悬垂引用

在具有指针的语言中,很容易通过释放内存时保留指向它的指针而错误地生成一个 悬垂指针(dangling pointer),所谓悬垂指针是其指向的内存可能已经被分配给其它持有者。相比之下,在 Rust 中编译器确保引用永远也不会变成悬垂状态:当你拥有一些数据的引用,编译器确保数据不会在其引用之前离开作用域。

悬垂引用就是野指针的意思,有C/C++基础的就不用多说了。

fn main() {
    let reference_to_nothing = dangle();	//访问到了已经被释放内存的地址,野指针
}

fn dangle() -> &String {
    let s = String::from("hello");

    &s	//返回s所指向的地址
}	//s离开其作用域,则s所指向的内存被释放
$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0106]: missing lifetime specifier
 --> src/main.rs:5:16
  |
5 | fn dangle() -> &String {
  |                ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime
  |
5 | fn dangle() -> &'static String {
  |                ~~~~~~~~

For more information about this error, try `rustc --explain E0106`.
error: could not compile `ownership` due to previous error

像以下的代码是没有错的:

fn no_dangle() -> String {
    let s = String::from("hello");

    s
}

因为返回的不是引用,所以就相当于把s的所有权移交给一个String类型的无名对象,然后在函数调用那块又把这个无名对象的所有权移交给接收者。

引用的规则

  • 在任意给定时间,要么 只能有一个可变引用,要么 只能有多个不可变引用。
  • 引用必须总是有效的。

Slice类型(切片)

slice 允许你引用集合中一段连续的元素序列,而不用引用整个集合。slice 是一类引用,所以它没有所有权。

字符串切片

因为slice是一类部分引用,所以字符串切片就是原来字符串的一部分。

    let s = String::from("hello world");
	//0..5 左闭右开 [0,5)	左闭右闭 0..=5 [0,5]
    let hello = &s[0..5];	//hello
    let world = &s[6..11];	//world
  • 在Range(范围)中,如果左是0,则可以简写0
  • 在Range(范围)中,如果右是字符串长度(字符串尾部),则可以简写
  • 如果以上两个都满足,则左和右都可以简写
let s = String::from("hello");
let slice = &s[0..2];	//he
let slice = &s[..2];	//he
let len = s.len();

let slice = &s[3..len];	//lo
let slice = &s[3..];	//lo
注意:字符串 slice range 的索引必须位于有效的 UTF-8 字符边界内,如果尝试从一个多字节字符的中间位置创建字符串 slice,则程序将会因错误而退出。出于介绍字符串 slice 的目的,本部分假设只使用 ASCII 字符集;第八章的 “使用字符串存储 UTF-8 编码的文本” 部分会更加全面的讨论 UTF-8 处理问题

字符串字面值被储存在二进制文件中。

let s = "Hello, world!";

这里 s 的类型是 &str:它是一个指向二进制程序特定位置的 slice。这也就是为什么字符串字面值是不可变的;&str 是一个不可变引用。

其他类型的 slice

以整型数组为例,当然其他的类型也都是类似的。

let a = [1, 2, 3, 4, 5];

let slice = &a[1..3];	//[2, 3]

assert_eq!(slice, &[2, 3]);

总结

所有权、借用和 slice 这些概念让 Rust 程序在编译时确保内存安全。

Rust 语言提供了跟其他系统编程语言相同的方式来控制你使用的内存,但拥有数据所有者在离开作用域后自动清除其数据的功能意味着你无须额外编写和调试相关的控制代码。