Rust-QuickStart

Include:

  • This book?

  • Before Start

  • Quick Start with a small Game

  • Improve your Rust code (TODO)

  • Others (TODO)

This book?

本文是为已经有一定编程基础(像C/Cpp, Python, Ruby, C#等)的读者所编写的Rust快速入门教程,因此本文会假定读者已经对通用的基本的编程语言中的概念(如类型,结构体,流程控制等)具有一定的了解。如果在此之前你没有学习过任何有关编程语言的知识,我们强烈建议你应该先去学习一门其他语言。

如果你有基础且对部分语言比较熟练,可以通过《Rust语言圣经》进行更加系统的学习。这本书可以在Rust官网找到, 或者你可以阅读这本不错的中译版:https://course.rs/。 另外对于这部分爷(先跪了 Orz),您们完全可以跳过第一阶段(Before Start && Quick Start with a small Game),直接看后面的部分。

如果你想要快速上手写点什么东西,那么这本书将非常适合你!本书将会带你从一个小项目中逐步学习Rust,比较和其他语言的异同点。另外如果你学习过rust圣经,你可能会发现本书的知识点顺序和圣经差别很大,这是因为本书是以项目讲解为优先的,辅以知识点讲解,每个知识点我都会尽量放在一个比较合适的位置。

最后,祝各位能从中收获乐趣!

Before Start

Install Rust

你可以通过Rust官网下载rustup-init,并在本机运行,根据提示安装。rustup会帮你安装好工具链和一些常用工具,比如cargo,rustc等,并且会添加到PATH,所以无须手动添加环境变量。

Cargo quick-start

cargo是rust自带的包管理器,拥有非常强大的功能,如果你熟练掌握cargo的使用你会很快爱上它~

本部分只介绍一些本人经常使用到的命令。

help(应该一款正经的工具都该有help功能:D)

1
cargo help / cargo

如果你使用的cargo版本是2023-10-18之前的,可能没有颜色高亮

你还可以用help命令来查看某个命令的详细信息,比如

1
cargo help new

这里不放图了,因为实在太长了:(

new

通过cargo new在当前文件夹下创建一个新的项目文件夹,里面的结构大概是

1
2
3
4
5
6
7
8
PS D:\Rust-test> cargo new hello
Created binary (application) `hello` package
PS D:\Rust-test> cd hello
PS D:\Rust-test\hello> tree
.
├── Cargo.toml
└── src
└── main.rs

Cargo.toml里面是一些配置信息,比如name,version,author,dependence之类的。src文件夹存放你的rs源代码。

build

你可以通过build命令来编译当前项目,默认是debug,你可以通过cargo build --release指定为release,它会进行更加激进的优化,当然与之对应的是编译时间也会增加。如果你想要交叉编译到其他平台,比如windows编译到linux,你可以通过cargo build --target x86_64-unknown-linux-musl来指定平台。build命令生成的文件会放在target文件夹下,不同平台,debug,release是分开放的,所以无须担心会覆盖。

run

cargo run实际上相当于执行了2个命令:

1
2
cargo build
./target/debug/编译得到的可执行文件

所以build命令的参数在run是可以用的。

使用new命令创建的项目中main.rs里默认内容是

1
2
3
fn main() {
println!("Hello, world!");
}

快去run一下你的第一个rust程序吧:D

1
2
3
4
5
PS D:\Rust-test\hello> cargo run
Compiling hello v0.1.0 (D:\Rust-test\hello)
Finished dev [unoptimized + debuginfo] target(s) in 2.80s
Running `target\debug\hello.exe`
Hello, world!

check

cargo check应该是使用频率最高的命令之一,无他,谁不愿意在写代码的时候有一个帮手能帮你检查代码呢。check命令就是会对你的rust代码做亿点点检查,如果你写的问题,还会贴心的给予你提示。

比如:

1
2
3
4
// path: src/main.rs
fn main() {
let a = 1;
}

check一下看看

警告了你a这个变量没有使用,并且给了你提示,可以在变量名前面加一个下划线,这样编译器就不会警告这个值,当然这个值还是可以使用的。check的功能远不止这些,得要靠自己探索了:D

fmt

rust又一大杀器,除了可以帮你控制代码缩进,还可以帮你的代码书写风格变得更加rusty!

比如:

1
2
3
4
5
6
7
8
9
// path: src/main.rs
fn main() {
let mut s = String::new();
std::io::stdin().read_line(&mut s).unwrap();
let nums = s.trim().split_whitespace().map(|x| x.parse::<usize>().unwrap()).collect::<Vec<usize>>();
{
println!("{:?}", nums);
}
}

执行cargo fmt

1
2
3
4
5
6
7
8
9
10
11
12
fn main() {
let mut s = String::new();
std::io::stdin().read_line(&mut s).unwrap();
let nums = s
.trim()
.split_whitespace()
.map(|x| x.parse::<usize>().unwrap())
.collect::<Vec<usize>>();
{
println!("{:?}", nums);
}
}

是不是看的舒服多了,所以再也不用担心重构时一堆缩进问题的(bushi

clippy

check + fmt + clippy大概就是写代码过程中最常用的3件套了,写rs嘎嘎爽~

clippy类似于check,会对你的代码做一些常见错误的检查,同时也像命令描述中说的,improve your Rust code!

对上面那段代码分别运行check和clippy对比看看

发现check没有发现语法问题,所以没有任何信息抛出;而clippy则是在语法层面提供了一个简单的优化方案,比如这里提示在.split_whitespace()前不需要使用.trim(), 后面其实还有一个提示说可以用clippy帮你改,不用手动改。我们运行一下cargo clippy --fix --bin 'hello' --allow-dirty, 再回去看我们的源代码,果然.trim()已经被去除了。

others

还有一些不是那么的常用但是也很有用的命令,这里简单列一下:

  • cargo add 可以把指定的依赖添加到当前项目,比如cargo add regex,就可以把regex库添加到当前项目,使用cargo add会修改你的Cargo.toml文件,默认添加的是最新版本,也可以指定它的版本和feature啥的。当然你也可以直接修改cargo.toml文件来达到一样的效果
  • cargo remove 于cargo add对应,就是把指定的依赖从当前项目去除
  • cargo --version 查看cargo的版本,包括是stable还是nightly
  • cargo install 看起来和add很像,不过install命令是安装二进制文件的,比如运行cargo install tauri-cli,然后你就可以使用cargo tauri 命令来构建你的tauri项目:D
  • cargo clean 这是和build一对的,clean用来删除./target文件夹
  • cargo search 可以在crates.io查找指定的crate,记得科学上网,要不然可能有亿点点慢:(
  • ……

Quick Start with a small Game

Create your Tic-tac-toe

通过一个小项目来快速上手可能是一个好方法。

先找一个你觉得比较舒服的地方,然后通过cargo来创建一个新项目,并且进入项目文件夹

1
2
cargo new tic-tac-toe
cd tic-tac-toe

现在你位于当前项目的根目录,你需要确保你的终端的当前目录在项目文件夹内,以便cargo可以定位到你的 cargo.toml 文件,除此之外在根目录还是在其他目录(比如src/ 或者 target/ 等)没有影响

好!现在你可以使用一个编辑器来打开main.rs文件准备编写项目了!

Start with putting something

哪怕是终端,交互也是必不可少的,几乎任何语言的教程教你写的第一句代码都十分默契的选择了 Hello, World! (当然在这里我们不是要输出这句话就是了)

打开你的rs源文件,它应该位于src/main.rs, 会发现里面已经有内容了,那么如果仔细观察会发现使用的输出函数 println!() 带有一个!,按理说!属于非法命名,在rs当然也一样,只是因为这个!不是包含在名字内的罢了,另外,println!不是一个函数,而是

在rust里,带有!的就是宏了,比如 print!, println!, write!等,我们这里暂时先不谈宏,只要知道是用来输出的就行了

把main函数里面清空,并写上这句话

1
println!("Welcome to Rust Tic Tac Toe!");

记得运行一下确保能看到输出这句话:D

Game initialize

细想一个井字棋需要些什么,嗯哼~,我想需要一个棋盘,两个玩家,当然还有配套的一系列判定系统。那我们就先看棋盘吧!

棋盘显然是一个3x3的,我们可以选择创建一个3x3的二维数组,或者长度为9的一维数组。这里为了更加符合直觉,我选择了前者。

1
let board: [[char; 3]; 3] = [[' '; 3]; 3];

在rust中你可以使用let来把一个值绑定到一个变量,如果你写过JavaScript,应该会比较亲切:D, 一个标准的用法就是let 变量名: 类型 = 表达式;, 这里由于rust有强大的类型推导系统,所以你可以不用写名类型,编译器会自动推导出来!所以虽然是强类型语言,但是也不用像C/Cpp在创建变量时必须注明类型。

我们再来看看这个类型,是一个嵌套的数组,在rust里面数组是固定长度的,也就是说你在声明时必须显示的标注长度, 这就导致,哪怕数组内部每个元素的类型相同,长度不同的话也是不同类型,比如 [u32; 3][u32; 4]就是两个不同的类型!

右边的表达式就和类型长得差不多,只要把数组里面的类型替换成对于类型的某个具体值就行了,像这里就是使用了空格来初始化这个数组。

不是所有类型都可以作为数组的元素,除非实现Copy trait, copy特征简单来说就是能够快速拷贝,性能开销非常的小,一般储存在栈上的数据都默认实现了Copy特征;而像String这种在堆上的数据,无法实现Copy特征(但是实现了Clone特征,可以用.clone()来复制一份),所以不能作为数组的元素,像 [String; 3],这种类型是不被允许的

那么地图就这么弄好了,我们每下一步,就只要把数组对应位置的char换成我们下的字符。

比如我们假定玩家使用 ‘X’ 和 ‘O’, X先手。我们先来测试一下吧

1
board[0][0] = 'X';

ok, 非常完美!直接cargo run 运行一手

1
2
3
4
5
6
7
8
9
10
11
12
13
14
   Compiling playground v0.0.1 (/playground)
error[E0594]: cannot assign to `board[_][_]`, as `board` is not declared as mutable
--> src/main.rs:3:5
|
3 | board[0][0] = 'X';
| ^^^^^^^^^^^^^^^^^ cannot assign
|
help: consider changing this to be mutable
|
2 | let mut board: [[char; 3]; 3] = [[' '; 3]; 3];
| +++

For more information about this error, try `rustc --explain E0594`.
error: could not compile `playground` (bin "playground") due to previous error

Oh no! 报错了,编译器提醒我们board没有被声明为可变,所以不能再次分配值给board[_][_], 当然还贴心的给你了提示,在board前面加一个mut关键字即可

1
2
let mut board: [[char; 3]; 3] = [[' '; 3]; 3];
board[0][0] = 'X';

运行一下,编译通过!

在rust中用let来声明一个变量默认是不可变的,如果你想要能在后面改变它的值,你必须用 mut 关键字来显示的声明为一个可变变量,当然你也可以重新用let来绑定,比如你可以像这样:

1
2
let a = 1;
let a = a + 1;

然后我们可以加一个current_player来记录当前的玩家,和一个input来储存你每次的输入,目前的main应该大概是这样的:

1
2
3
4
5
6
fn main() {
println!("Welcome to Rust Tic Tac Toe!");
let mut board = [[' '; 3]; 3];
let mut current_player = 'X';
let mut input = String::new();
}

这里我们调用了String类的new方法来申请了一块内存来储存我们的字符串,当然现在还没有读入东西,还是空的。

ok,记得运行确保你的代码是正确的

Game Update

接下来就要构建我们游戏的主要逻辑了,我们肯定要放在一个循环里面,然后当有人获胜,或者棋盘被填满(平局)就退出游戏。那么我们的每次循环要做些什么呢?

首先我们肯定要输出我们的棋盘,不可能盲下吧:cry:, 我们不妨创建一个函数来在每次循环进行调用。

我们先创建一个循环,这里我比较倾向使用 loop,当然你也可以用while true(如果使用while true,编译器应该会给你一个warning,并建议你改成loop)

1
2
3
loop {
// todo
}

loop支持常用的流程控制如break等,这跟大部分语言差不多,不做赘述。

Definite a function to put chessboard

如果仔细观察过main函数,应该可以猜得出来声明一个函数要使用 fn 关键字,其具体的格式是 fn 函数名<特征和生命周期>(参数名1: 参数类型1, ...) -> 返回值类型 {} , 特征和生命周期先不用管后面再说,如果不注明特征约束或者生命周期,尖括号的部分是可以省略的,就像main一样函数名后面直接就是圆括号了。那我们就照猫画虎的先写一个函数看看吧!

注意函数的类型标注不能省略

1
2
3
fn print_board(board: &[[char; 3]; 3]) {
// todo
}

由于我们只需要输出,所以可以不用返回值,当然实际上是有返回值的,这个函数返回的是一个空元组,或者说是一个单元类型,它长这样 () , 它可以忽略掉,当然如果你写上 -> () 也不会错。

这个函数只有一个参数board,类型是一个 3x3的char数组的引用,一般来说,我们定义一个函数如果要传入一个复杂类型的参数,我们一般采用传入一个引用的形式,这会涉及到所有权的知识。

所有权和借用是Rust的一个非常重要的内容。对于内存管理,相信大家都很熟悉GC(比如Java,GO)和手动管理内存的分配和释放(比如C/Cpp),而所有权是不同与前2种流派的第三种流派,它会在编译期就根据一系列规则进行检查,因此对于运行期不会有任何性能上的损失。

这里因为篇幅关系不便详谈,简单来说所有权有以下几条规则:

1)Rust中的每个值都被一个变量所拥有,该变量被称为值的所有者;

2)一个值同时只能被一个变量所拥有;

3)当所有者(变量)离开作用域范围时,这个值将被丢弃(drop)

以String为例,它是被分配到堆上的数据,所以没有实现Copy特征,那么如果我执行了如下代码

1
2
3
4
let s1 = String::from("Test_string");
let s2 = s1;
println!("{}", s2);
println!("{}", s1);

这段代码会报错,因为let s2 = s1 的时候,s1的值的所有权被转移给了s2了,所以后面不能调用s1了

这里可以把第二行改成 let s2 = &s1; ,这样s2就是s1的值的一个引用(就相当于你借来用用,但是不具有它的所有权)就可以正常运行;或者改为 let s2 = s1.clone();, String实现了clone特征,所以可以调用clone方法来复制一份,注意这会在堆上再申请一块内存,所以使用clone会有一点的性能开销。但是如果我们把这段代码改成:

1
2
3
4
let s1 = 0x114514;
let s2 = s1;
println!("{:x}", s2);
println!("{:x}", s1);

运行发现可以正常输出两行114514,这是因为整型是基本类型,默认实现了Copy特征,可以进行快速拷贝,let s2的时候就是把s1的值拷贝了一份然后在栈上的另一个地方开一块空间存放0x114514作为s2,和上一个例子相比较发现copy和clone都是把值复制了一遍在另外找一块空间来存放,区别就是一个在堆上,一个在栈上。

好,话说回来,我们该写这个函数的具体逻辑了。其实只要简单的遍历以下board的每个值就可以吧,当然为了好看,我们可以加一个边框,像这样:

1
2
3
4
5
6
7
8
9
10
fn print_board(board: &[[char; 3]; 3]) {
println!("+---+---+---+");
for row in board {
print!("| ");
for col in row {
print!("{} | ", col);
}
println!("\n+---+---+---+");
}
}

然后在main里面添加 print_board(&board);, 运行一下,输出的应该是这样:(我在https://play.rust-lang.org/运行的)

这里我用了两个for来遍历数组,rust的for是类似与python这类语言的for,是遍历一个迭代器的值,而不是简单的遍历索引。

当然如果想要像c一样或者像python的for i in range(n)这种当然也可以,像这样就可以。

1
for idx in 0..10 {}

要注意0..10类似python的range是左取又不取的,如果你想要从0遍历到10,可以 0..11, 或者 0..=10

注意for不支持给迭代的变量标注类型,也就是说比如 for row: &[char; 3] in board {...} 这种是不被允许的!当然也无须担心,因为编译器会自动推导出类型!

Receive input and Process it

你可能期待rust的输入可以像c/cpp的scanf/fread,或者python的input()一样,可以直接调用一个函数来获取终端输入。但是很遗憾,rust并不直接存在这么一个函数,或者说,它被封装在Stdin类下,比如最常见的是.read_line()

1
2
let stdin = std::io::stdin();
stdin.read_line(&mut input).unwrap();

这样就会从终端中读取一行(包括换行符)拼接在input结尾处,所以在循环中我们必须在每次开头都清空一下input中的内容。你需要在read_line的上一行插入:

1
input.clear();

需要注意:clear和read_line都是改变input的值,所以我们需要在声明input的时候添加mut关键字使其可变,而 &mut input 是input的可变引用,它也是一种引用,不具有所有权,但是具有其使用权和更改权限,所以可以对input本身进行更改。

一个变量最多只能有一个可变引用,并且有可变引用时,不允许存在不可变引用,也不能通过所有者来访问值

来看这段代码

1
2
3
4
let mut a = 10;
let b = &mut a;
*b += 1;
println!("{} {}", a, b);

运行后果不其然报错了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
error[E0502]: cannot borrow `a` as immutable because it is also borrowed as mutable
--> src/main.rs:5:23
|
3 | let b = &mut a;
| ------ mutable borrow occurs here
4 | *b += 1;
5 | println!("{} {}", a, b);
| ^ - mutable borrow later used here
| |
| immutable borrow occurs here
|
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0502`.

可以这么来想,A有一个PS,B借过来玩(可变引用),B在玩的过程中(B的作用范围没有结束),A(所有者)和其他人(其他借用)显然不能借走吧,除非B还回去(B的作用结束),这样就回到A手上了,A又可以继续玩(A可以访问或修改值了)

如果把上面那段改成这样就Ok了

1
2
3
4
5
let mut a = 10;
let b = &mut a; // 可变借用 b 作用域开始
*b += 1; /* ps: *是解引用,因为b是&i32, 没有实现+=,所以要解引用对值本身+= */
println!("{}", b); // b 作用域结束,因为后面都没有再使用b了
println!("{}", a); // b 作用结束后值的使用权自动还给a,所以a可以输出

建议自己写写尝试一下:D

那么read_line后面的 .unwrap() 是干嘛的呢,你可能会有这种疑问。

其实功能就像字面意思那样,“拆开”,这里因为read_line,返回的是一个Result,而result需要手动处理,所以这里就用unwrap来取出里面的值

Result是一个枚举类型,定义是这样的

1
2
3
4
pub enum Result<T, E> {
Ok(T),
Err(E),
}

这里T,E都是泛型,一般T是一个你期望的值,E是一个错误。当unwrap遇到Ok时就会正常取出值,而如果是Err,就会panic! ,然后你就发现程序崩溃并且输出了一堆奇奇怪怪的东西。

一般返回result就是让你自己来错误处理,常常会配合match之类的。在后面讲错误处理的时候再说:D

哦,差点忘记了,我们需要些什么数据好像还没说。不如输入的格式就是 row col吧。所以我们在输入之前加上一条说明。

1
2
3
print_board(&board);
println!("Please input your move: (fmt: row col)");
// ...

然后我们得要把行列数据提取出来,我们可以使用split_whitespace,它会去掉空格、tab、回车之类的字符,并且会由此把其他可见字符字串隔开,返回的是一个迭代器。

1
2
3
4
5
6
let input = input
.split_whitespace()
.map(|x| x.parse::<usize>().unwrap())
.collect::<Vec<usize>>();
let row = input[0];
let col = input[1];

map其实功能就是你们印象的那个map,在rust里,只要实现了迭代器特征的类型就可以调用map方法,map接收一个闭包作为参数,如果你用过一些函数式语言可能会比较熟悉 “闭包” 这个概念,其实它就是类似lambda的东西,其具体的形式是接近Ruby语言的闭包的。

|| 里面的是入参,闭包会自动把捕获的值绑定到里面的变量,你可以标注类型,但是同样也没必要这样做,因为编译器会自动推导!|| 后面是一个表达式,这也意味着,你可以在 {} 中写多行代码,而不用像python的lambda一样只能想办法把表达式压缩到一行

然后里面的入参 x 是一个&str类型,这里通过parse()方法解析成usize类型(通过 ::<> 的格式来指定),返回的也是Result(因为可能解析失败),然后unwrap解包。

再通过collect方法把map返回的迭代器收集成一个Vec(是不定长的数组,所以创建时不用像数组一样标注长度)

Move

接下来就要落子了,我们定义一个on_move,返回值是一个bool,来表示是否落子成功。

1
2
3
4
5
6
7
8
9
10
11
fn on_move(board: &mut [[char; 3]; 3], row: usize, col: usize, symbol: char) -> bool {
// symbol 是当前玩家对应的符号
// 先检验范围,保证只能是0, 1, 2
// 超出棋盘范围或者该处已经落子了,就代表这一步不成功
if row >= 3 || col >= 3 || board[row][col] != ' ' {
false
} else {
board[row][col] = symbol;
true
}
}

这里得说明一下,每个作用域的最后一个表达式就会作为返回值,这里on_move函数只有一个表达式(最后一句不要有分号,有分号就是语句,语句没有返回值,或者准确来说语句返回一个单元() ),就是if {} else {},然后if和else里面分别有false和true作为返回值。

注意每个语句块只有最后一行才能不写分号,像是if, for,函数,或者仅仅只是{} 包裹的内容就是一个语句块。当然语句块也是一个语句

而return往往用于一个函数的中间,会直接跳出这个函数并返回值。

这样的好处是不用写return(PS:本人比较倾向于不写return,一般不是迫不得已我不会写return)

然后我们得处理一下on_move的结果

1
2
3
4
if !on_move(&mut board, row, col, current_player) {
println!("Invalid move!");
continue;
}

然后每个人落子成功就轮到下一个玩家,我们写一个next_player来轮换玩家,你可能会想写个if,else if好了,不过这里我们用match来更优雅的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fn main() {
// ...
loop {
// ...
if !on_move(...) {
// ...
}
current_player = next_player(current_player);
}
}

fn next_player(symbol: char) -> char {
match symbol {
'X' => 'O',
'O' => 'X',
_ => unreachable!(),
}
}

可能跟C的switch很像,不过match的强大还远远不止这些,match有个大杀器就是模式匹配(没错,又是从函数式语言借鉴的:D),会在后续内容细嗦。

顺带一提,在Rust里面,无论是函数还是全局变量,或者是结构体等的定义,只要有定义就行了,不需要太关注位置,也就是说,你不必像C/Cpp一样得先在main前面声明某个函数,才能在main后面写函数的具体实现。

Win or Draw

现在距离一个功能完备的游戏只差胜负判断了,我们得写一个is_win函数来再每次落子后判断是否有人胜利(win),然后还得写一个函数is_full来判断棋盘时候已经下满了,也就意味着平局(draw),当然这个函数得放在is_win后面调用:D, 最后在loop里面调用即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
fn main() {
// ...
loop {
// ...
if is_win(&board) {
println!("{} win!", current_player);
break; // 或者 return;
}
if is_full(&board) {
println!("Draw!");
break;
}
// 保证上一步下完既没有人win,也没有下满棋盘,才能轮到下一手
current_player = next_player(current_player);
}
}

fn is_win(board: &[[char; 3]; 3]) -> bool {
// 行
for row in board {
if row[0] == row[1] && row[1] == row[2] && row[0] != ' ' {
return true
}
}
// 列
for col in 0..3 {
if board[0][col] == board[1][col] && board[1][col] == board[2][col] && board[0][col] != ' ' {
return true
}
}
// 对角线
if board[0][0] == board[1][1] && board[1][1] == board[2][2] && board[0][0] != ' ' {
return true
}
if board[0][2] == board[1][1] && board[1][1] == board[2][0] && board[0][2] != ' ' {
return true
}
false
}

fn is_full(board: &[[char; 3]; 3]) -> bool {
for row in board {
for col in row {
if *col == ' ' {
return false
}
}
}
true
}

Ok,基本的功能已经基本实现了,可以运行玩一玩。这节内容其实没有涉及多少Rust真正深入的部分,上面遗留的很多问题(挖的很多坑),会在下一节解答,下一节也会逐渐带你认识rust一些真正吸引人的特性。

Improve your Rust code

相信大佬们看了上面的很不尽兴吧,我想要看Rust独特的特性(震声,别急,本节会给出一个答复。

使用struct封装

我们可以定义一个Game结构体,来记录一些游戏属性。

在rust中,定义结构体和C是类似的:

1
2
3
4
5
struct Game {
board: [char; 9],
players: [char; 2],
current_player: usize,
}

在原来的版本中,我们是使用X和O来代替玩家,但是这样太草率了,很多读者肯定不会买账的,所以这里我们定义一个Player结构体,储存名字和对应的符号

1
2
3
4
struct Player {
name: String,
symbol: char,
}

当然前面的Game的players的类型也要做相应的修改。

那么我们怎么封装逻辑呢,这里我们是可以像其他语言的class一样为创建的结构体定义一组方法的。我们可以使用impl (implement)关键字为一个struct定义一组或多组实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
impl Game {
fn new() -> Game {
Game {
board: [' '; 9],
players: [Player::new(); 2],
current_turn: 0,
}
}
}

impl Player {
fn new() -> Player {
Player {
name: "Test Player".to_string(),
symbol: 'X',
}
}
}

在main里创建一个Game试试,

会发现爆了一个错误

1
2
3
4
5
6
7
8
9
10
11
Compiling playground v0.0.1 (/playground)
error[E0277]: the trait bound `Player: Copy` is not satisfied
--> src/main.rs:21:23
|
21 | players: [Player::new(); 2],
| ^^^^^^^^^^^^^ the trait `Copy` is not implemented for `Player`
|
= note: the `Copy` trait is required because this value will be copied for each element of the array

For more information about this error, try `rustc --explain E0277`.
error: could not compile `playground` (bin "playground") due to previous error

这个在前面说过,是由于没有实现Copy特征,我们可以试试通过派生宏来快速实现一个特征

1
2
3
4
#[derive(Copy)]
struct Player {
// ...
}

发现还是报错了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Compiling playground v0.0.1 (/playground)
error[E0204]: the trait `Copy` cannot be implemented for this type
--> src/main.rs:11:10
|
11 | #[derive(Copy)]
| ^^^^
12 | struct Player {
13 | name: String,
| ------------ this field does not implement `Copy`
|
= note: this error originates in the derive macro `Copy` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0204`.
error: could not compile `playground` (bin "playground") due to previous error

由于Player里面包含了String,所以不能为Player实现Copy特征,这是因为数据在堆上,不能快速拷贝,所以无法实现Copy,这也意味着我们不能用这个[xxx; n] 的格式来快速生成了,其实这是一个语法糖,当数据长度比较大,用这个就很方便,当然我们只能采用最普通的方法: [Player::new(), Player::new()], 就是有几个就写几遍,或者也可以创建一个空的Vec后面在push两个Player进去。

改好后我们可以输出一下Game的信息看看。

如果你习惯写C,可能会想要依次访问每个成员,再输出,在rust里当然可以,不过更加优雅的方式是为该结构体实现一个Debug特征

1
2
3
4
5
6
7
8
9
#[derive(Debug)]
struct Game {
//
}

#[derive(Debug)]
struct Player {
//
}

这里面的#[derive()]是一个派生宏,可以快速实现某些特征,当然如果想要自定义效果,你也可以自己手动实现该特征。通过派生宏来实现的一个条件是必须每个成员都实现了该特征,因为Game里面有成员的类型包含了Player,所以我们得给Player也实现Debug,由于Player里面的String和char是默认实现了该特征,所以Player才能实现Debug,像前面Copy特征正是由于String在堆上,不能实现Copy,导致Player也不能实现Copy特征。

1
2
3
4
5
6
fn main() {
let game = Game::new();
println!("{:?}", &game);
// 或者选择更好的输出方式
println!("{:#?}", &game);
}

输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Game { board: [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '], players: [Player { name: "Test Player", symbol: 'X' }, Player { name: "Test Player", symbol: 'X' }], current_player: 0 }
Game {
board: [
' ',
' ',
' ',
' ',
' ',
' ',
' ',
' ',
' ',
],
players: [
Player {
name: "Test Player",
symbol: 'X',
},
Player {
name: "Test Player",
symbol: 'X',
},
],
current_player: 0,
}

具体选择哪种取决你自己实际需求,如果都不合你胃口,就可以自己实现一个Debug特征

自定义Debug样式

如果学过面向对象,那么肯定知道继承这个概念,但是一上手可能很容易写出屎山,而且不好维护(如果你对某个基类做了修改的话),而Rust选择抛弃了继承这一个糟糕的特性,选择另一种更加优雅的方式来实现类的多态,也就是trait。

Trait(特征)是为一些类定义的一组行为,如果要一个struct使用某个特征的功能,只要为它实现该特征即可,一个struct可以实现多个特征。来看看例子,我们来为Player和Game实现自定义的Debug

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use std::fmt::{Debug, Formatter, Result};

impl Debug for Player {
fn fmt(&self, f: &mut Formatter<'_>) -> Result {
write!(f, "Player {} with {}", &self.name, &self.symbol)
// 注意不要带分号,因为要返回Result,带分号就返回()了
}
}

impl Debug for Game {
fn fmt(&self, f: &mut Formatter<'_>) -> Result {
f.debug_struct("Game")
.field("board", &format!("{:?}", &self.board) as &dyn Debug)
.field("players", &self.players)
.field("current_player", &self.current_player)
.finish()
}
}

再运行看看就发先变成自定义的了,记得去掉#[derive(Debug)],否则会提示你实现Debug特征冲突了

1
2
3
4
5
6
7
8
9
Game { board: "[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ']", players: [Player Test Player with X, Player Test Player with X], current_player: 0 }
Game {
board: "[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ']",
players: [
Player Test Player with X,
Player Test Player with X,
],
current_player: 0,
}

使用Option

我们不妨把函数迁移到我们定义的struct吧,但这里其实会发现:我们很多函数都是直接对与board的操作,那么这里我们如果直接为Game实现这些功能,就会发现非常的丑陋,尤其是is_win,is_full这些只用到了board的函数。那么我们为什么不定义一个Board,而把这部分逻辑封装到board里面呢?

1
2
3
struct Board {
cells: [....]
}

这次我们就不按照之前的使用char数组了,我们使用char数组时是用空格作为初始值的,但是这样其实并不好(如果我想用空格作为玩家的符号呢)。在这种情况下,Option是一个很好的解决办法!Option和Result的定义很像

1
2
3
4
pub enum Option<T> {
Some(T),
None,
}

很好理解吧,要么有值(Some),要么是空(None)。这样设计的好处就是避免了像C一样0和NULL冲突。你只需要吧None作为一个仅代表空这个意义的东西。

那么我们来改写一下board

1
2
3
4
5
6
7
8
9
10
11
struct Board {
cells: [Option<char>; 9],
}

impl Board {
fn new() -> Board {
Board { cells: [None; 9] }
}

// other function
}

因为Option是被放在了prelude模块中的,prelude会放一下比较常用的东西,可以不需要导入就可以使用。这里当然也可以使用Option::None

那么该如何处理这些值呢?可以用前面介绍的 .unwrap(),来取出里面的值,但是注意!如果遇到None就会错误(因为None代表空的意义,也就是没有值,所以当然unwrap不了),然后程序崩溃。所以前面我也说过unwrap适合你几乎能确定不会报错的情况下

用模式匹配来处理异常

比如要实现输出board,那么就不可避免的要处理Option,那么就可以像这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
impl Board {
fn render(&self) {
println!("+---+---+---+");
for row in 0..3 {
print!("| ");
for col in 0..3 {
let cell = self.cells[row * 3 + col];
match cell {
Some(value) => print!("{} | ", value),
None => print!(" | "),
}
}
}
println!();
println!("+---+---+---+");
}
}

match会自动匹配到格式一样的模式,如果你是Some,就会匹配到第一种,如果是None就会匹配到第二个。要注意:match必须穷尽所有可能, 如果你想不到更多的可能,你可以匹配到 _, 这代表除了上面的模式以外的全部模式(必须放在最后)。

来回去看我们的前面写的读入终端内容的那一行 stdin.read_line(&mut input).unwrap();, 我们如果不能确保是否一定没有问题,我们就可以用match来手动处理,但是这里我们介绍另一种常用的模式匹配 if let

因为如果是正常的话我们不需要做额外的处理,所有我们只需要匹配Result::Err这一种模式就可以了(只要匹配一种或者少量的模式,那么if let就非常适合)

1
2
3
4
5
// 因为让程序自己出现异常来panic的话,会输出很多额外的东西(给开发者看的,但是显然不是用户想看到的)
if let Err(e) = stdin.read_line(&mut input) {
println!("[Error]: {}", e);
std::process::exit(1);
}

Move(移动) and Move(落子)

简单的与C互操作

如果只是像原来那样输入坐标的话也太难受了,这一点也不游戏!!!相信读者大爹们肯定不会买账的。所以我们来实现一下通过键盘的WASD来选择棋盘的落点(这样也不用担心输入坐标的越界问题),SPACE来确定落子。

也就是说我们需要每次按键后,程序都能及时反应。但是Rust没有直接提供一个类似于C的getch方法,当然还有第三方库可以实现按键监控,不过这里为了介绍与C互操作所以选择了调用C API。

我们需要使用extern关键字

1
extern "C" {}

我们需要一个getch用来接收按键,和一个kbhit监测是否有按键事件。在extern中定义接口

1
2
3
4
extern "C" {
fn _getch() -> u8;
fn _kbhit() -> bool;
}

然后就可以封装一个按键监控的函数了,要注意ffi是不安全的,所以代码必须用unsafe包裹,(或者把函数声明为unsafe,但是这会导致每次调用都得使用unsafe)

1
2
3
4
5
6
7
8
9
10
fn getch(on_block: bool) -> Option<u8> {
unsafe {
// 设置了一个on_block来指示是否阻塞,true就会等待按下
if on_block || _kbhit() {
Some(_getch())
} else {
None
}
}
}

我们把原来的main里面的逻辑迁移到Game的run方法里,为了提供一个更良好的游戏环境,你做了一个决定是清空屏幕!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use std::process::Command;

impl Game {
// other fn
fn run(&mut self) {
println!("you can use WASD to move the cursor, and press <SPACE> to place the symbol.");
print!("We will clear the screen before start.\nAre you sure to start? (Y/n): ");
std::io::stdout().flush().unwrap(); // 刷新缓冲区,否则会在输入后才输入print!里面的东西
let opt = getch(true); // 等待输入
if let Some(opt) = opt {
if !matches!(opt as char, 'Y'|'y'|'\r'|'\n') {
return;
}
}
Command::new("Powershell")
.args(&["-c", "cls"])
.status()
.expect("failed to clear screen");
loop {
//
}
}
}

然后处理输入,给Game添加一个current_pos: usize记录当前位置(记得改new)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// in loop
self.board.render();
let opt = getch(false);
match opt {
Some(opt) => match opt {
b'q' => break,
b'w' => if self.current_pos >= 3 { self.current_pos -= 3 },
b's' => if self.current_pos < 6 { self.current_pos += 3 },
b'a' => if self.current_pos % 3 > 0 { self.current_pos -= 1 },
b'd' => if self.current_pos % 3 < 2 { self.current_pos += 1 },
b' ' => {
if self.board.cells[self.current_pos].is_none() {
self.board.cells[self.current_pos] =
Some(self.players[self.current_player].as_ref().unwrap().symbol);
self.current_player ^= 1;
}
}
}
None => {},
}
// is win and is full

Register players

现在好像player还是默认的,我们得实现传参自定义玩家。我们为Game实现一个register_player来注册玩家,所以在注册前玩家是空(None),我们把Game的player字段改成 [Option<Player>; 2],并在Game::new()初始化为None。

register_player应该接收一个player数组,并且把前2个复制给Game,这里为了能链式调用选择返回本身

1
2
3
4
5
6
7
8
9
impl Game {
fn register_player(&mut self, players: &[Player]) -> &mut Game {
self.players = [
Some(players[0].clone()),
Some(players[1].clone())
];
self
}
}

然后你可以在像这样调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fn main() {
Game::new()
.register_player(&vec![
Player::new(name: &str, symbol: char),
Player { name: "B".to_string(), symbol: 'O' },
]).run();
}

impl Player {
fn new(name: &str, symbol: char) -> Player {
Player {
name: name.to_string(),
symbol, // 变量名和成员名相同可以省略
}
}
}

但是比较麻烦,因为你每个都要new一下,有没有能够更加简化书写的方式呢?

当然有,你可以用宏来实现。

编写一个简单的宏

Rust的宏非常强大,可以做很多事情,比如不定长参数等等,宏一般分为声明宏(也就是马上要讲的,和派生宏等),相信大家在前面已经见识到了派生宏的强大了,接下来看看如何定义一个宏。

我们定义一个register_player宏来帮我更方便的生成一个Player数组

1
2
3
4
5
6
7
8
9
#[macro_export]
macro_rules! register_player {
($($name:expr => $sym:expr),*) => {
{
// return the start two
vec![$(Player::new($name, $sym),)*][..2].to_vec()
}
}
}

然后你可以这么调用,要注意宏后面可以跟(), [], {}, 虽然都可以,但是一般会有一个约定的写法,比如println!是 (), vec!是[]

1
2
3
4
5
6
7
8
9
fn main() {
Game::new()
.register_player(
&register_player!{
"Player1" => 'X'
"Player2" => 'O'
}
).run();
}

宏的定义允许像match一样写多个模式,只不过宏匹配的是一段Rust源代码。

register_player宏接受一系列的表达式,每个表达式由一个名字和一个符号组成,然后为每个表达式创建一个新的 Player 对象。

宏的参数使用 $(...),* 的形式定义,这表示宏接受任意数量的参数,每个参数都应该匹配 ... 中的模式,参数之间用逗号分隔。

在宏的主体中,首先使用 vec! 宏创建一个 Player 对象的向量,每个 Player 对象都使用 Player::new($name, $sym) 创建。然后,使用 [..2].to_vec() 取向量的前两个元素并转换为新的向量。

总的来说,这个宏的作用是接受一系列的名字和符号,为每个名字和符号创建一个 Player 对象,然后返回前两个 Player 对象的向量。

为不同平台分别实现

在上面我们实现了getch功能使用了C接口,而其中kbhit是在windows平台的函数(如果没有记错应该在windows.h里面),所以如果你尝试编译为其他平台的可执行文件,就会出问题,在这里问题很可能是编译时找不到kbhit这个符号。(可以自己编译看看错误)

那么如果能找到天然跨平台的实现就好了(显然使用纯rust不会有这种问题),但是这往往比较困难。所以我们可以考虑另一种方式,也就是为不同的平台分别实现相应的方法即可!

假定只要编译到windows和linux,我们来改写一下上面实现的getch

1
2
3
4
5
#[cfg(target_os = "windows")]
extern "C" {
fn _getch() -> u8;
fn _kbhit() -> bool;
}

我们通过cfg来设置一个(块)语句的某些属性,这里是指定这段代码只在windows平台有效,只有编译成windows平台的可执行文件才会编译这部分。

同样我们也给getch标上(这里改了个名字,因为感觉key会更合适一点)

1
2
3
4
5
6
7
8
#[cfg(target_os = "windows")]
pub fn getkey(on_blocking: bool) -> Option<u8> {
if on_blocking || unsafe { _kbhit() } {
unsafe { Some(_getch()) }
} else {
None
}
}

接下来要写linux部分的实现了,linux里并没有(大概?)直接提供类似kbhit的函数,但是我们可以在读一个字节后返回。

我们得先导入libc库 cargo add libc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#[cfg(target_os = "linux")]
extern crate libc;

#[cfg(target_os = "linux")]
pub fn getkey(on_blocking: bool) -> Option<u8> {
// 建议直接在函数内部use就可以了,毕竟如果不是linux平台也用不到
use libc::{termios, ECHO, ICANON, TCSANOW, VMIN, VTIME};
use std::io::{stdin, Read};
use std::mem::zeroed;

let mut termios = unsafe { zeroed::<termios>() };
unsafe { libc::tcgetattr(0, &mut termios) };
let old = termios;
termios.c_lflag &= !(ICANON | ECHO);
termios.c_cc[VMIN] = if on_blocking { 1 } else { 0 };
termios.c_cc[VTIME] = 0;
unsafe { libc::tcsetattr(0, TCSANOW, &termios) };
let mut buf = [0_u8; 1];
let res = if stdin().read(&mut buf).is_ok() {
Some(buf[0])
} else {
None
};
unsafe { libc::tcsetattr(0, TCSANOW, &old) };
res
}

首先,这段代码使用了条件编译(#[cfg(target_os = "linux")]),这意味着这段代码只有在目标操作系统为 Linux 时才会编译和运行。

然后,函数 getkey 接受一个布尔参数 on_blocking,用于指定是否在等待用户输入时阻塞。

在函数内部,首先使用 libc 库的 termios 结构体来获取和设置终端的 I/O 属性。termios 结构体中的 c_lflag 字段用于控制输入模式,ICANONECHO 是两个标志位,分别用于控制规范模式(即行缓冲)和回显。通过将这两个标志位清零,函数设置终端为非规范模式和非回显模式,这样就可以立即读取用户的每一个键入,而不需要等待回车键。

termios 结构体中的 c_cc 字段是一个数组,用于控制特殊字符的行为。VMINVTIME 是数组中的两个索引,分别用于控制在非规范模式下的最小读取字符数和超时时间。通过设置 VMINon_blocking 参数(如果 on_blockingtrue,则 VMIN 为 1,否则为 0),函数可以在没有输入时立即返回,而不是等待用户输入。VTIME 被设置为 0,表示不使用超时。

然后,函数使用 stdin().read(&mut buf) 从标准输入读取一个字符到 buf 中。如果读取成功,函数返回读取到的字符,否则返回 None

最后,函数使用 libc::tcsetattr(0, TCSANOW, &old) 恢复终端的原始 I/O 属性。

当然到这里其实也可以了,但是读者肯定不会买账的,因为每次调用还必须指定bool,我就不能像其他语言一样整个默认参数吗。

由于rust的函数是不支持默认参数的,但是我们可以用宏来实现类似的功能。

前面有提过宏可以像match一样匹配模式,我们这里不妨就设置2种模式,对应阻塞和非阻塞。我们简单把我们的函数包装一下。

1
2
3
4
5
6
7
8
9
#[macro_export]
macro_rules! getkey {
() => {
getkey(false)
};
(block) => {
getkey(true)
};
}

这样如果你使用比如 getkey!(), 就会不阻塞。如果使用getkey!(block),就会阻塞等待用户输入了。这里面的block并不是一个变量,只是一个模式。所以你无须定义block为一个具体的东西,你当然可以写成其他的:D

Others

编写简单测试

有时候你想测试某个或着说一部分功能,直接编译整个项目再测试是非常低效的做法。这种情况非常建议你写test模块。

假定我们要测试我们写的register_player宏是否能按照我们想的那样运作,可以像这样写个测试函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#[cfg(test)]
mod test {
use super::*;

#[test]
fn player_reg() {
let players = register_player!(
"Player 1" => 'X',
"Player 2" => 'O'
);
/* dbg!(players);
assert!(false); */
assert_eq!(
players,
vec![Player::new("Player 1", 'X'), Player::new("Player 2", 'O')]
);
}
}

然后在你的终端运行 cargo test

如果正常就只能看到ok,出错就会看到你的输出和错误信息。一般如果我们确定大概会有个什么输出,我们就用assert系列的宏,这很好理解。当我们想要比较的内容太多,你懒得输入,你可以主动报错骗它输出, 比如本人就喜欢 assert!(false);, 然后前面记得输出一下,然后你就可以看到回显了:D

测试函数可以有很多个,会挨个跑一遍

super关键字代表当前模块(这里指test)的父模块,所以 use super::*;就是导入父模块的所有项。如果你的register_player等都定义在main.rs中,那么你应该把这段mod test{ }放在main.rs中的某个位置(这保证了main是test的父模块)。

优化你的文件结构(todo)

Welcome to my other publishing channels