外观
第 6 章 小游戏合集
小螃蟹说
前五章,我们做了好几个小游戏:猜拳、猜数字、动物叫声模拟器,还有宠物图鉴。它们都散落在各自的文件夹里,想玩哪个就得跑到哪个项目里,太不方便了!
这一章,我们把它们全部装进一个程序:打开它,一个菜单,想玩哪个点哪个。这就像把散落的玩具收进一个玩具箱——玩具还是那些玩具,但收拾整齐了。
这个"收拾"的过程,在 Rust 里叫模块化(modularity)。它是程序长大的必经之路:代码一多,不整理,就会变成一团乱麻。
这也是一个"省力"的章节:大部分代码都是前几章的老朋友,我们只是给它们换个新家。新知识其实只有一个——模块怎么搭。
6.1 预览:我们要做什么
- 新建一个"合集"项目
- 把猜拳、猜数字、动物叫声三个游戏,各装进一个模块
- 写一个菜单:输入数字,玩对应的游戏
运行结果
text
欢迎来到小游戏合集!
== 菜单 ==
1. 猜拳游戏
2. 猜数字游戏
3. 动物叫声模拟器
输入 0 退出
1
欢迎来到猜拳游戏!
小螃蟹 Ferris 陪你玩,三局两胜!
=== 第 1 回合 ===
请出拳(输入:石头 / 剪刀 / 布):
石头
你出了:石头
小螃蟹出了:布
你赢了!
……
== 菜单 ==
2
猜数字游戏!
我心里想了一个 1 到 100 之间的秘密数字,你猜猜看!
……
== 菜单 ==
0
再见!欢迎下次再来玩!运行结果仅供参考
中间的过程省略了——因为游戏还是那些游戏,你早就玩熟啦。这一章的重点不是游戏本身,而是它们被装进了什么。
这一章你会学到:
| 知识 | 是什么 | 会用在哪儿 |
|---|---|---|
| 模块 | 装代码的玩具箱 | 每个游戏一个箱子 |
mod 声明 | 告诉编译器"有这样一个箱子" | mod games; |
| 私有性 | 箱子里的东西默认不给外人看 | pub fn play() 才对外开放 |
| 路径 | 怎么走到箱子里的东西 | games::rps::play() |
use | 把常用的路记成短名 | use games::rps; |
6.2 动手做
步骤一:新建项目
老规矩,不过这次要给合集请 rand 帮手(猜拳和猜数字都要用它):
bash
cargo new game_collection
cd game_collection在 Cargo.toml 末尾加上:
toml
[dependencies]
rand = "0.9"步骤二:为什么要收拾?
想一想:如果我们把三个游戏全写进一个 main.rs,会怎样?光是猜拳就快 70 行了,三个游戏加起来 200 多行挤在一个文件里——想找猜拳的规则,得从头翻到尾;想改动物叫声,还得小心别碰到猜数字的代码。而且所有函数的名字都挤在一个大房间里,谁都能看到谁。
Rust 的答案是模块(module):把代码装进一个个有名字的"箱子"里。
玩具箱比喻
模块就像玩具箱。猜拳一个箱子,猜数字一个箱子,动物叫声一个箱子。每个箱子里的东西自己管自己,箱子外的人只能看到箱子上"欢迎来玩"的标签,看不到里面乱糟糟的内部结构。
步骤三:建第一个模块:games
我们来建一个叫 games 的模块,它是装所有游戏的"大箱子"。
先在 src 文件夹里建一个子文件夹和两个新文件:
text
src/
├── main.rs (菜单,程序的入口)
├── games.rs (大箱子的"清单页")
└── games/ (大箱子本体,放各个游戏)
├── rps.rs
├── guessing.rs
└── animals.rsgames.rs 和 games/ 文件夹是一对:.rs 文件是"清单页",文件夹是"箱子"。清单页上写着箱子里有哪些玩具。
把 main.rs 和 games.rs 先写成最简单的样子:
文件:src/main.rs
rust
mod games;
fn main() {
println!("欢迎来到小游戏合集!");
}文件:src/games.rs
rust
pub mod rps;
pub mod guessing;
pub mod animals;现在 games/ 文件夹里还没有这三个文件,所以 cargo run 会报错"找不到文件"。这正是 Cargo 在提醒我们:清单上写了三件玩具,箱子里却空空的。我们马上把玩具放进去。
解剖一下
mod games; 是说"我要用 games 这个模块"。编译器看到它,就会去找 games.rs 或 games/ 文件夹。
games.rs 里的 pub mod rps; 是说"games 箱子里有一个 rps 小箱子,而且对外开放"。pub 是 public 的缩写,意思是"公开"——这个我们马上细讲。
步骤四:把猜拳装进箱子
在 src/games/ 里新建 rps.rs,内容就是第 1 章 1.5 节的完整代码,只改两处:
- 把
fn main()改成pub fn play() - 在函数开头加一行
println!();(空一行,和菜单隔开)
文件开头长这样:
rust
use std::io;
use rand::Rng;
pub fn play() {
println!();
println!("欢迎来到猜拳游戏!");
// ……其余部分和第 1 章一模一样……
}为什么是 pub fn play?
pub 是"公开"的意思。main.rs 要从外面调用猜拳,就得让它对外开放。如果写成 fn play()(不带 pub),它就是 rps 箱子的私有物品——外人喊不动,编译器会直接报错:"函数 play 是私有的!"这个我们练习三会亲手撞一次。
main 函数改成喊一下试试:
rust
mod games;
use games::rps;
fn main() {
println!("欢迎来到小游戏合集!");
rps::play();
}use games::rps; 是"把路记成短名":以后写 rps::play() 就行,不用每次写完整路径 games::rps::play()。
运行——猜拳游戏从新家跑起来了!
运行结果
text
欢迎来到小游戏合集!
欢迎来到猜拳游戏!
小螃蟹 Ferris 陪你玩,三局两胜!
……步骤五:把另外两个游戏也装进来
猜数字和动物叫声是一模一样的改造:
- 把第 2 章 2.5 节的完整代码存成
src/games/guessing.rs,fn main()改成pub fn play(),开头加一行println!(); - 把第 5 章 5.5 节的完整代码存成
src/games/animals.rs,同样改法
小贴士
注意每个文件都带着自己的 use(比如 guessing.rs 开头有 use std::cmp::Ordering; 和 use rand::Rng;)。模块的 use 只管自己的箱子——玩具箱里的东西,清单页不用管,主菜单更不用管。每个箱子自给自足。
改完 main.rs,三个游戏一起上:
rust
mod games;
use games::{animals, guessing, rps};
fn main() {
println!("欢迎来到小游戏合集!");
rps::play();
guessing::play();
animals::play();
}use games::{animals, guessing, rps}; 是 use 的花括号套餐:一次记三条路,省三行。
步骤六:组装菜单
三个游戏不能排队一个个玩,得让玩家自己选。把 main.rs 换成最终的菜单版:
rust
mod games;
use games::{animals, guessing, rps};
fn main() {
println!("欢迎来到小游戏合集!");
loop {
println!();
println!("== 菜单 ==");
println!("1. 猜拳游戏");
println!("2. 猜数字游戏");
println!("3. 动物叫声模拟器");
println!("输入 0 退出");
let mut input = String::new();
std::io::stdin().read_line(&mut input).expect("读取输入失败");
match input.trim() {
"1" => rps::play(),
"2" => guessing::play(),
"3" => animals::play(),
"0" => break,
_ => println!("没有这个游戏哦。"),
}
}
println!("再见!欢迎下次再来玩!");
}这里全是老朋友:菜单用 loop 转圈(第 1 章)、match 选路(第 2 章)、read_line 读输入(第 1 章)。只有一个细节:std::io::stdin() 写了完整路径——main.rs 这次没写 use std::io;,所以喊帮手时要连"家谱"一起喊。完整路径、短名,两种写法 Rust 都认。
运行,开始玩吧!
运行结果
text
欢迎来到小游戏合集!
== 菜单 ==
1. 猜拳游戏
2. 猜数字游戏
3. 动物叫声模拟器
输入 0 退出
1
欢迎来到猜拳游戏!
小螃蟹 Ferris 陪你玩,三局两胜!
……
== 菜单 ==
0
再见!欢迎下次再来玩!小游戏合集完工!三个游戏住进了一个屋檐下。而且看看这个项目的样子——每个游戏一个文件,想改猜拳就去 games/rps.rs,想改动物叫声就去 games/animals.rs,互不打扰。这就是整理玩具箱的威力。
6.3 知识深挖
6.3.1 包和 crate
先认识两个大词:包(package)和 crate。
crate 是 Rust 的"编译单元"——一次编译,造出一个东西。它有两种:
- 二进制 crate(binary crate):能直接运行的程序,有
main函数。我们前五章做的都是它 - 库 crate(library crate):给别人用的工具箱,没有
main。第 9 章我们要做第一个库
包是一个项目(一个 Cargo.toml 的户口本下)。一个包可以装一个二进制 crate 加一个库 crate,还可以装更多二进制。我们的小游戏合集就是一个包,目前里面有一个二进制 crate(整个 src)。
其实这些词不用死记。你只要知道:项目 = 包,包 = 装着 crate,二进制 crate = 能跑的程序。第 12 章我们玩"工作空间"时,还会再见到它们。
6.3.2 模块:玩具箱
模块(module)是 crate 内部的"箱子"。声明一个模块:
rust
mod games;编译器看到 mod games;,就会去找文件:先找 games.rs,再找 games/ 文件夹。找到了,整个文件的内容就是 games 模块的内容。
模块还能套模块(箱子套箱子):
rust
// games.rs
pub mod rps;
pub mod guessing;
pub mod animals;这告诉编译器:games 箱子里还有三个小箱子,它们分别在 games/rps.rs、games/guessing.rs、games/animals.rs 里。
文件系统就是我们最好的"箱子地图":
| 模块 | 对应文件 |
|---|---|
games | games.rs + games/ 文件夹 |
games::rps | games/rps.rs |
games::guessing | games/guessing.rs |
games::animals | games/animals.rs |
:: 就是"里面的":games 里面的 rps。名字越堆越长,箱子就越套越深。
6.3.3 私有性:默认锁着
模块最实用的规矩:箱子里的东西,默认不对外开放。Rust 里这叫私有性(privacy)。
fn play():私有。只有 rps 自己(和它的子模块)能调用pub fn play():公开。谁都能从外面喊
main.rs 要玩猜拳,rps::play 就必须是 pub。而猜拳内部的 judge、random_choice 这些辅助函数,保持私有——它们只是内部工具,外面的人不需要知道猜拳是怎么实现输赢判断的。
这就是封装(encapsulation):对外只露一个门把手(play),内部细节关在箱子里。好处是:以后想改进猜拳的内部算法,只要 play 还是 play,菜单一行都不用改。
从里到外,一路都要公开
main.rs 想用 games::rps::play,这一路上每一个环节都要开门:rps 要 pub(games.rs 里的 pub mod rps;),play 要 pub(pub fn play)。中间任何一环锁着,编译器都报错。Rust 的规矩很死板,但死板意味着不会漏。
6.3.4 路径:怎么走到箱子里的东西
要找箱子里的东西,得走路径(path)。路径有两种:
绝对路径,从 crate 的根部开始走:
rust
crate::games::rps::play()crate 是"这个 crate 自己"的意思。不管在哪个文件里写,crate:: 都从根出发,不会迷路。
相对路径,从当前位置出发:
rust
// 在 main.rs 里
games::rps::play()
// 在 games.rs 里
rps::play()哪个更好?和所有"导航"问题一样:绝对路径稳,相对路径短。代码里混着用也完全没问题。
super 是相对路径里的"上一级"(爸爸箱子):
rust
// 在 games/rps.rs 里
super::some_helper()super:: 就是"回上一格"。这个我们练习里会用上。
6.3.5 use:把长路记成短名
每次写 crate::games::rps::play() 太长了。use 就是"记短名":
rust
use games::rps; // 之后写 rps::play()
use games::rps::play; // 之后直接写 play()
use games::{rps, guessing}; // 花括号套餐,一次记两条use 只做一件事:把路径引进作用域,让手边有个短名字。它不改任何东西的可见性——箱子还是那个箱子,门还是那扇门。
小贴士
我们的 main.rs 用了 use games::rps; 这种"记箱子名"的写法,调用时写 rps::play()。也有人喜欢 use games::rps::play; 直接记函数名。两种都行,看哪种读起来顺。
6.4 动脑筋练习
练习一:宠物图鉴也搬进来
第 4 章的宠物图鉴也想住进合集!这次要动三处:
- 把第 4 章 4.5 节的代码存成
src/games/gallery.rs,fn main()改成pub fn play(),开头加println!(); - 在
games.rs的清单上加一行 - 菜单加一项
4. 宠物图鉴,match 加一个分支
点开看答案
三处改动:
第一处,新建 src/games/gallery.rs,内容照抄第 4 章的 main.rs,把 fn main() 改成:
rust
pub fn play() {
println!();
println!("欢迎来到宠物图鉴!请登记三只宠物。");第二处,games.rs 清单加一行:
rust
pub mod animals;
pub mod gallery;
pub mod guessing;
pub mod rps;第三处,main.rs 的菜单加一项,use 加一个名字,match 加一个分支:
rust
use games::{animals, gallery, guessing, rps};rust
println!("4. 宠物图鉴");rust
"4" => gallery::play(),注意宠物图鉴模块里也有个 read_input 函数,猜拳模块里也有个 read_player_choice——两个箱子各叫各的,互不打架!这就是模块的隔离魔法:不同的箱子,可以有同名的函数,谁也不碍着谁。
练习二:菜单支持名字
菜单只能输入数字,有点不友好。改成:输入 1 或 猜拳 都行,0 或 退出 都能结束。
提示:match 的分支可以一次匹配多个模式,用竖线 | 连起来,比如 "1" | "猜拳" => rps::play(),。这个语法在第 19 章会深入讲,先用起来。
点开看答案
把 main.rs 里的 match 改成:
rust
match input.trim() {
"1" | "猜拳" => rps::play(),
"2" | "猜数字" => guessing::play(),
"3" | "动物" => animals::play(),
"0" | "退出" => break,
_ => println!("没有这个游戏哦。"),
}"1" | "猜拳" 读作:"1 或者 猜拳"——不管是哪个,都走这条路。| 就是"或者",和第 1 章的 || 是亲戚,但一个是 match 模式里的,一个是 if 条件里的。
运行试试:输入 猜拳,游戏直接开始;输入 退出,程序道别。输 2 呢?照常玩猜数字。
练习三:私有的门把手(实验)
这个练习不用写新代码,而是动手做三个小实验,亲眼看看私有性长什么样:
- 把
games/rps.rs里的pub fn play()改成fn play()(去掉 pub),运行cargo build——看看编译器怎么"抓小偷" - 改回来,再把
games.rs里的pub mod rps;改成mod rps;,再次cargo build——这次又是谁在报警? - 全部改回公开后,把
main.rs里的use games::rps;删掉,调用改成完整路径crate::games::rps::play(),确认还能运行
点开看答案
实验一:去掉 pub fn play() 的 pub 后:
text
error[E0603]: function `play` is private编译器说:play 是私有的!猜拳箱子锁着门,main.rs 从外面喊不开。
实验二:去掉 games.rs 里 pub mod rps; 的 pub 后:
text
error[E0603]: module `rps` is private这次是 rps 模块本身私有了——main.rs 连猜拳箱子都摸不到。还记得"从里到外一路都要公开"吗?两把锁,缺一把都开不了门。
实验三:
rust
mod games;
fn main() {
// ……菜单……
"1" => crate::games::rps::play(),crate:: 从根出发,不管你在哪个文件里写都稳稳当当。运行,效果和用 use 时一模一样。
三个实验做完,私有性的规则你已经亲手摸过一遍了:默认全锁,pub 才开门,一路上的门都要开。 这就是 Rust 的封装——想喊一个函数,先看清楚它家门牌号。
6.5 完整代码清单
项目结构:
text
game_collection/
├── Cargo.toml
└── src/
├── main.rs
├── games.rs
└── games/
├── animals.rs
├── guessing.rs
└── rps.rs文件:Cargo.toml
toml
[package]
name = "game_collection"
version = "0.1.0"
edition = "2024"
[dependencies]
rand = "0.9"文件:src/main.rs
rust
mod games;
use games::{animals, guessing, rps};
fn main() {
println!("欢迎来到小游戏合集!");
loop {
println!();
println!("== 菜单 ==");
println!("1. 猜拳游戏");
println!("2. 猜数字游戏");
println!("3. 动物叫声模拟器");
println!("输入 0 退出");
let mut input = String::new();
std::io::stdin().read_line(&mut input).expect("读取输入失败");
match input.trim() {
"1" => rps::play(),
"2" => guessing::play(),
"3" => animals::play(),
"0" => break,
_ => println!("没有这个游戏哦。"),
}
}
println!("再见!欢迎下次再来玩!");
}文件:src/games.rs
rust
pub mod animals;
pub mod guessing;
pub mod rps;文件:src/games/rps.rs
rust
use std::io;
use rand::Rng;
pub fn play() {
println!();
println!("欢迎来到猜拳游戏!");
println!("小螃蟹 Ferris 陪你玩,三局两胜!");
let mut player_wins = 0;
let mut ferris_wins = 0;
let mut round = 0;
loop {
round += 1;
println!();
println!("=== 第 {} 回合 ===", round);
let player = read_player_choice();
let ferris = random_choice();
println!("你出了:{}", player);
println!("小螃蟹出了:{}", ferris);
let result = judge(&player, ferris);
println!("{}", result);
if result == "你赢了!" {
player_wins += 1;
} else if result == "小螃蟹赢了!" {
ferris_wins += 1;
}
if player_wins == 2 || ferris_wins == 2 {
break;
}
}
println!();
if player_wins == 2 {
println!("你赢了比赛!你真厉害!");
} else {
println!("小螃蟹赢了比赛!再试一次吧!");
}
}
fn read_player_choice() -> String {
loop {
println!("请出拳(输入:石头 / 剪刀 / 布):");
let mut input = String::new();
io::stdin().read_line(&mut input).expect("读取输入失败");
let choice = input.trim().to_string();
if choice == "石头" || choice == "剪刀" || choice == "布" {
return choice;
}
println!("小螃蟹没听懂,请重新输入哦。");
}
}
fn random_choice() -> &'static str {
let choices = ["石头", "剪刀", "布"];
let index = rand::rng().random_range(0..choices.len());
choices[index]
}
fn judge(player: &str, ferris: &str) -> &'static str {
if player == ferris {
"平局!"
} else if (player == "石头" && ferris == "剪刀")
|| (player == "剪刀" && ferris == "布")
|| (player == "布" && ferris == "石头")
{
"你赢了!"
} else {
"小螃蟹赢了!"
}
}文件:src/games/guessing.rs
rust
use std::cmp::Ordering;
use std::io;
use rand::Rng;
pub fn play() {
println!();
println!("猜数字游戏!");
println!("我心里想了一个 1 到 100 之间的秘密数字,你猜猜看!");
let secret = rand::rng().random_range(1..=100);
let mut attempts = 0;
loop {
println!();
println!("请输入你的猜测(1~100):");
let mut input = String::new();
io::stdin().read_line(&mut input).expect("读取输入失败");
let guess: u32 = match input.trim().parse() {
Ok(number) => number,
Err(_) => {
println!("这不是一个数字哦,请重新输入。");
continue;
}
};
attempts += 1;
match guess.cmp(&secret) {
Ordering::Less => println!("太小了!再往大猜猜。"),
Ordering::Greater => println!("太大了!再往小猜猜。"),
Ordering::Equal => {
println!("猜对了!秘密数字就是 {}!", secret);
println!("你一共猜了 {} 次。", attempts);
break;
}
}
}
}文件:src/games/animals.rs
rust
use std::io;
enum Animal {
Cat,
Dog,
Cow,
Duck,
Sheep,
}
impl Animal {
fn speak(&self) -> &'static str {
match self {
Animal::Cat => "喵~",
Animal::Dog => "汪汪!",
Animal::Cow => "哞~",
Animal::Duck => "嘎嘎嘎!",
Animal::Sheep => "咩~",
}
}
}
fn from_name(name: &str) -> Option<Animal> {
match name {
"猫" => Some(Animal::Cat),
"狗" => Some(Animal::Dog),
"牛" => Some(Animal::Cow),
"鸭" => Some(Animal::Duck),
"羊" => Some(Animal::Sheep),
_ => None,
}
}
pub fn play() {
println!();
println!("动物叫声模拟器!");
println!("输入动物名字(猫/狗/牛/鸭/羊),听它说话。输入\"退出\"结束。");
let mut total = 0;
loop {
println!();
println!("动物?");
let mut input = String::new();
io::stdin().read_line(&mut input).expect("读取输入失败");
let name = input.trim();
if name == "退出" {
break;
}
match from_name(name) {
Some(animal) => {
total += 1;
println!("{}说:{}", name, animal.speak());
}
None => println!("{}?不认识这种动物哦。", name),
}
}
println!("一共叫了 {} 声。再见!", total);
}怎么运行:
bash
cd game_collection
cargo run运行检查单:
菜单能显示四个选项(三个游戏加退出)
输入 1 能玩猜拳,玩完回到菜单
输入 2 能玩猜数字,玩完回到菜单
输入 3 能玩动物叫声模拟器,玩完回到菜单
输入 0 能退出
玩完一个游戏后,菜单还活着,不会"一次性"