Rust 学习笔记(卷一)
〇、写在前面
0. 这篇学习笔记适合什么人
适合我。
如果你想看,最好能够:
- 熟练掌握种类不同的两门自然语言。例如简体中文和英文。 因为可能需要直接阅读英文文档,本笔记不负责翻译。
- 至少入门三门自然语言。 因为可能会有其他语言出现在样例代码中作为示例。
- 熟练掌握范式不同的两门编程语言。例如 C 和 C++。 因为可能会有 Rust 和 C/C++ 的双向翻译。熟练的标准是至少能看懂我写的代码。
- 至少入门四门编程语言。 因为可能会有 Rust 与其他编程语言的类比。
- 基本了解目标平台是冯·诺依曼计算机的大致工作原理。 因为可能会从计算机运行原理的角度进行考虑。
序号为奇数的点更重要。
1. 为什么要学习 Rust
想学了。
2. Rust 是一个怎样的语言
参见 www.zhihu.com/question/49…,是一个融合了面向过程和函数式的混合范式语言。
3. 本学习笔记是一篇怎样的笔记
始终根据(编写时的)最新情况,边学编写的笔记。以语言本身为核心,但也重视开发环境中的实践。
一、Hello, Cargo World
1. 安装 Rust
根据官网教程操作即可。假设安装的过程可以正常使用代理、速度正常。
无论是 Windows 还是类 UNIX 操作系统,都选择安装到默认位置(%userprofile%/.cargo 或 ~/.cargo),符合系统基础软件安装的习惯。
官网教程说道,Cargo 是 Rust 语言的构建系统和包管理器,一切命令都是 cargo 而非 rust。使用以下命令检查安装是否正常。
cargo --version
2. Hello World
见官网教程。直接按其操作即可。
此时先不要理会代码。该章的目的是搭建开发环境。
项目自动创建完成后,可以看到源文件是 src/main.rs。教程提供的创建项目指令可能会创建 git 仓库,学习时,将源代码目录下的 .git/ 目录删掉即可。
3. 使用 VS Code 扩展
在 VS Code 扩展商店中安装 rust-analyzer 扩展即可获得 Rust 语言的语法支持。在 main 函数上点击 Run 或 Debug 按钮即可进行运行和调试。
此外,该扩展支持调用 Cargo 提供的代码格式化等功能。在之后的实践中具体感受。
考虑到网络环境不佳,请最好先进行下面的操作。
4. 更改 Cargo 的源为清华源
参考 blog.csdn.net/tanshiqian/…,在 Cargo 安装目录(.cargo/)下新建名为 config.toml 的文件,文件内容如下。
[source.crates-io]
registry = "https://github.com/rust-lang/crates.io-index"
replace-with = "tuna"
[source.tuna]
registry = "https://mirrors.tuna.tsinghua.edu.cn/git/crates.io-index.git"
更改完成后,删除 .cargo/.package-cache 文件,之后会根据该配置文件设定的源重新配置缓存。
5. 使用第三方包
Cargo 作为包管理器(package manager),可以方便地使用和管理第三方包(Rust 中的包称为 crate)。
根据官网教程操作即可。要点是修改清单文件(the manifest)Cargo.toml 中的 [dependencies] 栏目。
二、Rust 的数据(一):认识 Rust
学会了 Rust 的数据和控制,就能够使用 Rust 编写任意程序了。下面我们首先学习数据部分,这也是 Rust 的精髓所在。
1. 定义变量和常量
定义不可变变量
Rust 使用 let 关键字定义不可变变量。
fn main() {
let a_number = 114514;
println!("{}", a_number);
}
C++ 程序 1:输出一个数(C++23,暂时没有编译器支持)
import std.core; // 按 C++23 标准,应该使用 `import std;`。但考虑到实际编译器实现,暂时全部使用 `std.core`。
int main()
{
const auto a_number = 114514;
std::println("{}", a_number);
}
很显然,指明一个变量不可变有利于编译器给出相关警告或进行优化。
Rust 中,使用 println! 输出一行文字,与 C++ 中的 std::println 一样。如果你不知道怎么使用,可以参见 C++ 的 std::format,或者 Python 的 str.format,或者 C# 的 String.Format,等等,反正现在大家觉得这种用法是最好的。
println 后的感叹号(!)表示 println! 是一个宏。Rust 中,函数的签名是确定的,要使得函数支持任意数量的参数,只能利用宏。另外,由于 println 是一个宏,所以它甚至支持字符串插值,请自行查阅相关资料。
定义可变变量
要定义可变变量,需要结合使用 mut 关键字。
fn main() {
let mut dual_number = 114;
print!("{}", dual_number);
dual_number = 514;
println!("{}", dual_number);
}
C++ 程序 2:输出两个数(C++23,暂时没有编译器支持)
import std.core;
int main()
{
auto dual_number = 114;
std::print("{}", dual_number);
dual_number = 514;
std::println("{}", dual_number);
}
同 C++ 类似,Rust 中使用 print! 宏进行输出时不会额外输出换行符。之后的 C++ 程序中,将使用编译器已经支持的特性进行输出(使用 cout 和 format 代替 print 和 println)。
如果删去 mut 关键字,Rust 编译器会报错:
error[E0384]: cannot assign twice to immutable variable `dual_number`
--> src\main.rs:4:5
|
2 | let dual_number = 114;
| -----------
| |
| first assignment to `dual_number`
| help: consider making this binding mutable: `mut dual_number`
3 | print!("{}", dual_number);
4 | dual_number = 514;
| ^^^^^^^^^^^^^^^^^ cannot assign twice to immutable variable
error: aborting due to previous error
For more information about this error, try `rustc --explain E0384`.
这就如同给 C++ 程序 2 加上 const 关键字后,编译器(MSVC)报错:
error C3892: “dual_number”: 不能给常量赋值
相比之下,Rust 编译器的前端往往比 C++ 的编译器更强,能给出的修正方法更多。
显式指定变量的类型
要显式指定变量的类型,使用的语法为:
"let" ("mut") <identifier> ":" <type> ("=" <initial_value>) ";"
Rust 程序 3:输出三个数
fn main() {
let x: i32 = 11;
print!("{}", x);
let x: u64 = 45;
print!("{}", x);
let x: i128 = 14;
println!("{}", x);
}
C++ 程序 3:输出三个数(暂时没有编译器支持)
import std.core;
int main()
{
const int32_t x_1 = 11;
std::cout << std::format("{}", x_1);
const uint64_t x_2 = 45; // C++ 不能定义同名变量。
std::cout << std::format("{}", x_2);
const __int128_t x_3 = 14; // C++ 标准不支持 128 位整数。
std::cout << std::format("{}", x_3) << std::endl;
// 之后只能使用 x_3,但编译器不知道。
}
从程序 3 可以看到 Rust 基本整数类型的命名逻辑。
程序 3 说明了 Rust 的变量**遮蔽(shadowing)**特性。显然,这样的特性有利于编译器对代码进行优化,因为可以肯定之前的 x 不会再被使用了。要注意,Rust 是强类型的语言。
使用整数字面量时,如果不显式指明类型,IDE 将提示我们变量的类型是 i32。但事实上,使用无后缀整数字面量初始化的变量应该被视为“整数”类型(记作 {integer}),而不能进一步推导出具体类型。下面很快就会见到一个例子。
定义常量
要定义编译时确定的常量,使用 const 关键字代替 let 关键字,同时必须显式指定类型。
fn main() {
const X : i32 = 114;
let y = 514;
println!("{}{}", X, y);
}
C++ 程序 4:常量
import std.core;
int main()
{
constexpr int32_t X = 114;
const auto y = 514;
std::cout << std::format("{}{}", X, y) << std::endl;
}
Rust 编译器规定,常量最好用大写字母加下划线,变量最好用小写字母加下划线,否则编译器会警告。
是否有必要必须为常量指明类型还在讨论中。
2. 面向对象支持
此处首先介绍部分概念。
方法
Rust 不是面向对象的语言,但是借用了部分面向对象的概念,我们可以在 Rust 中使用方法(method)。
Rust 程序 5:绝对值fn main() {
let x: i32 = -114514;
let x = x.abs();
let x = i32::abs(x);
println!("{}", x);
}
C++ 程序 5(不支持)
其中,x.abs() 等价于 i32::abs(),因为此处已经明确 x 的类型为 i32。
程序 5 中,第一次定义 x 时必须指明类型为 i32,否则第二次定义 x 时只能知道 x.abs() 的类型为一个整数,无法知道具体的类型,会出现以下错误。
error[E0689]: can't call method `abs` on ambiguous numeric type `{integer}`
--> src\main.rs:3:15
|
3 | let x = x.abs();
| ^^^
|
help: you must specify a type for this binding, like `i32`
|
2 | let x: i32 = -114514;
| +++++
For more information about this error, try `rustc --explain E0689`.
可以看出,Rust 具有很强的类型系统。
特征
特征(trait)其实就是 C++ 中的概念(concept)。大家也都知道,没有概念时,C++ 中也用特征(type_trait)解决问题。
特征可以说明一个类型的能力,比如这个类型是否具有 abs 方法、是否“平凡”。对于泛型(generics)函数,即 C++ 中的模板函数,我们可以限制泛型类型必须具有某些特征,从而让编译器帮我们检查,在正确的地方给出错误提示。
import std.core;
template <typename T>
concept quackable = requires(T t) // 名为 quackable 的特征。
{
{t.quack()} -> std::same_as<void>;
}; // 对于类型为 T 的值,必须具有 quack() 方法,且返回值类型为 void。
template <quackable T> // 要求类型 T 具有该特征。
void f(T t)
{
t.quack();
}
int main()
{
// f(114514); // 不满足特征,编译错误。
}
取消注释 f(114514),编译器将给出以下错误信息:
源.cpp(17,2): error C2672: “f”: 未找到匹配的重载函数
源.cpp(10,6): message : 可能是“void f(T)”
源.cpp(17,2): message : 未满足关联约束
源.cpp(9,11): message : 计算结果为 false 的概念“quackable<int>”
源.cpp(6,2): message : 表达式无效
据此可以知道,错误应该发生在第 17 行。如果在第 9 行使用 typename,不约束类型 T,则错误信息变成:
源.cpp(12,4): error C2228: “.quack”的左边必须有类/结构/联合
源.cpp(12,4): message : 类型是“T”
with
[
T=int
]
源.cpp(17,10): message : 查看对正在编译的函数 模板 实例化“void f<int>(T)”的引用
with
[
T=int
]
虽然正确给出了需要检查的地方,但出现 error 的代码与错误无关,更容易造成误解。
目前,C++ 编译器还在努力实现中。而对于 Rust 而言,相关功能的错误提示会更强大。
除了帮我们检查类型错误,如果我们知道了一个类型具有某个特征,我们就知道了这个类型应该有的行为,这是我们接下来要关注的。
3. 变量的所有权
内存回收机制
传统的内存回收机制包括:
- 垃圾回收(garbage collection, GC)。例如 C#。
- 引用计数(reference count)。例如 C++。
复习编译原理,引用计数相比垃圾回收的优点包括不会出现性能抖动。Rust 在引用计数的基础上限制了计数数量,一个值最多只能被一个变量持有,称该机制为所有权,这使得编译时就知道何时回收内存成为可能。
Rust 程序 7:所有权fn main() {
let x = String::from("114514");
let y = x; // 编译时在逻辑上转移所有权。
println!("{}", y);
// println!("{}", x); // 编译错误:x 失去值的所有权。
}
C++ 程序 7:“所有权”
import std.core;
int main()
{
auto x = std::make_unique<const std::string>("114514");
auto y = std::move(x); // 运行时转移“所有权”。
std::cout << std::format("{}", *y) << std::endl;
std::cout << std::format("{}", *x) << std::endl; // 运行时错误:x 失去值的“所有权”。
}
可以看到,Rust 语言在编译时能做的事比 C++ 还要多很多。
Rust 很严格。C++ 编译器虽然可能知道程序 7 会出现运行时错误,给出警告:
使用已移动的 from 对象: x (lifetime.1)。
但 Rust 编译器根本不允许这样的程序通过编译。
注:事实上,应该把所有权机制看作一个新的机制,它不完全等同于最大计数为 1 的引用计数。所有权与变量名绑定,如果不存在变量名(例如数组中的元素),就没有所有权一说。所有权存在的目的是用于计算变量被回收的时机,如果一个变量回收的时间本身就能确定(例如已初始化的数组中的元素,一定正好在数组销毁前回收),也就无需引入所有权。
变量绑定与变量复制
由此看来,let 语句称为变量绑定更为合适。那要复制数据该怎么办?分为两种情况。
- 简单数据。直接绑定,自动复制。适用于具有
Copy特征的类型。 - 复杂数据。使用
clone方法复制。适用于具有Clone特征的类型。
fn main() {
let x1: i32 = 114514;
let x2 = x1; // i32 具有 Copy 特征,直接复制,x1, x2 分别具有一个整数的所有权。
let x3 = x2.clone(); // 亦可调用 clone() 方法。
let s1 = String::from("114514");
let s2 = s1; // String 不具有 Copy 特征,不复制,s1 失去所有权。
let s3 = s2.clone(); // s2, s3 分别具有一个 String 的所有权。
println!("{}", x1);
println!("{}", x2);
println!("{}", x3);
// println!("{}", s1); // 编译错误:s1 失去值的所有权。
println!("{}", s2);
println!("{}", s3);
}
C++ 程序 8:变量复制
import std.core;
int main()
{
const int32_t x1 = 114514;
const auto x2 = x1;
const auto x3 = x2; // 对应于 Rust,clone 不一定会分配堆内存。
auto s1 = std::make_unique<const std::string>("114514");
auto s2 = std::move(s1);
auto s3 = std::make_unique<const std::string>(*s2);
std::cout << std::format("{}", x1) << std::endl;
std::cout << std::format("{}", x2) << std::endl;
std::cout << std::format("{}", x3) << std::endl;
std::cout << std::format("{}", *s1) << std::endl; // 运行时错误。
std::cout << std::format("{}", *s2) << std::endl;
std::cout << std::format("{}", *s3) << std::endl;
}
要注意,clone 方法不代表会在堆上分配内存。可以认为 Rust 会在编译时期计算出哪些值需要在堆上分配内存,且尽可能会在栈上分配内存。
目前需要知道,i32 这样的类型具有 Copy 特征,String 不具有 Copy 特征。如何知道一个类型是否具有 Copy 特征呢?
fn is_copy<T>()
where
T: Copy,
{
}
fn main() {
is_copy::<i32>();
// is_copy::<String>(); // 编译错误:不满足约束。
}
C++ 程序 9:检查 is_trivially_copyable_v 特征
import std.core;
template <typename T>
void is_copy()
requires std::is_trivially_copyable_v<T>
{
}
int main()
{
is_copy<int32_t>();
// is_copy<std::string>(); // 编译错误:不满足约束。
}
4. 引用变量
只讨论变量的所有权是简单的,但功能也弱。要有力地使用变量,需要**借用(borrow)**变量,也即引用变量。这也使得下面的内容非常复杂,是 Rust 的精髓所在。
借用即取“地址”
此处,应该将取“地址”理解为:
- 知道“地址”后,我们将可以使用变量。之所以给“地址”打上引号,是因为变量也可以放在寄存器中,甚至在编译中被优化。应该将它理解成“逻辑上变量存放的位置”。
- 只知道地址,我们并没拥有变量的所有权。通俗地说,我们只有敲门的权力,没有拆房的权力。
Rust 编译器保证,你敲门时房子一定还在。敲门的方法是使用 & 运算符,敲门得到的类型也是在原类型前加 &。
fn main() {
let x = String::from("114514");
let x = &x; // 注意上一个 x 还在,只是访问不到了。
println!("{}", x);
let s_ref: &String; // 利用大括号限制作用域。
{
let s = String::from("114514");
// s_ref = &s; // 编译错误:离开大括号后,家被拆了,不允许再敲门。
}
// println!("{}", s_ref); // 如果没有赋值,则不允许使用。
}
C++ 程序 10:Trick
import std.core;
int main()
{
auto x_1 = std::make_unique<const std::string>("114514");
auto x_2 = x_1.get(); // 使用指针模拟行为,编译器不检查。
// 注意 x_2 的类型是 const std::string*。
// 由于是指针,所以需要手动解引用。
std::cout << std::format("{}", *x_2) << std::endl;
const std::string* s_ref;
{
auto s = std::make_unique<const std::string>("114514");
s_ref = s.get(); // 编译器不检查,可以敲拆了的门。
}
std::cout << std::format("{}", *s_ref) << std::endl; // 运行时错误。
}
可以看出,所有权借用机制有力地规避了悬挂指针问题,极大地提升了程序的安全性。这也为程序员提出了挑战:如何让 Rust 程序编译通过呢?这是我们接下来重点学习的内容。
C++ 中,引用只能在初始化时赋值,但从程序 10 可以看出,Rust 中引用可以在稍后赋值。这说明引用类型与原类型的地位是不同的,不能像 C++ 一样,处处像使用原类型那样使用引用类型。Rust 中的引用更像是 C++ 中指针和引用的结合体。
可变引用
注意在等价的 C++ 程序 10 中,指针都是 const 型的。那么在 Rust 中如何声明可变的引用呢?使用 mut 关键字即可。
fn main() {
let mut s = String::from("114"); // 当然,必须是 mut。
let s_ref = &mut s;
s_ref.push_str("514");
println!("{}", s_ref);
}
C++ 程序 11:可变引用
import std.core;
int main()
{
auto s = std::make_unique<std::string>("114");
auto s_ref = s.get();
// 注意 s_ref 的类型是 std::string*。
s_ref->append("514");
std::cout << std::format("{}", *s_ref) << std::endl;
}
读者与写者
Rust 规定,不可变引用(形如 &i32)的类型是读者,可变引用(形如 &mut i32)是写者,并且:
- 在同一时刻,读者和写者不能同时存在。
- 读者可以同时存在多个,但写者只能同时存在一个。
如果以上规定始终成立,且我们只通过引用来实现变量值的读写,那么 Rust 的变量读写天生就是线程安全的。事实上,Rust 的线程安全是通过其他东西实现的,但以上规定是线程安全的基础。
Rust 程序 12:读者与写者fn main() {
let mut x = 114514;
let x_ref_1 = &x;
let x_ref_2 = &x;
println!("{} {}", x_ref_1, x_ref_2); // 可以同时存在多个读者。
let x_mut_ref_1 = &mut x; // 只要用 &mut 就是写者。
println!("{}", x_mut_ref_1);
let x_mut_ref_2 = &mut x;
// println!("{} {}", x_mut_ref_1, x_mut_ref_2); // 不可同时存在多个写者。
let x_ref_3 = &x;
// println!("{} {}", x_mut_ref_1, x_ref_3); // 读者和写者不可同时存在。
}
C++ 程序 12:读者与写者
import std.core;
int main()
{
auto x = 114514;
const auto* x_ref_1 = &x;
const auto* x_ref_2 = &x;
std::cout << std::format("{} {}", *x_ref_1, *x_ref_2) << std::endl; // 没这些限制。
auto* x_mut_ref_1 = &x;
std::cout << std::format("{}", *x_mut_ref_1) << std::endl; // 没这些限制。
auto* x_mut_ref_2 = &x;
std::cout << std::format("{} {}", *x_mut_ref_1, *x_mut_ref_2) << std::endl; // 没这些限制。
const auto* x_ref_3 = &x;
std::cout << std::format("{} {}", *x_mut_ref_1, *x_ref_3) << std::endl; // 没这些限制。
}
理论上,Rust 中作用域的概念与 C++ 一样,变量名的生命在右大括号结束。但 Rust 在分析读者和写者是否存在时,变量名的生命在最后使用的地方结束,这种特性被称为非词汇生命周期(non-lexical lifetimes, NLL)。
5. 生命周期
Rust 检查引用变量是否合法离不开生命周期这个概念。检查引用变量是否合法由借用检查器(borrow checker)完成。
消除悬挂指针
借用检查器的一大作用是完全消除悬挂指针(dangling pointer)。
Rust 程序 13:悬挂引用fn main() {
let x_ref; // 定义变量时可以不指定类型,类型在首次赋值时确定。
{
let x = 114514;
x_ref = &x;
println!("{}", x_ref);
}
// println!("{}", x_ref); // 编译错误:此处 x_ref 已经是悬挂引用。
}
C++ 程序 13:悬挂指针
import std.core;
int main()
{
const int* x_ref;
{
const auto x = 114514;
x_ref = &x;
std::cout << std::format("{}", *x_ref) << std::endl;
}
std::cout << std::format("{}", *x_ref) << std::endl; // 运行时错误:此处 x_ref 已经是悬挂指针。
// 由于栈可能没有被改变,所以测试时可能不会发生运行时错误。
}
之所以借用检查器知道 x_ref 在第 8 行已经是悬挂引用,是因为它知道在第八行 x 所拥有的值已经死了,对应的引用 x_ref 比值 x 活得更久。
追踪生命周期
在函数体内追踪值和引用的生命周期是简单的:除了返回值,其他变量都在函数结束前被终结。问题是如何知道返回值的生命周期?
首先我们要明确,仅当返回值是引用时,计算生命周期才是有意义的。因为如果返回值是一个值,那么该值的所有权将由外层函数的一个变量名持有,与函数内的操作、调用函数前的代码都无关。而如果返回值是一个引用,则它一定来自与参数。
由于程序中可能存在分支,参数也可能是一个结构体的引用,所以编译器不可能在运行时准确知道返回值的生命周期到底等于哪个参数的生命周期,也很难在编译时知道返回值的生命周期至少是多少。
Rust 程序 14:不支持(编译失败)fn min_ref(x: &i32, y: &i32) -> &i32 {
if x < y {
return x;
}
return y;
}
fn main() {
let x = 114;
let y = 514;
let x_or_y_ref = min_ref(&x, &y);
println!("{}", x_or_y_ref);
}
C++ 程序 14:不支持
import std.core;
const int* min_ref(const int* x, const int* y)
{
if (*x < *y)
return x;
return y;
}
int main()
{
const auto x = 114;
const auto y = 514;
const auto* x_or_y_ref = min_ref(&x, &y);
std::cout << std::format("{}", *x_or_y_ref) << std::endl;
}
Rust 程序 14 报出以下错误:
error[E0106]: missing lifetime specifier
--> src\main.rs:1:33
|
1 | fn min_ref(x: &i32, y: &i32) -> &i32 {
| ---- ---- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x`
or `y`
help: consider introducing a named lifetime parameter
|
1 | fn min_ref<'a>(x: &'a i32, y: &'a i32) -> &'a i32 {
| ++++ ++ ++ ++
For more information about this error, try `rustc --explain E0106`.
意思是,编译器不知道函数 min_ref 的返回值的生命周期到底应该是多少。实际上,由于 min_ref 既有可能返回 x,也有可能返回 y,所以 min_ref 的生命周期应该是 x 和 y 中的最小者。只要参数中包含多个与返回值类型相同的引用,Rust 编译器就无法帮我确定,必须像上面的提示那样使用生命周期标记解决问题。
fn min_ref<'a>(x: &'a i32, y: &'a i32) -> &'a i32 {
if x < y {
return x;
}
return y;
}
fn main() {
let x = 114;
let y = 514;
let x_or_y_ref = min_ref(&x, &y);
println!("{}", x_or_y_ref);
}
C++ 程序 15:不支持
import std.core;
const int* min_ref(const int* x, const int* y)
{
if (*x < *y)
return x;
return y;
}
int main()
{
const auto x = 114;
const auto y = 514;
const auto* x_or_y_ref = min_ref(&x, &y);
std::cout << std::format("{}", *x_or_y_ref) << std::endl;
}
其中,'a 是生命周期标记的名字(通常都取名为 'a),它本身就代表了一个生命周期。对于参数(已知生命周期的引用),其生命周期至少和 'a 一样长(大于等于),称为输入生命周期;对于返回值(未确定生命周期的引用),其生命周期不能比 'a 长(小于等于),称为输出生命周期。
知道了返回值的生命周期,Rust 编译器就能在编译时帮我们在函数间追踪生命周期了,可以完全规避悬挂指针问题,如下所示。
Rust 程序 16:好支持顶(编译失败)fn min_ref<'a>(x: &'a i32, y: &'a i32) -> &'a i32 {
if x < y {
return x;
}
return y;
}
fn main() {
let x_or_y_ref;
let x = 114;
{
let y = 514;
x_or_y_ref = min_ref(&x, &y);
}
println!("{}", x_or_y_ref); // 编译失败:x_or_y_ref 的生命周期比生命周期最短的 y 还长。
}
C++ 程序 16:不支持
import std.core;
const int* min_ref(const int* x, const int* y)
{
if (*x < *y)
return x;
return y;
}
int main()
{
const int* x_or_y_ref;
const auto x = 114;
{
const auto y = 514;
x_or_y_ref = min_ref(&x, &y);
}
std::cout << std::format("{}", *x_or_y_ref) << std::endl;
// 尽管这个程序没问题,但把 x 和 y 的值换一下就在逻辑上出错了。
}
虽然后面还会遇到更复杂的生命周期标记问题,但只需牢记,Rust 引入生命周期标记的目的是帮助编译器推断程序中所有变量的生命周期,从而完全规避悬挂指针问题。当你觉得编译器在某个地方不好自动推断生命周期时,就该在新的地方引入生命周期标记了。
生命周期是 Rust 的核心概念之一,在之后学习更多概念后,会不断补充生命周期相关的知识。
6. 基本数据类型
常见基本数据类型
前面已经让大家熟悉了 Rust 常见整数类型的命名规范,下面的程序列出了所有的常见基本数据类型。
Rust 程序 17:常见基本类型fn main() {
let x_1: i8 = 0b00000000; // 使用 0b 表示二进制。
let x_2: i16 = 0o007; // 使用 0o 表示八进制。
let x_3: i32 = 008; // 十进制可加前导零。
let x_4: i64 = 0xBEAF; // 使用 0x 表示十六进制。
let x_5: i128 = 114_514; // 使用下划线作为分隔符。
let x_6: isize = 0xDEADDEAD; // 大小与目标处理器架构的指针大小相同。
let x_7: u8; // = -1; // 编译错误:不允许超出范围。
let x_8: u16;
let x_9: u32;
let x_10: u64 = 2147483649; // 字面量超出 i32 范围时,必须显式指明类型。
let x_11: u128;
let x_12: usize;
let x_13: f32 = 114.514;
let x_14: f64 = 114.514; // 字面量默认是 f64。
let x_15: char = '字'; // UTF32。
let x_16: bool = true;
let x_17: (); // 称为单元类型。
}
C++ 程序 17:常见基本类型
import std.core;
int main()
{
// 省去 const。
int8_t x_1 = 0b00000000; // 使用 0b 表示二进制。
int16_t x_2 = 007; // 使用 0 表示八进制。
int32_t x_3 = 8; // 十进制不可加前导零。
int64_t x_4 = 0xBEAF; // 使用 0x 表示十六进制。
__int128_t x_5 = 114'514; // 使用单引号作为分隔符。
intptr_t x_6 = 0xDEADDEAD; // 大小与目标处理器架构的指针大小相同。
uint8_t x_7; // = -1; // 编译错误:不允许超出范围。
uint16_t x_8;
uint32_t x_9;
uint64_t x_10 = 2147483649; // 字面量超出 int 范围时,隐式转换为更大类型。
__uint128_t x_11;
uintptr_t x_12;
float x_13 = 114.514;
double x_14 = 114.514; // 字面量默认是 double。
char x_15 = 'A'; // 取决于环境的多字节编码。
bool x_16 = true;
struct unit_t {} x_17; // C++ 没有单元类型。
}
单元类型本身写作 (),其值也写作 (),其含义与 Python 中的 None 类似。
fn f1() {}
fn f2() -> () {
return;
}
fn f3() {
return ();
}
fn main() {}
C++ 程序 18:返回类型为 void 的函数
void f1() {} // 不写 -> () 不对应 auto,对应 void。
void f2() {
return;
}
void f3() {
return void();
}
int main() {}
序列类型
Rust 中,使用形如 114..514 的格式表示序列,用于 for 循环。
fn main() {
for i in 114..514 {
println!("{}", i);
}
}
C++ 程序 19:序列类型
import std.core;
int main()
{
for (auto i : std::views::iota(114, 514))
std::cout << std::format("{}", i) << std::endl;
}
Rust 中,亦可在 .. 右端加等号,表示包含右侧数值,如 114..=514。
语句与表达式
得益于引入了单元类型 (),Rust 中的语句块也是表达式:前面我们看到的语句块对应的表达式的类型都是 ()。语句块的值取决于最后一条没有分号的语句。
fn iiyo_koiyo() -> i32 {
114514
}
fn main() {
let x = {
let y = iiyo_koiyo();
y
};
}
C++ 程序 20:语句块表达式
int iiyo_koiyo()
{
return [&] {
return 114514; // 应当认为总是进行内联优化,没有函数调用。
}();
}
int main()
{
const int x = [&] {
const int y = iiyo_koiyo();
return y;
}();
}
要注意,定义变量语句不是表达式,但 return 语句是表达式,所以 return 可以省略分号;但不推荐这样做,格式化代码时也会帮你加上分号。
枚举类型
Rust 的枚举类型(enumeration)是 C++ 中的 variant。
fn main() {
enum MessageType {
EmptyMessage,
KeybdDown(i32), // 括号内写单个类型,是原类型。
MouseDown(i32, i32), // 括号内写多个类型,是元组,相关语法在之后介绍。
MouseUp { x: i32, y: i32 }, // 用大括号,是结构体,相关语法在之后介绍。
} // 无需分号。
let msg1 = MessageType::EmptyMessage;
let msg2 = MessageType::KeybdDown(65);
let msg3 = MessageType::MouseDown(114, 514);
let msg4 = MessageType::MouseUp { x: (114), y: (514) };
}
C++ 程序 21:variant
import std.core;
struct unit_t {}; // C++ 没有单元类型。
int main()
{
enum MessageType_enum
{
EmptyMessage,
KeybdDown,
MouseDown,
MouseUp,
};
struct MouseUp_struct
{
int32_t x;
int32_t y;
};
using MessageType = std::variant<
unit_t,
int32_t,
std::tuple<int32_t, int32_t>,
MouseUp_struct
>;
const auto msg1 = MessageType::variant(std::in_place_index<EmptyMessage>);
const auto msg2 = MessageType::variant(std::in_place_index<KeybdDown>, 65);
const auto msg3 = MessageType::variant(std::in_place_index<MouseDown>, 114, 514);
const auto msg4 = MessageType::variant(std::in_place_index<MouseUp>,
MouseUp_struct{ .x = 114, .y = 514 });
}
可以看出,Rust 在语法上支持 variant,比 C++ 简洁许多。
三、Rust 的控制(一):计算密集型程序
1. 选择结构
if 语句
if 语句的语法为:
<expression> ::= <if_else_statement>
| ...
<if_else_statement> ::= "if" <condition> <block> "else" <block>
需要注意:
if语句本身也是表达式,其值等于某个分支的语句块对应的值。注意,前面讲过语句块也是表达式。- 可以利用
if语句的表达式身份实现三目运算符。此时,两个分支对应的语句块的返回值类型要么一致,要么有的分支进行了跳转。 - 语句块的大括号不可省略。
- 条件不加括号。条件必须是布尔型。
由于语句块的大括号不可省略,所以 else if 不能像 C++ 那样视为 else 和 if 组合,而要将其视为单独的语法。
fn main() {
let x = if true { 114 } else { 514 };
}
C++ 程序 22:三目
int main()
{
const auto x = true ? 114 : 514;
}
枚举类型与 match 语句
应用于枚举类型时,Rust 的 match 语句类似于 C++ 中的 visit,但完全按照枚举编号访问函数。
fn main() {
enum MessageType {
EmptyMessage,
KeybdDown(i32),
MouseDown(i32, i32),
MouseUp { x: i32, y: i32 },
}
let msg = MessageType::KeybdDown(65);
let msg_result = match msg {
MessageType::EmptyMessage => 1,
MessageType::KeybdDown(_) => {
// _ 表示不关心该参数。
println!("Some key is pressed."); // _ 特殊:不可使用。
1 // 所有分支的类型必须一样。
}
MessageType::MouseUp { x, y } => {
println!("Mouse up (x = {}, y = {})", x, y);
1
}
_ => 0, // _ 特殊:其他类型均属于该分支。
};
}
C++ 程序 23:类似于 visit
import std.core;
struct unit_t {}; // C++ 没有单元类型。
int main()
{
enum MessageType_enum
{
EmptyMessage,
KeybdDown,
MouseDown,
MouseUp,
};
struct MouseUp_struct
{
int32_t x;
int32_t y;
};
using MessageType = std::variant<
unit_t,
int32_t,
std::tuple<int32_t, int32_t>,
MouseUp_struct
>;
const auto msg = MessageType::variant(std::in_place_index<KeybdDown>, 65);
const auto msg_result = [&]
{
if (msg.index() == EmptyMessage)
return 1;
else if (msg.index() == KeybdDown)
{
const auto _ = std::get<KeybdDown>(msg); // 不是引用,而是新的变量。
// 所以在 Rust 中,所有权会被拿走!
std::cout << std::format("Key {} is pressed.", _) << std::endl; // _ 没什么特殊的。
return 1; // 所有分支的类型必须一样。
}
else if (msg.index() == MouseDown)
{
const auto [x, y] = std::get<MouseDown>(msg);
std::cout << std::format("Mouse up (x = {}, y = {})", x, y) << std::endl;
return 1;
}
else // 对应 _。参数不可访问。
{
// 也可以执行其他代码。
return 0;
}
}();
}
如同 C++ 中的 visit 必须支持 variant 的所有类型,Rust 中的 match 必须支持 enum 的所有类型。特别地,我们不关心的类型用 _ 这个特殊的符号跳过。
要注意,参数会转移所有权。如果不希望转移所有权,需要在 match 的对象前加上 &,即改为 match &msg。
C++ 程序 23 中,我们其实没有使用 visit,是因为按序号进行索引更符合 Rust 的行为;以 visit 为参照只是为了说明 Rust 中 match 语句必须涵盖 enum 的所有类型。另外,我们也没有使用 C++ 中的 switch,这是因为 Rust 中的 match 语句的行为在本质上是后面马上要讲到的模式匹配,在 C++ 中用 if 语句进行模拟更为合适。
枚举类型与 if let 语句
当我们只关心枚举类型是否与一种具体类型相匹配时,我们无需使用 match 语句,而应当使用 if let 语句。
fn main() {
enum MessageType {
EmptyMessage,
KeybdDown(i32),
MouseDown(i32, i32),
MouseUp { x: i32, y: i32 }, // 结构体,相关语法在之后介绍。
}
let msg = MessageType::KeybdDown(65);
let msg_result = match &msg {
// 不转移所有权。
MessageType::KeybdDown(key) => {
println!("Key {} is pressed.", key);
1
}
_ => 0,
};
let msg_result = if let MessageType::KeybdDown(key) = &msg {
println!("Key {} is pressed.", key);
1
} else {
0
}; // 与前面的 match 完全等价。
}
C++ 程序 24:if let 语句
import std.core;
struct unit_t {}; // C++ 没有单元类型。
int main()
{
enum MessageType_enum
{
EmptyMessage,
KeybdDown,
MouseDown,
MouseUp,
};
struct MouseUp_struct
{
int32_t x;
int32_t y;
};
using MessageType = std::variant<
unit_t,
int32_t,
std::tuple<int32_t, int32_t>,
MouseUp_struct
>;
const auto msg = MessageType::variant(std::in_place_index<KeybdDown>, 65);
const auto msg_result_1 = [&]
{
if (msg.index() == KeybdDown)
{
const auto& key = std::get<KeybdDown>(msg); // 注意使用了 &,是引用。
// 所以在 Rust 中,所有权不会改变。
std::cout << std::format("Key {} is pressed.", key) << std::endl;
return 1;
}
else
return 0;
}();
const auto msg_result_2 = [&]
{
if (msg.index() == KeybdDown)
{
const auto& key = std::get<KeybdDown>(msg);
std::cout << std::format("Key {} is pressed.", key) << std::endl;
return 1;
}
else
return 0;
}(); // 与前面的 match 完全等价。
}
要注意,if let 语句的语法为:
"if" "let" <pattern> "=" <enum_value> ...
其中的 = 应该翻译为“匹配”,而非“等于”或者“赋值”。模式匹配的含义将在之后讲解。
Option
Rust 中的 Option 是一个枚举类型,也就是说 Rust 用 C++ 中的 variant 来定义 optional。
Rust 中的 Option 是一个泛型,类似于 C++ 中的:
struct unit_t {}; // C++ 没有单元类型。
enum Option_enum
{
Some,
None,
};
template <typename T>
using Option = std::variant<T, unit_t>; // 前者称为 Some,后者称为 None。
由于 Option 十分有用、十分常用,所以 Rust 中无需写 Option<T>::Some(...)、Option<T>::None,直接写 Some(...)、None 即可。但不要忘记 Option 本身是枚举类型,所以一般使用 if let 语句处理 Option。
fn main() {
let mut optional_integer: Option<i32> = None; // 无法推断,不可省略类型。
let mut optional_string = None; // 可根据后文推断,可省略类型。
optional_string = Some(String::from("114514")); // Some 不可省略。
if let Some(value) = optional_string {
println!("{}", value);
}
if let None = optional_integer {
println!("No integer value.");
}
}
C++ 程序 25:optional
import std.core;
int main()
{
std::optional<int32_t> optional_integer = std::nullopt; // 无法推断,不可省略类型。
std::optional<std::string> optional_string = std::nullopt; // C++ 无法根据下文推断类型。
optional_string = std::string("114514"); // C++ 为 optional 赋值时可省略 optional。
if (optional_string)
{
const auto value = *optional_string;
std::cout << std::format("{}", value) << std::endl;
}
if (!optional_integer)
{
std::cout << std::format("No integer value.") << std::endl;
}
}
模式匹配与 match 语句、if let 语句
终于到了模式匹配。前面提到 match 语句和 if let 语句都是模式匹配,这里说的模式匹配实际上与 Python 中的模式匹配类似,C++ 不支持。
Rust 程序 26:模式匹配fn main() {
let x = 114;
let y = 514;
match (x, y) {
(114, 514) => println!("On point."),
(114, another_y) => println!("On x (y = {}).", another_y),
(another_x, 514) => println!("On y (x = {}).", another_x),
_ => println!("Off."),
}
}
Python 程序 26:模式匹配(Python 3.10)
x = 114;
y = 514;
match (x, y):
case (114, 514):
print("On point.")
case (114, another_y):
print("On x (y = {}).", another_y)
case (another_x, 514):
print("On y (x = {}).", another_x)
case default:
print("Off.")
以防有人不懂 Python 的模式匹配,下面再给出 C++ 的等价版本。
C++ 程序 26:不支持模式匹配import std.core;
int main()
{
const auto x = 114;
const auto y = 514;
if (x == 114 && y == 514)
std::cout << std::format("On point.") << std::endl;
else if (x == 114)
{
const auto another_y = y;
std::cout << std::format("On x (y = {}).", another_y) << std::endl;
}
else if (y == 514)
{
const auto another_x = x;
std::cout << std::format("On y (x = {}).", another_x) << std::endl;
}
else
std::cout << std::format("Off.") << std::endl;
}
除了 match 语句和 if let 语句,Rust 的模式匹配还能用在其他地方,例如定义变量时。
Rust 程序 27:定义变量时的模式匹配fn main() {
let (x, y) = (114, 514);
}
C++ 程序 27:定义变量时的结构化绑定
import std.core;
int main()
{
const auto [x, y] = std::tuple(114, 514);
}
可以认为模式匹配就是一种解包。Rust 中,模式匹配分为两种:
-
不可驳模式匹配(irrefutable pattern)。要求模式(
x, y)完全匹配右侧表达式((114, 514))对应类型((i32, i32))的所有取值。let 语句是典型的不可驳模式匹配。
-
可驳模式匹配(refutable pattern)。允许模式(例如
Some(x))不匹配表达式(例如an_option)对应类型(例如Option<i32>)的所有取值(例中包括Some(...)和None)。if let 语句是典型的可驳模式匹配。
模式还有很多形式,也还有很多场景可以使用模式匹配。
2. 循环结构
while 循环
while 语句的语法为:
<expression> ::= <while_statement>
| ...
<while_statement> ::= "while" <condition> <block>
while 语句在语法上充当表达式的角色,但其值永远都是 ()。
loop 循环
loop 就是 while true,但由于它只可能通过 break 结束,所以 loop 循环本身是可以有值的,这个值通过 break 语句传递。如果要使用 while true,总是应该替换为 loop。
fn main() {
const RESULT: i64 = {
let mut i = 0;
let mut j: i64 = 1;
loop {
i = i + 1;
j = j << 1;
if i == 63 {
break j;
}
}
};
println!("{}", RESULT);
}
C++ 程序 28:while while while
import std.core;
int main()
{
constexpr int64_t RESULT = []
{
int i = 0;
int64_t j = 1;
int64_t _while_result;
while (true)
{
i = i + 1;
j = j << 1;
if (i == 63)
{
_while_result = j;
break;
}
}
return _while_result;
}();
std::cout << std::format("{}", RESULT) << std::endl;
}
要注意,程序 28 中,无论是 Rust 还是 C++,RESULT 都是在编译时计算得出的。
break 语句只能在 loop 循环中才能把值接在后面,在 while 循环中是不可的。
for 循环
与 C++ 一样,Rust 的 for 循环作用于可迭代对象。与 match 语句、if let 语句一样,使用 for 循环时需要注意所有权的转移。
Rust 程序 29:for 循环fn main() {
let mut array = [1, 1, 4, 5, 1, 4]; // 数组,将在之后讲解。
for v in array { // v: i32
println!("{}", v);
}
for v in &array { // v: &i32
println!("{}", v);
}
for v in &mut array { // v: &mut i32
*v -= 1; // 运算时需要解引用。
}
for v in 114..514 {}
}
C++ 程序 29:range for 循环
import std.core;
int main()
{
auto array = std::array{ 1, 1, 4, 5, 1, 4 };
for (const auto v : array) // 不是引用,转移所有权。
std::cout << std::format("{}", v) << std::endl;
for (const auto& v : array) // 不转移所有权。
std::cout << std::format("{}", v) << std::endl;
for (auto& v : array) // 不转移所有权。
v -= 1;
for (const auto v : std::views::iota(114, 514));
}
四、Rust 的数据(二):数据密集型程序
1. 元组
Rust 在语法上支持元组。
Rust 程序 30:元组fn main() {
let tup = (114, 5.14, "114514"); // 使用小括号表示元组。
let (x, y, z) = tup; // 复习模式匹配。
let e0 = tup.0;
let e1 = tup.1;
let e2 = tup.2;
}
C++ 程序 30:元组
import std.core;
int main()
{
const auto tup = std::tuple(114, 5.14, "114514"); // 类型不确定时,必须显式写出 tuple。
const auto [x, y, z] = tup; // 复习结构化绑定。
const auto e0 = std::get<0>(tup);
const auto e1 = std::get<1>(tup);
const auto e2 = std::get<2>(tup);
}
使用元组时,无需引入新的生命周期知识,因为元组是匿名的,这意味着:
- 除了函数参数,元组中各元素的生命周期是确定的,只需将元组内的元素看作一个单独的变量即可。这也意味着元组内的各个元素的生命周期和所有权是分别管理的。
- 对于函数参数,只需将元组内的元素看作一个单独的变量即可,用与普通参数一样的方法指定生命周期标记。
fn min_ref<'a>((x, y): (&'a i32, &'a i32)) -> &'a i32 { // 使用小括号表示元组类型。在元素类型中指明生命周期。
// 此处 x, y 是不可驳模式匹配。
if x < y {
return x;
}
return y;
}
fn main() {
let x = 114;
let y = 514;
let x_or_y_ref = min_ref((&x, &y)); // 只有一个参数,类型是元组。
println!("{}", x_or_y_ref);
}
C++ 程序 31:不支持
import std.core;
const int* min_ref(std::tuple<const int*, const int*> _tuple) // 使用 tuple 类型表示元组类型。
{
auto& [x, y] = _tuple;
if (*x < *y)
return x;
return y;
}
int main()
{
const auto x = 114;
const auto y = 514;
const auto* x_or_y_ref = min_ref({ &x, &y }); // 类型确定时,可以使用聚合初始化。
std::cout << std::format("{}", *x_or_y_ref) << std::endl;
}
程序 31 和程序 15 没有什么本质上的差别,只是引入了一点点关于元组的新语法。
可以预见,由于结构体不是匿名的,所以会出现如何标记生命周期的问题,学习起来比元组稍微困难一点,所以我们在第六章再学习结构体。原理上,元组和结构体是一样的,都是一系列数据类型的复合。以如何标记生命周期这一问题为分界线,可以认为 Rust 中结构体和元组最大的区别是写出元组类型时相当于必须写出每个元素的类型,而写出结构体类型时只写一个名字,因此,第六章介绍的**元组结构体(tuple struct)**应当被视为一个结构体,尽管引入它的目的只不过是为元组类型定义一个别名。
2. 数组
Rust 中,使用 array 表示静态数组(之后简称为数组),使用 Vec 表示动态数组,与 C++ 完全一样。因此,本节只介绍静态数组 array,动态数组 Vec 留在标准库那章介绍。
可以说,Rust 的 array 与 C++ 的 std::array 完全一样,但 Rust 在语法上支持 array,写起来比 C++ 更简单。
fn main() {
let a1 = [1, 1, 4, 5, 1, 4];
let a2: [i32; 6] = [1, 1, 4, 5, 1, 4]; // 指明类型时,初始化列表长度必须和数组长度一样。
// 由于 Rust 不支持隐式类型转换,所以指明类型而不指明长度的数组是没有意义的。
let v1 = a1[0]; // 使用中括号访问数组元素。
let v2;
// v2 = a1[6]; // 默认情况下,编译和运行时均检查数组越界。
unsafe {
v2 = a1.get_unchecked(6); // 要不检查数组越界,代码是 unsafe 的。
}
let a3: [[i32; 2]; 3] = [[1, 2], [3, 4], [5, 6]]; // 多维数组只能用数组的数组表示,方括号一个也不能省。
let v3 = a3[1][2]; // 使用连续的方括号访问多维数组。
}
C++ 程序 32:简单类型的数组
import std.core;
int main()
{
const auto a1 = std::array{ 1, 1, 4, 5, 1, 4 }; // 根据初始化列表推导全部模板。
const std::array<int, 6> a2 = { 1, 1, 4 }; // 指明类型时,初始化列表长度可以和数组长度不一样,剩余元素补 0(默认构造函数)。
// const auto a3 = std::to_array<float>({ 1, 1, 4 }); // 使用 to_array 可以在指明类型的同时自动推导长度。
const auto v1 = a1.at(0); // 使用 at 函数进行带下标检查的元素访问。
const auto v2 = a1[6]; // 使用中括号访问数组元素。默认情况下,编译和运行时不检查数组越界。
// 编译器可能会警告。Debug 模式下可能会运行时错误。
const std::array<std::array<int, 2>, 3> a3 = { 1, 2, 3, 4, 5, 6 }; // int a3[3][2];
// 大括号一个也不能多。
const auto v3 = a3[2][1]; // 使用连续的方括号访问多维数组。注意顺序。
}
C++ 中,数组和单个数一样,作局部变量时允许不在定义时初始化。作为一门安全的语言,Rust 编译器要如何进行初始化检查?由于追踪每个元素是不可能的,所以 Rust 只承认对整个数组的初始化操作,只有对整个数组赋值才算完成数组的初始化。为此,Rust 引入了一个简单的语法,表示含有 n 个值为 v 的元素。
fn main() {
const n: usize = 114; // 不能用 let。注意类型是 usize。
let v = 514;
let a = [v; n]; // 将含有 114 个值为 514 的数组赋值给 a。
// 实际上没有数组移动的操作,只是让 a 拥有了数组的所有权。
}
C++ 程序 33:数组的初始化
import std.core;
int main()
{
constexpr size_t n = 114; // 可以用 const,但不推荐。
const auto v = 514;
std::array<std::decay_t<decltype(v)>, n> a;
a.fill(v);
}
以上 Rust 程序中,[v; n] 表示将 v Copy n 次,所以 v 对应的类型必须具有 Copy 特征,否则无法通过编译。对于没有 Copy 特征的复杂类型(例如 String),我们首先需要关注所有权问题和初始化问题。
- 所有权问题:所有权属于数组,而不属于数组中的任何一个元素。因为所有权的作用只是为了确认回收内存的时机。数组中的元素一定会在数组本身被回收后回收,所以无需为数组元素引入所有权的概念。所有权只与变量名绑定。
- 初始化问题:Rust 不允许数组未经初始化就使用,所以必然需要一个生成含有非 Copy 类型的数组的工具函数。
fn main() {
const n: usize = 114;
let v = "514";
let a: [String; n] = std::array::from_fn(|_| String::from(v));
for s in a {
break;
}
// a 已经失去对数组的所有权。
}
C++ 程序 34:复杂类型的数组
import std.core;
int main()
{
const size_t n = 114; // 可以用 const,但不推荐。
const auto v = "514";
auto a = std::make_unique<std::array<std::string, n>>();
std::for_each(a->begin(), a->end(), [&](std::string& s)
{
// _ 是下标。
s = v;
});
for (auto t = std::move(a); const auto &s : *t)
break;
// a 已经失去对数组的所有权。
}
对比程序 33 和程序 34 可以看出,如果数组元素的类型具有 Copy 特征,那么数组本身就是具有 Copy 特征的,使用时无需考虑所有权问题,因为每个变量名都会拥有一个值。反之,如果数组元素的类型不具有 Copy 特征,那么数组本身也不具有 Copy 特征,需要考虑所有权。
鉴于所有权的概念与 C++ 中 std::unique_ptr 的区别日渐凸显,今后的程序不再使用 std::unique_ptr 解释所有权。
3. 数组切片
数组切片其实就是 C++ 中的 std::span,只不过 Rust 在语法层面上支持它。
fn main() {
const N: usize = 114;
let v = 514;
let mut a = [v; N]; // 复习:[v; N],类型是 [T; N]。
let a1 = &a; // 类型为 &[i32; 114]。
let a2 = &mut a[1..2]; // 类型为 &mut [i32],左闭右开。
a2[0] = 114514;
println!("{}", a[1]);
}
C++ 程序 35:span
import std.core;
int main()
{
constexpr size_t N = 114;
const auto v = 514;
std::array<std::decay_t<decltype(v)>, N> a;
a.fill(v);
const auto& a1 = a; // 类型为 const std::array<...>&。
auto a2 = std::span(a.begin() + 1, a.begin() + 2); // 类型为 std::span<int>,左闭右开。
a2[0] = 114514;
std::cout << std::format("{}", a[1]) << std::endl;
}
Rust 中,切片其实是切片引用的简称,类型记为 &[i32]。实际上 [i32] 才是切片,但这种切片表示一个数据块,在代码中是不允许直接访问的,就像 C++ 中 new int[114514] 只能用指针指向,不存在一个长度为 sizeof(int) * 114514 的类型包含它。之后,我们总是用“切片”指代“切片引用”。
原理上,要实现切片,只需在编译时知道元素类型,在运行时知道起始地址和结尾地址(或元素个数),C++ 的 std::span 便是如此。虽然从 Rust 的语法看来,切片是一个引用,但其实它内部保存的信息和 C++ 的 std::span 一样,而不仅仅只是一个指针。可见,Rust 的引用不止可以表示一个指针,还可以带有更复杂的信息,这样的引用常被称为胖指针。
最后,我们讨论 &[i32; 114] 和 &[i32] 的区别。与 C++ 一样,前者是一个数组的引用,编译器明确知道数组的元素类型、大小,也明确地知道整个数组在哪里;而后者只在编译时知道元素类型,在运行时记录起始地址和终止地址,其余内容,包括原数组地址、原数组大小,都一概不知。
4. 字符串、字符串切片、字符、字符串字面量
我们很早以前就接触过 String:为了讲解所有权,我们用到了 String 这一“复杂类型”。事实上,String 就是 C++ 中的 std::string。回忆,我们构造 String 的写法是:
String::from("114514")
既然如此,我们自然知道了 "114514" 和 String::from("114514") 不是同一个类型。如果以 C++ 的角度来看,字面量 "114514" 的类型是 const char*。事实上,Rust 与之类似,不过更高级:Rust 中的字面量的类型等价于 C++ 中的 std::string_view。
fn main() {
let l: &str = "114514"; // 字面量的类型是 &str。
println!("{}", l.len()); // &str 提供了一系列实用方法。
let mut s = String::from(l);
s.push_str("114514"); // 该方法的参数类型是 &str。
println!("{}", s);
}
C++ 程序 36:字符串
import std.core;
int main()
{
const std::string_view l = "114514"; // 调用 std::string_view 的 const char* 构造函数。
std::cout << std::format("{}", l.length()) << std::endl; // std::string_view 提供了一系列实用方法。
auto s = std::string(l);
s.append("114514"); // 该方法的参数类型是 const char*。
std::cout << std::format("{}", s) << std::endl;
}
从程序 36 中,我们又一次感受到了 Rust 的高级,字符串字面量的类型自然就相当于 C++ 中更抽象的 std::string_view(事实上是 std::u8string_view,为了让 C++ 程序便于通过编译,此处略去),不会涉及无意义的 const char*。事实上,不同于 C 或 C++,Rust 中所有的字符串都不应当看作以 \0 结尾,而应当天然地看作起始地址加长度的组合。
从 Rust 的语法来看,String、&str 与 Vec(动态数组)、&[T](切片)的原理是相同的,&str 在语法上就应当看作一个切片。进而,str 和 [T] 的原理也相同,我们不能定义一个类型为 str 的变量,就像在 C++ 中我们不能用非指针类型保存 new char[114514] 的结果。既然 String 和 &str 在原理上与 Vec 和 &[T] 相同,那为什么在基础语法中就要单独定义 String 和 &str?这是因为字符串作为最常用的类型之一,需要抽象为一个单独的类型。
字符串的一个重要抽象特征是字符串编码。Rust 中,字符串的编码是 UTF-8,所以不可以使用索引处理字符串或字符串切片本身,需要用到 String 或 &str 的一些方法,甚至一些第三方库,才能正确地处理字符串。例如,可以用 as_bytes() 方法得到 &[u8] 类型的切片,表示字符串内部保存的字节,这样就可以访问某一特定字节了。
虽然不能索引字符串,但是可以对字符串进行切片,不过一旦切片的位置错误,就会出现运行时错误。要正确处理存放自然语言的字符串,首先需要良好地定义字符类型。Rust 定义了字符类型 char,表示一个 UTF-32 字符。字符字面量使用单引号表示。
fn main() {
let utf8_string = "いいよこいよ";
println!("Length: {}", utf8_string.len()); // 18。
for byte in utf8_string.bytes() {
// byte 的类型是 &u8。
print!("{} ", byte);
}
println!("");
for ch in utf8_string.chars() {
// ch 的类型是 char。
print!("{}", ch);
}
println!("");
}
C++ 程序 37:字符
import std.core;
int main()
{
const std::u8string_view utf8_string = u8"いいよこいよ";
std::cout << std::format("Length: {}", utf8_string.length()) << std::endl; // 18。
for (const auto& byte : utf8_string)
{
// byte 的类型是 const char8_t&。
// std::cout << std::format("{} ", byte);
// 不支持。
}
std::cout << std::endl;
for (auto ch : std::filesystem::path(utf8_string).u32string()) // 转换为 UTF-32 字符串。
{
// ch 的类型是 char32_t。
// std::cout << std::format("{}", ch);
// 不支持。
}
std::cout << std::endl;
}
在程序 37 中,值得注意的是 Rust 的 utf8_string.chars(),它并没有构造了一个完整的 UTF-32 字符串,而是产生了一个迭代器(事实上 bytes() 方法也是如此)。由于 C++ 处理字符串编码的能力较弱,所以 C++ 程序 37 很难正确反映 Rust 程序 37。
看完程序 36 和程序 37,我们关注一下一些常用的字符串方法。
String::from(...):很早就见过。可以从字符串字面量、字符串切片、其他字符串构造新的字符串。len():获取字符串的字节数。push_str(...):向字符串尾部附加其他字符串。bytes():返回一个迭代器,逐字节迭代。chars():返回一个迭代器,逐字符迭代。as_bytes():将字符串视为一个&[u8]切片,便于逐字节访问。push(...):向字符串尾部附加一个字符。pop():删除字符串最后那个字符。String/&str + &str:拼接字符串。注意右操作数必须是字符串切片。如果左操作数是String,则之后失去所有权。表达式的结果是一个新的String。这就是运算符重载。mut String += &str:等价于mut String = String + &str。
最后,我们学习一下 Rust 中字符串字面量的相关语法,重点在转义符和原始字符串。
Rust 程序 38:字符串字面量fn main() {
let s1 = "\x31\x31\x34\x35\x31\x34"; // ASCII 字符使用 \x 转义。
println!("{}", s1);
let s2 = "\u{211D}"; // UCS 字符使用 \u{} 转义。
println!("{}", s2);
let s3 = r##"C:\Windows\System32\"##; // 使用 r#""# 表示原始字符串。可以加任意多井号。
println!("{}", s3);
}
C++ 程序 38:字符串字面量
import std.core;
int main()
{
const auto s1 = "\x31\x31\x34\x35\x31\x34"; // ASCII 字符使用 \x 转义。
std::cout << s1 << std::endl;
// 无法转义 UCS 字符。
const auto s3 = R"114514(C:\Windows\System32\)114514"; // 使用 R"()" 表示原始字符串。可以在括号和引号间同时加上任意字符串。
std::cout << s3 << std::endl;
}
五、Rust 的控制(二):跨函数的控制
1. 函数
复习
我们已经写出了不少函数,下面以一个例子简单复习下定义函数的语法。
Rust 程序 39:复习函数语法fn main() {
println!("{}", iiyo_koiyo(0xDEADBEAF)); // 字面量超出 i32 范围,不允许用于 i32 类型的参数。
}
fn iiyo_koiyo(_x: u32) -> i32 { // 实现可以在使用之后。
114514 // 注意复习语句块作为表达式的语法。
}
C++ 程序 39:复习函数语法
import std.core;
int32_t iiyo_koiyo([[maybe_unused]] uint32_t _x) // 先声明,后使用。下划线开头的名字表示可以不用。
{
return 114514;
}
int main()
{
std::cout << std::format("{}", iiyo_koiyo(0xDEADBEAF)) << std::endl; // 允许字面量决定类型。
}
另外,可以参见程序 18,复习返回值类型、单元类型的相关语法。
发散函数
**发散函数(diverge function)**表示永不返回的函数。例如,如果调用某函数一定会发生错误导致程序终止,则该函数属于发散函数。又例如,如果某个函数是永不跳出的死循环,则该函数也是发散函数。
发散函数的语法是将返回值类型写为 -> !。
fn dead_beaf() -> ! {
loop {}
}
fn main() {
dead_beaf();
}
C++ 程序 40:发散函数(C++23)
import std.core;
[[noreturn]] void dead_beaf()
{
while (true);
}
int main()
{
dead_beaf();
std::unreachable(); // 如果发散函数返回,则行为不确定(UB)。
}
Rust 会在编译时检查发散函数是否一定发散,如果不是,则发生编译错误。所以正常情况下发散函数真的不可能返回。
2. 错误处理
没有异常
很多编程语言中,使用**异常(exception)处理错误。异常本身是一个很深奥的话题,因为它涉及了跨函数的控制,需要编译器和操作系统做很多工作。例如,在 Windows 中,C++ 的异常系统就是利用 Windows 的结构化异常处理(structured exception handling, SEH)**实现的。
Rust 没有异常系统,而是将问题分为两类:
- 可恢复错误。既然可恢复,就手动多判断下是否成功。Rust 在语法上支持相关功能。
- 不可恢复错误。既然都寄了,程序就该终止了。
可恢复错误相对简单,因为本质上它只是多做几次判断,我们首先学习它。不可恢复错误涉及程序需要终止时的行为,我们之后再学习。
可恢复错误:Result 类型
可恢复错误使用 Result 类型处理。Result 类型在成功时保存结果值,在失败时保存错误代码,所以它是一个枚举类型。
enum Result<T, E> {
Ok(T),
Err(E),
}
要处理 Result,可以使用 match 语句。
enum error_code_t {
FileNotFound,
PasswordError,
}
fn read_file(file_name: &str, password: &str) -> Result<String, error_code_t> {
if file_name != "114514" {
return Err(error_code_t::FileNotFound); // Err 是枚举类型 Result 的成员。
}
if password != "114514" {
return Err(error_code_t::PasswordError);
}
return Ok(String::from("114514")); // Ok 也是枚举类型 Result 的成员。
}
fn main() {
match read_file("114514", "114514") {
Ok(value) => {
println!("File content: {}", value);
}
Err(error_code) => match error_code {
error_code_t::FileNotFound => {
println!("Error: File not found!");
}
error_code_t::PasswordError => {
println!("Error: Password error!");
}
},
}
}
C++ 程序 41:expected 类型(C++23)
import std.core;
enum class error_code_t
{
FileNotFound,
PasswordError,
};
std::expected<std::string, error_code_t> read_file(
const std::string& file_name,
const std::string& password)
{
if (file_name != "114514")
return std::unexpected{ error_code_t::FileNotFound }; // std::unexpected 不可省略。
if (password != "114514")
return std::unexpected{ error_code_t::PasswordError };
return "114514";
}
int main()
{
if (const auto value = read_file("114514", "114514"))
{
std::cout << std::format("File content: {}", *value) << std::endl;
}
else
{
switch (value.error())
{
case error_code_t::FileNotFound:
std::cout << "Error: File not found!" << std::endl;
break;
case error_code_t::PasswordError:
std::cout << "Error: Password error!" << std::endl;
break;
default:
std::unreachable();
}
}
}
可恢复错误的传播
所谓传播,是指将调用函数得到的 Err<E> 类型返回值继续向外返回。当然可以直接编写如下代码:
match read_file("114514", "114514") {
Ok(value) => {
println!("File content: {}", value);
}
Err(error_code) => return Err(error_code), // 假设函数返回值类型为 Result<T, E>。
}
但由于错误传播使用得很广,所以当我们希望在调用函数出现错误就直接返回 Err<E> 时,可以直接在函数后加上 ?。如果成功,将直接得到 T 类型的结果,否则自动返回 Err<E>,如下所示。
let value = read_file("114514", "114514")?;
println!("File content: {}", value);
要注意,只有可以进行错误传播时,才能用 ?,若当前函数的返回类型不是 Result(或 Option;可以将 Option 看作特殊的 Result),则不能用 ?。
之前,我们所有的 main 函数都返回单元类型。如何指定 main 函数的返回值呢?可以将 main 函数的返回值类型指定为 Result<(), E>,如下所示。事实上,只有能够满足“能够从类型中提取出程序退出代码”的特征,就能作为 main 函数的返回值类型。
fn read_file(file_name: &str, password: &str) -> Result<String, i32> {
if file_name != "114514" {
return Err(1); // Err 是枚举类型 Result 的成员。
}
if password != "114514" {
return Err(2);
}
return Ok(String::from("114514")); // Ok 也是枚举类型 Result 的成员。
}
fn main() -> Result<(), i32> {
let value = read_file("?", "114514")?; // 注意结果是 T,而不是 Result<T, E>。
println!("File content: {}", value); // 因为上面的结果是 T,所以可以直接用。
return Ok(()); // 不可省略,因为即使返回 (),也要显式写为 Ok(())。
}
C++ 程序 42(不支持)
要注意,程序 42 中将 i32 错误代码并不是一个标准而正确的做法,此处只是为了解释 ? 的语法。
最后,我们提一下 ? 的独有优势。? 能够自动将返回值中的 Err<E1> 类型转换为当前函数返回值对应的 Err<E2>,只要满足“E1 能够转换为 E2”的特征。
不可恢复错误:panic!
发生不可恢复错误程序就该崩溃了,在 Rust 中被称为 panic。首先,panic 可以由其他函数触发。
Rust 程序 43:6fn main() {
let v = vec![1, 1, 4, 5, 1, 4];
v[6];
}
C++ 程序 43:6
import std.core;
int main()
{
const auto v = std::vector{ 1, 1, 4, 5, 1, 4 };
v.at(6); // Rust 方括号访问自带下标检查。
}
其次,也可以主动调用 panic! 宏。
fn main() {
panic!("Basketball code {}.", 114514); // 可格式化。
}
C++ 程序 44:7777
import std.core;
int main()
{
std::abort(); // 记住 panic! 是异常结束。
}
如果程序只有一个主线程,则可以按上述方式理解。但子线程中的 panic 只会导致单个子线程被安全销毁。
除了 panic!,还有其变体 unimplemented!、todo! 等,它们可以在还没有实现的函数内使用,以让程序具有更强的语义。
如果某个函数专用于 panic,则应该将那个函数的返回值类型设置为 !。
输出栈帧信息
默认情况下,Rust 程序发生 panic 时会自动输出栈帧信息。通过配置清单文件可以让程序只崩溃,不输出栈帧信息,此处不做详细介绍。
C++ 中,默认不会输出栈帧信息。但可以通过 stacktrace 类手动获取栈帧信息。
fn main() {
panic!();
}
C++ 程序 45:输出栈帧信息后再退出(C++23)
import std.core;
int main()
{
std::cout << std::stacktrace::current() << std::endl;
std::abort();
}
可恢复错误的处理范式
最后,我们来看 Rust 中正确运用错误系统的常用范式。
如果开发者知道一个操作一定成功。例如,对于将字符串转为整数的函数,开发者可以知道输入 "114514" 一定可以成功。这种情况下,可以使用 unwrap 函数直接提取结果,不需要判断是否出错。当然,如果出错,则直接 panic。
如果开发者知道一个操作失败后就必须 panic。例如,对于输入文件名,如果找不到文件,程序就该退出,并且还应该告诉用户一些信息。这种情况下,可以使用 expect 函数尝试直接提取结果。expect 和 unwrap 看上去只相差一条用户提示信息。
fn read_file(file_name: &str, password: &str) -> Result<String, i32> {
if file_name != "114514" {
return Err(1);
}
if password != "114514" {
return Err(2);
}
return Ok(String::from("114514114514"));
}
fn main() {
let password = read_file("114514", "114514").unwrap();
println!("{}", password);
let user_value = read_file("114514", &password).expect("Password error!");
println!("{}", user_value);
}
C++ 程序 46:unwrap 和 expect
import std.core;
std::expected<std::string, int> read_file(
const std::string& file_name,
const std::string& password)
{
if (file_name != "114514")
return std::unexpected{ 1 };
if (password != "114514")
return std::unexpected{ 2 };
return "114514114514";
}
int main()
{
const auto password = read_file("114514", "114514").value();
std::cout << std::format("{}", password) << std::endl;
// 不支持。
}
注意在 Rust 程序 46 中,错误代码选用了 i32 类型,而没有选用此前定义过的 error_code_t,是因为 error_code_t 缺少一些特征,暂时不太适合作为错误代码类型,我们在下一章类型系统中会再讨论这个问题。
如果开发者要对一个 Result 或 Option 作链式处理,可以选用组合器模式。此处不再详细讲解。
六、Rust 的数据(三):类型系统入门
1. 结构体
定义结构体
Rust 使用 struct 关键字定义结构体。定义的最后无需使用分号。构造结构体时,需要具名给出所有的成员初始值。
fn main() {
struct User {
user_name: String,
password_hash: String,
}
let user1: User;
let user2 = User {
password_hash: String::from("114514"),
user_name: String::from("114514"),
};
}
C++ 程序 47:定义结构体
import std.core;
int main()
{
struct User
{
std::string user_name;
std::string password_hash;
};
User user1;
const auto user2 = User{ // 不同于 Rust,C++ 中要求必须有序。
.user_name = std::string("114514"),
.password_hash = std::string("114514"),
};
}
以上定义语法还可以再简化。
- 如果希望用与成员变量同名的变量对该成员初始化,可以只写一次名字。
- 如果希望剩余变量全部来自另一个同类型对象,可以用结构体更新语法。
struct User {
user_name: String,
password_hash: String,
}
fn main() {
let password_hash = String::from("114514");
let user1 = User {
password_hash, // 只用写一次名字。注意所有权的转移。
user_name: String::from("114514"),
};
let user2 = User {
user_name: String::from("lbwnb"),
..user1 // 结构体更新语法。只能放在最后,不可再加逗号。注意所有权的转移。
};
}
C++ 程序 48(不支持)
最后提醒,结构体的可变性是整体的。不能为结构体中的单个成员指定可变性。
结构体的生命周期
最基本的原则是,结构体中各个成员的生命周期是单独管理的,这与前面讲解元组时不矛盾。然而,前面讲解元组时提出,结构体在定义后可以反复使用,所以需要为其中的引用类型标记生命周期。在继续下面的内容前,请先反复复习第二章第 5 节的内容。
与函数类似,为结构体打上生命周期标记分为两步:
- 指定结构体的生命周期标记为
'a,表示结构体本身的生命周期不能比'a长(小于等于)。 - 指定引用类型的生命周期标记为
'a,表示结构体中引用类型的生命周期至少和'a一样长(大于等于)。
这样,就显式指明了结构体中引用类型的生命周期必须比结构体本身的生命周期长。对于存在引用类型的结构体,必须显式指明其生命周期。
Rust 程序 49:结构体的生命周期struct StringView<'a> {
string: &'a str,
}
fn main() {
let literal = "114514"; // 本身就是 &str,理论上存储在程序只读区,生命周期与程序本身相同。
let string_view = StringView { string: literal };
println!("{}", string_view.string);
let string_view;
{
let string_value = String::from("114514"); // String 才能保证在右大括号结束生命。
string_view = StringView {
string: &string_value,
};
}
// println!("{}", string_view.string); // 不能再使用 string 成员。
}
C++ 程序 49(不支持)
为什么不直接让结构体中的所有引用类型成员活得比结构体本身更长,这样就无需额外打标记了?这是因为在其他地方还会用到结构体的生命周期标记,例如为结构体实现方法时。应当将生命周期标记看作结构体名字的一部分。
目前,关于生命周期的知识我们暂时学到这儿,之后还有很多关于生命周期的内容。
元组结构体与单元结构体
元组结构体相当于为元组起别名,而单元结构体就是一个空的结构体。
Rust 程序 50:元组结构体与单元结构体struct TupleStruct<'a>(i32, &'a str);
struct UnitStruct;
fn main() {
// 语法:在元组的前面加上元组结构体的名字。
let t = TupleStruct(114514, "114514");
// 语法:直接只写名字。
let u = UnitStruct;
}
C++ 程序 50:元组类型与空结构体
import std.core;
using TupleStruct = std::tuple<int32_t, std::string_view>;
struct UnitStruct {};
int main()
{
const auto t = TupleStruct(114514, "114514");
const auto u = UnitStruct();
}
结构体的方法
Rust 中,方法的定义和实现与结构体的定义是完全分开的。方法放在 impl 块中。
struct Rect {
left: i32,
top: i32,
right: i32,
bottom: i32,
}
// impl 块与 struct 完全分离,可以存在多个。
impl Rect {
// 不以 self 开头的方法称为关联方法(即 C++ 中的静态方法)。约定俗成以 new 为构造器的名称。
// Self 是关键字,表示当前类型。
fn new(left: i32, top: i32, width: i32, height: i32) -> Self {
Rect {
left,
top,
right: left + width,
bottom: top + height,
}
}
// 以 self 开头的方法为非静态方法。self 是关键字。另外,成员函数可以和成员变量同名。
fn width(&self) -> i32 {
self.right - self.left
}
// 将 self 指定为可变,可以修改 self。
fn set_width(&mut self, new_width: i32) {
self.right = self.left + new_width;
}
}
fn main() {
let mut r = Rect::new(114, 514, 114, 514);
r.set_width(114514);
println!("{}", r.width());
}
C++ 程序 51:方法(C++23)
import std.core;
struct Rect
{
int32_t left;
int32_t top;
int32_t right;
int32_t bottom;
static Rect _new(int32_t left, int32_t top, int32_t width, int32_t height)
{
return Rect{ .left = left, .top = top, .right = left + width, .bottom = top + height };
}
int32_t width(this const Rect& self) // C++23。旨在说明 Rust 中 &self 的含义。
{
return self.right - self.left;
}
void set_width(this Rect& self, int32_t new_width)
{
self.right = self.left + new_width;
}
};
int main()
{
auto r = Rect::_new(114, 514, 114, 514);
r.set_width(114514);
std::cout << std::format("{}", r.width()) << std::endl;
}
对于非静态方法,也可以将第一个参数记为 self,这样会发生所有权的转移,目前我们暂时没有这种情况的应用场景,也就不讨论了。
但由此可以注意到,方法的第一个参数 &self 或 &mut self 是引用,则必然需要讨论生命周期问题。对于 self 相关的生命周期,编译器在自动推导时会进行特殊处理。先回忆:对于一般的函数,如果返回值是引用类型且只存在一个引用类型的参数,则无需生命标记;但如果存在多个引用类型的参数,则需要手动标记生命周期。事实上,对于一般的函数,编译器帮我们做了下列工作:
- 为每一个引用参数分配独自的生命周期。
- 若只存在一个引用参数,则将该参数的生命周期赋给所有输出生命周期。
因此,对于编译器自动标记生命周期的情况,当存在多个引用类型的参数时,编译器会告诉你它不知道返回的引用应该借用自谁,这时就需要手动指定生命周期标记。
而对于非静态的方法,编译器帮我们做的工作有所不同:
- 为每一个引用参数分配独自的生命周期。
- 若存在
&self或&mut self,则将&self或&mut self的生命周期赋给所有输出生命周期。
这样的设计思路是,对于非静态方法,默认就认为返回的引用类型借用自 self 对象。如果并非如此,则需要手动指定生命周期。
struct Student {
name: String,
}
impl Student {
// 有两个引用类型的参数,但仍然不会报错。
fn name(&self, _: &str) -> &str {
&self.name
}
// 借用自其他参数,必须手动标记生命周期。
fn append_name_to<'a>(&self, another: &'a mut String) -> &'a str {
another.push_str(&self.name);
return another;
}
}
fn main() {
let student = Student {
name: String::from("马老师复活了"),
};
println!("{}", student.name("dummy"));
let mut s = String::from("114514: ");
println!("{}", student.append_name_to(&mut s));
println!("{}", s);
}
C++ 程序 52(不支持)
至此,我们已经明确学完了编译器自动标注生命周期的原则。之后关于生命周期的难点只剩下手动标记生命周期的方法。
2. 特征
特征的概念
Rust 中的特征(trait)基本上就是 C++ 中的概念(concept),但为了适合 Rust 的编程范式,Rust 中的特征还发挥着 C# 中接口(interface)的作用。虽然应用特征时往往离不开泛型(generics),但我们可以先较为完整地介绍特征,再仔细地了解泛型,以尽早掌握特征带来的抽象能力。事实上,我们很早之前在程序 9 就接触过特征及其少量语法了。当时,我们学习了 Copy 特征;我们完全不需要了解 Copy 特征是什么,只需要知道一个满足 Copy 特征的类型是不用理会所有权转移的。虽然本节中我们将了解部分特征的本质,但使用特征的正确姿势应该是按特征的字面意思理解即可——否则这个特征的设计就是有问题的。
关于特征的学习,我们分为两个部分:
- 与 C++ 中概念(concept)相似的部分,在本节和下节学习。
- 与 C# 中接口相似的部分,主要在第七章学习。
我们首先关注 Rust 中如何定义特征。
Rust 程序 6:鸭子类型trait quackable // 名为 quackable 的特征。
{
fn quack(&self) -> ();
} // 必须具有 self.quack() 方法,且返回值类型为 ()。
fn f(t: impl quackable) // 要求 t 的类型具有该特征。
{
t.quack();
}
fn main() {
// f(114514); // 不满足特征,编译错误。
}
C++ 程序 6:鸭子类型(小改)
import std.core;
template <typename T>
concept quackable = requires(T t) // 名为 quackable 的特征。
{
{t.quack()} -> std::same_as<void>;
}; // 对于类型为 T 的值,必须具有 quack() 方法,且返回值类型为 void。
void f(quackable auto t) // 要求 t 的类型具有该特征。
{
t.quack();
}
int main()
{
// f(114514); // 不满足特征,编译错误。
}
相比 C++,Rust 定义特征的语法更友好,其中的格式类似于函数声明,比 C++ 的语法更加简单易懂。
如何要求一个特征包含另一个特征?C++ 在定义概念时可以直接在约束表达式中写出希望包含的其他概念,但 Rust 不能如此。在这一点上,Rust 的特征更像 C# 中的接口,通过“继承”其他特征来实现特征的包含。事实上,这种写法被称为特征定义中的特征约束(supertrait),即要求先具有其他特征才有可能具有这个特征。
注
此处的约束不表示真正的继承。例如,设
trait B : A,一个对象要具有B特征只需要实现B要求的方法,而无需实现A要求的方法;假设只有A特征没有被实现,编译器的报错将会是“未实现要求的A特征”,而非“未实现要求的B特征”。很快我们就会看到这一概念对我们所写程序的影响。
如果一个特征在语义上不应该包含另一个特征(例如 Copy 和 Display,毫不相干),那么在使用时,如何要求某个类型同时具有多个特征呢?可以使用 + 连接。
trait Hund {
fn x(&self); // 不要忘记 self。
}
// 要具有猫特征,必须具有狗特征。
trait Katze: Hund {
fn y(&self);
}
fn f(cat: impl Katze) {
cat.x();
cat.y();
}
// 注意此处语法。引用符号在括号外,括号内以 impl 开头,使用 + 连接各特征。
fn g(dog_and_cat: &(impl Hund + Katze)) {
dog_and_cat.x();
dog_and_cat.y();
}
fn main() {
// f(114514); // 不满足特征,编译错误。
}
C++ 程序 53:鸭同鸡讲
import std.core;
template <typename T>
concept Hund = requires(T t) {
{t.x()} -> std::same_as<void>;
};
template <typename T>
concept Katze = requires(T t) {
Hund<T>; // 要满足猫这个概念,需先满足狗这个概念。
{t.y()} -> std::same_as<void>;
};
void f(Katze auto cat)
{
cat.x();
cat.y();
}
// C++ 不支持在参数列表中用 && 组合概念。
template <typename T>
concept _temp = Hund<T> && Katze<T>;
void g(const _temp auto& dog_and_cat) {
dog_and_cat.x();
dog_and_cat.y();
}
int main()
{
// f(114514); // 不满足特征,编译错误。
}
要进一步使用特征,离不开泛型:C++ 在定义概念(concept)时就必须写一个 template。但在学习 Rust 的泛型之前,我们先了解一下 Rust 中内置的一些常用特征,以更好地了解 Rust 程序的行为。
克隆特征与复制特征
程序 8 中,我们使用 clone() 方法克隆了对象。事实上,可以克隆的对象被定义为具有 Clone 特征,Rust 标准库定义 Clone 特征为:
// 摘自标准库。略作修改,仅表示语义。
trait Clone {
fn clone(&self) -> Self; // Self 表示 self 的类型,此前没有介绍。
fn clone_from(&mut self, source: &Self) // 不使用分号表示默认有以下实现,在第七章介绍。
{
*self = source.clone()
}
}
也就是说,只需要为一个类型实现 Clone 特征中的 clone 方法,即可让一个类型的对象可被克隆。此处并没有规定 clone 的具体实现,但一个正常的程序一定是满足语义和规约的。
注
看到这儿,很容易想到,我在我的源代码里为
String实现一个Copy特征,String不就能Copy了吗?Rust 不允许这么做,称为孤儿规则:为类型A实现特征T时,要求A和T至少有一个在当前作用域定义。
我们在程序 9 就接触了复制特征 Copy。当时,我们说 Copy 的含义是“赋值时不会发生所有权转移,而是会隐式生成一份额外拷贝”。事实确实如此,Copy 就表示这个语义,它在 Rust 中不过是一个标记:
// 摘自标准库。
pub trait Copy: Clone {
// Empty.
}
需要注意,此处的“额外拷贝”指的是逐字节拷贝,正如标准库中注释所说:
/// Types whose values can be duplicated simply by copying bits.
/// Copies happen implicitly, for example as part of an assignment `y = x`. The behavior of
/// `Copy` is not overloadable; it is always a simple bit-wise copy.
也就是说,Copy 特征会直接影响编译器的行为,在赋值时直接发生逐字节拷贝,与其父特征 Clone 的实现无关。但由于 Clone 是其父类型,所以必须实现:
/// [`Clone`] is a supertrait of `Copy`, so everything which is `Copy` must also implement
/// [`Clone`]. If a type is `Copy` then its [`Clone`] implementation only needs to return `*self`
/// (see the example above).
调试特征
前面的程序 46 讨论了可恢复错误的处理方式,使用了 Result 的 unwrap 和 expect 方法。当时提到,由于此前自定义的错误代码枚举类型缺少一些特征,所以无法作为 Result 的 E 类型。事实上,问题主要出在无法使用 unwrap 和 expect 方法,这两个方法要求错误代码类型具有调试特征。
调试特征的名字是 Debug,全称是 std::fmt::Debug,其定义如下:
pub trait Debug {
fn fmt(&self, f: &mut Formatter<'_>) -> Result;
}
标准库中的介绍如下:
/// `?` formatting.
///
/// `Debug` should format the output in a programmer-facing, debugging context.
///
/// Generally speaking, you should just `derive` a `Debug` implementation.
///
/// When used with the alternate format specifier `#?`, the output is pretty-printed.
///
/// For more information on formatters, see [the module-level documentation][module].
///
/// [module]: ../../std/fmt/index.html
///
/// This trait can be used with `#[derive]` if all fields implement `Debug`. When
/// `derive`d for structs, it will use the name of the `struct`, then `{`, then a
/// comma-separated list of each field's name and `Debug` value, then `}`. For
/// `enum`s, it will use the name of the variant and, if applicable, `(`, then the
/// `Debug` values of the fields, then `)`.
简而言之:该特征的作用是输出适合调试时查看的字符串,能够反应对应类型的状态,具体的输出内容由实现的 fmt 方法决定。可以想到,当 unwrap 或 expect 的断言失败时,程序要输出相应对象的状态才能便于我们调试,所以这两个方法要求类型具有 Debug 特征也就不奇怪了。如果我们要手动输出其中的状态,格式说明符应当记为 {:?} 或 {:#?},后者会自动换行,更便于查看。
但是,难道我们要手动为每个类型实现 fmt 方法?这太麻烦了。Rust 中,可以用下面的方法快速实现 Debug 特征。
#[derive(Debug)] // 在 enum 或 struct 前加上这句话即可。
enum ErrorType {
FilenameError,
PasswordError,
}
fn read_file(file_name: &str, password: &str) -> Result<String, ErrorType> {
if file_name != "114514" {
return Err(ErrorType::FilenameError);
}
if password != "114514" {
return Err(ErrorType::PasswordError);
}
return Ok(String::from("114514114514"));
}
fn main() {
let password = read_file("114514", "114514").unwrap();
println!("{}", password);
let user_value = read_file("114514", &password).expect("Password error!");
println!("{}", user_value);
}
C++ 程序 54(不支持)
程序 54 中,#[derive(...)] 是一个宏,作用是自动实现 Rust 默认提供给我们的特征,称为派生特征。有关宏的知识我们会在第七章进行学习。很容易想到,并不是所有特征都能够被自动实现,只有下面这些特征能用这种语法让 Rust 帮我们实现:
Debug特征。Clone特征。必须所有成员都可以Clone时才能自动克隆。Copy特征。必须所有成员都可以Copy时才能自动复制。- 其他特征还没学过……按需查询!
基于此,我们也就能用 #[derive(Clone)]、#[derive(Copy)] 来分别实现 Clone 和 Copy 特征。需要注意,尽管 Clone 特征是 Copy 特征的“父特征”,但使用 #[derive(...)] 宏时,想要让类型具有 Copy 特征,必须同时派生 Clone 特征,因为 trait B : A 语法并不表示“继承”,而是表示“约束”。
#[derive(Debug, Copy, Clone)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let p1 = Point { x: 114, y: 514 };
println!("{:#?}", p1); // 实现了 Debug 特征,可行。
let p2 = p1.clone(); // 实现了 Clone 特征,可行。
let mut p3 = p1;
p3.x = 114514;
p3.y = 114514;
println!("{:#?}", p1); // 实现了 Copy 特征,可行。
println!("{:#?}", p3);
}
C++ 程序 55(不支持)
程序 55 中,删除任一派生特征都会让程序无法通过编译。
3. 泛型
泛型就是 C++ 中的模板,其重要性不言而喻。
在结构体和枚举中使用泛型
Rust 中,定义泛型时,一般直接在名字后面用尖括号包含泛型形参列表;而使用泛型时,一般在名字后先写 ::,再用尖括号包含泛型实参列表。
#[derive(Clone, Copy)]
struct Point<T> {
x: T,
y: T,
}
fn main() {
let integer_point = Point::<i32> { x: 114, y: 514 }; // 注意类型名后的 ::。
let float_point = Point::<f32> {
x: 114.514, // Rust 不要求 32 位浮点字面量额外加上后缀。
y: 514.114, // 其类型类似于 {float},而非具体类型。
};
let auto_point = Point {
x: 114514,
y: 114514,
};
println!("{}{}", integer_point.x, integer_point.y);
println!("{} {}", float_point.x, float_point.y);
println!("{} {}", auto_point.x, auto_point.y);
}
C++ 程序 56:好好点
import std.core;
template<typename T>
struct Point
{
T x;
T y;
};
int main()
{
const auto integer_point = Point<int32_t>{ .x = 114, .y = 514 };
// C++ 要求为 float 类型字面量加上 f 后缀。
const auto float_point = Point<float>{ .x = 114.514f, .y = 514.114f };
// C++17 自动推导结构体模板类型时,不能指定成员变量名。
const auto auto_point = Point{ 114514, 114514 };
std::cout << std::format("{}{}", integer_point.x, integer_point.y) << std::endl;
std::cout << std::format("{} {}", float_point.x, float_point.y) << std::endl;
std::cout << std::format("{} {}", auto_point.x, auto_point.y) << std::endl;
}
泛型亦可用于枚举,例如 Option 的实现为:
enum Option<T> {
Some(T),
None,
}
在函数中使用泛型
与 C++ 不同,Rust 在实例化泛型函数前就会检查方法调用是否合理,因此 Rust 的泛型函数总是离不开特征。
Rust 程序 57:泛型函数trait Quackable {
fn quack(&self);
}
// T 必须具有 Quackable 特征,才能调用 quack 方法。
fn quack<T: Quackable>(obj: &T) {
obj.quack();
}
// T 必须具有 Quackable 特征,才能调用 quack 方法。
fn quack_twice<T>(obj: &T)
where
T: Quackable,
{
obj.quack();
obj.quack();
}
struct Duck;
impl Quackable for Duck {
fn quack(&self) {
println!("Quack!");
}
}
fn main() {
let duck = Duck {};
quack(&duck);
duck.quack();
quack_twice(&duck);
}
C++ 程序 57:泛型函数
import std.core;
template <typename T>
concept Quackable = requires(T t) {
{t.quack()} -> std::same_as<void>;
};
// T 无需有 Quackable 特征,就能调用 quack 方法。
// 加上可以增加可读性和可维护性。
template <Quackable T>
void quack(const T& obj) {
obj.quack();
}
// T 无需有 Quackable 特征,就能调用 quack 方法。
// 加上可以增加可读性和可维护性。
template <typename T>
void quack_twice(const T& obj) {
obj.quack();
obj.quack();
}
struct Duck {
void quack() const {
std::cout << std::format("Quack!") << std::endl;
}
};
int main() {
const auto& duck = Duck{};
quack(duck);
duck.quack();
quack_twice(duck);
}
Rust 程序 57 中,出现了一个新的关键字 where,其含义和适用条件完全等价于 C++ 程序 57 中的第二个 requires 关键字。
实现泛型结构体
为泛型结构体提供实现时,注意需要写两次模板,其原因可以参见下面的 C++ 程序。
Rust 程序 58:实现泛型结构体struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn new(x: T, y: T) -> Self {
return Self { x, y }; // 复习:定义结构体。
}
}
fn main() {
let p = Point::new(114, 514);
}
C++ 程序 58:实现泛型结构体
import std.core;
template <typename T>
struct Point {
T x;
T y;
static Point<T> _new(T x, T y);
};
template<typename T>
Point<T> Point<T>::_new(T x, T y) {
return Point{ .x = x, .y = y };
}
int main() {
const auto& p = Point<int>::_new(114, 514);
}
Rust 的泛型亦可特化。由于 Rust 中结构体的定义和实现完全分离,所以泛型结构体的偏特化比 C++ 优美。
Rust 程序 59:全特化struct Point<T> {
x: T,
y: T,
}
// 特化 T = i32,实现 new 函数。
impl Point<i32> {
fn new(x: i32, y: i32) -> Self {
return Self { x, y };
}
}
fn main() {
let p = Point::new(114, 514);
}
C++ 程序 59:全特化
import std.core;
template <typename T>
struct Point {
T x;
T y;
};
// 特化 T = int,实现 _new 函数。
// 由于定义与实现不分离,所以必须书写两次。
template <>
struct Point<int> {
int x;
int y;
static Point<int> _new(int x, int y) {
return Point{ .x = x, .y = y };
}
};
int main() {
const auto& p = Point<int>::_new(114, 514);
}
程序 59 是全特化。还可以利用特征对一系列希望有实现的类型进行偏特化。
Rust 程序 60:特征偏特化struct Point<T> {
x: T,
y: T,
}
// 偏特化浮点数,实现 new 函数。
// 需添加依赖项 num = "*"。
impl<T: num::Float> Point<T> {
fn new(x: T, y: T) -> Self {
return Self { x, y };
}
}
fn main() {
let p = Point::new(114.514, 514.114);
}
C++ 程序 60:概念偏特化
import std.core;
template <typename T>
struct Point {
T x;
T y;
};
// 偏特化浮点数,实现 new 函数。
template <std::floating_point T>
struct Point<T> {
T x;
T y;
static Point<T> _new(T x, T y) {
return Point{ .x = x, .y = y };
}
};
int main() {
const auto& p = Point<double>::_new(114.514, 514.114);
}
为返回值使用泛型
我们可以不显式地写出返回值的类型,而只指定返回值应当具有的特征。但由于 Rust 中不会发生隐式类型转换,所以返回值的类型实际上是由 return 语句决定的,函数签名只是提供了一个显式的参考。那为返回值使用泛型有什么用呢?答案是:以丢失具体类型为代价,使返回值的类型写起来更短。
trait Point {
fn norm(&self) -> i32;
}
struct PointTypeWithTwoDimensions {
x: i32,
y: i32,
}
impl Point for PointTypeWithTwoDimensions {
fn norm(&self) -> i32 {
return self.x * self.x + self.y * self.y;
}
}
fn make_point(x: i32, y: i32) -> impl Point {
PointTypeWithTwoDimensions { x, y }
}
fn main() {
let p = make_point(114, 514);
// 只知道 p 是 Point,哪怕编译器知道其实 p 是 PointTypeWithTwoDimensions。
println!("{}", p.norm());
}
C++ 程序 61:auto
import std.core;
template <typename Self>
concept Point = requires(const Self& self) {
{ self.norm() } -> std::same_as<int>;
};
struct PointTypeWithTwoDimensions {
int x, y;
int norm() const {
return x * x + y * y;
}
};
static_assert(Point<PointTypeWithTwoDimensions>);
Point auto make_point(int x, int y) {
return PointTypeWithTwoDimensions{ x, y };
}
int main() {
const auto p = make_point(114, 514);
// 你和编译器都知道 p 是 PointTypeWithTwoDimensions。
std::cout << std::format("{}", p.norm()) << std::endl;
}
const 泛型
const 泛型就是 C++ 中的 template<int N>。
struct MyArray<T, const N: usize> {
data: [T; N],
}
fn main() {
let a = MyArray::<i32, 10> { data: [0; 10] };
}
C++ 程序 62:const 泛型
template <typename T, size_t N>
struct MyArray {
T data[N];
};
int main() {
const auto a = MyArray<int, 10>{
.data = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
};
}
七、多范式编程
1. 面向对象编程
虽然 Rust 不是面向对象的语言,但它用其独特的语法为面向对象的概念提供了部分支持。
接口:特征的另一面
第六章介绍了 Rust 中的特征,并将其与 C++ 中的概念作了比较。但显然,Rust 的特征与 C++ 的概念不完全相同:Rust 中的特征只能指定一系列函数,而 C++ 的约束表达式(require expression)指定的却是一系列表达式。从这一点来讲,Rust 的特征更类似于 Java 中的接口(interface)。
接口的一个重要特征便是允许具有默认实现。下面用 Rust、C++、C# 作演示(也可以把 C# 换成 Java, Kotlin 等等)。
Rust 程序 63:接口的默认实现trait Runnable {
fn run(&self) {
println!("I'm running.")
}
}
struct Runner; // 复习:单元结构体。
impl Runnable for Runner {
fn run(&self) {
println!("As a runner, I'm running.")
}
}
struct Orange;
impl Runnable for Orange {}
fn main() {
// 复习:单元结构体的初始化可以只写名字,也可以写空的大括号。
let runner = Runner;
let orange = Orange {};
runner.run();
orange.run();
}
C++ 程序 63:虚函数的默认实现
// 2023-6-21,MSVC 已经支持 import std;
import std;
// C++ 中没有接口,使用类模拟接口的行为。注意虽然定义了虚函数,但并没有用到多态。
struct Runnable {
virtual void run() const {
std::cout << std::format("I'm running") << std::endl;
}
};
struct Runner : Runnable {
void run() const override {
std::cout << std::format("As a runner, I'm running.") << std::endl;
}
};
struct Orange : Runnable {};
int main() {
const auto runner = Runner{};
const auto orange = Orange{};
runner.run();
orange.run();
}
C# 程序 63:接口的默认实现
// 使用顶级语句。
// C# 中,接口与类分离,只能利用多态。即:Runner 是不会 run 的,只有 Runnable 会。
Runnable runner = new Runner();
Runnable orange = new Orange();
runner.run();
orange.run();
interface Runnable
{
void run()
{
Console.WriteLine("I'm running.");
}
}
class Runner : Runnable
{
// 重写默认实现时,需要用 <接口名>.<方法名> 显式指定。
void Runnable.run()
{
Console.WriteLine("As a runner, I'm running.");
}
}
class Orange : Runnable { }
要注意,Rust 中并不支持继承。数据的继承可以用内部类模拟,而方法的继承则可以用特征的特征约束(supertrait)模拟。在涉及多个接口的实现时,会优先调用类自己的方法,如果不存在才会调用所实现的特征的方法(不像 C# 程序 63,不允许隐式调用接口的方法)。如果总是希望调用所实现的特征的方法,需要指定具体特征名。当然,真实的程序中一定要避免这种情况。
Rust 程序 64:特征导致的方法重名trait IFoo {
fn name(&self) -> &str;
}
trait IBar {
fn name(&self) -> &str;
}
struct Orange {
name: String,
}
// 实现特征的方法。
impl IFoo for Orange {
fn name(&self) -> &str {
"Orange.IFoo"
}
}
impl IBar for Orange {
fn name(&self) -> &str {
"Orange.IBar"
}
}
// 实现结构体自己的方法。
impl Orange {
fn name(&self) -> &str {
&self.name
}
}
fn main() {
let orange = Orange {
name: String::from("Orange"),
};
// 调用类自身的方法。
println!("{}", orange.name());
// 显式调用特征的方法。
println!("{}", IFoo::name(&orange));
println!("{}", IBar::name(&orange));
}
C++ 程序 64:继承导致的成员函数重名
import std;
struct IFoo {
virtual std::string_view name() const = 0;
};
struct IBar {
virtual std::string_view name() const = 0;
};
struct Orange : IFoo, IBar {
// C++ 中,不允许成员变量和成员函数同名。
std::string _name;
// 存在虚函数,无法聚合初始化。
Orange(std::string_view name = {}) : _name(name) {}
// 无法定义与虚函数同名的非虚函数。
// 实现基类的方法。
std::string_view IFoo::name() const override
{
return "Orange.IFoo";
}
std::string_view IBar::name() const override
{
return "Orange.IBar";
}
};
int main() {
const auto orange = Orange{ "Orange" };
// 命名冲突时,C++ 中只能通过将对象转换为基类解决冲突。
// 注意,因为调用的是虚函数,所以此处涉及查找虚函数表。
std::cout << std::format("{}", static_cast<const IFoo&>(orange).name()) << std::endl;
std::cout << std::format("{}", static_cast<const IBar&>(orange).name()) << std::endl;
}
C# 程序 64:接口导致的方法重名
// 使用顶级语句。
var orange = new Orange("Orange");
// 调用类自身的方法。
Console.WriteLine(orange.name());
// C# 中总是只能通过将对象转换为接口来调用接口的方法。
Console.WriteLine(((IFoo)orange).name());
Console.WriteLine(((IBar)orange).name());
interface IFoo
{
string name();
}
interface IBar
{
string name();
}
class Orange : IFoo, IBar
{
string _name;
// C# 中,需显式定义构造函数。
public Orange(string name)
{
_name = name;
}
// 实现类自己的方法。
public string name() => _name;
// 实现特征的方法。
string IFoo.name() => "Orange.IFoo";
string IBar.name() => "Orange.IBar";
}
多态:特征对象与动态分发
通常,我们称多态的函数调用为动态分发(dynamic dispatch),相对的概念则称为静态分发(static dispatch)。C++ 中,(常规的)运行时多态必须借由虚函数表实现,Rust 中也是如此。要在 Rust 中实现动态分发,需要使用 dyn 关键字。
trait Runnable {
fn run(&self);
}
struct Orange;
impl Runnable for Orange {
fn run(&self) {
println!("Orange is running.");
}
}
// 复习:Rust 程序 6。
fn static_run(runnable: &impl Runnable) {
runnable.run();
}
fn dynamic_run(runnable: &dyn Runnable) {
runnable.run();
}
fn main() {
let orange = Orange;
static_run(&orange);
dynamic_run(&orange);
}
C++ 程序 65:静态分发与动态分发
import std;
template <typename Self>
struct RunnableTrait {
void run() const {
static_cast<const Self*>(this)->run();
}
};
struct RunnableInterface {
virtual void run() const = 0;
};
struct StaticOrange : RunnableTrait<StaticOrange> {
void run() const {
std::cout << std::format("StaticOrange is running.") << std::endl;
}
};
struct Orange : RunnableInterface {
void run() const override {
std::cout << std::format("Orange is running.") << std::endl;
}
};
template <typename T>
void static_run(const RunnableTrait<T>& runnable) {
runnable.run();
}
void dynamic_run(const RunnableInterface& runnable) {
runnable.run();
}
int main() {
const auto static_orange = StaticOrange{};
const auto orange = Orange{};
static_run(static_orange);
dynamic_run(orange);
static_orange.run();
orange.run();
}
程序 65 清楚地说明了 Rust 的特征具有概念和接口两重属性,无需多解释了。
那如何存储对象使得它们表现出多态呢?难点在于,特征作为接口时,它并不是一个类型,不能直接以特征为类型声明变量。解决方法是使用智能指针 Box<T>。
trait ExclaimTrait {
fn exclaim(&self);
}
struct Foo;
impl ExclaimTrait for Foo {
fn exclaim(&self) {
println!("Foo!");
}
}
struct Bar;
impl ExclaimTrait for Bar {
fn exclaim(&self) {
println!("Bar!");
}
}
fn exclaim_static(x: &impl ExclaimTrait) {
x.exclaim();
}
fn exclaim_1(x: &dyn ExclaimTrait) {
x.exclaim();
}
// 不能写 &Box<dyn ExclaimTrait>。
// 从逻辑上来说这就是不对的,暂时不讨论 Rust 语法是如何限制这一点的。
fn exclaim_2(x: Box<dyn ExclaimTrait>) {
(*x).exclaim();
}
fn main() {
let known_foo = Box::new(Foo);
let known_bar = Box::new(Bar);
// 具体类型可以自动转换为特征对象。
let unknown_foo: Box<dyn ExclaimTrait> = Box::new(Foo);
let unknown_bar: Box<dyn ExclaimTrait> = Box::new(Bar);
exclaim_static(&*known_foo);
exclaim_static(&*known_bar);
exclaim_1(&*known_foo);
exclaim_1(&*known_bar);
// 不允许。语法上的具体错误此处暂不讨论。
// exclaim_static(&*unknown_foo);
exclaim_1(&*unknown_foo);
exclaim_1(&*unknown_bar);
// 不能借用 Box,不会自动转换类型。
// exclaim_borrow_box(&known_foo);
// 转移 Box 所有权。
exclaim_2(known_foo); // 自动转换类型。
exclaim_2(unknown_foo);
}
C++ 程序 66:unique_ptr
import std;
struct ExclaimTrait {
virtual void exclaim() const = 0;
};
struct Foo : ExclaimTrait {
void exclaim() const override {
std::cout << std::format("Foo!") << std::endl;
}
};
struct Bar : ExclaimTrait {
void exclaim() const override {
std::cout << std::format("Bar!") << std::endl;
}
};
// 静态分发的展示见程序 C++ 程序 65。
void exclaim_1(const ExclaimTrait& x) {
x.exclaim();
}
// 不能写 const std::unique_ptr<ExciamTrait>&。
// 从逻辑上来说这就是不对的。
void exclaim_2(std::unique_ptr<ExclaimTrait> x) {
x->exclaim();
}
int main() {
auto known_foo = std::make_unique<Foo>();
auto known_bar = std::make_unique<Bar>();
// 具体类型可以自动转换为基类。
std::unique_ptr<ExclaimTrait> unknown_foo = std::make_unique<Foo>();
std::unique_ptr<ExclaimTrait> unknown_bar = std::make_unique<Bar>();
exclaim_1(*known_foo);
exclaim_1(*known_bar);
// 不能传递 unique_ptr 引用,不会自动转换类型。
// exclaim_ref_unique_ptr(known_foo);
// 转移 unique_ptr。
exclaim_2(std::move(known_foo));
exclaim_2(std::move(unknown_foo));
}
程序 66 清楚地说明了 Rust 的 Box 就是 C++ 中的 std::unique_ptr,无需多解释了。Rust 智能指针的语法原理可以参见本章第 3 节 RAII 范式。
需要特别注意的是,前面我们曾用 std::unique_ptr 解释 Rust 中的所有权机制,但学到一定水平后,我们也清楚地指出,Rust 的所有权不是智能指针,之后的程序也不会再用 C++ 的 std::unique_ptr 来反映 Rust 的所有权机制。在这里学到 Box 后,我们可以知道 Rust 中也存在智能指针,并且智能指针和所有权机制确实不是一个东西。分析程序 66 时,需要注意哪些地方涉及所有权,哪些地方涉及智能指针。
继承:没有
Rust 中,没有原生的语法支持继承。使用 Rust 进行面向对象程序设计时,只需要时时刻刻考虑每个类型所能提供的特征。在明确了一个类型应当具有的特征后,再通过内部类的形式实现代码重用。
没有继承的坏处便是需要全部重写父类型的方法。幸运的是,Rust 的宏编程(见本章第 4 节)功能非常强大,有一些库可以稍微减少由此导致的无意义代码。
2. 函数式编程
函数式编程最主要的特征是允许将函数作为参数、返回值,或赋值给变量。
嵌套函数
首先我们来看一下嵌套函数。
Rust 程序 67:嵌套函数fn main() {
fn local_function(s : &str) {
println!("{}, I'm local function.", s);
}
let mut another = local_function;
let yet_another = &local_function; // 函数类型总具有 Copy 特征。
local_function("114");
another("514");
yet_another("114514");
}
C++ 程序 67:嵌套函数
import std;
int main() {
constexpr auto local_function = [](std::string_view s) {
std::cout << std::format("{}, I'm local function.", s) << std::endl;
};
auto another = local_function;
const auto& yet_another = local_function;
local_function("114");
another("514");
yet_another("114514");
}
据此我们可以知道,Rust 的嵌套函数仅仅将函数的作用域限定在另一个函数内,并不能起到捕获变量的作用;如果尝试将函数赋值给变量,则变量的类型与该函数紧密联系,就像 C++ 程序 67 一样,任何其他函数都不能赋值给 another。
如果希望用一个变量保存任何满足指定签名的函数,应该如何实现?Rust 中需要利用 Fn 特征来存储这样的函数,因为所有用 fn 定义的函数都满足 Fn 特征。Fn 特征是一个具有模板的特征,使用方法如程序 68 所示。
// 复习泛型:Rust 中,定义泛型列表时往往直接在名字后面写。
type FunctionType<T> = dyn Fn(T, T) -> T;
fn main() {
fn add(x: i32, y: i32) -> i32 {
x + y
}
fn mul(x: i32, y: i32) -> i32 {
x * y
}
let mut func: Box<FunctionType<i32>>;
func = Box::new(add);
println!("{}", func(114, 514));
func = Box::new(mul);
println!("{}", func(114, 514));
}
C++ 程序 68:function
import std;
template <typename T>
using FunctionType = T(T, T);
int main() {
constexpr auto add = [](int x, int y) {
return x + y;
};
constexpr auto mul = [](int x, int y) {
return x * y;
};
std::function<FunctionType<int>> func;
func = std::function(add);
std::cout << std::format("{}", func(114, 514)) << std::endl;
func = std::function(mul);
std::cout << std::format("{}", func(114, 514)) << std::endl;
}
需要提前注意,之所以说 Box<FunctionType<i32>> 类似于 std::function<FunctionType<int>>,是因为前者和后者一样,也可以存储捕获了变量的闭包(马上讲解);但 Rust 中,与 Fn 同属一个系列的特征还有两个,以应对闭包带来的借用检查问题,这比 std::function 复杂许多。
不可变闭包
我们其实在程序 34 中见过闭包的基本语法。因此,下面我们重点关注闭包带来的变量捕获问题。
Rust 程序 69:闭包与捕获fn demo() {
let mut s = String::from("114514");
// print 的类型是 impl Fn() -> i32。
let print = || -> i32 {
println!("{}", s);
114514
};
print();
}
fn main() {
demo();
}
C++ 程序 69:闭包与捕获
import std;
void demo() {
auto s = std::string("114514");
const auto print = [&]() -> int {
std::cout << std::format("{}", s) << std::endl;
return 114514;
};
print();
}
int main() {
demo();
}
Rust 中,闭包总是自动按引用捕获所有变量。因此,当我们尝试将闭包作为返回值时,需要考虑各个变量的生命周期,以下代码是不被允许的:
fn demo() -> impl Fn() -> i32 {
let mut s = String::from("114514");
// print 的类型是 impl Fn() -> i32。
let print = || -> i32 {
println!("{}", s);
114514
};
print // error[E0373]: closure may outlive the current function, but it borrows `s`, which is owned by the current function
}
fn main() {
demo()();
}
这样的问题在 C++ 中也会同样出现,但 C++ 的编译器却不帮我们做这个检查。
import std;
auto demo() {
auto s = std::string("114514");
const auto print = [&]() -> int {
std::cout << std::format("{}", s) << std::endl;
return 114514;
};
return print;
}
int main() {
demo()(); // 运行时什么也没输出,不合预期。
}
因此,当把闭包作为返回值时,如果涉及局部变量的捕获,一定需要取得局部变量的所有权。在 Rust 中,通过在闭包定义前加上 move 关键字来实现所有权的转移。
fn demo() -> impl Fn() -> i32 {
let mut s = String::from("114514");
// print 的类型是 impl Fn() -> i32。
let print = move || -> i32 {
// 使用到 s,于是编译器将 s 的所有权转移到闭包中。
println!("{}", s);
114514
};
// s 已失去所有权。
print
}
fn main() {
demo()();
}
C++ 程序 70:闭包与按值捕获
import std;
auto demo() {
auto s = std::string("114514");
const auto print = [=]() -> int {
// 使用到 s,于是编译器将 s 复制赋值到闭包中。
std::cout << std::format("{}", s) << std::endl;
return 114514;
};
// s 已被复制。
return print;
}
int main() {
demo()();
}
当然,Rust 程序 70 中,所有权转移并不会发生 clone,而 C++ 程序 70 中发生了一次复制赋值。如果把这个复制赋值换成移动赋值,C++ 程序 70 就和 Rust 程序 70 基本一样了。
可变闭包
Rust 严格规定,如果闭包会修改任何捕获变量,则这个闭包就是可变的,声明变量时需要使用 mut。具有修改行为闭包不再满足 Fn 特征,而是会满足名为 FnMut 的特征,后者是前者的必要条件。
fn demo() {
let mut s = String::from("114514");
// print 的类型是 impl FnMut() -> i32。
// 此处的 mut 关键字不能省略。
let mut print = || -> i32 {
s.push_str("1919810");
println!("{}", s);
114514
};
print();
print();
}
fn main() {
demo();
}
C++ 程序 71:修改捕获变量
import std;
auto demo() {
auto s = std::string("114514");
// C++ 中,此处 const 可以继续保留,因为 C++ 认为修改的是引用指向的内容。
const auto print = [&]() -> int {
s.append("1919810");
std::cout << std::format("{}", s) << std::endl;
return 114514;
};
print();
print();
}
int main() {
demo();
}
程序 71 是在局部区域内复用代码的常见方法,因此对比 Rust 程序 71 和 C++ 程序 71 便可能觉得 Rust 要求 print 闭包必须是 mut 有点奇怪。但如果同时用 move 关键字让闭包取得捕获变量的所有权,这个要求就一点也不奇怪了,因为调用结构体的 &mut self 方法当然要求结构体是 mut 的。
fn demo() -> impl FnMut() -> i32 {
let mut s = String::from("114514");
// print 的类型是 impl FnMut() -> i32。
// 此处的 mut 关键字可以省略,仅当调用了它才需要可变。
// 注意,Rust 中所有权的转移不涉及是否可变,这不是返回引用。
let print = move || -> i32 {
s.push_str("1919810");
println!("{}", s);
114514
};
print
}
fn main() {
// 此处 mut 不可省略,demo 的返回值只满足 FnMut 特征,调用时必须是 mut 的。
let mut t = demo();
t();
t();
}
C++ 程序 72:闭包就是结构体
import std;
auto demo() {
auto s = std::string("114514");
// 此处的 mutable 关键字不可省略,否则捕获变量不可变。
// 此处可以有 const 关键字,仅当调用了它才需要不是 const。
// 注意,返回它时不涉及是否是 const,这不是返回引用。
const auto print = [=]() mutable -> int {
s.append("1919810");
std::cout << std::format("{}", s) << std::endl;
return 114514;
};
return print;
}
int main() {
// 此处不能是 const,调用不是 const 的。
auto t = demo();
t();
t();
}
单次闭包
如果调用闭包会导致任何捕获变量失去所有权,则这个闭包只能调用一次。原因很简单:第二次调用时,捕获变量已经失去所有权了,所以无法执行关于该捕获变量的代码。
这种只能调用一次的闭包不再满足 Fn 特征或 FnMut 特征,只满足 FnOnce 特征,后者是前两者的必要条件。
fn demo() -> impl FnOnce() -> i32 {
let mut s = String::from("114514");
// print 的类型是 impl FnOnce() -> i32。
let print = move || -> i32 {
s.push_str("1919810");
println!("{}", s);
// 取走 s 的所有权。
drop(s); // 见下一节《RAII 范式》。
114514
};
print
}
fn main() {
// 尽管调用 t 会导致状态发生变化,但无需使用 mut。
let t = demo();
// 调用 t() 后,t 直接失去所有权。
t();
}
C++ 程序 73:单次闭包还是结构体
import std;
auto demo() {
auto s = std::string("114514");
// 此处的 mutable 关键字不可省略,否则捕获变量不可变。
const auto print = [=]() mutable -> int {
s.append("1919810");
std::cout << std::format("{}", s) << std::endl;
// 将 s 置于不确定的状态,不应在下次赋值前继续使用。
const auto _ = std::move(s);
return 114514;
};
return print;
}
int main() {
// 此处不能是 const,调用不是 const 的。
auto t = demo();
// 调用 t 后,理论上不可再调用,但编译器不做检查。
t();
}
综上,Rust 可以根据闭包对捕获变量的操作自动将可调用对象分为由强到弱的三个类型,进而允许借用检查器帮助我们检查调用是否合理。
| Rust | Rust 特征 | C++ | |||
|---|---|---|---|---|---|
| 嵌套函数 | fn foo() {}; | Fn | constexpr auto foo = [] {}; | ||
| 不可变闭包 | `let foo = | {};`(闭包函数体需读取捕获变量) | Fn | const auto foo = [&] {}; | |
| 取得所有权的不可变闭包 | `let foo = move | {};`(闭包函数体需读取捕获变量) | Fn | const auto foo = [=] {};(若是移动赋值则基本等价) | |
| 可变闭包 | `let mut foo = | {};`(闭包函数体需修改捕获变量) | FnMut | const auto foo = [&] {}; | |
| 取得所有权的可变闭包 | `let mut foo = move | {};`(闭包函数体需修改捕获变量) | FnMut | auto foo = [=] mutable {};(若是移动赋值则基本等价) | |
| 单次闭包 | `let foo = | {};`(闭包函数体需取走捕获变量的所有权) | FnOnce | (只应调用一次的闭包) | |
| 取得所有权的单次闭包 | `let foo = move | {};`(闭包函数体需取走捕获变量的所有权) | FnOnce | (只应调用一次的闭包) |
注意:
- 闭包具有的特征与是否使用
move关键字取得捕获变量所有权无关,只与闭包函数体中对捕获变量的操作有关。 - 闭包的可变性只在调用闭包时有要求,使用
let语句定义闭包时不一定需要可变。上表假设了定义闭包后需要立即用foo();调用闭包。
闭包的参数
最后,我们来看一下闭包的参数。与 C++ 不同,Rust 常常根据上下文推导类型。
Rust 程序 74:自动闭包fn main() {
let logic = false;
let logic_or = |another| logic || another;
// 推导出 another 的类型为 bool,因为只有 bool 类型才能作逻辑或运算。
// 只有一句返回值时,可以省略大括号。
let float_add = |another| 1.14 + (another as f64);
// 推导出 another 的类型为 f64,因为 5.14 是 f64。
println!("{}", float_add(5.14));
// println!("{}", float_add(514)); // 参数类型 i32 与此前推导出的 f64 不匹配,编译错误。
let int_add = |another: i32| 114 + another;
// 手动指明 another 的类型为 i32。
}
C++ 程序 74:手动闭包
import std;
int main() {
const auto logic = false;
const auto logic_or = [&](bool another) { return logic || another; };
// 只能手动指定 another 的类型。
constexpr auto float_add = [](double another) { return 1.14 + another; };
// 只能手动指定 another 的类型。
std::cout << std::format("{}", float_add(5.14)) << std::endl;
// C++ 允许类型隐式转换。
std::cout << std::format("{}", float_add(514)) << std::endl;
constexpr auto int_add = [](int another) { return 114 + another; };
// 只能手动指定 another 的类型。
}
Rust 不支持泛型闭包,即闭包参数的类型无法指定为 &impl ...。
3. RAII 范式
析构:Drop 特征
实现了 Drop 特征的结构体就是具有析构函数的结构体。
Rust 程序 75:Dropstruct Foo(i32); // 复习:元组结构体。
struct Bar(i32);
impl Drop for Foo {
fn drop(&mut self) {
println!("drop Foo({})", self.0);
}
}
impl Drop for Bar {
fn drop(&mut self) {
println!("drop Bar({})", self.0);
}
}
fn main() {
let f = Foo(114);
let b = Bar(514);
}
C++ 程序 75:Destructor
import std;
struct Foo {
int _0;
~Foo() {
std::cout << std::format("destruct Foo({})", _0) << std::endl;
}
};
struct Bar {
int _0;
~Bar() {
std::cout << std::format("destruct Bar({})", _0) << std::endl;
}
};
int main() {
const auto f = Foo(114);
const auto b = Bar(514);
}
通常,析构函数的作用是回收资源,且析构函数只会被调用一次。如果析构函数被多次调用,则程序的行为通常是不确定的。然而,C++ 中却允许我们手动调用析构函数,调用方法形如 对象名.~类名()。这几乎总是会导致运行时错误,因为离开当前作用域时析构函数总是还会再被调用一次。因此,Rust 不允许我们调用 对象名.drop()。
如果真的希望提前析构对象该怎么办?除了突兀地打上一个大括号,Rust 中还允许我们使用全局的 drop 函数析构对象。drop 函数实际上取走了对象的所有权,并且让编译器知道对象已经可以被回收。
struct Foo(i32);
impl Drop for Foo {
fn drop(&mut self) {
println!("drop Foo({})", self.0);
}
}
fn main() {
let f = Foo(114);
drop(f);
println!("end of main");
}
C++ 程序 76:用 unique_ptr 模拟提前析构
import std;
struct Foo {
int _0;
~Foo() {
std::cout << std::format("destruct Foo({})", _0) << std::endl;
}
};
int main() {
auto f = std::make_unique<Foo>(114);
f.reset();
std::cout << std::format("end of main") << std::endl;
}
C++ 程序 76 用 unique_ptr 模拟了 Rust 中对象因 drop 函数丧失所有权而回收,从而发生析构,实际上 Rust 程序 76 并不涉及智能指针(复习:Box)。如果在程序 68 中不使用 C++ 中的智能指针,而是直接调用析构函数,析构函数就会被调用两次,导致 "destruct Foo(114)" 被输出两次:这并不符合预期。
一旦一个类型具有 Drop 特征,即具有析构函数,就不应该允许它进行逐字节拷贝了,即 Drop 和 Copy 这两个特征是互斥的。这就好比 C++ 中你不应该对一个不满足 is_trivial 的类型调用 memcpy 函数一样。
最后,不要忘记元组和结构体的成员的所有权都是单独管理的,所以可以使用 drop 函数手动析构它们的成员。相对应的,数组的元素无法被 drop,只能通过 drop 整个数组使得所有数组元素被回收。
智能指针:Rc 与 Arc
刚刚复习了,Rust 中的 Box 就是 C++ 中的 unique_ptr,是智能指针。下面我们来看 Rust 中的“shared_ptr”,叫作 Rc,即引用计数(reference count)。
要使用 Rc,需要先写:
use std::rc::Rc;
Rust 程序 77:引用计数
use std::rc::Rc;
fn main() {
let a = Rc::new(String::from("114514"));
// 复习:Clone 特征,具体行为由 Rc 的实现决定。
// 注意不能写 let b = a,这是转移所有权。
let b = a.clone(); // 实际的行为是指向同一个实际数据,并令引用计数加一。
println!(
"{} {} {}",
a.as_str(),
Rc::strong_count(&a),
Rc::strong_count(&b)
);
}
C++ 程序 77:引用计数
import std;
int main() {
const auto a = std::make_shared<const std::string>("114514");
const auto b = a; // C++ 的复制构造函数可以等同于 Rust 中的 Clone,所以可以直接写 b = a。
// a->c_str() 只是为了说明 Rust 中 a.as_str() 里点的作用,并不是说 C++ 中的 c_str() 和 Rust 中的 as_str() 一样。
std::cout << std::format("{} {} {}", a->c_str(), a.use_count(), b.use_count()) << std::endl;
}
Rust 程序 77 中,string_count 是 Rc 的“静态函数”,在 Rust 中被称为关联函数(associated function),与方法(method)相对。在 impl 块中,只要第一个参数不是 self, &self, &mut self 之一,这个函数就被称为关联函数,而非方法。例如,strong_count 的签名如下:
fn strong_count(this: &Self) -> usize;
Rust 程序中,可以直接写 a.as_str,而无需调用 Rc<String> 的某个方法将 &Rc<String> 转换为 &String,这是因为 Rc 实现了 Deref 特征,类似于 C++ 中重载了 * 和 -> 运算符。
需要特别注意的是,Rc 中保存的类型是不可变的,那如果想要改该怎么办?一般而言,需要借助 Rust 中的“内部可变性”这一特性,类似于 C++ 中的 mutable 关键字,我们将在第十一章学习。
标题中的另一个智能指针叫作 Arc,多的字母 A 表示 Atomic。要使用 Arc,需要先写:
use std::sync::Arc;
要讨论 Arc,必然需要讨论线程安全,我们将在第十章具体学习相关内容。需要注意的是,程序 77 中 Rc 和 shared_ptr 的等价仅在语义上成立,详情可以参见 stackoverflow.com/a/49834496 和 www.appsloveworld.com/cplus/100/3…
C++ 的
shared_ptr的控制块(control block)是线程安全的,但是多个线程调用同一个shared_ptr对象不是线程安全的。Rust 的
Rc完全不是线程安全的,要在多个线程中传递智能指针,必须使用Arc。C++ 的
shared_ptr在运行时处理引用计数,而 Rust 的Rc在编译时处理引用计数。
Arc 与 Rc 的不同是,前者的控制块(引用计数)是原子化的,并且实现了线程安全相关的特征(详见第十章)。因此,C++ 的 shared_ptr 很大程度上应该等价于 Rust 的 Arc 而非 Rc。
另一方面,C++ 的 atomic<shared_ptr<T>> 也没有 Rust 的等价物。atomic<shared_ptr<T>> 解决了多个线程同时写一个 shared_ptr 的线程安全问题,而 Rust 不允许在不同步的情况下让多个线程同时写一个变量。
弱引用:Weak
Rust 中,弱引用的名字是 Weak。要使用 Weak,需要先写:
use std::rc::Weak;
对比 Rc 和 Arc 的使用方法,不难想到上面的 use 语句引入的 Weak 只能用于 Rc。事实上,还有:
use std::sync::Weak;
Rust 程序 78:简单弱弱
use std::sync::Arc;
fn main() {
let s = Arc::new(String::from("114514"));
let w = Arc::downgrade(&s);
let u = w.upgrade().unwrap();
println!("{}", u);
drop(s);
println!("w is {}None", if w.upgrade() == None { "" } else { "not " });
drop(u);
println!("w is {}None", if w.upgrade() == None { "" } else { "not " });
}
C++ 程序 78:简单弱弱
import std;
int main() {
auto s = std::make_shared<const std::string>("114514");
const auto w = std::weak_ptr(s);
auto u = w.lock();
std::cout << std::format("{}", *u) << std::endl;
s.reset();
std::cout << std::format("w is {}None", w.expired() ? "" : "not ") << std::endl;
u.reset();
std::cout << std::format("w is {}None", w.expired() ? "" : "not ") << std::endl;
}
弱引用的使用案例与 C++ 相同,这里不再赘述。
4. 宏编程
C++ 中的宏只能作简单的文本替换,而像 Rust 这样的现代编程语言的宏通常都支持对所编代码的抽象语法树(abstract syntax tree, AST)进行映射。
很容易想到,具有如此强大能力的宏编程必然非常难,所以本节只从原理出发介绍 Rust 的宏,不要求完全掌握编写 Rust 宏的能力。
过程宏简介
Rust 中最基本的宏是过程宏(procedural macro),编写过程宏就好像编写一个函数一样,伪代码如下:
fn 过程宏名(input: TokenStream) -> TokenStream {
// ...
}
与一般的函数不同的是,过程宏的输入输出均代表 AST 形式的 Rust 代码,因此不难想到:
- 过程宏是由编译器在编译时调用的。
- 过程宏必须先编译,后使用,也就是说在正式编译你的完整程序前,用到的过程宏就已经是编译好了的。
查阅资料可知,以上两个想法会导致以下后果:
-
过程宏必须定义在一个独立的、带有特殊标记的包中。
我们还没学 Rust 的包,可以理解为:要编写过程宏,需要再创建一个包,并做一些额外的配置工作。
-
过程宏可以访问编译器能访问的所有资源,包括文件。因此不要使用你不信任的过程宏。
过程宏可以分为三类:
-
类函数宏(function-like macros)。
我们已经见过了:
println!("{}", 114514); -
派生宏(derive macros)。
我们已经见过了:
#[derive(Copy)] -
属性宏(attribute macros)。
还没见过,但很容易举一个例子:
#[route(GET, "/")] fn index() { ... }
这三者的不同之处在于它们的输入输出。
-
类函数宏。
输入是
println!(...)中括号里的省略号对应的 token 流,输出是替换整个println!(...)的代码。 -
派生宏。
输入是结构体、枚举、联合体(union)的 token 流,输出是附加到它之后的代码。
-
属性宏。
输入有两个:属性的值(上例中是
GET, "/")和被修饰的函数,输出是替换整个被修饰函数的代码。
编写过程宏就像编写函数一样,不同的是过程宏本身会用预定义的属性宏进行修饰,比如(摘自 doc.rust-lang.org/reference/p…
extern crate proc_macro;
use proc_macro::TokenStream;
// make_answer 是类函数宏。
#[proc_macro]
pub fn make_answer(_item: TokenStream) -> TokenStream {
"fn answer() -> u32 { 42 }".parse().unwrap()
}
extern crate proc_macro;
use proc_macro::TokenStream;
// derive_answer_fn 是派生宏,使用时写 #[derive(AnswerFn)]。
#[proc_macro_derive(AnswerFn)]
pub fn derive_answer_fn(_item: TokenStream) -> TokenStream {
"fn answer() -> u32 { 42 }".parse().unwrap()
}
不用想也知道,要实现一个真正可用的过程宏非常难,必须对 Rust、编译原理等领域有熟练的了解,所以关于过程宏的介绍就到此为止了。
声明宏
如果我希望编写接收任意数量参数的函数该怎么办?能不能不要写过程宏、不去手工处理 AST?
Rust 还提供了声明宏,又称示例宏(macros by example)。声明宏让我们用类似于声明的语句定义宏。定义声明宏时,就好像我们手写了一份这个宏可以生成的代码,如同写了一个例子一样,所以声明宏又叫示例宏。
声明宏的写法为:
#[macro_export]
macro_rules! 宏名 {
// ...
}
声明宏的内部是语法分析器(摘自 course.rs/advance/mac…
#[macro_export]
macro_rules! vec {
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};
}
具体的语法规则可以按需查阅。
最后,我们补充一下宏的使用。使用宏时,没有规定必须使用小括号作为最外层的分界符,也可以使用中括号或大括号。