用类型化选择表达规则

RustBeginner
立即练习

介绍

这个五枚骰子的游戏提供三个选择:sum、pair 和 straight。同一次掷骰可因你的选择而得到不同分数。例如 [2, 2, 4, 4, 6] 的总和为 18,而最高对子得 8 分。你将把这些简短规则写成带有明确类型的 Rust 代码,并尝试已知点数和随机点数。

你应已了解 Rust 函数、数组、enum、match 和 Option,以及基本的终端路径操作。Cargo 和输入外壳已准备好,你负责实现计分判断。本实验不依赖其他 VM。预计约需 40 分钟,包括阅读和普通错误处理;这是作者估计。

建模选择并计算总和

在本步骤中,你将阅读准备好的类型、预测分数,并在真实游戏库中写出简短、可用的求和逻辑。从 Terminal 标签页开始。Setup 已在 /home/labex/project/dice-game 中准备好 Cargo 项目。src/lib.rs 是规则库,src/main.rs 提供输入和随机骰子。保持 src/main.rs 和 Cargo.toml 不变。

工具链位于 /home/labex/.cargo/bin。export 把这个目录加入 PATH,也就是 shell 查找命令的目录列表。此设置只适用于当前 Terminal 会话;打开新 shell 后需要重新执行。cd 进入项目,pwd 确认当前目录。

export PATH=/home/labex/.cargo/bin:$PATH
cd /home/labex/project/dice-game
pwd

应显示 /home/labex/project/dice-game。路径输错时用上面的 cd 修正,不要重置环境。编辑前,预测 [2, 2, 4, 4, 6] 的总和:18。你将先让这个普通计分命令正常工作。

用 cat 阅读已有库;它打印文件而不改变文件:

cat src/lib.rs

Dice 是 [u8; 5] 的别名,表示恰好五个单字节无符号值。输入外壳只接受 1 到 6 的点数。Category 命名三种选择。Category::parse 对已识别的单词返回 Some(category),对未知单词返回 None。预测 sum 和 sums 的返回结果,再找到对应的 match 分支。拒绝输入由外壳负责;已识别的类别可以合法地得零分。

类型和辅助方法已经提供。pub 把名称公开给外壳。impl Category 将方法组织在该类型上,其中 Self 表示 Category。derive 为这个枚举提供复制、调试显示和相等比较。name 返回用于显示的固定字符串字面量。保留这些定义。只编辑 score;其当前函数体对所有类别都返回零。

编辑前保留一个撤销点。mktemp -d 创建新目录,将六个 X 替换为唯一后缀,避免复用旧备份。$() 捕获打印的路径,保存到会话变量 rs02_before_sum。&& 只在目录创建成功后执行复制。cp -p 复制源码并保留元数据;引号让变量中的路径保持为一个参数。这个备份只包含允许编辑的库,不包含准备好的输入外壳或 Cargo 配置。任何一步失败时,阅读错误并成功备份后再编辑。

rs02_before_sum=$(mktemp -d .edit-before-sum.XXXXXX) && cp -p src/lib.rs "$rs02_before_sum/lib.rs"

用 nano 打开已有文件。-c 显示光标位置,方便根据编译行号定位:

nano -c src/lib.rs

Nano 的 ^ 表示 Ctrl 键。按 Ctrl+W,输入 pub fn score,按 Enter 找到函数。用方向键移动,用 Backspace/Delete 修改字符。编辑前,从函数左花括号 { 读到匹配的右花括号 }。不要删除前面的枚举或辅助方法。

初始程序将未使用的参数命名为 _dice 和 _category。只把参数绑定重命名为 dice 和 category,让函数体可以使用它们;保留函数名和参数/返回类型。把单独的 0 表达式替换为 match category 表达式。分支由模式、=> 和结果组成,例如临时分支 Category::Pair => 0,。也让 Category::Straight 暂时返回零。下一步实现这两条规则。

对 Category::Sum,使用代码块:先创建可变累加器,再访问每个点数并加上扩宽后的值。这个短循环展示数值操作:

let mut total = 0;
for face in dice {
    total += u16::from(face);
}

u16::from(face) 在相加前扩宽数值。即使五枚六点也能容纳在 u16 中。循环后,让 total 成为 Sum 分支的最后一个表达式:末尾不加分号,让分支返回该值。结束 match 和函数的花括号。你已经在已有程序中实现一个真实类别,另外两个选择有意暂未完成。

按 Ctrl+O 写入,按 Enter 确认 src/lib.rs,再按 Ctrl+X 退出 nano。编辑器仍打开时不要输入终端命令。编译前阅读保存的修改:

cat src/lib.rs

Cargo 编译并运行准备好的程序。--quiet 隐藏常规构建消息;-- 将 Cargo 选项与程序参数分开。运行刚才预测的五个点数:

cargo run --quiet -- score sum 2 2 4 4 6
Dice: [2, 2, 4, 4, 6]
sum: 18

骰子没有改变,选择的规则产生了分数。继续前,检查你的循环是否也能预测五枚六点得 30。

如果 Cargo 报告语法错误,阅读文件名和行号,重新打开 nano -c src/lib.rs,搜索 pub fn score,修复对应花括号、分支或表达式。保存并退出,再运行失败的命令。如果修改仍未保存,按 Ctrl+X,在保存问题处按 N,即可放弃未保存的编辑;这不会撤销已经写入的修改。

对于无法修复的已保存错误,用下面的命令恢复自己的编辑前库。这会有意只替换允许编辑的 src/lib.rs,不会恢复外壳或 Cargo 文件。备份变量属于当前 Terminal 会话。在另一个 shell 中,按下方条件恢复说明,选择并检查实际存在的早期备份。

仅在恢复已保存错误时:原 Terminal 中的检查点变量仍保存路径。在新 Terminal 中,先重复之前的 PATH 和项目目录命令。列出本阶段备份:

ls -d .edit-before-sum.*

ls -d 列出目录名而非内容;* 匹配本阶段唯一目录的后缀。这些是真实生成的名称,不要编造后缀。如果没有匹配备份,确认项目目录及是否创建过检查点;不要从无关阶段复制。选择一个打印出的路径时,运行:

read rs02_before_sum

shell 会等待一行输入。粘贴所选目录的准确名称并按 Enter。read 将该行保存到本会话的具名变量中。恢复前确认备份内容:

cat "$rs02_before_sum/lib.rs"

它应包含实现 Sum 之前的初始程序。如果路径或阶段错误,用正确的已打印路径重新执行 read 并再次检查。只有确认后才恢复。如果你不确定原 Terminal 变量指向哪个检查点,也应先做这项检查。

cp -p "$rs02_before_sum/lib.rs" src/lib.rs

这会恢复初始程序,所以要重新实现 Sum 并运行其计分命令。保留当前 Terminal 会话和可用的 Sum,继续下一步。

计算最高对子与顺子

在本步骤中,你将设计并实现其余计分判断,同时保留可用的 Sum 分支。继续使用同一个 Terminal,工作目录为 /home/labex/project/dice-game。如果打开了新 shell,请重复上一步的 export PATH 和 cd 命令。

对子得分是出现至少两次的最大点数的两倍。三个、四个或五个相同点数仍只计其中两枚。对于 [2, 2, 4, 4, 6],两个对子都计分,还是只计四点?只计四点:8 分。五枚六点得 12,而不是 30。没有对子是合法零分。

一种有用的表示是 [0u8; 7]:七个计数器,索引为 0 到 6,0 不使用。对每枚骰子,usize::from(face) 将点数转换为数组索引;counts[usize::from(face)] += 1 递增该点数的计数。第二个循环可检查 1..=6,包含两个端点。某个计数至少为二时,把可变的最佳分数替换为该点数的两倍;递增访问会留下最高的有效对子。as u16 将这个小整数结果转换为分数类型。你也可组织等价的计数或排序算法,但仍须正确处理同时有两个对子及三至五枚相同点数。

顺子只有在五个互不重复的点数恰好是 1–5 或 2–6、顺序任意时才得 15 分。预测 [6, 5, 4, 3, 2] 与 [1, 2, 3, 4, 6] 的区别。前者是顺子,后者有缺口。用 let mut sorted = dice 复制,再调用 sorted.sort(),即可将排序数组与两组准确数组比较,不改变原掷骰。|| 表示任一比较可以成立。判断何时返回 15、何时返回零;重复点数不能通过。

编辑前保留已可用的阶段。与之前一样,唯一目录避免覆盖旧备份,复制只在目录创建成功后执行:

rs02_before_rules=$(mktemp -d .edit-before-rules.XXXXXX) && cp -p src/lib.rs "$rs02_before_rules/lib.rs"

打开已有文件,用 Ctrl+W 搜索 pub fn score,只改变 Pair 和 Straight 分支。保留 Sum、公开 API 和全部辅助定义。根据规则编写实现,不要替换整个模块。

nano -c src/lib.rs

按 Ctrl+O、Enter 保存,按 Ctrl+X 退出。运行前阅读保存的分支并预测每个结果:

cat src/lib.rs
cargo run --quiet -- score pair 2 2 4 4 6
Dice: [2, 2, 4, 4, 6]
pair: 8

现在比较五枚相同骰子、两个顺子范围和合法零分:

cargo run --quiet -- score pair 6 6 6 6 6
cargo run --quiet -- score straight 6 5 4 3 2
cargo run --quiet -- score straight 1 2 3 4 5
cargo run --quiet -- score pair 1 2 3 4 6
cargo run --quiet -- score straight 1 2 3 4 6

分数应依次为 12、15、15、0 和 0。零表示已识别规则没有找到得分组合,并非输入被拒绝。另选一个你自己的合法五骰样例,预测某类别的分数,再用该类别及全部五个点数运行 score。若结果不同,应根据规则解释,不要改变样例来掩盖问题。

省略五个点数会使用操作系统随机源。这是本地游戏,不是安全或赌博系统。运行一次,也可以重复:

cargo run --quiet -- score pair

你的骰子和分数可能变化,随机抽取也可能碰巧重复。独立使用最高对子规则检查显示的骰子。未知单词不同于合法零分:下面故意拼错的命令以状态 1 退出,打印 Error: choose sum, pair, or straight,不对掷骰计分:

cargo run --quiet -- score pairs 2 2 4 4 6

在命令行修正类别:

cargo run --quiet -- score pair 2 2 4 4 6

应得到 pair: 8。编译错误则指向源代码:在 nano 中重新打开 score,修复指出的行,保存并重试。需要时恢复自己可用的 Sum 阶段,再实现两个剩余分支:

仅在恢复已保存错误时:原 Terminal 中的检查点变量仍保存路径。在新 Terminal 中,先重复之前的 PATH 和项目目录命令。列出本阶段备份:

ls -d .edit-before-rules.*

ls -d 列出目录名而非内容;* 匹配本阶段唯一目录的后缀。这些是真实生成的名称,不要编造后缀。如果没有匹配备份,确认项目目录及是否创建过检查点;不要从无关阶段复制。选择一个打印出的路径时,运行:

read rs02_before_rules

shell 会等待一行输入。粘贴所选目录的准确名称并按 Enter。read 将该行保存到本会话的具名变量中。恢复前确认备份内容:

cat "$rs02_before_rules/lib.rs"

它应包含实现 Pair 和 Straight 前已经可用的 Sum。如果路径或阶段错误,用正确的已打印路径重新执行 read 并再次检查。只有确认后才恢复。如果你不确定原 Terminal 变量指向哪个检查点,也应先做这项检查。

cp -p "$rs02_before_rules/lib.rs" src/lib.rs

这会有意只恢复允许编辑的库。不要重置环境或覆盖 src/main.rs、Cargo.toml。尝试后,可在比较或恢复某个具体函数时查看以下参考;它不是默认编辑顺序。

用于比较和恢复的完整库参考

对照相关分支,只通过 nano 修复需要的部分。之后重新运行已知样例和边界样例。下方完整库也是用于准备下一实验已完成计分规则的唯一维护源。

pub type Dice = [u8; 5];

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Category {
    Sum,
    Pair,
    Straight,
}

impl Category {
    pub fn parse(input: &str) -> Option<Self> {
        match input {
            "sum" => Some(Self::Sum),
            "pair" => Some(Self::Pair),
            "straight" => Some(Self::Straight),
            _ => None,
        }
    }
    pub fn name(self) -> &'static str {
        match self {
            Self::Sum => "sum",
            Self::Pair => "pair",
            Self::Straight => "straight",
        }
    }
}

pub fn score(dice: Dice, category: Category) -> u16 {
    match category {
        Category::Sum => {
            let mut total = 0;
            for face in dice {
                total += u16::from(face);
            }
            total
        }
        Category::Pair => {
            let mut counts = [0u8; 7];
            for face in dice {
                counts[usize::from(face)] += 1;
            }
            let mut best = 0;
            for face in 1..=6 {
                if counts[face] >= 2 {
                    best = (face * 2) as u16;
                }
            }
            best
        }
        Category::Straight => {
            let mut sorted = dice;
            sorted.sort();
            if sorted == [1, 2, 3, 4, 5] || sorted == [2, 3, 4, 5, 6] {
                15
            } else {
                0
            }
        }
    }
}

总结

你使用有明确类型的类别、match、累加总和、点数计数器和排序副本,表达了三条完整规则。固定样例帮助解释结果,随机掷骰则让同样的规则用于新点数。没有匹配的计分组合时得零分;未知类别则属于输入错误。