Rust 什么是所有权

发布时间 2023-06-12 15:40:24作者: CloverYou

创建于 2023-5-2 15:34

本笔记主要来自于 Rust 程序设计语言 中文版 [4.1],旨在记录个人学习过程中的重点和心得体会。在记录过程中,笔者会对文档中的某些内容进行摘抄或修改,并添加自己的注释或说明。如有不当之处,请指正。

“所欧运行的程序都必须管理其使用计算机内存的方式。一些语言中具有垃圾回收机制,在程序运行时不断地寻找不再使用的内存。在另一些语言中,开发者必须亲自分配和释放内存。Rust 选择第三种方式:通过所有权系统管理内存,在编译器编译时会根据一系列的规则进行检查。运行时,所有权系统的任何功能都不会减慢程序。

因为所有权对很多开发者来说都是一个新的概念。需要一些时间来适应。好消息是随着你对 Rust 和所有权系统熟练之后(原文 “的规则越来越有经验” 改为为 “熟练之后”。),你就越自然而然地编写出安全和高效的代码,持之以恒!

当你理解了所有权,你将有一个坚实的基础来理解那些使 Ruat 独特的功能。”
以上段落摘自Rust 程序设计语言 中文版[4.1]

在很多语言中,你并不需要经常考虑到栈与堆。不过在像 Rust 这样的系统编程语言中,值是位于栈上还是在堆上会比较大程度的影响语言的行为以及为何必须做出这样的抉择。

栈和堆都是代码在运行时可供实用的内存,但是它们的结构不同。栈以放入值的顺序存储值并以相反的顺序取出值。这也被称为先进后出(last in,first out)。想象一下有一叠盘子:当增加更多盘子的时候,把它们凡在盘子堆的顶部,当需要盘子时,也从顶部拿走。不能从中间也不能从地步增加或者拿走盘子!增加数据这一行为叫做进栈(pushing onto the stack),而移出数据叫做出栈(popping off the stack)。

栈中的所有数据都必须占用已知且固定的大小。在编译时大小未知或大小可能变化的数据,要改为存储在堆上。堆是缺乏组织的:当向堆放入数据时,你要请求一定大小的空间。内存分配器(memory allocator)在堆的某处找到一块足够大的空位,把它标记为已使用,并返回一个表示该位置地址的指针(pointer)。这个过程称做在堆上分配内存(allcating on the heap),有时简称为“分配”(allocating)。将数据推入栈中并不被认为是分配。因为指针的大小是已知且固定的,你可以将指针存储在栈上,不过需要实际数据时,必须访问指针。

可以想象一下去餐馆就座吃饭:当进入时你说明有几个人,餐馆员工会找到一个足够大的空桌子并领你们过去,如果有人来迟了,它们也可以通过询问来找到你们坐在哪。

入栈比在堆上分配内存要快,因为入栈时分配器无需为存储新数据而去搜索内存空间;其位置总是在栈顶。相比之下,在堆上分配内存则需要更多的工作,这是因为分配器必须首先找到一块足够存放数据的内存空间,并接着做一些记录为下一次分配做准备。

访问堆上的数据比访问栈上的数据要慢,因为必须通过指针来访问。现代处理器在内存中跳转越少就越快(缓存)。假设有一个服务员在餐厅里处理多个桌子的点菜。在一个桌子报完所有的菜后再移动到下一个桌子是最有效率的。从桌子 A 听一个菜,接着去桌子 B 听一个菜,然后再去桌子 A 再去桌子 B ...这样的流程会更加缓慢。处于同样的原因,处理器在处理的数据彼此较近的时候(比如在栈中)比较远的时候(比如可能在堆上)能更好的工作。在堆上分配大量的空间也可能消耗时间。

当你的代码调用一个函数时,传递给函数的值(包括可能指向堆上数据的指针)和函数的局部变量被压入栈中。当函数结束时,这些值被移出栈。

跟踪哪部分代码正在使用堆上的哪些数据,最大限度减少堆上的重复数据,以及清理堆上不再使用的数据确保不会耗尽空间,这些问题正是所有权系统要处理的。一旦理解了所有权,你就不需要经常考虑栈和堆了,不过明白了所有权的存在就是为了管理堆数据,能够帮助解释为什么所有权要以这种方式工作。

以上段落摘自Rust 程序设计语言 中文版[4.1]

所有权规则

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

变量作用域

作用域是一个项(item)在程序中有效的范围

let s = "hello";

变量 s 绑定到了一个字符串字面量,这个字符串值是硬编码进程序代码中的。该变量从声明的那一刻开始直到当前作用域结束时都是有效的。

{
    // s 在这里无效,因为在这它尚未声明
    let s = "hello"; // 从此处其,s 开始有效
    // 使用 s
} // 此作用域已结束,s 不再有效

有两个比较重要的时间点:

  • s 进入作用域时,他就是有效的。
  • 这一直持续到它离开作用域位置
    目前为止,Rust 中变量是否有效与作用域的关系跟其他语言时类似的

