Skip to content

初窥门径:第一个 Rust 程序!

环境安装

Windows 按照 https://rustup.rs/ 的指示安装即可。

因为运行 Rust 程序需要 Visual C++ Build Tools ,遵循命令行提示下载即可,注意勾选 Windows SDK 。

选用 VS Code 时,可选择以下插件:

  1. rust-analyzer / Rust 官方插件 :实时编译和分析 Rust 代码,提示代码错误,并对类型进行标注 ( 需写分号 )
  2. rust syntax :语法高亮
  3. crates :帮助分析当前项目的依赖是否是最新的版本
  4. better toml :Rust 使用 toml 做项目的配置管理。better toml 提供语法高亮,并展示 toml 文件中的错误

第一个 Rust 程序

通过 HTTP 请求 Rust 官网首页,把获得的 HTML 转换成 Markdown 保存。

html2md-rs

  • cargo new <project_name> : 新建 Rust 项目
  • cargo run :运行

基本特点

Rust 使用 cargo 管理项目,它类似于 Node.js 的 npm 、Golang 的 go,用来做依赖管理以及开发过程中的任务管理,比如编译、运行、测试、代码格式化等。

Rust 的整体语法偏 C / C++ 风格:

  1. 函数体用 {} 包裹
  2. 表达式之间用分号 ; 分隔
  3. 访问结构体打的成员函数或者变量使用 . 运算符
  4. 访问命名空间 ( namespace ) 或者对象的静态函数使用双冒号 :: 运算符
  5. 简化对命名空间内部的函数或者数据类型的引用,使用 use 关键字,比如 use std::fs
  6. 可执行体的入口函数是 main()

Rust 是一门强类型语言,但编译器支持类型推导,这使得开发时的直观感受和写脚本语言差不多。

Rust 支持宏编程。很多基础功能,如 println!() 被封装成一个宏,便于写出更简洁的代码。

Rust 的其他特点:

  1. Rust 的变量默认是不可变的,需要显式地使用 mut 关键字才能修改变量的值
  2. 除了 let / static / const /fn 等少数语句外,Rust 绝大多数代码都是表达式 ( expression ) 。所以 if / while / for / loop 都会返回一个值,函数最后一个表达式就是函数的返回值,和函数式编程语言一致
  3. Rust 支持面向接口编程和泛型编程
  4. Rust 有非常丰富的数据类型和强大的标准库
  5. Rust 有非常丰富的控制流程,包括模式匹配 ( pattern match )

基本内容

basic content

变量

Rust 支持类型推导,在编译器能够推导类型的情况下,变量类型一般可以省略,但常量 ( const ) 和静态变量 ( static ) 必须声明类型。

定义变量时,可以根据需要,添加 mut 关键字让变量具备可变性。变量默认不可变。它符合最小权限原则 ( Principle of Least Privilege ) ,有助于写出健壮且正确的代码。当使用 mut 却没有修改变量,Rust 会在编译期友好的报警,提示移除不必要的 mut

函数

在 Rust 下,函数是一等公民,可以作为参数或者返回值。

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 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 。

rust
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);
}
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);
}

数据结构

数据结构是程序的核心组成部分,在对复杂的问题进行建模时,需要自定义数据结构。

Rust 非常强大,可以用 struct 定义结构体,用 enum 定义标签联合体 ( tagged union ) ,还可以像 Python 一样随手定义元组 ( tuple ) 。

比如一个聊天服务的数据结构

rust
#[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::Join((alice.id, topic.id, "Hello World!".into()));

  println!("event1: {:?}, event2: {:?}, event3: {:?}", event1, event2, event3);
}
#[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::Join((alice.id, topic.id, "Hello World!".into()));

  println!("event1: {:?}, event2: {:?}, event3: {:?}", event1, event2, event3);
}

解释:

  1. Gender :枚举类型,在 Rust 下,使用 enum 可以定义类似 C 的枚举类型
  2. UserId / TopicId :struct 的特殊形式,称为元组结构体。它的域都是匿名的,可以用索引访问,适用于简单的结构体。
  3. User / Topic :标准的结构体,可以把任何类型组合在结构体里使用。
  4. Event :标准的标签联合体,它定义了三种事件:Join、Leave、Message 。每种事件都有各自的数据结构。

定义数据结构时,一会会加入修饰,为数据结构引入一些额外的行为。

在 Rust 里,数据的行为通过 trait 定义,暂时可以认为 trait 定义了数据结构可以实现的结构。

一般用 impl 关键字为数据结构实现 trait ,但 Rust 也提供了派生宏 ( derive macro ) ,可以大大简化一些标准接口的定义,比如 #[derive(Debug)] 为数据结构实现了 Debug trait ,提供了 debug 能力,因此可以通过 {:?} ,用 println! 打印出来。

在定义 UserId / TopicId 时,还用到了 Copy / Clone 两个派生宏,Clone 让数据结构可以被复制,而 Copy 则让数据结构可以在参数传递的时候自动按字节拷贝。

