前置篇 (3讲)
基础篇 (21讲)
03|初窥门径:从第一个Rust程序开始!
会讲到很多 Rust 的基础知识,我都精心构造了代码案例来帮你理解,非常推荐你自己一行行敲入这些代码,边写边思考为什么这么写,然后在运行时体会执行和输出的过程。如果遇到了问题,你也可以点击每个例子附带的代码链接,在 Rust playground 中运行。
Rust 安装起来非常方便,用 rustup.rs ,根据操作系统进行安装。如在 UNIX 下,直接运行:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
在你的系统上安装 Rust 工具链。在本地用 cargo new 新建 Rust 项目、尝试 Rust 功能。
fn main() {
println!("Hello world!");
}
可用任何编辑器,个人偏爱 VS Code,免费,功能强大且速度很快。
在 VS Code 下为 Rust 安装插件,安装顺序:
- rust-analyzer:实时编译和分析你的 Rust 代码,提示代码中的错误,并对类型进行标注。你也可以使用官方的 Rust 插件取代。
- rust syntax:提供语法高亮。
- crates:分析当前项目的依赖是否是最新的版本。
- better toml:Rust 使用 toml 做项目的配置管理。better toml 语法高亮,并展示 toml 文件中的错误。
- rust test lens:快速运行某个 Rust 测试。
- Tabnine:基于 AI 的自动补全,更快地撰写代码。
第一个实用的 Rust 程序
建议用自己常用的编程语言做同样的需求,和 Rust 对比,看简洁程度、代码可读性孰优孰劣。
通过 HTTP 请求 Rust 官网首页,把获得的 HTML 转换成 Markdown 保存起来。若用 JavaScript 或 Python,选好相关的依赖,也就十多行代码。
用 cargo new scrape_url 生成新项目。默认情况下,生成一个可执行项目 scrape_url,入口在 src/main.rs。在 Cargo.toml 文件里,加入依赖:
[dependencies]
reqwest = { version = "0.11", features = ["blocking"] }
html2md = "0.2"
Cargo.toml 是 Rust 项目的配置管理文件,它符合 toml 的语法。这个项目添加了两个依赖:reqwest 和 html2md。reqwest 是一个 HTTP 客户端,使用方式和 Python 下的 request 类似;html2md ,把 HTML 文本转换成 Markdown。
在 src/main.rs 里,为 main() 函数加入以下代码:
use std::fs;
fn main() {
let url = "https://www.rust-lang.org/";
let output = "rust.md";
println!("Fetching url: {}", url);
let body = reqwest::blocking::get(url).unwrap().text().unwrap();
println!("Converting html to markdown...");
let md = html2md::parse_html(&body);
fs::write(output, md.as_bytes()).unwrap();
println!("Converted markdown has been saved in {}.", output);
}
保存后,在命令行下,进入这个项目的目录,运行 cargo run,在一段略微漫长的编译后,程序开始运行,在命令行下,你会看到如下的输出:
Fetching url: https://www.rust-lang.org/
Converting html to markdown...
Converted markdown has been saved in rust.md.
并且,在当前目录下,一个 rust.md 文件被创建出来了。打开一看,其内容就是 Rust 官网主页的内容。
第一个 Rust 程序运行成功!
Rust 的一些基本特点:
- Rust 用名为 cargo 的工具来管理项目,它类似 Node.js 的 npm、Golang 的 go,做依赖管理及开发过程中的任务管理,如编译、运行、测试、代码格式化等。
- Rust 整体语法偏 C/C++ 风格。函数体用花括号 {} 包裹,表达式之间用分号 ; 分隔,访问结构体的成员函数或变量使用点 . 运算符,访问命名空间(namespace)或对象的静态函数使用双冒号 :: 运算符。简化对命名空间内部的函数或数据类型的引用,用 use 关键字,如 use std::fs。此外,可执行体的入口函数是 main()。
- Rust 是强类型语言,编译器支持类型推导,写代码时的直观感受和写脚本语言差不多。
若在编辑器中使用了 rust-analyzer 插件,变量的类型会自动提示出来:
- Rust 支持宏编程,很多基础的功能如 println!() 都被封装成一个宏,便于写出简洁的代码。
Rust 的其它特点:
- Rust 变量默认是不可变的,要修改变量的值,需要显式地使用 mut 关键字。
- 除了 let / static / const / fn 等少数语句外,Rust 绝大多数代码都是表达式(expression)。所以 if / while / for / loop 都会返回一个值,函数最后一个表达式就是函数的返回值,这和函数式编程语言一致。
- Rust 支持面向接口编程和泛型编程。
- Rust 有非常丰富的数据类型和强大的标准库。
- Rust 有非常丰富的控制流程,包括模式匹配(pattern match)。
一起梳理 Rust 开发的基本内容。
这部分涉及的知识在各个编程语言中都大同小异,略微枯燥。建议每段示例代码都写一下,运行一下,并且和自己熟悉的语言对比来加深印象。
基本语法和基础数据类型
如何定义变量、函数和数据结构。
变量和函数
Rust 支持类型推导,在编译器能够推导类型的情况下,变量类型一般可以省略,但常量(const)和静态变量(static)必须声明类型。
定义变量的时候,根据需要,可以添加 mut 关键字让变量具备可变性。默认变量不可变是一个很重要的特性,它符合最小权限原则(Principle of Least Privilege)。当用 mut 却没有修改变量,Rust 编译期会提示移除不必要的 mut。
Rust 中,函数是一等公民,可作为参数或返回值。函数作为参数(代码):
fn apply(value: i32, f: fn(i32) -> i32) -> i32 {
f(value)
}
fn square(value: i32) -> i32 {
value * value
}
fn cube(value: i32) -> i32 {
value * value * value
}
fn main() {
println!("apply square: {}", apply(2, square));
println!("apply cube: {}", apply(2, cube));
}
fn(i32) -> i32 是 apply 函数第二个参数的类型,表明接受一个函数作为参数,传入的函数必须:参数只有一个,且类型为 i32,返回值类型也是 i32。
Rust 函数参数的类型和返回值的类型都必须显式定义,如果没有返回值可以省略,返回 unit。函数内部如果提前返回,要用 return 关键字,否则最后一个表达式就是其返回值。如果最后一个表达式后添加了; 分号,隐含其返回值为 unit。例子(代码):
fn pi() -> f64 {
3.1415926
}
fn not_pi() {
3.1415926;
}
fn main() {
let is_pi = pi();
let is_unit1 = not_pi();
let is_unit2 = {
pi();
};
println!("is_pi: {:?}, is_unit1: {:?}, is_unit2: {:?}", is_pi, is_unit1, is_unit2);
}
is_pi: 3.1415926, is_unit1: (), is_unit2: ()
数据结构
数据结构是程序的核心组成部分,对复杂的问题进行建模,就要自定义数据结构。
struct 定义结构体,enum 定义标签联合体(tagged union),随手定义元组(tuple)类型。
定义一个聊天服务的数据结构(代码):
#[derive(Debug)]
enum Gender {
Unspecified = 0,
Female = 1,
Male = 2,
}
#[derive(Debug, Copy, Clone)]
struct UserId(u64);
#[derive(Debug, Copy, Clone)]
struct TopicId(u64);
#[derive(Debug)]
struct User {
id: UserId,
name: String,
gender: Gender,
}
#[derive(Debug)]
struct Topic {
id: TopicId,
name: String,
owner: UserId,
}
// 定义聊天室中可能发生的事件
#[derive(Debug)]
enum Event {
Join((UserId, TopicId)),
Leave((UserId, TopicId)),
Message((UserId, TopicId, String)),
}
fn main() {
let alice = User { id: UserId(1), name: "Alice".into(), gender: Gender::Female };
let bob = User { id: UserId(2), name: "Bob".into(), gender: Gender::Male };
let topic = Topic { id: TopicId(1), name: "rust".into(), owner: UserId(1) };
let event1 = Event::Join((alice.id, topic.id));
let event2 = Event::Join((bob.id, topic.id));
let event3 = Event::Message((alice.id, topic.id, "Hello world!".into()));
println!("event1: {:?}, event2: {:?}, event3: {:?}", event1, event2, event3);
}
解释一下:
- Gender:一个枚举类型,在 Rust 下,使用 enum 可以定义类似 C 的枚举类型
- UserId/TopicId :struct 的特殊形式,称元组结构体。它的域都是匿名的,可以用索引访问,适用于简单的结构体。
- User/Topic:标准的结构体,可以把任何类型组合在结构体里使用。
- Event:标准的标签联合体,定义了三种事件:Join、Leave、Message。每种事件都有自己的数据结构。
定义数据结构时,一般会加入修饰,为数据结构引入一些额外的行为。在 Rust 里,数据的行为通过 trait 来定义,后续我们会详细介绍 trait,你现在可以暂时认为 trait 定义了数据结构可以实现的接口,类似 Java 中的 interface。
用 impl 关键字为数据结构实现 trait,但 Rust 贴心地提供了派生宏(derive macro),可大大简化一些标准接口的定义,比如 #[derive(Debug)] 为数据结构实现了 Debug trait,提供了 debug 能力,这样可以通过 {:?},用 println! 打印出来。
定义 UserId / TopicId 时用到了 Copy / Clone 两个派生宏,Clone 让数据结构可以被复制,而 Copy 则让数据结构可以在参数传递的时候自动按字节拷贝。
简单总结一下 Rust 定义变量、函数和数据结构:
控制流程
程序的基本控制流程分为以下几种,重点看如何在 Rust 中运行。
顺序执行就是一行行代码往下执行。在执行的过程中,遇到函数,会发生函数调用。函数调用是代码在执行过程中,调用另一个函数,跳入其上下文执行,直到返回。
Rust 的循环和大部分语言都一致,支持死循环 loop、条件循环 while,以对迭代器的循环 for。循环可以通过 break 提前终止,或 continue 来跳到下一轮循环。
满足某个条件时会跳转, Rust 支持分支跳转、模式匹配、错误跳转和异步跳转。
- 分支跳转: if/else;
- Rust 的模式匹配:通过匹配表达式或值的某部分的内容,来进行分支跳转;
- 错误跳转:当调用的函数返回错误,Rust 会提前终止当前函数的执行,向上一层返回错误。
- Rust 的异步跳转:当 async 函数执行 await 时,程序当前上下文可能被阻塞,执行流程会跳转到另一个异步任务执行,直至 await 不再阻塞。
斐波那契数列,用 if 和 loop / while / for 这几种循环,来实现(代码):
fn fib_loop(n: u8) {
let mut a = 1;
let mut b = 1;
let mut i = 2u8;
loop {
let c = a + b;
a = b;
b = c;
i += 1;
println!("next val is {}", b);
if i >= n {
break;
}
}
}
fn fib_while(n: u8) {
let (mut a, mut b, mut i) = (1, 1, 2);
while i < n {
let c = a + b;
a = b;
b = c;
i += 1;
println!("next val is {}", b);
}
}
fn fib_for(n: u8) {
let (mut a, mut b) = (1, 1);
for _i in 2..n {
let c = a + b;
a = b;
b = c;
println!("next val is {}", b);
}
}
fn main() {
let n = 10;
fib_loop(n);
fib_while(n);
fib_for(n);
}
Rust 的 for 循环可用于任何实现了 IntoIterator trait 的数据结构。
执行过程中,IntoIterator 会生成一个迭代器,for 循环不断从迭代器中取值,直到迭代器返回 None 为止。for 循环实际上只是一个语法糖,编译器会将其展开使用 loop 循环对迭代器进行循环访问,直至返回 None。
在 fib_for 函数中, 2…n 这样的语法,这是 Range 操作,2…n 包含 2<= x < n 的所有值。和 Python 一样,Rust 中,也可以省略 Range 的下标或者上标,如:
let arr = [1, 2, 3];
assert_eq!(arr[..], [1, 2, 3]);
assert_eq!(arr[0..=1], [1, 2]);
和 Python 不同,Range 不支持负数,不能使用 arr[1..-1] 这样的代码。Range 的下标上标都是 usize 类型,不能为负数。
Rust 主要控制流程的总结:
模式匹配
Rust 的模式匹配吸取了函数式编程语言的优点,强大优雅且效率很高。可用于 struct / enum 中匹配部分或全部内容,如 Event 可以这样匹配(代码):
fn process_event(event: &Event) {
match event {
Event::Join((uid, _tid)) => println!("user {:?} joined", uid),
Event::Leave((uid, tid)) => println!("user {:?} left {:?}", uid, tid),
Event::Message((_, _, msg)) => println!("broadcast: {}", msg),
}
}
直接对 enum 内层的数据进行匹配并赋值,这比很多只支持简单模式匹配的语言,例如 JavaScript 、Python ,可以省出好几行代码。
除了用 match 关键字做模式匹配外,还可用 if let / while let 做简单的匹配,若只关心 Event::Message,这么写(代码):
fn process_message(event: &Event) {
if let Event::Message((_, _, msg)) = event {
println!("broadcast: {}", msg);
}
}
Rust 的模式匹配是一个很重要的语言特性,被广泛应用在状态机处理、消息处理和错误处理中,如果你之前使用的语言是 C / Java / Python / JavaScript ,没有强大的模式匹配支持,要好好练习这一块。
错误处理
Rust 借鉴 Haskell,把错误封装在 Result<T, E> 类型中,同时提供了 ? 操作符来传播错误,方便开发。Result<T, E> 类型是一个泛型数据结构,T 代表成功执行返回的结果类型,E 代表错误类型。
scrape_url 项目,其实里面很多调用已经使用了 Result<T, E> 类型,这里我再展示一下代码,不过我们使用了 unwrap() 方法,只关心成功返回的结果,如果出错,整个程序会终止。
use std::fs;
fn main() {
let url = "https://www.rust-lang.org/";
let output = "rust.md";
println!("Fetching url: {}", url);
let body = reqwest::blocking::get(url).unwrap().text().unwrap();
println!("Converting html to markdown...");
let md = html2md::parse_html(&body);
fs::write(output, md.as_bytes()).unwrap();
println!("Converted markdown has been saved in {}.", output);
}
如果想让错误传播,可以把所有的 unwrap() 换成 ? 操作符,并让 main() 函数返回一个 Result<T, E>,如下所示:
use std::fs;
// main 函数现在返回一个 Result
fn main() -> Result<(), Box<dyn std::error::Error>> {
let url = "https://www.rust-lang.org/";
let output = "rust.md";
println!("Fetching url: {}", url);
let body = reqwest::blocking::get(url)?.text()?;
println!("Converting html to markdown...");
let md = html2md::parse_html(&body);
fs::write(output, md.as_bytes())?;
println!("Converted markdown has been saved in {}.", output);
Ok(())
}
会单开一讲,对比其他语言,详细学习 Rust 的错误处理。
Rust 项目的组织
当 Rust 代码规模越来越大时,要多个文件甚至多个目录协同工作,用 mod 来组织代码。
具体做法是:
在项目的入口文件 lib.rs / main.rs 里,用 mod 来声明要加载的其它代码文件。
如果模块内容比较多,可以放在一个目录下,在该目录下放一个 mod.rs 引入该模块的其它文件。这个文件,和 Python 的 __init__.py 有异曲同工之妙。这样处理之后,就可以用 mod + 目录名引入这个模块了,如下图所示:
在 Rust 里,一个项目也被称为一个 crate。crate 可以是可执行项目,也可以是一个库,我们可以用 cargo new <name> -- lib 来创建一个库。当 crate 里的代码改变时,这个 crate 需要被重新编译。
在一个 crate 下,除了项目的源代码,单元测试和集成测试的代码也会放在 crate 里。
Rust 的单元测试一般放在和被测代码相同的文件中,使用条件编译 #[cfg(test)] 来确保测试代码只在测试环境下编译。以下是一个单元测试的例子:
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
assert_eq!(2 + 2, 4);
}
}
集成测试一般放在 tests 目录下,和 src 平行。和单元测试不同,集成测试只能测试 crate 下的公开接口,编译时编译成单独的可执行文件。
在 crate 下,如果要运行测试用例,可以使用 cargo test。
当代码规模继续增长,把所有代码放在一个 crate 里就不是一个好主意了,因为任何代码的修改都会导致这个 crate 重新编译,这样效率不高。我们可以使用 workspace。
一个 workspace 可以包含一到多个 crates,当代码发生改变时,只有涉及的 crates 才需要重新编译。当我们要构建一个 workspace 时,需要先在某个目录下生成一个如图所示的 Cargo.toml,包含 workspace 里所有的 crates,然后可以 cargo new 生成对应的 crates:
crate 和 workspace 还有一些更高级的用法,在后面遇到的时候会具体讲解。如果你有兴趣,也可以先阅读 Rust book 第 14 章了解更多的知识。
小结
简单梳理了 Rust 的基本概念。通过 let/let mut 定义变量、用 fn 定义函数、用 struct / enum 定义复杂的数据结构,也学习了 Rust 的基本的控制流程,了解了模式匹配如何运作,知道如何处理错误。
最后考虑到代码规模问题,如何使用 mod、crate 和 workspace 来组织 Rust 代码。
今天是让你对 Rust 形成非常基本的认识,能够开始尝试写一些简单的 Rust 项目。
你也许会惊奇,用 Rust 写类似于 scrape_url 的功能,竟然和 Python 这样的脚本语言的体验几乎一致,太简单了!
下一讲我们会继续写一写代码,从实用的小工具的编写中真实感受 Rust 的魅力。
思考题
1. 在上面的斐波那契数列的代码中,你也许注意到计算数列中下一个数的代码在三个函数中不断重复。这不符合 DRY(Don’t Repeat Yourself)原则。你可以写一个函数把它抽取出来么?
2. 在 scrape_url 的例子里,我们在代码中写死了要获取的 URL 和要输出的文件名,这太不灵活了。你能改进这个代码,从命令行参数中获取用户提供的信息来绑定 URL 和文件名么?类似这样:
cargo run -- https://www.rust-lang.org rust.md
提示一下,打印一下 std::env::args() 看看会发生什么?
for arg in std::env::args() {
println!("{}", arg);
}
参考资料
- TOML
- static 关键字
- lazy_static
- unit 类型
- How to write tests
- More about cargo and crates.io
- Rust 支持声明宏(declarative macro)和过程宏(procedure macro),其中过程宏又包含三种方式:函数宏(function macro),派生宏(derive macro)和属性宏(attribute macro)。println! 是函数宏,是因为 Rust 是强类型语言,函数的类型需要在编译期敲定,而 println! 接受任意个数的参数,所以只能用宏来表达。
2022-12-28,这是一条较新的错误处理解决留言。 如果你是在windows环境下cargo run课程中的代码发现出现以下错误: error: linking with `x86_64-w64-mingw32-gcc` failed: exit code: 1 网上解决方案是安装x86_64-pc-windows-msvc,但是你已经成功安装,却依然报错。 原因是除了安装msvc工具链以外,你还需要切换rust当前默认的工具链。 # 显示当前安装的工具链信息 rustup show # 设置当前默认工具链 rustup default stable-x86_64-pc-windows-msvc 这样你就可以正常编译运行了。
编辑回复: 👍 给你置顶了
export RUSTUP_DIST_SERVER=https://mirrors.sjtug.sjtu.edu.cn/rust-static
export RUSTUP_UPDATE_ROOT=https://mirrors.sjtug.sjtu.edu.cn/rust-static/rustup rust
国内安装必备环境配置
作者回复: 👍 谢谢提醒!我倒没有关注国内需要镜像的问题。
Copy 和 Clone 两者的区别和联系有: Copy内部没有方法,Clone内部有两个方法。 Copy trait 是给编译器用的,告诉编译器这个类型默认采用 copy 语义,而不是 move 语义。Clone trait 是给程序员用的,我们必须手动调用clone方法,它才能发挥作用。 Copy trait不是你想实现就实现,它对类型是有要求的,有些类型就不可能 impl Copy。Clone trait 没有什么前提条件,任何类型都可以实现(unsized 类型除外)。 Copy trait规定了这个类型在执行变量绑定、函数参数传递、函数返回等场景下的操作方式。即这个类型在这种场景下,必然执行的是“简单内存拷贝”操作,这是由编译器保证的,程序员无法控制。Clone trait 里面的 clone 方法究竟会执行什么操作,则是取决于程序员自己写的逻辑。一般情况下,clone 方法应该执行一个“深拷贝”操作,但这不是强制的,如果你愿意,也可以在里面启动一个人工智能程序,都是有可能的。 链接:https://zhuanlan.zhihu.com/p/21730929