Rust 入门:从语法到所有权

Rust 入门:从语法到所有权

exdoubled Lv5

Rust 的真正核心在所有权


Rust 到底在解决什么问题

如果概括一下,Rust 要同时追求三件事:

  • 接近系统语言的性能
  • 不依赖垃圾回收
  • 尽量在编译期保证内存安全和并发安全

这三件事放在一起,是有冲突的

像 C 和 C++,性能和控制力都很强,但内存安全高度依赖程序员经验:可以手动管理内存,也可以写出悬垂指针、重复释放、越界访问

像 Java、Go、Python 这类带 GC 的语言,在内存管理上轻松很多,但运行时成本和抽象边界又是另一套

Rust 的思路是:既然程序总归要遵守一套资源使用规则,那不如让编译器检查

Rust 的很多语法设计,背后都是这个目的


工具链、编译器与 Cargo

Rust 官方把工具链打包得比较完整

通常安装 Rust 之后,至少会接触到下面几样东西:

  • rustc:编译器
  • cargo:构建、依赖管理、测试、文档生成等工具
  • rustup:工具链管理器

在实际开发里,rustc 当然重要,但最常打交道的往往是 cargo

因为 Rust 项目从一开始就被鼓励放进标准化工作流,而不是手写一堆编译脚本

最常见的命令有这些:

1
2
3
4
5
cargo new hello_cargo
cargo build
cargo run
cargo check
cargo test

cargo new 会直接帮你生成一个标准项目目录。

1
2
3
4
hello_cargo/
├── Cargo.toml
└── src/
└── main.rs

这里最关键的是 Cargo.toml

一个例子:

1
2
3
4
5
6
[package]
name = "hello_cargo"
version = "0.1.0"
edition = "2024"

[dependencies]

第一,Rust 项目默认是一个 package

第二,构建、运行、测试这些事情默认都交给 Cargo,不要一上来就想着手敲 rustc xxx.rs

Rust 中,代码包被称为 crates

rustc 适合帮助理解 Rust 最小编译过程

1
2
rustc main.rs
./main

但从第二个文件开始,就应该尽快回到 Cargo

因为 Rust 后面的模块、依赖、测试、文档、workspace,全部都围绕 Cargo 这套体系展开

  • 可以使用 cargo new 创建项目
  • 可以使用 cargo build 构建项目
  • 可以使用 cargo run 一步构建并运行项目
  • 可以使用 cargo check 在不生成二进制文件的情况下构建项目来检查错误
  • 有别于将构建结果放在与源码相同的目录,Cargo 会将其放到 target/debug 目录

Hello, world!

Rust 的 Hello, world! :

1
2
3
fn main() {
println!("Hello, world!");
}

语句结尾通常分号

会和后面的表达式语义连起来

main 是入口函数

和 C/C++ 类似,程序从 main 开始执行

println! 不是函数,是宏

这一点先留个印象即可

现在只要知道带 ! 的通常是宏。宏在 Rust 里很重要,但前期不需要深究

格式化输出是类型安全的

1
2
let x = 10;
println!("x = {}", x);

和 C 风格不同,需要保证占位符和参数对得上,rust 的格式化输出由编译器参与检查的一套接口

它背后体现的倾向:让更多错误尽可能在编译期暴露


变量、可变性与 shadowing

Rust 里,变量默认不可变:

1
2
let x = 5;
// x = 6; // 报错

如果要改,就显式加 mut

1
2
let mut x = 5;
x = 6;

可变状态会放大程序复杂度

常量和变量

Rust 对常量的命名约定是全部大写,并用下划线分隔单词

常量用 const 定义:

1
const MAX_POINTS: u32 = 100_000;

const 特点:

  • 必须显式标注类型
  • 必须在编译期就能确定值
  • 在整个作用域里都按常量处理

shadowing

Rust 允许用同名变量重新绑定:

1
2
3
let x = 5;
let x = x + 1;
let x = x * 2;

最后的 x 是新的绑定,不是把原来的内存位原地改掉

这个和 mut 的区别很大

1
2
let spaces = "   ";
let spaces = spaces.len();

这里如果用 mut 就不行,因为类型变了

shadowing 允许保留同一个名字,但把它理解成“不同阶段上的新值”

这在 Rust 里很常见,尤其是在做输入解析、类型转换、逐步收紧数据形态时特别自然

  • mut 是同一个绑定上的内容变化
  • shadowing 是新的绑定遮蔽旧的绑定

