外观
第 9 章 游戏得分统计库
小螃蟹说
前八章我们做的都是能跑的程序(猜拳、收藏册……)。这一章,我们要做一个完全不同的东西:工具箱——它自己不能跑,但里面装的工具,别人写的程序可以拿来用。
我们做的工具箱叫"游戏得分统计库":把一堆游戏得分丢进去,它帮你算出最高分、最低分、平均分、中位数、众数。第 12 章,我们要把它发布到网上,全世界的人都能下载!
不过,做工具箱只是引子。这一章真正的主角,是 Rust 世界里最著名的三兄弟:泛型、trait 和生命周期。它们分别在解决三个问题:
- 泛型:同一种逻辑,适配所有类型
- trait:要求类型"会做某些事"
- 生命周期:保证"借来的东西不会过期"
这三兄弟抽象、难懂,是全世界的 Rust 新手最头疼的三座大山。别怕,我准备了三个比喻,保证把它们变成三座小土坡。
9.1 预览:我们要做什么
- 建一个库 crate(工具箱):
game_stats - 在库里边写统计工具:
largest(最高分)、smallest(最低分)、average(平均分)、median(中位数)、mode(众数) - 再写一个主程序用这个库:输入一堆得分,打印统计报告
运行结果
text
欢迎来到游戏得分统计器!
输入一个得分(输入"结束"完成):90
输入一个得分(输入"结束"完成):75
输入一个得分(输入"结束"完成):88
输入一个得分(输入"结束"完成):60
输入一个得分(输入"结束"完成):结束
== 得分报告 ==
参赛人数:4
最高分:90
最低分:60
平均分:78.2
中位数:81.5
没有众数!运行结果仅供参考
中位数是"最中间的那个数":把分数从低到高排成一队,站在正中间的那个就是中位数。四个人,中间是两个——所以取了 75 和 88 的平均 81.5。众数是"出现次数最多的数":大家分数都不同,就没有众数。
这一章你会学到:
| 知识 | 是什么 | 会用在哪儿 |
|---|---|---|
| 库 crate | 给别人用的工具箱 | game_stats 本身 |
泛型 <T> | 给类型留空位的图纸 | largest<T> 通吃所有类型 |
| trait | 能力证书,要求类型会做某事 | T: PartialOrd + Copy |
生命周期 'a | 借条的保质期 | longest_text<'a> 保证借条不过期 |
as 转换 | 把数字变成另一种数字 | u32 → f64 |
9.2 动手做
步骤一:建一个库
前八章我们用的是 cargo new 项目名,它默认造的是二进制 crate(能跑的程序)。这一次,我们要造库 crate——没有 main 函数的工具箱:
bash
cargo new --lib game_stats
cd game_stats--lib 是 library(库)的缩写。看看 Cargo 造了什么:
text
game_stats/
├── Cargo.toml
└── src/
└── lib.rs没有 main.rs,只有 lib.rs!lib.rs 是库的大门,里面的函数都是给外面的人用的。Cargo 还送了一个示例函数,我们把它全部删掉,换上自己的工具。
现在这个包(game_stats)里只有一个库 crate。等会儿我们在 src/ 里再加一个 main.rs,它就同时拥有一个库和一个能跑的程序了——一个包,两个 crate(第 6 章讲过:包 = 户口本,crate = 箱子)。
打开 src/lib.rs,清空,写上第一个工具:
步骤二:第一个工具:最高分
rust
pub fn largest(numbers: &[u32]) -> Option<u32> {
if numbers.is_empty() {
return None;
}
let mut best = numbers[0];
for &number in numbers.iter() {
if number > best {
best = number;
}
}
Some(best)
}largest(最大的)是我们第 3 章"擂台赛"的老套路:第一个上场当擂主,后面一个个来挑战,比擂主大就换人。
两个新面孔:
&[u32]是"借来的一串 u32"。方括号加类型,是切片(第 3 章的&str是文字切片,这是数字切片):不看整个 Vec,只看"一串数字"。&Vec<u32>和&[u32]在传参时是好朋友(自动转换),以后函数参数写&[T]更通用- 返回
Option<u32>:空列表时没有最高分,返回None(第 5 章的老朋友);有分数就返回Some(最高分)
注意 for &number in numbers.iter() 里的 &:迭代器递出来的是"每个数字的引用",加上 & 是"拆开引用,把数字本身拿出来"。这样 number 就是个普通的 u32,可以直接比大小。
步骤三:泛型登场
largest 现在只认 u32。如果游戏得分有小数(9.5 分、8.0 分),怎么办?再抄一份 largest 把 u32 全换成 f64?分数类型再多几种,就要抄几份——代码是"复制粘贴"出来的,以后改 bug 要改好几处,想想就可怕。
Rust 的答案是:泛型(generics)——给类型留一个空位 T,写一份代码,什么类型都能填:
rust
pub fn largest<T: PartialOrd + Copy>(numbers: &[T]) -> Option<T> {
if numbers.is_empty() {
return None;
}
let mut best = numbers[0];
for &number in numbers.iter() {
if number > best {
best = number;
}
}
Some(best)
}只改了三处:
largest<T>:函数名后面加<T>,声明"这里有个类型空位,我叫它 T"&[u32]→&[T]:参数变成"一串 T";返回Option<T>T: PartialOrd + Copy:空位不是谁都能填的,要满足两个条件——这就是trait 约束(trait bound)
那 T: PartialOrd + Copy 是什么意思?这就要介绍三兄弟中的老二了。
**trait(特征)**是"能力证书"。PartialOrd 是"会比大小"证书,Copy 是"能复印"证书(第 3 章讲过,数字自带复印机):
u32、f64都会比大小、能复印 → 能填String不会比大小(得先比长度)、不能复印 → 不能填
largest<T: PartialOrd + Copy> 读作:"泛型 T,要求会比大小、能复印。" 这样编译器就放心了:函数里 number > best(需要比大小)和 numbers[0](需要复印)都干得成。
现在这份 largest 是"万能最高分"了:整数分数、小数分数,一套代码通吃。
小贴士
Option<T>、Vec<T>、HashMap<K, V>——第 5 章和第 7 章的老朋友,原来它们身上的尖括号就是泛型!当时我们说"T 是占位符,装什么都行",今天这个占位符终于有了名字。所有的坑,原来都是同一张图纸。
步骤四:平均分与中位数
最高分有了,再来两个统计。在 lib.rs 里继续加:
rust
pub fn average(numbers: &[u32]) -> f64 {
if numbers.is_empty() {
return 0.0;
}
let mut total = 0;
for &number in numbers.iter() {
total += number;
}
total as f64 / numbers.len() as f64
}
pub fn median(numbers: &[u32]) -> f64 {
if numbers.is_empty() {
return 0.0;
}
let mut sorted = numbers.to_vec();
sorted.sort();
let length = sorted.len();
if length % 2 == 1 {
sorted[length / 2] as f64
} else {
(sorted[length / 2 - 1] + sorted[length / 2]) as f64 / 2.0
}
}四个新姿势:
as 转换。total as f64 是"把 total 变成 f64"。平均分是 313 分 4 人 = 78.25,带着小数,得用 f64(能装小数的类型)。as 就是"变身"——数字之间互变。注意:变身是复印件,不改变原来的值。
to_vec() 和 sort()。numbers.to_vec() 把借来的切片复印一份成自己的 Vec(不复印的话,sort 会把别人的数据弄乱);sorted.sort() 从小到大排序。排好序,中位数就好找了。
% 是"求余数"。length % 2 == 1 是"长度除以 2 余 1",也就是奇数。小学就学过:奇数个人,正中间那个就是中位数(length / 2 号位);偶数个人,取中间两个的平均。
小数运算:(a + b) as f64 / 2.0——注意分母是 2.0 而不是 2!2.0 是小数,小数除以小数,结果才是小数。用 2 的话,整数除法会直接把小数砍掉(7 ÷ 2 = 3,余 1 就扔了)。
小贴士
numbers.to_vec() 要复印一份再排序,是因为"借来的东西不能乱动"(第 3 章)。sort 会打乱顺序——打了借来的,原主人会生气。复印一份,随便怎么排。
如果想偷懒,也可以让函数自己"要一份"(参数改成 &mut [u32] 可变借用)——但那样调用者就得先准备一份可改的。两种都行,我们选了不打扰别人的。
步骤五:众数
"出现次数最多的数"——第 7 章的 HashMap 计数组合拳又出马了:
rust
pub fn mode(numbers: &[u32]) -> Option<u32> {
let mut counts: HashMap<u32, u32> = HashMap::new();
for &number in numbers.iter() {
let count = counts.entry(number).or_insert(0);
*count += 1;
}
let mut best: Option<u32> = None;
let mut best_count = 0;
for (number, count) in counts.iter() {
if *count > 1 && *count > best_count {
best_count = *count;
best = Some(*number);
}
}
best
}第一段是第 7 章的老朋友:entry().or_insert(0) 数每个数字出现几次。这次键是 u32 不是 String——数字是复印件一族,不用 clone,直接放。
第二段是"众数擂台赛":遍历账本,*count > best_count 时换擂主。注意 *count > 1——出现一次的不算众数("众"是"许多人",孤零零一个不算)。
别忘了文件最上面加 use std::collections::HashMap;。
步骤六:主程序
库里的工具齐了,写个主程序用它们。在 src/ 里新建 main.rs:
rust
use std::io;
use game_stats::{average, largest, median, mode, smallest};
fn main() {
println!("欢迎来到游戏得分统计器!");
let scores = read_scores();
println!();
println!("== 得分报告 ==");
println!("参赛人数:{}", scores.len());
match largest(&scores) {
Some(score) => println!("最高分:{}", score),
None => println!("没有分数!"),
}
match smallest(&scores) {
Some(score) => println!("最低分:{}", score),
None => println!("没有分数!"),
}
println!("平均分:{:.1}", average(&scores));
println!("中位数:{:.1}", median(&scores));
match mode(&scores) {
Some(score) => println!("众数:{}", score),
None => println!("没有众数!"),
}
}
fn read_scores() -> Vec<u32> {
let mut scores = Vec::new();
loop {
let input = read_input("输入一个得分(输入\"结束\"完成):");
if input == "结束" {
break;
}
match input.parse() {
Ok(number) => scores.push(number),
Err(_) => println!("要输入数字哦。"),
}
}
scores
}
fn read_input(prompt: &str) -> String {
println!("{}", prompt);
let mut input = String::new();
io::stdin().read_line(&mut input).expect("读取输入失败");
input.trim().to_string()
}两个新知识:
use game_stats::{...} —— 包里有库也有主程序,主程序通过包名(game_stats)用库里的工具(第 6 章学过:use 记短名)。库里的函数都有 pub,所以外面用得了;没有 pub 的内部工具,外面看不见。
{:.1} 是"保留一位小数"。{} 是挖坑(第 1 章),: .1 是"填小数,留一位"。平均分 78.25 打出来就是 78.2,好看多了。
largest 和 smallest 都返回 Option,空列表时 match 的 None 分支会打印"没有分数!"——还记得库里的函数为什么都返回 Option 而不是直接给数字吗?把"可能没有"明明白白地交出来,让调用者自己决定怎么办(第 5 章精神)。
运行,玩一把:
运行结果
text
欢迎来到游戏得分统计器!
输入一个得分(输入"结束"完成):90
输入一个得分(输入"结束"完成):75
输入一个得分(输入"结束"完成):88
输入一个得分(输入"结束"完成):60
输入一个得分(输入"结束"完成):结束
== 得分报告 ==
参赛人数:4
最高分:90
最低分:60
平均分:78.2
中位数:81.5
没有众数!试试分数里有重复的,比如 90, 75, 75, 60——看看众数是不是 75!
9.3 知识深挖
三兄弟的本尊来了。这一节有点烧脑,慢慢读,读完再看一眼 9.2 节的代码,你会发现它们其实一直都在。
9.3.1 泛型:留空位的图纸
泛型就是"给类型留空位"。
rust
pub fn largest<T: PartialOrd + Copy>(numbers: &[T]) -> Option<T> {
// ...
}<T> 是空位声明,T 是占位符。函数像一张图纸:图纸上画着"这里放一个类型,名字叫 T",具体放 u32 还是 f64,由调用的时候决定:
rust
largest(&[90u32, 75, 88]); // T = u32
largest(&[9.5f64, 8.0, 9.9]); // T = f64编译器遇到 largest(&[90u32, ...]),就照着图纸浇筑一份 u32 专用版;遇到 f64,再浇筑一份 f64 专用版。这个"浇筑"过程叫单态化(monomorphization,意思是"变成具体形态")——所以你不用担心泛型拖慢程序,编译时它就变成普通函数了。
泛型不只用于函数。结构体也能留空位:
rust
struct Scoreboard<T> {
scores: Vec<T>,
}我们早就见过很多:Option<T>、Vec<T>、HashMap<K, V>——Rust 标准库一半的功劳都在这一张图纸上。看到尖括号,就是泛型;泛型,就是留空位。
9.3.2 trait:能力证书
泛型留了空位,但不能谁都能填——trait 就是"能力证书",规定"想填这个空位,你得会什么"。
rust
pub fn largest<T: PartialOrd + Copy>(...)
// ^^^^^^^^^^^^^^^^^^^ 两本证书PartialOrd:会比大小(>、<)Copy:能复印
u32 两本证书都有,能填;String 不会比大小、不能复印,不能填。编译器检查证书,不合格的直接拒绝编译——比"运行到一半才发现"强一万倍。
证书还能自己造。比如给分数发评级证书:
rust
pub trait Grade {
fn grade(&self) -> String;
}
impl Grade for u32 {
fn grade(&self) -> String {
if *self >= 90 {
String::from("优秀")
} else if *self >= 80 {
String::from("良好")
} else if *self >= 60 {
String::from("及格")
} else {
String::from("加油")
}
}
}trait Grade 定义了一张证书:"会给自己评级的能力"。impl Grade for u32 是给 u32 发证书:90 以上优秀,80 以上良好,60 以上及格,以下加油。
有了证书,任何 u32 都能评级:
rust
let score: u32 = 95;
println!("{}", score.grade()); // 优秀想在别的程序里用这个能力,记得把 trait 也 use 进来(练习一你会亲手撞到这个细节)。
trait 就是接口:它不关心你是什么类型,只关心你会不会做那件事。第 5 章动物叫声的 speak 其实已经暗示了这条路,只是当时直接写在 impl Animal 里。现在你知道更通用的玩法了:一套能力,多种类型。
9.3.3 生命周期:借条的保质期
三兄弟的老三最抽象,但它解决的是最朴素的担心:借出去的东西,会不会过期?(第 3 章:引用 = 借条。)
看这个工具——"两段文字里,借更长的那段":
rust
pub fn longest_text<'a>(first: &'a str, second: &'a str) -> &'a str {
if first.chars().count() >= second.chars().count() {
first
} else {
second
}
}'a 是生命周期参数(lifetime),念"撇 a"。它回答一个问题:返回的借条,寿命是多久?
<'a> 声明"有一个共同的保质期 a";三个 &'a str 是说"两个输入和返回值,都在这个保质期内有效"。合起来的意思是:
返回的借条,保质期不能超过两个输入里先过期的那个。
为什么需要这行声明?看如果 'a 不存在会怎样:
rust
fn longest_text(first: &str, second: &str) -> &str {
// 返回的 &str 借自 first 还是 second?
// 编译器不知道它有多长寿命,没法保证它不过期
}返回的引用可能借自 first,也可能借自 second——编译器两眼一抹黑,直接拒绝编译。'a 就是告诉编译器:"放心,返回的借条和两个输入共享保质期,不会活过它们。"
悬垂引用(dangling reference)是生命周期要消灭的 bug:借了别人的东西,别人把东西还了/扔了,你还拿着借条用。比如:
rust
let result;
{
let temporary = String::from("临时文字");
result = longest_text(&temporary, "另一个"); // 借了临时文字
} // temporary 在这里被清理了!
println!("{}", result); // 借条过期了!这个代码编译不过——编译器(保安)检查发现 result 的借条活过了 temporary 的寿命,当场拦截:"你这借条过期了!"
小贴士
好消息:日常写代码,大部分时候不用亲手写 'a。Rust 有"借条自动续期"(生命周期省略):编译器能自己推断出寿命时,你就不必写。只有像 longest_text 这种"返回值可能借自哪个参数不清楚"的情况,才需要手动标注。所以生命周期是"麻烦但很少碰到"的知识——碰到了,就是你写函数的地方,需要想清楚借条的保质期。
9.3.4 三兄弟合体
三兄弟在一行代码里见面了:
rust
pub fn largest<T: PartialOrd + Copy>(numbers: &[T]) -> Option<T>- 泛型
<T>:类型留空位 - trait
T: PartialOrd + Copy:空位的要求(会比大小、能复印) - 生命周期:这行没有,因为
largest返回的是复印件(T是 Copy),不是借条——没有借条,就不需要保质期
还记得三兄弟各管什么吗?
| 兄弟 | 管什么 | 像什么 |
|---|---|---|
| 泛型 | 类型的空位 | 图纸 |
| trait | 类型的能力要求 | 能力证书 |
| 生命周期 | 借条的保质期 | 借书证上的日期 |
泛型管类型, trait 管能力,生命周期管借条。 遇到带 <T: Trait> 的签名,就可以按这三个问题拆解:T 是什么?T 要会什么?返回值是借的还是复印的?
9.4 动脑筋练习
三个练习都在 game_stats 项目里做。
练习一:给分数评级
把 9.3.2 节的 Grade trait 和它的 impl 加进 lib.rs(在 use 后面),然后在 main.rs 的报告末尾打印"评级"区:
text
== 评级 ==
1. 90 分 - 优秀
2. 75 分 - 及格提示:别忘了两件事——①在 main.rs 的 use 里加上 Grade(不用 trait,score.grade() 会报错,试试就知道为什么);②遍历 scores 用第 7 章的 iter().enumerate()。
点开看答案
第一步,lib.rs 开头加 trait 和 impl(照抄 9.3.2 节的代码)。
第二步,main.rs 的 use 加上 Grade:
rust
use game_stats::{average, largest, median, mode, smallest, Grade};第三步,报告末尾加评级区:
rust
println!();
println!("== 评级 ==");
for (index, &score) in scores.iter().enumerate() {
println!("{}. {} 分 - {}", index + 1, score, score.grade());
}运行,报告最后多了一排评级。现在试试把 use 里的 Grade 删掉——score.grade() 立刻报错:"找不到 grade 方法"。因为 trait 定义的能力,要先把 trait 请进门才能用(第 6 章私有性精神:门要一路打开)。
练习二:排行榜
加一个 show_ranking 函数:把分数从高到低排好,打印"排行榜":
text
== 排行榜 ==
第 1 名:90 分
第 2 名:88 分提示:和 median 一样先 to_vec() 复印一份再排序,sort() 是从小到大——想反过来,排序后再调用 sorted.reverse()(倒过来)。
点开看答案
在 main.rs 里加函数,并在报告末尾调用:
rust
fn show_ranking(scores: &Vec<u32>) {
let mut sorted = scores.to_vec();
sorted.sort();
sorted.reverse();
println!();
println!("== 排行榜 ==");
for (index, &score) in sorted.iter().enumerate() {
println!("第 {} 名:{} 分", index + 1, score);
}
}调用:
rust
show_ranking(&scores);sort() 排好从小到大的队,reverse() 整个倒过来,就变成从大到小了。复印(to_vec)再排——借来的分数,一个都不动,原报告还是原来的顺序。
练习三:名字大赛
库里的 longest_text 一直没派上用场。写一个 name_contest 函数:输入两位玩家的名字,宣布"名字更长的是 XX"。这是生命周期 'a 的现实版——longest_text 返回的借条,借自你的两个输入,程序结束时它们都还活着,所以绝对安全。
点开看答案
rust
fn name_contest() {
let first = read_input("第一位玩家的名字:");
let second = read_input("第二位玩家的名字:");
let winner = game_stats::longest_text(&first, &second);
println!("名字更长的是:{}", winner);
}在 main 的报告末尾调用:
rust
name_contest();运行:
text
第一位玩家的名字:小螃蟹
第二位玩家的名字:旺财
名字更长的是:小螃蟹小螃蟹三个字,旺财两个字,小螃蟹赢了!看 winner 的类型——&str,一个借条。它借自 first 或 second,而这两个变量在 main 结束前都活着,借条不过期,安全。
试试把 longest_text 改坏:去掉 'a 标注,看看编译器怎么拒绝你——这就是生命周期在工作。
9.5 完整代码清单
项目结构:
text
game_stats/
├── Cargo.toml
└── src/
├── lib.rs (库:工具箱)
└── main.rs (主程序:得分统计器)文件:Cargo.toml
toml
[package]
name = "game_stats"
version = "0.1.0"
edition = "2024"文件:src/lib.rs
rust
use std::collections::HashMap;
pub fn largest<T: PartialOrd + Copy>(numbers: &[T]) -> Option<T> {
if numbers.is_empty() {
return None;
}
let mut best = numbers[0];
for &number in numbers.iter() {
if number > best {
best = number;
}
}
Some(best)
}
pub fn smallest<T: PartialOrd + Copy>(numbers: &[T]) -> Option<T> {
if numbers.is_empty() {
return None;
}
let mut best = numbers[0];
for &number in numbers.iter() {
if number < best {
best = number;
}
}
Some(best)
}
pub fn average(numbers: &[u32]) -> f64 {
if numbers.is_empty() {
return 0.0;
}
let mut total = 0;
for &number in numbers.iter() {
total += number;
}
total as f64 / numbers.len() as f64
}
pub fn median(numbers: &[u32]) -> f64 {
if numbers.is_empty() {
return 0.0;
}
let mut sorted = numbers.to_vec();
sorted.sort();
let length = sorted.len();
if length % 2 == 1 {
sorted[length / 2] as f64
} else {
(sorted[length / 2 - 1] + sorted[length / 2]) as f64 / 2.0
}
}
pub fn mode(numbers: &[u32]) -> Option<u32> {
let mut counts: HashMap<u32, u32> = HashMap::new();
for &number in numbers.iter() {
let count = counts.entry(number).or_insert(0);
*count += 1;
}
let mut best: Option<u32> = None;
let mut best_count = 0;
for (number, count) in counts.iter() {
if *count > 1 && *count > best_count {
best_count = *count;
best = Some(*number);
}
}
best
}
pub fn longest_text<'a>(first: &'a str, second: &'a str) -> &'a str {
if first.chars().count() >= second.chars().count() {
first
} else {
second
}
}文件:src/main.rs
rust
use std::io;
use game_stats::{average, largest, median, mode, smallest};
fn main() {
println!("欢迎来到游戏得分统计器!");
let scores = read_scores();
println!();
println!("== 得分报告 ==");
println!("参赛人数:{}", scores.len());
match largest(&scores) {
Some(score) => println!("最高分:{}", score),
None => println!("没有分数!"),
}
match smallest(&scores) {
Some(score) => println!("最低分:{}", score),
None => println!("没有分数!"),
}
println!("平均分:{:.1}", average(&scores));
println!("中位数:{:.1}", median(&scores));
match mode(&scores) {
Some(score) => println!("众数:{}", score),
None => println!("没有众数!"),
}
}
fn read_scores() -> Vec<u32> {
let mut scores = Vec::new();
loop {
let input = read_input("输入一个得分(输入\"结束\"完成):");
if input == "结束" {
break;
}
match input.parse() {
Ok(number) => scores.push(number),
Err(_) => println!("要输入数字哦。"),
}
}
scores
}
fn read_input(prompt: &str) -> String {
println!("{}", prompt);
let mut input = String::new();
io::stdin().read_line(&mut input).expect("读取输入失败");
input.trim().to_string()
}怎么运行:
bash
cd game_stats
cargo run运行检查单:
输入几个分数,报告能显示参赛人数、最高分、最低分、平均分、中位数
平均分带一位小数(比如 78.2)
分数有重复时,众数能显示;全都不重复时显示"没有众数!"
什么都不输入直接"结束",程序不崩溃,报告显示"没有分数!"
输入 abc,程序提示"要输入数字哦",不崩溃
试试把 largest 签名里的 <T: PartialOrd + Copy> 改成 <T>,看看编译器怎么拒绝你