Rust 语言从入门到实战
基础篇 (11讲)
02|所有权(上):Rust 如何管理程序中的资源?
Rust 语言设计的出发点——所有权,Rust 的精髓。
Rust 的值有两大类:
- 固定内存长度(固定尺寸)的值,如 i32、u32、由固定尺寸的类型组成的结构体等;
- 不固定内存长度(非固定尺寸)的值,如字符串 String。
两种值的本质特征完全不一样。怎么处理这两种值的差异,往往是语言设计的差异性所在。
拿数字类型举例:
- C、C++、Java 明确定义了数字类型会占用几个字节,如 8 位(1字节),16 位(1字节)。
- JavaScript 语言,完全屏蔽了底层的细节,统一用一个 Number 表示数字。
- Python 给出了 int 整数、float 浮点、complex 复数三种数字类型。
- Rust --定位为通用的编程语言(对标 C++),应用范围广(从最底层的嵌入式开发、OS 开发,到最上层的 Web 应用开发,都要兼顾),暴露出具体的字节数,有 i8、i16、i32、i64 等类型。
类型具有固定尺寸,就能在编译期做更多的分析。用固定尺寸类型来管理非固定尺寸类型。Rust 中的非固定尺寸类型就是靠指针或引用来指向,而指针或引用本身就是一种固定尺寸的类型。
栈与堆
把内存划分为很多区。如,二进制代码区、静态数据区、栈、堆等。
栈上的操作比堆高效:栈上内存的分配和回收只需移动栈顶指针 --> 分配和回收时都必须精确计算指针的增减量 --> 栈上放固定尺寸的值。
栈的容量有限 --> 不适合放尺寸太大的值,如 1000 万个元素的数组。
专门在内存中拿出一大块区域来存放非固定尺寸的值,这个区域叫“堆”。
栈空间与堆空间
一般的程序语言设计中,栈空间都会与函数关联起来。
每一个函数的调用,都对应一个 frame 栈帧,就像图片栈空间里的方块 main、fn1、fn2 等。一个函数被调用,就会分配一个新的帧,函数调用结束后,这个帧就会被自动释放掉。栈帧是一个运行时的事物。函数中的参数、局部变量之类的资源,都放在这个帧里,如图里 fn2 中的局部变量 a,这个帧释放时,这些局部变量就会被一起回收掉。
函数的调用会形成层级关系,因此栈空间中的帧可能会同时存在很多个,也对应地形成层级关系。如上图,可能的函数调用关系为,main 函数中调用了函数 fn1,fn1 中调用了函数 fn2,fn2 中调用了函数 fn3,fn3 中调用了函数 fn4,fn4 调用了更深层次的其他函数。这样的话,在程序执行的某个时刻,main 函数、fn1、fn2、fn3、fn4 等对应的帧副本就同时存在于栈中了。
图中右边堆空间里面的一些小圈表示堆空间中资源,是被分配的内存。从图中可以看到,栈空间中函数帧的局部变量可以引用这些堆上资源。一个栈帧中的多个局部变量可以指向堆中的多个资源,如 fn3 中的 b 指向资源 A,c 指向资源 B;同时存在的多个栈帧中的局部变量还可以指向堆上的同一个资源,如图中的 a 和 b,c 和 d;堆上的资源也可以存在引用关系,如图中的 D 和 E。
如果一个资源没有被任何一个栈帧中的变量引用或间接引用,如图中的 C,是一个被泄漏的资源,发生了内存泄漏。被泄漏的资源会一直伴随程序的运行,直到程序自身的进程被停止时,才会一起被 OS 回收掉。
计算机程序内存管理的复杂性,主要就在于堆内存的管理比较复杂——既要高效,又要安全。
有了栈和堆的知识作为铺垫,你会更容易理解 Rust 中的一些特性为什么要那样设计。
变量与可变性
Rust 定义变量,用 let variable = value; ,如 let x = 10u32; 。值 10u32 被绑定到变量x上。
默认变量是不可变的。
fn main() {
let x = 5;
println!("The value of x is: {x}");
x = 6;
println!("The value of x is: {x}");
}
//输出
$ cargo run
Compiling variables v0.1.0 (file:///projects/variables)
error[E0384]: cannot assign twice to immutable variable `x`
--> src/main.rs:4:5
|
2 | let x = 5;
| -
| |
| first assignment to `x`
| help: consider making this binding mutable: `mut x`
3 | println!("The value of x is: {x}");
4 | x = 6;
| ^^^^^ cannot assign twice to immutable variable
为了减少很低级的 Bug。
下面这样做是可以的。
fn main() {
let x = 5;
println!("The value of x is: {x}");
let x = 6; // 注意这里,重新使用了 let 来定义新变量
println!("The value of x is: {x}");
}
这种方式在 Rust 中叫变量的 Shadowing。
定义了一个新的变量,只不过这个变量名和老的相同。原来那个变量就被遮盖起来了,访问不到了。
变量的 Shadow 支持新的变量的类型和原来的不一样。
fn main() {
let a = 10u32;
let a = 'a';
println!("{}", a);
}
在变量名前加一个 mut ,声明一个变量为可修改的。
fn main() {
let mut x = 5;
println!("The value of x is: {x}");
x = 6;
println!("The value of x is: {x}");
}
// 输出
The value of x is: 5
The value of x is: 6
值的改变只能在同一种类型中变化。
对比:可修改变量和变量的 Shadow 的不同之处。
一个变量,其内容是否可变,被称作这个变量的可变性(mutability)。mut 叫可变性修饰符(modifier)。
Rust 中变量的可变性是一种潜力,只要有可能会变化,就可以称之为变量。Rust 给这种潜力加了一道开关,想让变量的可变性暴露出来时,就在变量名前面明确地加个 mut 修饰符。
变量名加 mut,多打 4 个字符,在代码中留下了足迹。读到这个变量的定义,知道后面一定会修改这个变量。如果后面没修改它,Rust 编译器会提示把这个 mut 去掉。
减少滥用概率。编程语言界的墨菲定律:如果一个特性不太利于程序的健壮性,但很好用,滥用的成本非常低,那它一定会被滥用。
TypeScript 中的 any 类型,先跑通了再说,结果就是代码里 any 满天飞。偷懒是人的天性,Rust 让你想要修改一个变量的时候,要多打 4 个字符。
JS 中的 var 和 let,都是三个字符,成本一样,结果就是在语言层面并不能驱动程序员往好的实践方面靠。不如 rust 从语言层面强制约束。
变量的类型
值是有类型的,如 10u32,是 u32 类型的数字。一旦一个变量绑定了一个值(一个值被绑定到了一个变量上),那这个变量就被指定为这种值的类型。如 let x = 10u32; 编译器会自动推导出变量 x 的类型为 u32。完整的写法是 let x: u32 = 10u32;。
另一种方式:先指定变量的类型,再把一个值绑定上去,如 let x: u32 = 10;。这种方式更好,说明写这句代码的时候就已经对它做了一个架构上的规划和设计,这种形式能在编译阶段阻止一些错误。
如:
fn main() {
let a: u8 = 323232;
println!("{a}");
}
编译器报错,指出 u8 类型装不下这么大一个数。
error: literal out of range for `u8`
--> src/main.rs:5:17
|
5 | let a: u8 = 323232;
| ^^^^^^
|
= note: the literal `323232` does not fit into the type `u8` whose range is `0..=255`
所有的变量都应该具有明确的类型是 Rust 程序的基本设计。不同语言对类型重视的程度不一样。
Rust 中“奇怪”的行为
fn main() {
let a = 10u32;
let b = a;
println!("{a}");
println!("{b}");
}
打印出:
10
10
再看字符串的行为,猜一下会输出什么。
fn main() {
let s1 = String::from("I am a superman.");
let s2 = s1;
println!("{s1}");
println!("{s2}");
}
两行“I am a superman” ?在其他语言中是这样的。
结果,编译出错。
Compiling playground v0.0.1 (/playground)
error[E0382]: borrow of moved value: `s1`
// 借用了移动后的值 `s1`
--> src/main.rs:4:15
|
2 | let s1 = String::from("I am a superman.");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
// 移动发生了,因为 `s1` 的类型是 `String`,而这种类型并没有实现 `Copy` trait."。
3 | let s2 = s1;
| -- value moved here
// 在这里值移动了。
4 | println!("{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();
| ++++++++
直接照着代码建议改一下试试。
fn main() {
let s1 = String::from("I am a superman.");
let s2 = s1.clone();
println!("{s1}");
println!("{s2}");
}
输出预期的结果了。
I am a superman.
I am a superman.
Rust 中的字符串为何有如此奇怪的行为呢?
所有权
Rust 中,字符串的行为与 u32 这种数字类型不一样。u32 是固定尺寸类型,String 是非固定尺寸类型。
固定尺寸类型,会默认放在栈上;非固定尺寸类型,会默认创建在堆上,成为堆上的一个资源,然后在栈上用一个局部变量来指向它,如代码中的 s1。
将一个变量赋值给另一个变量的时候,不同语言对底层细节的处理不一样。
局部变量都是定义在栈帧中的,Java 也是。Java 语言对于 int 这类固定尺寸类型,在复制给另一个变量的时候,会直接复制它的值。在面对 Object 这种复杂对象的时候,默认只会复制这个 Object 的引用给另一个变量。这个引用的值(内存地址)就存在栈上的局部变量里面。
为什么会这样设计呢?如果 Object 占用的内存很大,每一次重新赋值,就把那个对象重新拷贝一次(完全克隆),非常低效。用 Java 编程时,实际上是隐藏了对象引用的复制这个细节。
对 u32 这种固定尺寸类型,Rust 与 Java 一样,直接在栈上进行内容的拷贝。对于字符串这种动态长度的类型来说,在变量的再赋值上,Rust 除了拷贝字符串的引用外,实际还做了更多事情。看修改后的例子。
fn main() {
let s1 = String::from("I am a superman.");
let s2 = s1;
//println!("{s1}");
println!("{s2}");
}
//输出
//I am a superman.
Rust 里,s1 把内容“复制”给 s2 后,s2 可用,s1 不能用了!代码层面,s1 把值(资源)“移动”给了 s2,原来的变量就没有那个值了。
- Java 默认做了引用的拷贝,且新旧两个变量同时指向原来那个对象。
- Rust 也把字符串的引用由 s1 拷贝到了 s2,但只保留了最新的 s2 到字符串的指向,同时把 s1 到字符串的指向给“抹去”了。s1 之后都处于一种“不可用”的状态,直到函数结束。这就是 Rust 编译器做的那个“更多”的部分。
这两种行为上的差异。
这正是 Rust 从头开始梳理整个软件体系的地方,剑指一个目标:内存安全。
所有权
如何写出更安全的程序, Rust 采用全新的思路,用所有权来管理内存资源,保证内存安全。先卸下之前固有的思维,将脑袋放空一下。
Rust 明确了所有权的概念,值也可以叫资源,所有权就是拥有资源的权利。一个变量拥有一个资源的所有权,那它就要负责那个资源的回收、释放。Rust 基于所有权定义出发,推导出了整个世界。
所有权的基础是三条定义。
- Rust 中,每一个值都有一个所有者。
- 任何一个时刻,一个值只有一个所有者。
- 当所有者所在作用域(scope)结束的时候,其管理的值会被一起释放掉。
涉及两个概念:所有者和作用域。
所有者,在代码里就用变量表示。变量的作用域,是变量有效(valid)的那个代码区间。Rust 中,一个所有权型变量的作用域,是它定义时所在的那个最里层的花括号括起的部分,从变量创建时开始,到花括号结束的地方。
如:
fn main() {
let s = String::from("hello");
// do stuff with s
} // 变量s的作用域到这里结束
fn main() {
let a = 1u32;
{
let s = String::from("hello");
} // 变量s的作用域到这里结束
// xxxx
} // 变量a的作用域到这里结束
变量在其作用域内是有效的,离开作用域就无效了。
尝试用所有权规则去翻新一下对前面例子的理解
fn main() {
let a = 10u32;
let b = a;
println!("{a}");
println!("{b}");
}
在这个例子中,a 具有对值 10u32 的所有权。执行 let b = a 的时候,把值 10u32 复制了一份,b 具有对这个新的 10u32 值的所有权。当 main 函数结束的时候,a、b 两个变量就离开了作用域,其对应的两个 10u32,就都被回收了。这里是栈帧结束,栈帧内存被回收,局部变量位于栈帧中,所以它们所占用的内存就被回收了。
字符串的例子。
fn main() {
let s1 = String::from("I am a superman.");
println!("{s1}");
}
局部变量 s1 拥有这个字符串的所有权。s1 的作用域从定义到开始,直到花括号结束。s1(栈帧上的局部变量)离开作用域时,变量 s1 上绑定的内存资源(字符串)就被回收掉了。注意,这里发生的事情是,栈帧中的局部变量离开作用域了,顺带要求堆内存中的字符串资源被回收。堆中的字符串资源被栈帧中的局部变量所指向。
从 Rust 的语法层面,是变量 s1 对那个字符串拥有所有权。 s1 离开作用域的时候,那个资源就一起被回收了。是一个自动的过程,不必像 C 那样,需要手动调用 free() 函数去释放堆中的字符串资源。
这种堆内存资源随着关联的栈上局部变量一起被回收的内存管理特性,叫 RAII(Resource Acquisition Is Initialization)。不是 Rust 的原创,是 C++ 创造的。C 中 malloc() 分配堆内存,必须由程序员手动在后续的代码中使用 free() 来释放堆内存中的资源。不需手动写 free(), RAII 内存管理方式是一个相当大的进步。
用所有权的知识,再回头来分析上面那个例子。
fn main() {
let s1 = String::from("I am a superman.");
let s2 = s1;
//println!("{s1}");
println!("{s2}");
}
变量 s1 持有这个字符串的所有权。s1 对字符串的所有权从第 2 行定义时开始,到 let s2 = s1 执行后结束。这一行执行后,s2 持有那个字符串的所有权。此时 s1 处于不可用的状态,或无效状态(invalid),这个状态是由 Rust 编译器在编译阶段帮我们管理的。
然后直到花括号结束,s2 及 s2 所拥有的字符串内存,就被回收掉了,s1 所对应的那个局部变量的内存空间也一并被回收了。
所有权是 Rust 语言的出发点,任何 Rust 程序,都必须遵循这套规则。
需要注意的一点是,所有权其实是内存结构之上的更上层概念,并不是说只有在堆中分配的资源才有所有权。实际上,栈上的资源也是有所有权的。所有权这个概念实际上屏蔽了底层内存结构的细节,让我们可以站在一个新的层次上更有效地对问题进行建模。
这个思维一定要注意,Rust 语言中并不是所有的分析都需要归结到内存结构上去才能搞清楚,思维一直停留在内存结构上,会妨碍你的抽象建模能力。
使用所有权书写函数
基于所有权规则,函数的写法会变成什么样。
fn foo(s: String) {
println!("{s}");
}
fn main() {
let s1 = String::from("I am a superman.");
foo(s1);
}
//输出:I am a superman.
稍微改动一下,在函数调用结束后,在外面再打印一下 s1 的值。
fn foo(s: String) {
println!("{s}");
}
fn main() {
let s1 = String::from("I am a superman.");
foo(s1);
println!("{s1}"); // 这里加了一行
}
//咦,编译出错了。提示:
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:8:16
|
6 | let s1 = String::from("I am a superman.");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
7 | foo(s1);
| -- value moved here
8 | println!("{s1}");
| ^^ value borrowed here after move
|
note: consider changing this parameter type in function `foo` to borrow instead if owning the value isn't necessary
--> src/main.rs:1:11
|
1 | fn foo(s: String) {
| --- ^^^^^^ this parameter takes ownership of the value
| |
| in this function
= 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
|
7 | foo(s1.clone());
| ++++++++
这个例子在其他语言中,一般是不会有问题的。foo 函数也许会修改字符串的值,在外面重新打印的时候,会打印出新的值。但是,这其实是一种相当隐晦的设计模式,可能会造成一些错误(在下一讲我们会讲到),而 Rust 阻止了这种模式。
说 s1 所有权已经被移动进函数里面了,不能在移动后再使用了。
注意提示中的这一行:
1 | fn foo(s: String) {
| --- ^^^^^^ this parameter takes ownership of the value
函数的参数 s 获取了这个值的所有权。函数参数是这个函数的一个局部变量,它在这个函数栈帧结束的时候会被回收,因此这个字符串在这个函数调用结束后,就已经被回收了,无法再打印这个字符串。
上面例子的变形。
fn foo(s: String) {
println!("{s}");
}
fn main() {
let s1 = String::from("I am a superman.");
foo(s1);
foo(s1);
}
简单地想调用两次 foo() 函数都做不到,原因跟前面是一样的。
一个苹果,你给了别人,那你就没有了。一个知识,我教给了你,我们都会得到。Rust 的编程模型默认选择了前者,而以往的主流编程语言默认选择了后者。
回到前面例子,那后面的代码还想用 s1,怎么办?
既然能把所有权移动到函数里面,当然能把所有权转移出来。
fn foo(s: String) -> String {
println!("{s}");
s
}
fn main() {
let s1 = String::from("I am a superman.");
let s1 = foo(s1);
println!("{s1}");
}
//这样就输出了结果:
//I am a superman.
//I am a superman.
适配了 Rust 的所有权规则,实现了期望的函数调用效果。
移动还是复制
u32 这种类型在做变量的再赋值的时候,是做了复制所有权的操作。 String 这种类型在做变量再赋值的时候,是做了移动所有权的操作。
Rust 中,默认做复制所有权的操作的有 7 种。
- 所有的整数类型,如 u32;
- 布尔类型 bool;
- 浮点数类型,如 f32、f64;
- 字符类型 char;
- 由以上类型组成的元组类型 tuple,如(i32, i32, char);
- 由以上类型组成的数组类型 array,如 [9; 100];
- 不可变引用类型 &。
其他类型默认都是做移动所有权的操作。
小结
所有权是 Rust 语言中非常重要的一个概念,用于管理程序中使用的资源。这些资源可以是堆上的动态分配的内存资源,也可以是栈上的内存资源,或者是其他的系统资源,比如 IO 资源。所有权通过把语句绑定在变量上,封装了栈和堆的实现细节。对于固定尺寸基础类型(小尺寸类型),它们的值默认是可复制的,这主要是为了编程方便。对于非固定尺寸类型或大尺寸类型的变量再赋值时,默认使用移动操作。除非显式地 clone,否则它只保持一份所有权。
所有权可以被转移,一旦所有权被转移,原来持有该资源的变量就失效了。变量的作用域是在最近的花括号位置内。
思考题
1、将输出什么?
fn main() {
let s = "I am a superman.".to_string();
for i in 1..10 {
let tmp_s = s;
println!("s is {}", tmp_s);
}
}
2、一个由固定尺寸类型组成的结构体变量,如下面的 Point 类型,在赋值给另一个变量时,采用的是移动方式还是复制方式?
struct Point {
x: i64,
y: i64,
z: i64
}
回答:
思考题 1: 无法通过编译,可以将第 5 行代码修改为:let tmp_s = s.clone();
思考题 2: 由于 Point 没有实现 Copy trait,所以在赋值过程中会产生 Move。
思考题 1:编译报错;两处错误:变量 i 没有使用和 s 的所有权; 修改后如下:
fn main() {
let s = "I am a superman.".to_string();
for _ in 1..10 {
let tmp_s = s.clone(); //let tmp_s = &s;也行
println!("s is {}", tmp_s);
}
}
思考题 2:移动;如果结构体中包含实现了 Copy trait,则会进行复制而不是移动
1. String类型,实际数据在堆上存储。 let tmp_s = s 循环第一次的时候,会移动所有权,s在栈上的内存虽然还在,但是被编译器视为无效变量或无效状态,所以第二次及以后的循环就不能实验变量s了,编译器会报错。
2. 结构体类型默认没有实现Copy trait, 赋值过程也会移动所有权。 当然从底层看,这个Point结构体的成员都是基本类型(基本类型实现了Copy), 所以这个结构体的值是保存在栈上的,所以赋值操作,实际上底层是在栈上完整拷贝了一次Point结构体,但是编译器依然会把原来的Point结构体变量视为无效状态或无效变量。
老师,关于移动还是复制的那段话,我有个迷惑。。我能不能理解其实都是复制了栈上的数据,比如 a = 10u32 b = a 由于 10u32 是放栈上的,实际上是把 a 的数据复制了一份,然后 b 绑定了这份数据,因为数据是独立的,所以所有权也是独立的,a 和 b 各自拥有各自数据的所有权。 而 String 也是一样,把 a 的数据复制了一份到 b 上,但是这时候 a 的数据实际上是堆上数据的地址,所以复制的数据是这个堆上数据的地址,而不是堆上的数据,所以实际数据只有一份,所有权也是一份,这时候 b = a 就会把这一份所有权同时交给 b
作者回复: 你描述得很棒。所有权的背后意义是 资源管理。谁能掌控资源的管理谁就有所有权,然后是有所有权的变量要负责资源的释放。你理解到这一点就云开雾散了。