数据类型

Rust 是静态类型语言,也就是说,它必须在编译时就知道所有变量的类型

Rust 的类型系统往后会越来越复杂,但一开始先抓住最基础的部分就够了

标量类型

主要有:

  • 整数:i8i16i32i64i128isize,以及对应无符号版本
  • 浮点:f32f64
  • 布尔:bool
  • 字符:char

一般情况下,整数默认会推断成 i32,浮点默认推断成 f64

char 在 Rust 里表示一个 Unicode 标量值,而不是 C 里那种狭义的单字节字符

Rust 的 char 类型大小为 4 个字节

这意味着:

1
2
3
let c = 'z';
let heart = '❤';
let han = '中';

都是合法的 char

元组

元组适合把一组固定长度、类型可能不同的数据打包在一起

1
2
3
let tup: (i32, f64, u8) = (500, 6.4, 1);
let (x, y, z) = tup;
let first = tup.0;

它很像别的语言里的轻量复合返回值

数组

数组长度固定,元素类型相同

1
2
3
let a = [1, 2, 3, 4, 5];
let b: [i32; 3] = [1, 2, 3];
let c = [0; 5];

Rust 对数组访问会做边界检查,越界在编译期能发现就编译期报错,运行期才能发现就 panic

这一点和 C/C++ 很不一样。Rust 宁可在运行时直接失败


表达式和语句

Rust 是表达式导向比较强的语言

会深刻影响后面的函数写法、if 写法、match 写法

先看一个简单例子:

1
2
3
4
let y = {
let x = 3;
x + 1
};

这里块表达式的值是最后一行 x + 1,注意它后面没有分号

如果写成:

1
2
3
4
let y = {
let x = 3;
x + 1;
};

那最后一个表达式变成了语句,这个块的值就是 (),也就是 unit 类型

“类型不匹配”,往往就是因为该返回表达式的地方顺手多打了一个分号

  • 有分号,通常更偏语句,值被丢弃
  • 没分号,通常作为表达式值返回

这个思路后面会一路延伸到函数返回、if 分支、match 分支甚至闭包里

语句是执行一些操作但不返回值的指令

在 C/Ruby 中,赋值语句会返回被赋值的值,所以会出现 x=y=6,但是 rust 中的语句不会返回值,这意味着:

1
2
3
fn main(){
let x = (let y = 6);
}

是无法通过编译的


函数

函数定义:

1
2
3
fn add(x: i32, y: i32) -> i32 {
x + y
}

需要注意的点不多

参数必须标注类型

Rust 不允许在函数参数这里完全靠推断

返回值类型写在 -> 后面

最后一行表达式可以作为返回值

这就是刚才说的表达式风格

当然也可以显式写 return

1
2
3
fn add(x: i32, y: i32) -> i32 {
return x + y;
}

但在 Rust 里,更常见的风格是把最后一个表达式直接作为结果


控制流:if、循环和模式控制

Rust 基础控制流同样带着表达式导向的特点

if

有点反直觉, if 是一个表达式

1
2
3
4
5
6
7
let number = 6;

if number < 5 {
println!("condition was true");
} else {
println!("condition was false");
}

它还能这样写:

1
2
let condition = true;
let number = if condition { 5 } else { 6 };

这里 if 表达式两个分支的类型必须一致

条件必须是 bool 值,Rust 不会自动尝试把非布尔类型转换成布尔类型

Rust 不允许一边返回整数,一边返回字符串,让类型信息变得模糊

可以将 else if 表达式与 ifelse 组合来实现多重条件

loop

1
2
3
4
5
6
7
8
9
10
11
12
13
fn main() {
let mut counter = 0;

let result = loop {
counter += 1;

if counter == 10 {
break counter * 2;
}
};

println!("The result is {result}");
}

loop 可以配合 break 返回值

循环标签:如果循环中套了循环,可以选择给循环加上循环标签‘label,然后同 breakcontinue 使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
fn main() {
let mut count = 0;
'counting_up: loop {
println!("count = {count}");
let mut remaining = 10;

loop {
println!("remaining = {remaining}");
if remaining == 9 {
break;
}
if count == 2 {
break 'counting_up;
}
remaining -= 1;
}

count += 1;
}
println!("End count = {count}");
}

while

用于条件循环