String 类型

所有的基本类型都是已知大小的,可以直接存储在栈中,并且当离开作用域时被移出栈,如果代码的另一部分需要在不同的作用域中使用相同的值,可以快速并简单的复制它们来创建一个新的独立实例。不过我们需要寻找一个存储在堆上的数据来探索 Rust 时如何知道该在何时清理数据的。

我们使用 String 作为例子并且专注于 String 与所有权相关的部分。这些方面也同样适用于标准库提供的或者你自己创建的其它复杂数据类型。

我们已经见过字符串字面量,即被硬编码进程序里的字符串值。字符串字面量是很方便的,不过它们并不适合使用文本的每一种场景。原因是它们是不可变的。另外一个原因是并非所有的字符串的值都能在编写代码时就知道:例如,要是想获取用户输入并存储该怎么办呢?为此,Rust 有第二个字符串类型,String 。这个类型管理被分配到堆上的数据,所以能够存储在编译时未知大小额文本。可以使用 from 函数基于字符串字面量来创建 String

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

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

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

可以修改此类字符串:

# clover @ MacBook-Pro in ~/dev/rust/learn/ownership on git:master x [17:15:43] C:101
$ cargo run
   Compiling ownership v0.1.0 (/Users/clover/dev/rust/learn/ownership)
    Finished dev [unoptimized + debuginfo] target(s) in 0.37s
     Running `target/debug/ownership`
hello, world!

字面量字符串不可变String 可变的原因是这两个类型在堆内存的处理上不同导致的。

内存与分配

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

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

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

第一部分由我们完成:当调用 String::from 时,它的实现 (implementation) 请求所需的内存。这在编程语言中时非常通用的。
然而,第二部分实现起来就个有区别了。在有垃圾回收(garbage collector,GC) 的语言中,GC 记录并清楚不再使用的内存,而我们并不需要关心它。没有 GC 的话,识别出不再使用的内存并调用代码显示释放就是我们的责任了,跟请求内存的时候一样。从历史的角度上说正确处理内存回收曾经是一个困难的编程问题。如果我们忘记回收了,那么会造成内存浪费(内存泄露)。如果过早回收,那么将出现无效变量。如果重复回收,这也是个BUG。我们需要精确地为一个 allocate 配对一个 free

Rust 采用了一个不同的策略:内存再拥有它的变量离开作用域后就被自动释放。

{
    let s = String::from("hello");
}
// 在此处 s 变量不再有效

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

变量与数据交互的方式(一):移动

在 Rust 中,多个变量能够以不同的方式与同一数据交互

let x = 5;
let y = x;

这段代码将 5 绑定到 x ,接着生成一个值 x 的拷贝并绑定到 y 。现在有了两个变量,xy ,这都等于 5 。这也正是事实上发生了的,因为整数是已知固定大小的简单值,所以这两个 5 被加入了栈中。

这是一个 String 版本:

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

这行代码好像看起来和上面写的代码类似,所以我们可能会假设他们的运行方式也是类似的:也就是说,第二行可能会生成一个 s1 的拷贝并绑定到 s2 上,不过,这并不是完全一样。

在 Rust 中,String 由三个部分组成,第一个是指向存放字符串内容内存的指针,一个长度和一个容量。这一组数据存储在栈上。右侧是堆上存放内容的内存部分。

将值  绑定给  的  在内存中的表现形式

长度表示 String 的内容使用了多少字节的内存。容量是 String 从分配器总共获取了多少字节的内存。长度与容量的区别是很重要的,不过在当前章节并不重要,所以现在可以忽略容量。

当我们将 s1 赋值给 s2 , String 的数据被复制了,这意味着我们从栈上拷贝了他的指针,长度和容量。我们并没有复制指针指向的堆上数据,看下图:

变量  的内存表现,它有一份  指针、长度和容量的拷贝

再看下图,如果 Rust 也拷贝了堆上的数据,那么内存看起来就是这样的。如果 Rust 这么去做,那么在操作 s2 = s1 的时候,如果堆上的数据比较大,会对运行时的性能造成非常大的影响。

另一个  时可能的内存表现,如果 Rust 同时也拷贝了堆上的数据的话

之前提到当变量离开作用域后,Rust 自动调用 drop 函数清理变量的堆内存。如果有两个变量指向了同一个内存空间,这就有一个问题,例如:当 s1s2 离开作用域后他们都会尝试释放相同的内存。这是一个叫做二次释放(double free)的错误,也是之前提到的内存安全性的 BUG 之一。两次释放相同的内存会导致内存污染,可能会存在潜在的安全漏洞。

为了确保内存安全,这种场景下 Rust 有另外一个细节需要注意。在 let s2 = s1; 后,Rust 会认为 s1 不再有效,因此 Rust 不需要在 s1 离开后清理任何东西。当 s2 被创建后,如果尝试去使用 s1 ,会发生一个错误:

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

println!("{}, world!", s1);

Rust 禁止使用无效的引用