控制流程

顺序执行是代码一行行往下执行。在执行过程中,遇到函数,发生函数调用。函数调用是代码在执行过程中,调用另一个函数,跳入其上下文执行,直到返回。

Rust 的循环和大部分语言都一致,支持死循环 loop、条件循环 while,以及对迭代器的循环 for 。循环可以通过 break 提前终止,或者 continue 跳到下一轮循环。

满足某个条件时会跳转,Rust 支持分支跳转、模式匹配、错误跳转和异步跳转。

  • 分支跳转:if / else
  • 模式匹配:通过匹配表达式或值的某部分的内容,进行分支跳转
  • 错误跳转:当调用的函数返回错误时,Rust 会提前终止当前函数的执行,向上一层返回错误
  • 异步跳转:当 async 函数执行 await 时,程序当前上下文可能被阻塞,执行流程会跳转到另一个异步任务执行,直至 await 不再阻塞

if / loop / while / for 实现斐波那切数列的例子

rust
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);
}
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 的所有值。可以省略 Range 的下标或者上标,如:

rust
let arr = [1, 2, 3];
assert_eq!(arr[..], [1, 2, 3]);
assert_e1!(arr[0..=1], [1, 2]);
let arr = [1, 2, 3];
assert_eq!(arr[..], [1, 2, 3]);
assert_e1!(arr[0..=1], [1, 2]);

Rust 的 Range 不支持负数,不能使用类似 arr[1..-1] 这样的代码,Range 的上下标是 usize 类型,不能为负数。

control flow

模式匹配

Rust 的模式匹配吸取了函数式编程语言的有点,强大优雅且效率很高。它可以用于 struct / enum 中匹配部分或者全部内容。

rust
// 直接对 enum 内层的数据进行匹配并复制
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 内层的数据进行匹配并复制
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),
  }
}

除了使用 match 关键字做模式匹配外,还可以用 if let / while let 做简单的匹配,如:

rust
// 只关心 Event::Message 时
fn process_message(event: &Event) {
  if let Event::Message((_, _, msg)) = event {
    pritnln!("broadcast: {}", msg);
  }
}
// 只关心 Event::Message 时
fn process_message(event: &Event) {
  if let Event::Message((_, _, msg)) = event {
    pritnln!("broadcast: {}", msg);
  }
}

Rust 的模式匹配是一个很重要的语言特性,被广泛应用在状态机处理、消息处理和错误处理中。

错误处理

Rust 没有沿用 C++ / Java 的异常处理方式,而是借鉴 Haskell,把错误封装在 Result<T, E> 类型中,同时提供了 ? 操作符来传播错误,方便开发。

Result<T, E> 是一个泛型数据结构,T 代表成功执行返回的结果类型,E 代表错误类型。

rust
use std::fs;

fn main() {
  let url = "http://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);
}
use std::fs;

fn main() {
  let url = "http://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() 只关心成功返回的结果,如果出错,整个程序会终止。

如果想让错误传播,可以把所有的 unwrap() 换成 ? 操作符,并让 main() 函数返回一个 Result<T, E> ,如:

rust
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)?.test()?;

  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(())
}
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)?.test()?;

  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 代码规模越来越大时,需要多个文件甚至多个目录协同工作,可以用 mod 组织代码。

具体做法:在项目的入口文件 lib.rs / main.rs 里,用 mod 声明要加载的其它代码文件。如果模块内容较多,可以放在一个目录下,在该目录下放一个 mod.rs 引入该模块的其它文件。然后可以用 mod + 目录名 引入这个模块。

Rust 中,一个项目也被成为一个 crate 。create 可以是可执行项目,也可以是一个库,可以用 cargo new <name> -- lib 来创建一个库。当 crate 里的代码改变时,这个 crate 需要被重新编译。

在一个 crate 下,除了项目的源代码,单元测试和集成测试的代码也会放在 crate 里。

Rust 的单元测试一般放在和被测代码相同的文件中,使用条件编译 #[cfg(test)] 来确保测试代码只在测试环境下编译。比如:

rust
#[cfg(test)]
mod tests {
  #[test]
  fn it_works() {
    assert_eq!(2 + 2, 4);
  }
}
#[cfg(test)]
mod tests {
  #[test]
  fn it_works() {
    assert_eq!(2 + 2, 4);
  }
}

集成测试一般放在 tests 目录下,和 src 平行。和单元测试不同,集成测试只能测试 crate 下的公开接口,编译时编译成单独的可执行文件。

在 crate 下,可以使用 cargo test 运行测试用例。

当代码规模继续增长,任何代码的修改都会导致所在 crate 重新编译,效率不高,可以使用 workspace 。

一个 workspace 包含一到多个 crates ,当代码发生改变时,只有涉及的 crates 才需要重新编译。当要构建一个 workspace 时,需要先在某个目录下生成一个如图所示的 Cargo.toml ,包含 workspace 里所有的 crates ,然后通过 cargo new 生成对应的 crates。