1
2
3
4
5
6
7
8
9
10
11
fn main() {
let mut number = 3;

while number != 0 {
println!("{number}!");

number -= 1;
}

println!("LIFTOFF!!!");
}

for

Rust 更推荐 for 去遍历集合或范围

使用 for 循环来倒计时的例子,它还用到了一个我们尚未讲到的方法 rev,用于反转 range

1
2
3
4
5
6
fn main() {
for number in (1..4).rev() {
println!("{number}!");
}
println!("LIFTOFF!!!");
}

for 而不是手动控制下标,通常更安全也更清晰

因为“遍历元素”比“操作索引”更接近真正意图

1
2
3
4
5
6
7
fn main() {
let a = [10, 20, 30, 40, 50];

for element in a {
println!("the value is: {element}");
}
}

猜数字项目

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
println!("Guess the number!");

let secret_number = rand::thread_rng().gen_range(1..=100);

loop {
println!("Please input your guess.");

let mut guess = String::new();

io::stdin()
.read_line(&mut guess)
.expect("Failed to read line");

let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};

println!("You guessed: {guess}");

match guess.cmp(&secret_number) {
Ordering::Less => println!("Too small!"),
Ordering::Greater => println!("Too big!"),
Ordering::Equal => {
println!("You win!");
break;
}
}
}
}

Rust 的一些小巧思:

  1. read_line

.read_line(&mut guess) 调用了标准输入句柄上的 read_line 方法,以获取用户输入

read_line 的工作是,无论用户在标准输入中键入什么内容,都将其追加(不会覆盖其原有内容)到一个字符串中,因此它需要字符串作为参数

必须把从输入中读取到的 String 转换为一个数字类型

  1. &

& 表示这个参数是一个 引用reference),它允许多处代码访问同一处数据,而无需在内存中多次拷贝

  1. match 用来处理分支

Rust 希望把分支写出来

match 表达式由 分支(arms) 构成,一个分支包含一个 模式pattern)和表达式开头的值与分支模式相匹配时应该执行的代码

  1. Cargo.toml

例如:

1
2
[dependencies]
rand = "0.8"

String

Rust 里常见的字符串形态至少有两种:

  • String
  • &str

当前只要记住:

  • String 是可增长、拥有所有权的字符串类型
  • &str 通常是某段 UTF-8 字符串切片的借用视图

比如:

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

这里 s1 的类型是 Strings2 的类型是 &str

:: 是运算符,允许将特定的 from 函数置于 String 类型的命名空间下


栈和堆

Rust 讲所有权时,会先讲栈和堆

因为所有权正是在区分“值本身怎么存储”“资源什么时候释放”

栈上的数据通常有这些特点:

  • 大小在编译期已知
  • 分配和释放很快
  • 遵循后进先出

固定大小的整数、浮点数、布尔值,都适合直接放在栈上

堆上的数据特点通常是:

  • 大小可能在运行期决定
  • 分配需要向分配器申请空间
  • 访问通常通过指针或引用间接进行

String 这样的类型,栈上通常只放一些描述信息,而真正的字符数据放在堆上

比如一个 String 可以粗略想成栈上保存三样东西:

  • 指向堆数据的指针
  • 长度
  • 容量

真正的字符内容在堆里

一旦值背后拥有堆资源,“复制一个变量”就不再是随手按位复制那么简单

因为需要考虑资源到底归谁管,什么时候释放,会不会被释放两次

所有权就是在处理这个问题


所有权规则

  1. Rust 中的每一个值都有一个所有者
  2. 值在任意时刻只能有一个所有者
  3. 所有者离开作用域时,这个值会被丢弃

Rust 不愿意模糊处理,它要求“资源归属”在代码层面是明确的


作用域与 drop

看一个最简单的作用域例子:

1
2
3
{
let s = String::from("hello");
} // 这里 s 离开作用域

离开作用域后,Rust 会自动调用对应的清理逻辑

后面讲智能指针时会再看到 Drop trait

内存和分配

move

先看一个纯栈上的例子:

1
2
let x = 5;
let y = x;

这没有特别的,因为 i32 这种类型复制起来很便宜,也不会涉及堆资源的重复释放。

但对于 String

0X0/trpl04-01.svg
1
2
let s1 = String::from("hello");
let s2 = s1;

这里不能简单理解为“把 s1 完整复制一份给 s2”,是发生了 move,所有权转移

转移之后,s1 就失效了:

1
2
3
4
5
let s1 = String::from("hello");
let s2 = s1;

// println!("{s1}"); // 报错
println!("{s2}");

为什么要这么做?

因为如果只是浅拷贝栈上的指针、长度、容量,那么 s1s2 就会同时认为自己拥有同一块堆内存

变量离开作用域后,Rust 自动调用 drop 函数并清理变量的堆内存,那就是 double free

0X0/trpl04-02.svg

因此,为了确保内存安全,在 let s2 = s1; 之后,Rust 认为 s1 不再有效,因此 Rust 不需要在 s1 离开作用域后清理任何东西

Rust 原则:不把资源归属问题留到运行时模糊处理

其它语言中可能有浅拷贝和深拷贝的概念,而上述操作在 Rust 中称为移动 (move)

clone

对于深拷贝,需要显式的写 clone

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

这时候 s1s2 各自拥有自己的一份堆数据

0X0/trpl04-03.svg

缺点是十分耗费资源

copy

之前提到的简单的操作

1
2
3
let x = 5;
let y = x;
println!("{x}, {y}");

像整数、布尔、字符、浮点、某些只由 Copy 类型组成的元组,都可以按值复制

由于这些操作都在栈上,简单轻量,所以正常拷贝,赋值之后原变量仍然可用


函数传参和返回值

先看这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fn takes_ownership(some_string: String) {
println!("{some_string}");
}

fn makes_copy(some_integer: i32) {
println!("{some_integer}");
}

fn main() {
let s = String::from("hello");
takes_ownership(s);
// println!("{s}"); // 报错

let x = 5;
makes_copy(x);
println!("{x}");
}

这里本质上和赋值的规则一致:

  • String 传参时发生 move
  • i32 传参时发生 copy

返回值也一样

1
2
3
4
5
6
7
fn gives_ownership() -> String {
String::from("hello")
}

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

引用与借用

如果每次调用函数都发生 move,代码会非常僵硬,所以 Rust 引入了引用

引用有点像指针,他是一个地址,沿着他访问地址中数据,获得了值但是不获取所有权

借用

最基本的引用:

1
2
3
4
5
6
7
8
9
fn calculate_length(s: &String) -> usize {
s.len()
}

fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1);
println!("The length of '{s1}' is {len}.");
}

这里:

  • s1 还是所有者
  • 函数传入的是 &String
  • 函数结束后,s1 依然可用
0X0/trpl04-06.svg
1
2
3
4
fn calculate_length(s: &String) -> usize { // s 是 String 的引用
s.len()
} // 这里,s 离开了作用域。但因为它并不拥有引用值的所有权,
// 所以什么也不会发生

核心特点:可读但不能通过它修改原值

将创建一个引用的行为称为借用

可变引用

如果要修改值,需要可变引用:

1
2
3
4
5
6
7
8
9
fn change(some_string: &mut String) {
some_string.push_str(", world");
}

fn main() {
let mut s = String::from("hello");
change(&mut s);
println!("{s}");
}

这里同时出现了两个要求:

  • 变量本身必须是 mut
  • 借用时也必须是 &mut

首先,我们必须把 s 改成 mut

然后在调用 change 函数时创建一个可变引用 &mut s,并更新函数签名,让它接收一个可变引用 some_string: &mut String

这样就很清楚地表明,change 函数会修改它所借用的值

引用规则

Rust 有一条非常重要的规则:

在同一作用域中,要么可以有多个不可变引用,要么只能有一个可变引用,二者不能同时存在。

比如下面会报错:

1
2
3
4
let mut s = String::from("hello");

let r1 = &mut s;
let r2 = &mut s; // 报错

但可以使用大括号来创建一个新的作用域,以允许拥有多个可变引用

1
2
3
4
5
6
7
8
let mut s = String::from("hello");

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

let r2 = &mut s;

又比如:

1
2
3
4
5
let mut s = String::from("hello");

let r1 = &s;
let r2 = &s;
let r3 = &mut s; // 报错

但注意一个引用的作用域从声明的地方开始一直持续到最后一次使用为止

例如,因为最后一次使用不可变引用的位置在 println!,它发生在声明可变引用之前

1
2
3
4
5
6
7
8
9
10
let mut s = String::from("hello");

let r1 = &s; // 没问题
let r2 = &s; // 没问题
println!("{r1} and {r2}");
// 此位置之后 r1 和 r2 不再使用