$ cargo run main.rs
   Compiling ownership v0.1.0 (/Users/clover/dev/rust/learn/ownership)
warning: unused variable: `s2`
 --> src/main.rs:3:9
  |
3 |     let s2 = s1;
  |         ^^ help: if this is intentional, prefix it with an underscore: `_s2`
  |
  = note: `#[warn(unused_variables)]` on by default

error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:28
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 |
5 |     println!("{}, world!", s1);
  |                            ^^ value borrowed here after move
  |
  = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let s2 = s1.clone();
  |                ++++++++

For more information about this error, try `rustc --explain E0382`.
warning: `ownership` (bin "ownership") generated 1 warning
error: could not compile `ownership` due to previous error; 1 warning emitted

如果你在其他语言中听说过术语 浅拷贝shallow copy)和 深拷贝deep copy),那么拷贝指针、长度和容量而不拷贝数据可能听起来像浅拷贝。不过因为 Rust 使第一个变量无效了,这个操作被称为移动(move),而不是浅拷贝。可以理解为 s1 移动到了 s2

无效之后的内存表现

移动过后,只有 s2 是有效的,所以当其离开作用域的时候,它就释放自己的内存。

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

变量与数据交互的方式(二):克隆

如果我们确实需要深度复制 String 堆上的数据,而不仅仅是栈上的数据,可以使用一个叫做 clone通用函数。

fn main() {
    let str = String::from("hello world");
    let str = str.clone();
    println!("str {}", str);
}

当出现 clone 调用时,我们应该清楚的知道一些特定的代码被执行并且这些代码可能非常消耗资源,可以很容易察觉到可能有一些不寻常的事情正在发生。

只在栈上的数据:拷贝

  let x = 5;
  let y = x;
  println!("str {} {}", x, y)

这段代码似乎和我们上面所学的内容有些矛盾,它没有调用 clone , 不过 x 依然有效且没有被移动到 y

原因是像整形这样的正在编译时已知大小的类型被整个存储在栈上,所以拷贝实际的值是最快速的。这意味着没有理由在创建变量 y 后使 x 无效。

Rust 有一个叫做 Copy trait 的特殊标注,可以用在类似整型这样存储在栈上的类型上。如果一个类型实现了 Copy trait,那么一个旧的变量在将其赋值给其它变量后仍然可用。Rust 不允许自身或者其任何部分实现了 Drop trait 的类型使用 Copy trait。如果我们对其值离开作用域时需要特殊处理的类型使用 Copy 标注,将会出现一个编译时错误。

那么哪些类型实现了 Copy trait 呢?我们可以查看给定类型的文档来确认,不过作为一个通用的规则,任何一组简单标量值的组合都可以实现 Copy,任何不需要分配内存或某种形式资源的类型都可以实现 Copy 。下面是一些 Copy 的类型:

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

所有权与函数

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

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

    takes_ownership(s); // 将 s 移动到函数里

    println!("s {}", s); // s 被移走了,所以在这里 s 不再有效,这里会抛出一个编译错误

    let x = 5;

    makes_copy(x); // 在这里 x 应该被移走,但是它的类型是 i32 所以是 Copy,在后面可以继续使用

    println!("x {}", x)
} // 在这里 x 会被先移出作用域,然后移出 s ,但是因为 s 的值被移走了所以不会有任何操作

fn takes_ownership(some_string: String) {
    println!("some_string {}", some_string)
} // some_string 在这里被移出作用域,且调用 drop 方法将占用的内存释放

fn makes_copy(some_integer: i32) {
    println!("some_integer {}", some_integer)
} // some_integer 在这里被移出作用域

当尝试在调用 takes_ownership 后使用 s 时,Rust 会抛出一个编译时错误。这饿静态检查使我们免于犯错。

返回值与作用域

返回值也可以转移所有权

fn main() {
    let str = gives_ownership(); // gives_ownership 执行结束后将返回值移给 str

    let str1 = String::from("hello world");

    let str2 = takes_and_gives_back(str1); // str1 被移动到 takes_and_gives_back中,takes_and_gives_back 执行完成后将返回值移给 str2 
}

fn gives_ownership() -> String {
    let str = String::from("hello world");
    str
}

fn takes_and_gives_back(a_string: String) -> String {
    a_string
}

变量的所有权总是遵循相同的模式:将复制给另外一个变量时移动它。当持有堆中数据值的变量离开作用域时,其值将通过 drop 被清理掉,除非数据被移动到另外一个变量所有。

在每一个函数中都获取所有权并接着返回所有权时显得有些啰嗦,如果我们想要函数使用一个值但不获取所有权并且还需要接着使用它,那么每次都传进去再返回就显得又些繁琐了,除此之外,也可以返回函数中的某些数据。

我们可以使用元组来返回多个值

fn main() {
  let s1 = String::from("hello world");
  let (s2, len) = calculate_length(s1);
}

fn calculate_length(s: String) -> (String, usize) {
  let length = s.len();
  (s, length)
}

这种场景其实都很常见,但是 Rust 提供了一个功能,叫做引用(references)