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 | PS D:\Rust-test> cargo new hello |
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 | cargo build |
所以build命令的参数在run是可以用的。
使用new命令创建的项目中main.rs里默认内容是
1 | fn main() { |
快去run一下你的第一个rust程序吧:D
1 | PS D:\Rust-test\hello> cargo run |
check
cargo check
应该是使用频率最高的命令之一,无他,谁不愿意在写代码的时候有一个帮手能帮你检查代码呢。check命令就是会对你的rust代码做亿点点检查,如果你写的问题,还会贴心的给予你提示。
比如:
1 | // path: src/main.rs |
check一下看看
警告了你a这个变量没有使用,并且给了你提示,可以在变量名前面加一个下划线,这样编译器就不会警告这个值,当然这个值还是可以使用的。check的功能远不止这些,得要靠自己探索了:D
fmt
rust又一大杀器,除了可以帮你控制代码缩进,还可以帮你的代码书写风格变得更加rusty!
比如:
1 | // path: src/main.rs |
执行cargo fmt
后
1 | fn main() { |
是不是看的舒服多了,所以再也不用担心重构时一堆缩进问题的(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还是nightlycargo install
看起来和add很像,不过install命令是安装二进制文件的,比如运行cargo install tauri-cli
,然后你就可以使用cargo tauri
命令来构建你的tauri项目:Dcargo clean
这是和build一对的,clean用来删除./target
文件夹cargo search
可以在crates.io查找指定的crate,记得科学上网,要不然可能有亿点点慢:(- ……
Quick Start with a small Game
Create your Tic-tac-toe
通过一个小项目来快速上手可能是一个好方法。
先找一个你觉得比较舒服的地方,然后通过cargo来创建一个新项目,并且进入项目文件夹
1 | cargo new 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 | Compiling playground v0.0.1 (/playground) |
Oh no! 报错了,编译器提醒我们board没有被声明为可变,所以不能再次分配值给board[_][_]
, 当然还贴心的给你了提示,在board前面加一个mut关键字即可
1 | let mut board: [[char; 3]; 3] = [[' '; 3]; 3]; |
运行一下,编译通过!
在rust中用let来声明一个变量默认是不可变的,如果你想要能在后面改变它的值,你必须用 mut 关键字来显示的声明为一个可变变量,当然你也可以重新用let来绑定,比如你可以像这样:
1
2 let a = 1;
let a = a + 1;
然后我们可以加一个current_player来记录当前的玩家,和一个input来储存你每次的输入,目前的main应该大概是这样的:
1 | fn main() { |
这里我们调用了String类的new方法来申请了一块内存来储存我们的字符串,当然现在还没有读入东西,还是空的。
ok,记得运行确保你的代码是正确的
Game Update
接下来就要构建我们游戏的主要逻辑了,我们肯定要放在一个循环里面,然后当有人获胜,或者棋盘被填满(平局)就退出游戏。那么我们的每次循环要做些什么呢?
首先我们肯定要输出我们的棋盘,不可能盲下吧:cry:, 我们不妨创建一个函数来在每次循环进行调用。
我们先创建一个循环,这里我比较倾向使用 loop,当然你也可以用while true(如果使用while true,编译器应该会给你一个warning,并建议你改成loop)
1 | loop { |
loop支持常用的流程控制如break等,这跟大部分语言差不多,不做赘述。
Definite a function to put chessboard
如果仔细观察过main函数,应该可以猜得出来声明一个函数要使用 fn
关键字,其具体的格式是 fn 函数名<特征和生命周期>(参数名1: 参数类型1, ...) -> 返回值类型 {}
, 特征和生命周期先不用管后面再说,如果不注明特征约束或者生命周期,尖括号的部分是可以省略的,就像main一样函数名后面直接就是圆括号了。那我们就照猫画虎的先写一个函数看看吧!
注意函数的类型标注不能省略
1 | fn print_board(board: &[[char; 3]; 3]) { |
由于我们只需要输出,所以可以不用返回值,当然实际上是有返回值的,这个函数返回的是一个空元组,或者说是一个单元类型,它长这样 ()
, 它可以忽略掉,当然如果你写上 -> ()
也不会错。
这个函数只有一个参数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 | fn print_board(board: &[[char; 3]; 3]) { |
然后在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 | let stdin = std::io::stdin(); |
这样就会从终端中读取一行(包括换行符)拼接在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 | print_board(&board); |
然后我们得要把行列数据提取出来,我们可以使用split_whitespace,它会去掉空格、tab、回车之类的字符,并且会由此把其他可见字符字串隔开,返回的是一个迭代器。
1 | let input = input |
map其实功能就是你们印象的那个map,在rust里,只要实现了迭代器特征的类型就可以调用map方法,map接收一个闭包作为参数,如果你用过一些函数式语言可能会比较熟悉 “闭包” 这个概念,其实它就是类似lambda的东西,其具体的形式是接近Ruby语言的闭包的。
||
里面的是入参,闭包会自动把捕获的值绑定到里面的变量,你可以标注类型,但是同样也没必要这样做,因为编译器会自动推导!||
后面是一个表达式,这也意味着,你可以在 {} 中写多行代码,而不用像python的lambda一样只能想办法把表达式压缩到一行然后里面的入参 x 是一个&str类型,这里通过parse()方法解析成usize类型(通过
::<>
的格式来指定),返回的也是Result(因为可能解析失败),然后unwrap解包。再通过collect方法把map返回的迭代器收集成一个Vec(是不定长的数组,所以创建时不用像数组一样标注长度)
Move
接下来就要落子了,我们定义一个on_move,返回值是一个bool,来表示是否落子成功。
1 | fn on_move(board: &mut [[char; 3]; 3], row: usize, col: usize, symbol: char) -> bool { |
这里得说明一下,每个作用域的最后一个表达式就会作为返回值,这里on_move函数只有一个表达式(最后一句不要有分号,有分号就是语句,语句没有返回值,或者准确来说语句返回一个单元
()
),就是if {} else {},然后if和else里面分别有false和true作为返回值。注意每个语句块只有最后一行才能不写分号,像是if, for,函数,或者仅仅只是{} 包裹的内容就是一个语句块。当然语句块也是一个语句
而return往往用于一个函数的中间,会直接跳出这个函数并返回值。
这样的好处是不用写return(PS:本人比较倾向于不写return,一般不是迫不得已我不会写return)
然后我们得处理一下on_move的结果
1 | if !on_move(&mut board, row, col, current_player) { |
然后每个人落子成功就轮到下一个玩家,我们写一个next_player来轮换玩家,你可能会想写个if,else if好了,不过这里我们用match来更优雅的实现
1 | fn main() { |
可能跟C的switch很像,不过match的强大还远远不止这些,match有个大杀器就是模式匹配(没错,又是从函数式语言借鉴的:D),会在后续内容细嗦。
顺带一提,在Rust里面,无论是函数还是全局变量,或者是结构体等的定义,只要有定义就行了,不需要太关注位置,也就是说,你不必像C/Cpp一样得先在main前面声明某个函数,才能在main后面写函数的具体实现。
Win or Draw
现在距离一个功能完备的游戏只差胜负判断了,我们得写一个is_win函数来再每次落子后判断是否有人胜利(win),然后还得写一个函数is_full来判断棋盘时候已经下满了,也就意味着平局(draw),当然这个函数得放在is_win后面调用:D, 最后在loop里面调用即可
1 | fn main() { |
Ok,基本的功能已经基本实现了,可以运行玩一玩。这节内容其实没有涉及多少Rust真正深入的部分,上面遗留的很多问题(挖的很多坑),会在下一节解答,下一节也会逐渐带你认识rust一些真正吸引人的特性。
Improve your Rust code
相信大佬们看了上面的很不尽兴吧,我想要看Rust独特的特性(震声,别急,本节会给出一个答复。
使用struct封装
我们可以定义一个Game结构体,来记录一些游戏属性。
在rust中,定义结构体和C是类似的:
1 | struct Game { |
在原来的版本中,我们是使用X和O来代替玩家,但是这样太草率了,很多读者肯定不会买账的,所以这里我们定义一个Player结构体,储存名字和对应的符号
1 | struct Player { |
当然前面的Game的players的类型也要做相应的修改。
那么我们怎么封装逻辑呢,这里我们是可以像其他语言的class一样为创建的结构体定义一组方法的。我们可以使用impl (implement)关键字为一个struct定义一组或多组实现
1 | impl Game { |
在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
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 |
|
这里面的#[derive()]是一个派生宏,可以快速实现某些特征,当然如果想要自定义效果,你也可以自己手动实现该特征。通过派生宏来实现的一个条件是必须每个成员都实现了该特征,因为Game里面有成员的类型包含了Player,所以我们得给Player也实现Debug,由于Player里面的String和char是默认实现了该特征,所以Player才能实现Debug,像前面Copy特征正是由于String在堆上,不能实现Copy,导致Player也不能实现Copy特征。
1 | fn main() { |
输出如下:
1 | 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 | use std::fmt::{Debug, Formatter, Result}; |
再运行看看就发先变成自定义的了,记得去掉#[derive(Debug)]
,否则会提示你实现Debug特征冲突了
1 | 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 | struct Board { |
这次我们就不按照之前的使用char数组了,我们使用char数组时是用空格作为初始值的,但是这样其实并不好(如果我想用空格作为玩家的符号呢)。在这种情况下,Option是一个很好的解决办法!Option和Result的定义很像
1 | pub enum Option<T> { |
很好理解吧,要么有值(Some),要么是空(None)。这样设计的好处就是避免了像C一样0和NULL冲突。你只需要吧None作为一个仅代表空这个意义的东西。
那么我们来改写一下board
1 | struct Board { |
因为Option是被放在了prelude模块中的,prelude会放一下比较常用的东西,可以不需要导入就可以使用。这里当然也可以使用
Option::None
那么该如何处理这些值呢?可以用前面介绍的 .unwrap()
,来取出里面的值,但是注意!如果遇到None就会错误(因为None代表空的意义,也就是没有值,所以当然unwrap不了),然后程序崩溃。所以前面我也说过unwrap适合你几乎能确定不会报错的情况下。
用模式匹配来处理异常
比如要实现输出board,那么就不可避免的要处理Option,那么就可以像这样
1 | impl Board { |
match会自动匹配到格式一样的模式,如果你是Some,就会匹配到第一种,如果是None就会匹配到第二个。要注意:match必须穷尽所有可能, 如果你想不到更多的可能,你可以匹配到 _
, 这代表除了上面的模式以外的全部模式(必须放在最后)。
来回去看我们的前面写的读入终端内容的那一行 stdin.read_line(&mut input).unwrap();
, 我们如果不能确保是否一定没有问题,我们就可以用match来手动处理,但是这里我们介绍另一种常用的模式匹配 if let
因为如果是正常的话我们不需要做额外的处理,所有我们只需要匹配Result::Err这一种模式就可以了(只要匹配一种或者少量的模式,那么if let就非常适合)
1 | // 因为让程序自己出现异常来panic的话,会输出很多额外的东西(给开发者看的,但是显然不是用户想看到的) |
Move(移动) and Move(落子)
简单的与C互操作
如果只是像原来那样输入坐标的话也太难受了,这一点也不游戏!!!相信读者大爹们肯定不会买账的。所以我们来实现一下通过键盘的WASD来选择棋盘的落点(这样也不用担心输入坐标的越界问题),SPACE来确定落子。
也就是说我们需要每次按键后,程序都能及时反应。但是Rust没有直接提供一个类似于C的getch方法,当然还有第三方库可以实现按键监控,不过这里为了介绍与C互操作所以选择了调用C API。
我们需要使用extern关键字
1 | extern "C" {} |
我们需要一个getch用来接收按键,和一个kbhit监测是否有按键事件。在extern中定义接口
1 | extern "C" { |
然后就可以封装一个按键监控的函数了,要注意ffi是不安全的,所以代码必须用unsafe包裹,(或者把函数声明为unsafe,但是这会导致每次调用都得使用unsafe)
1 | fn getch(on_block: bool) -> Option<u8> { |
我们把原来的main里面的逻辑迁移到Game的run方法里,为了提供一个更良好的游戏环境,你做了一个决定是清空屏幕!
1 | use std::process::Command; |
然后处理输入,给Game添加一个current_pos: usize
记录当前位置(记得改new)
1 | // in loop |
Register players
现在好像player还是默认的,我们得实现传参自定义玩家。我们为Game实现一个register_player来注册玩家,所以在注册前玩家是空(None),我们把Game的player字段改成 [Option<Player>; 2]
,并在Game::new()初始化为None。
register_player应该接收一个player数组,并且把前2个复制给Game,这里为了能链式调用选择返回本身
1 | impl Game { |
然后你可以在像这样调用
1 | fn main() { |
但是比较麻烦,因为你每个都要new一下,有没有能够更加简化书写的方式呢?
当然有,你可以用宏来实现。
编写一个简单的宏
Rust的宏非常强大,可以做很多事情,比如不定长参数等等,宏一般分为声明宏(也就是马上要讲的,和派生宏等),相信大家在前面已经见识到了派生宏的强大了,接下来看看如何定义一个宏。
我们定义一个register_player宏来帮我更方便的生成一个Player数组
1 |
|
然后你可以这么调用,要注意宏后面可以跟(), [], {}, 虽然都可以,但是一般会有一个约定的写法,比如println!是 (), vec!是[]
1 | fn main() { |
宏的定义允许像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 |
|
我们通过cfg来设置一个(块)语句的某些属性,这里是指定这段代码只在windows平台有效,只有编译成windows平台的可执行文件才会编译这部分。
同样我们也给getch标上(这里改了个名字,因为感觉key会更合适一点)
1 |
|
接下来要写linux部分的实现了,linux里并没有(大概?)直接提供类似kbhit的函数,但是我们可以在读一个字节后返回。
我们得先导入libc库 cargo add libc
1 |
|
首先,这段代码使用了条件编译(
#[cfg(target_os = "linux")]
),这意味着这段代码只有在目标操作系统为 Linux 时才会编译和运行。然后,函数
getkey
接受一个布尔参数on_blocking
,用于指定是否在等待用户输入时阻塞。在函数内部,首先使用
libc
库的termios
结构体来获取和设置终端的 I/O 属性。termios
结构体中的c_lflag
字段用于控制输入模式,ICANON
和ECHO
是两个标志位,分别用于控制规范模式(即行缓冲)和回显。通过将这两个标志位清零,函数设置终端为非规范模式和非回显模式,这样就可以立即读取用户的每一个键入,而不需要等待回车键。
termios
结构体中的c_cc
字段是一个数组,用于控制特殊字符的行为。VMIN
和VTIME
是数组中的两个索引,分别用于控制在非规范模式下的最小读取字符数和超时时间。通过设置VMIN
为on_blocking
参数(如果on_blocking
为true
,则VMIN
为 1,否则为 0),函数可以在没有输入时立即返回,而不是等待用户输入。VTIME
被设置为 0,表示不使用超时。然后,函数使用
stdin().read(&mut buf)
从标准输入读取一个字符到buf
中。如果读取成功,函数返回读取到的字符,否则返回None
。最后,函数使用
libc::tcsetattr(0, TCSANOW, &old)
恢复终端的原始 I/O 属性。
当然到这里其实也可以了,但是读者肯定不会买账的,因为每次调用还必须指定bool,我就不能像其他语言一样整个默认参数吗。
由于rust的函数是不支持默认参数的,但是我们可以用宏来实现类似的功能。
前面有提过宏可以像match一样匹配模式,我们这里不妨就设置2种模式,对应阻塞和非阻塞。我们简单把我们的函数包装一下。
1 |
|
这样如果你使用比如 getkey!()
, 就会不阻塞。如果使用getkey!(block)
,就会阻塞等待用户输入了。这里面的block并不是一个变量,只是一个模式。所以你无须定义block为一个具体的东西,你当然可以写成其他的:D
Others
编写简单测试
有时候你想测试某个或着说一部分功能,直接编译整个项目再测试是非常低效的做法。这种情况非常建议你写test模块。
假定我们要测试我们写的register_player宏是否能按照我们想的那样运作,可以像这样写个测试函数
1 |
|
然后在你的终端运行 cargo test
如果正常就只能看到ok,出错就会看到你的输出和错误信息。一般如果我们确定大概会有个什么输出,我们就用assert系列的宏,这很好理解。当我们想要比较的内容太多,你懒得输入,你可以主动报错骗它输出, 比如本人就喜欢 assert!(false);
, 然后前面记得输出一下,然后你就可以看到回显了:D
测试函数可以有很多个,会挨个跑一遍
super关键字代表当前模块(这里指test)的父模块,所以
use super::*;
就是导入父模块的所有项。如果你的register_player等都定义在main.rs中,那么你应该把这段mod test{ }
放在main.rs中的某个位置(这保证了main是test的父模块)。