let r3 = &mut s; // 没问题
println!("{r3}");

不可变引用 r1r2 的作用域在 println! 最后一次使用之后结束,这发生在可变引用 r3 被创建之前,因为它们的作用域没有重叠

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

拦截悬垂引用

在带有指针的语言中,如果释放了一块内存,却保留了指向它的指针,就很容易错误地制造出一个悬垂指针dangling pointer

Rust 编译器保证引用永远不会变成悬垂引用

看一个经典错误:

1
2
3
4
fn dangle() -> &String {
let s = String::from("hello");
&s
}

这个函数的问题:s 在函数结束时就被释放了,但却试图返回指向它的引用

因为 s 是在 dangle 函数内部创建的,所以当 dangle 的代码执行完毕后,s 就会被释放

当尝试返回引用时就会报错

正确的做法通常是直接返回拥有所有权的值:

1
2
3
4
fn no_dangle() -> String {
let s = String::from("hello");
s
}

slice 切片

切片本质上是对一段连续数据的引用视图

所以 slice 只是一种引用,不获得所有权

最常见的是字符串切片:

1
2
3
let s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];

也可以省略一边:

1
2
3
let hello = &s[..5];
let world = &s[6..];
let whole = &s[..];

这时候得到的类型是 &str

字符串切片的重要性在于:可以只借用字符串的一部分,而不用复制数据

0X0/trpl04-07.svg

为什么要有切片

一个很经典的例子:写一个函数找出字符串里的第一个单词

如果返回索引:

1
2
3
4
5
6
7
8
9
10
11
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();

for (i, &item) in bytes.iter().enumerate() { // 迭代器
if item == b' ' {
return i;
}
}

s.len()
}

这是对的,但有个问题:返回的索引和原字符串本身没有绑定关系

调用方如果之后清空字符串,这个索引就失去意义了

更好的写法是直接返回切片:

1
2
3
4
5
6
7
8
9
10
11
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();

for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[..i];
}
}

&s[..]
}

这样一来,返回值和原数据的借用关系就被类型表达出来了

对于 Rust 的 .. range 语法,如果想要从索引 0 开始,可以不写两个点号之前的值。换句话说,如下两个语句是相同的:

1
2
3
4
let s = String::from("hello");

let slice = &s[0..2];
let slice = &s[..2];

依此类推,如果 slice 包含 String 的最后一个字节,也可以舍弃尾部的数字。这意味着如下也是相同的:

1
2
3
4
5
6
let s = String::from("hello");

let len = s.len();

let slice = &s[3..len];
let slice = &s[3..];

也可以同时舍弃这两个值来获取整个字符串的 slice。所以如下亦是相同的:

1
2
3
4
5
6
let s = String::from("hello");

let len = s.len();

let slice = &s[0..len];
let slice = &s[..];

slice 让这种 bug 不再可能发生,并且会更早告诉我们代码出了问题

因为引用不能修改,他不是可变引用


为什么函数参数更常写成 &str 而不是 &String

如果写:

1
fn first_word(s: &String) -> &str

那它只能接 String 的引用

如果写:

1
fn first_word(s: &str) -> &str

那它既能接字符串字面量,也能接 String 的切片,适用范围更广

1
2
3
4
5
let my_string = String::from("hello world");
let word = first_word(&my_string[..]);

let my_string_literal = "hello world";
let word = first_word(my_string_literal);

这体现了一个很常见的 Rust 设计倾向:

如果一个函数只需要“读一段字符串”,那就把接口写成最一般的借用形式,而不是强行要求调用方给你一个具体拥有者类型

这和后面很多 API 风格都有关


字符串字面量

1
let s = "hello";

这里的 s 类型是 &'static str

字符串字面量本来就是对程序二进制中某段只读字符串数据的切片引用

它不是 String,因为它不拥有一块可增长的堆内存

这件事能帮助你理解:

  • 为什么 "hello"String::from("hello") 不一样
  • 为什么很多只读 API 接受 &str
  • 为什么 String 常常可以借用成 &str

数组切片和其他切片

切片不只存在于字符串

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

这里的类型是 &[i32]

切片这个概念本身是通用的:它表示“对某段连续数据的借用视图”,字符串切片只是最常见的一种

这件事往后会和集合、迭代器、泛型接口连起来,因为 Rust 很多 API 都喜欢接受“借用视图”

Comments