Rust 编程基础

系统学习 Rust

这里有一些学习 Rust 的资源,如果你想系统学习 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 实现整个项目的构建、依赖的管理以及单元测试。以下是一个简单的例子:

$ cargo new my_project
$ cd my_project
$ cargo run

之后你就会看到类似于下面的输出:

$ 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!

你会发现整个项目的结构如下

$ tree
    .
    ├── Cargo.lock
    ├── Cargo.toml
    └── src
        └── main.rs

其中 Cargo.toml 是项目的配置文件,src/main.rs 是项目的入口文件。我们的编译器框架也会使用这样的结构。

小技巧

Cargo 是一个非常易用且可扩展的构建工具,你可以通过 cargo --help 查看所有的命令。 关于更加详细的使用方法,可以参考 The Cargo Book (强烈建议阅读,因为之后你会经常用到 Cargo)。

此处列举几个比较基础的 Cargo 使用方式

  • 使用 Release 模式构建项目:cargo build --release

  • 运行单元测试:cargo test

  • 检查代码风格:cargo fmt

  • 添加依赖包:cargo add <package-name>

Cargo 之于 Rust 就像 Pip 之于 Python,Npm 之于 Node.js,Maven 之于 Java 一样,是 Rust 生态中的一个重要组成部分。 你可以在 crates.io 上找到很多 Rust 的第三方库,你可以通过 Cargo 添加这些库到你的项目中。 如果速度太慢可以考虑使用 tuna 或者中科大的源,使用方法请自行 STFW。

小技巧

测试是保证你的代码质量的重要手段,Rust 语言的特性以及 Cargo 这个构建工具为测试提供了很大的便利,建议阅读: How to Write Tests 。

小技巧

你可以配置 Clippy 并运行 cargo clippy 来检查代码中更多的潜在问题,具体使用方式和配置方法可以参考 Clippy Documentation 。

Rust 编程技巧

备注

在阅读下面的内容之前,请确保你已经至少掌握了 Rust 程序设计语言的前七节和第十节,或者 Rust 语言圣经的第二节,或者 Rust By Example 的前十节和第十六节的内容。

以下内容只是对 Rust 语言一些惯用法或重要概念的介绍,这些内容只是你完成这个实验所需要的很少一部分,你仍然需要阅读前面给出的参考文档学习 Rust 的基础知识。

警告

如果你发现自己学习 Rust 所花的时间已经远远超过了学习编译原理理论所需要的时间,那么你可能需要重新确定一下课程实验所使用的编程语言了。 使用 Rust 编写编译器只是实验的一个选项,其目的仍然是让你更好地去学习编译原理,而非 Rust 这个编程语言。 如果你确实对 Rust 很感兴趣但是时间不够,可以考虑在课程实验结束后继续学习 Rust。

Arena 惯用法

编译器的实现中我们会用到非常多的图、树、链表等数据结构。而图、树、链表这种存在自引用的数据结构在 Rust 中是非常难以实现的, 因为 Rust 的生命周期检查器会阻止这种自引用的数据结构的实现。在 C++ 中,我们可以使用指针来实现这种数据结构:

struct Node {
    Node* previous;
    Node* next;
    // ...
};

但是相信大家已经遇到过了很多 C/C++ 中指针乱飞的问题。Rust 中一个可能的实现是使用 Rc 或者 Arc 来实现引用计数, 然而 Rc 不仅不好写,会带来额外开销,还有可能会导致循环引用,从而导致内存泄漏(是的,Rust 不能防止内存泄漏) [1] 。 下面是一个使用 Rc 的例子 [2] :

use std::rc::Rc;
use std::cell::RefCell;

struct Node<T> {
    previous: Rc<RefCell<Box<Node<T>>>>,
    //        ^  ^       ^   ^
    //        |  |       |   |
    //        |  |       |   - 下一个节点 `T`
    //        |  |       |
    //        |  |       - 堆上动态分配的内存
    //        |  |
    //        |  - RefCell 实现了内部可变性,可以在不可变引用的情况下修改内部值
    //        |
    //        - 引用计数,因为一个节点可能被多个节点引用

    next: Vec<Rc<RefCell<Box<T>>>>,
    data: T,
    // ...
}

备注

如果你感兴趣,可以用这个方法写个图试试。

一个解决方法是使用 Arena,将所有的节点都放在一个 Vec 中,通过索引来表示其中的节点,这样就不会有生命周期检查器的问题了。

pub struct Arena<T> {
    nodes: Vec<Node<T>>,
}

pub struct Node<T> {
    parent: Option<NodeId>,
    previous_sibling: Option<NodeId>,
    next_sibling: Option<NodeId>,
    first_child: Option<NodeId>,
    last_child: Option<NodeId>,

    /// 真实的数据存储在 Arena 中
    pub data: T,
}

pub struct NodeId {
    index: usize,
}

impl<T> Arena<T> {
    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 基础设施。你也可以尝试使用别的方法实现这种自引用的数据结构。

备注

如果你是一个 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 的类型会直接将移动语义变为拷贝语义, 这样就不会导致所有权的转移。下面是一个例子:

#[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,那么上面的代码就不会报错。

#[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 中,创建了一个副本
}