Rust 编程基础
================================================
.. toctree::
:hidden:
:maxdepth: 4
系统学习 Rust
---------------------
这里有一些学习 Rust 的资源,如果你想系统学习 Rust,可以参考这些资源。如果你想要快速入门 Rust,推荐 Rust By Example。
- `Rust 程序设计语言 `_
- `Rust 语言圣经 `_
- `Rust By Example `_
开始实验之前你至少需要学习并掌握 Rust 程序设计语言的前七节和第十节,或者 Rust 语言圣经的第二节,或者 Rust By Example 的前十节和第十六节。
下面是一个用于自查的 Rust 基础知识点列表:
- ``mut`` 关键字的作用有哪些?
- 变量的 Shadowing 是什么?
- Rust 的基础数据类型有哪些?
- 注释有哪几种写法?
- ``if`` 语句语法是什么样的?
- Rust 有哪几种循环语句?
- 可变引用和不可变引用有什么区别?如何使用?
- Rust 中的结构体怎么写?如何控制结构体内部成员可见性?如何为结构体实现方法?
- Rust 中的枚举怎么写?如何为枚举实现方法?
- Rust 中模式匹配是什么?其语法是什么?
- Rust 中的 Trait 是什么?如何实现 Trait?
- Rust 中的范型是什么?如何使用范型?
- 如何组织 Rust 项目中模块的层级结构?
更进一步,如果能够熟悉 Rust 中 ``std::collections`` 中的数据结构,以及迭代器的使用,会对你完成这个实验有很大的帮助。
如果你还有时间,你可以进一步学习 `Effective Rust `_ 中的内容。
使用 Cargo
--------------------------
你可以使用 Cargo 实现整个项目的构建、依赖的管理以及单元测试。以下是一个简单的例子:
.. code-block:: shell
$ cargo new my_project
$ cd my_project
$ cargo run
之后你就会看到类似于下面的输出:
.. code-block:: shell
$ cargo run
Compiling my_project v0.1.0 (/path/to/my_project)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.15s
Running `target/debug/my_project`
Hello, world!
你会发现整个项目的结构如下
.. code-block:: shell
$ tree
.
├── Cargo.lock
├── Cargo.toml
└── src
└── main.rs
其中 ``Cargo.toml`` 是项目的配置文件,``src/main.rs`` 是项目的入口文件。我们的编译器框架也会使用这样的结构。
.. tip::
Cargo 是一个非常易用且可扩展的构建工具,你可以通过 ``cargo --help`` 查看所有的命令。
关于更加详细的使用方法,可以参考 `The Cargo Book `_ (强烈建议阅读,因为之后你会经常用到 Cargo)。
此处列举几个比较基础的 Cargo 使用方式
- 使用 Release 模式构建项目:``cargo build --release``
- 运行单元测试:``cargo test``
- 检查代码风格:``cargo fmt``
- 添加依赖包:``cargo add ``
Cargo 之于 Rust 就像 Pip 之于 Python,Npm 之于 Node.js,Maven 之于 Java 一样,是 Rust 生态中的一个重要组成部分。
你可以在 `crates.io `_ 上找到很多 Rust 的第三方库,你可以通过 Cargo 添加这些库到你的项目中。
如果速度太慢可以考虑使用 tuna 或者中科大的源,使用方法请自行 STFW。
.. tip::
测试是保证你的代码质量的重要手段,Rust 语言的特性以及 Cargo 这个构建工具为测试提供了很大的便利,建议阅读: `How to Write Tests `_ 。
.. tip::
你可以配置 Clippy 并运行 ``cargo clippy`` 来检查代码中更多的潜在问题,具体使用方式和配置方法可以参考 `Clippy Documentation `_ 。
Rust 编程技巧
--------------------------
.. note::
在阅读下面的内容之前,请确保你已经至少掌握了 Rust 程序设计语言的前七节和第十节,或者 Rust 语言圣经的第二节,或者 Rust By Example 的前十节和第十六节的内容。
以下内容只是对 Rust 语言一些惯用法或重要概念的介绍,这些内容只是你完成这个实验所需要的很少一部分,你仍然需要阅读前面给出的参考文档学习 Rust 的基础知识。
.. warning::
如果你发现自己学习 Rust 所花的时间已经远远超过了学习编译原理理论所需要的时间,那么你可能需要重新确定一下课程实验所使用的编程语言了。
使用 Rust 编写编译器只是实验的一个选项,其目的仍然是让你更好地去学习编译原理,而非 Rust 这个编程语言。
如果你确实对 Rust 很感兴趣但是时间不够,可以考虑在课程实验结束后继续学习 Rust。
Arena 惯用法
^^^^^^^^^^^^^^^^^^^^^^^^
编译器的实现中我们会用到非常多的图、树、链表等数据结构。而图、树、链表这种存在自引用的数据结构在 Rust 中是非常难以实现的,
因为 Rust 的生命周期检查器会阻止这种自引用的数据结构的实现。在 C++ 中,我们可以使用指针来实现这种数据结构:
.. code-block:: cpp
struct Node {
Node* previous;
Node* next;
// ...
};
但是相信大家已经遇到过了很多 C/C++ 中指针乱飞的问题。Rust 中一个可能的实现是使用 ``Rc`` 或者 ``Arc`` 来实现引用计数,
然而 ``Rc`` 不仅不好写,会带来额外开销,还有可能会导致循环引用,从而导致内存泄漏(**是的,Rust 不能防止内存泄漏**) [#rc_leak]_ 。
下面是一个使用 ``Rc`` 的例子 [#arena]_ :
.. code-block:: rust
use std::rc::Rc;
use std::cell::RefCell;
struct Node {
previous: Rc>>>,
// ^ ^ ^ ^
// | | | |
// | | | - 下一个节点 `T`
// | | |
// | | - 堆上动态分配的内存
// | |
// | - RefCell 实现了内部可变性,可以在不可变引用的情况下修改内部值
// |
// - 引用计数,因为一个节点可能被多个节点引用
next: Vec>>>,
data: T,
// ...
}
.. note::
如果你感兴趣,可以用这个方法写个图试试。
一个解决方法是使用 Arena,将所有的节点都放在一个 ``Vec`` 中,通过索引来表示其中的节点,这样就不会有生命周期检查器的问题了。
.. code-block:: rust
pub struct Arena {
nodes: Vec>,
}
pub struct Node {
parent: Option,
previous_sibling: Option,
next_sibling: Option,
first_child: Option,
last_child: Option,
/// 真实的数据存储在 Arena 中
pub data: T,
}
pub struct NodeId {
index: usize,
}
impl Arena {
pub fn new_node(&mut self, data: T) -> NodeId {
let node = Node {
parent: None,
previous_sibling: None,
next_sibling: None,
first_child: None,
last_child: None,
data,
};
// 将节点放入 Arena 中
self.nodes.push(node);
// 我们不返回数据,而是返回数据在 Arena 中的索引作为其标识符
NodeId {
index: self.nodes.len() - 1,
}
}
}
在编译器的实现过程中,我们将大量用到这样的方式来实现各种带引用的数据结构。在代码框架的 ``src/infra/storage.rs``
中我们实现了一个较为通用的 Arena 基础设施。你也可以尝试使用别的方法实现这种自引用的数据结构。
.. note::
如果你是一个 OI 选手,你可能会发现这种写法和你在写图论题目时的写法非常相似。
newtype 惯用法
^^^^^^^^^^^^^^^^^^^^^^^^
你可能想问,为什么我们要使用 ``NodeId`` 而不是直接使用 ``usize`` 作为索引呢?主要的原因仍然是类型安全。
在上面的实现中,如果我们要创建一个 ``NodeId``,我们必须要通过 ``Arena`` 的方法来创建,而不能直接构造一个 ``NodeId``
(当然如果处于同一个模块下,你仍然可以直接访问 ``NodeId`` 的 ``index`` 字段)。这样我们就能够保证 ``NodeId`` 永远不会被错误地使用。
如果我们使用 ``usize`` 直接索引 ``Arena``,我们就无法区分这个索引是不是来自于 ``Arena``,这样就可能导致程序在运行时 panic。
另外一个原因在于 ``usize`` 和 ``NodeId`` 的语义不同,``usize`` 往往用于表达整数、索引,而 ``NodeId`` 虽然内部是一个索引,
但是它实际上是用于标识一个节点的。另外,如果我们需要为 ``NodeId`` 添加一些方法或实现 Trait,我们可以直接在 ``NodeId`` 上实现,
而不会影响到 ``usize`` 的其他用途(一般对于 ``usize`` 而言,为他添加方法的实现会被编译器禁止,但是 ``usize`` 只是一个例子,
可能会有其他类型可以添加方法)。你可以参考 `Newtype Index Pattern `_
和 `Rust Design Patterns `_ 以及
`Embrace the newtype pattern -- Effective Rust `_ 中对 newtype 模式的介绍。
在代码框架中,我们同样使用了很多 newtype 模式,但是他们内部并不都是 ``usize``,你可以在代码框架中找到这些例子。
标准库中的 Traits
^^^^^^^^^^^^^^^^^^^^^^^^
你可以阅读 Effective Rust 中的 `这一节 `_ 来熟悉 Rust 标准库提供的常用 Trait,
熟悉这些 Trait 可以帮助你更好的理解代码框架中的一些实现。此处主要对 ``Clone``, ``Copy`` 进行介绍。
``Clone`` 类似于 C++ 中的拷贝构造函数,实现这个 Trait 可以告诉编译器“我这个类型是可以拷贝的”,但需要注意的是,Rust 并不会自动插入 ``clone()`` 的调用,
你需要手动调用 ``clone()`` 方法来进行拷贝。
``Copy`` Trait 同样表示拷贝,但是 ``Copy`` 所表示的是 bitwise 拷贝,即直接将数据复制到新的变量中。另外,实现了 ``Copy`` 的类型会直接将移动语义变为拷贝语义,
这样就不会导致所有权的转移。下面是一个例子:
.. code-block:: rust
#[derive(Clone)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let p1 = Point { x: 1, y: 2 };
let p3 = p1;
println!("{:?}", p1); // 这里会报错,因为 p1 已经被移动到 p3 中了
}
如果我们将 ``Point`` 实现为 ``Copy``,那么上面的代码就不会报错。
.. code-block:: rust
#[derive(Copy, Clone)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let p1 = Point { x: 1, y: 2 };
let p3 = p1;
println!("{:?}", p1); // 这里不会报错,因为 p1 会被拷贝到 p3 中,创建了一个副本
}
.. [#rc_leak] 参考: `Reference Cycles Can Leak Memory `_
.. [#arena] 代码来自于: `Idiomatic tree and graph like structures in Rust `_