外观
第 12 章 把玩具分享出去
小螃蟹说
做了这么多章,你的玩具箱已经满满当当了:统计库、收藏册、猜拳、猜数字……这一章,我们学 Cargo 的三样本领,让你的玩具更有条理、跑得更快、还能分享给全世界:
- 工作空间(workspace):把好几个项目装进一个"大工具箱",统一管理
- 发布配置(release profile):给程序开"认真模式",跑得更快
- 发布到 crates.io:把统计库摆上全世界程序员的"玩具超市"货架
第 1 章我们说过,Cargo 是你的小管家;这一章结束,你会发现 Cargo 还能当仓库管理员(工作空间)、赛车调教师(release)、货架老板(crates.io)。这一章可能是全书最"大人"的一章——因为我们要做的,正是真正的程序员每天在做的事。
12.1 预览:我们要做什么
把第 9 章的 game_stats(统计库)整理成一个工作空间,让库和主程序各住一个房间,再由 Cargo 统一照看:
text
toyshop/ ← 大工具箱(工作空间)
├── Cargo.toml ← 大箱子的清单:里面有几个成员
├── game_stats/ ← 成员一:统计库(第 9 章搬来)
│ ├── Cargo.toml
│ └── src/
│ └── lib.rs
└── score_report/ ← 成员二:得分报告程序(第 9 章搬来)
├── Cargo.toml
└── src/
└── main.rs然后在工作空间里体验三样本领:一个命令跑成员、开"认真模式"编译、给库写文档。
这一章你会学到:
| 知识 | 是什么 | 会用在哪儿 |
|---|---|---|
| 工作空间 | 装多个项目的大工具箱 | 库和程序住在一起,统一管理 |
path 依赖 | "就用旁边这个箱子" | score_report 用 game_stats |
| 发布配置 | 认真模式,程序跑得更快 | cargo build --release |
文档注释 /// | 给别人看的说明书 | cargo doc 生成文档网页 |
| 发布 | 把库摆上 crates.io 货架 | 分享给全世界 |
12.2 动手做
步骤一:建大工具箱(工作空间)
回到放项目的目录,建一个叫 toyshop(玩具工坊)的文件夹,里面只放一个 Cargo.toml:
bash
mkdir toyshop
cd toyshop用 VS Code 新建文件 Cargo.toml,写:
toml
[workspace]
members = ["game_stats", "score_report"]
resolver = "3"这就是工作空间(workspace)的户口本:[workspace] 声明"这是一个大工具箱",members 列出里面装的成员(两个项目),resolver = "3" 是让 Cargo 用最新的依赖解析规则(照抄即可)。
再建两个成员文件夹,每个里面放自己的 Cargo.toml:
game_stats/Cargo.toml
toml
[package]
name = "game_stats"
version = "0.1.0"
edition = "2024"score_report/Cargo.toml
toml
[package]
name = "score_report"
version = "0.1.0"
edition = "2024"
[dependencies]
game_stats = { path = "../game_stats" }注意 score_report 的依赖写法:{ path = "../game_stats" } 是"路径依赖"——"就用旁边那个文件夹里的库,别去网上下载"。这就像邻居间借酱油:不用去超市(crates.io),隔壁就有。
工作空间 vs 单个项目
以前我们一个项目一个包(一个 Cargo.toml);工作空间是一个户口本管好几个包。什么时候用?一个库 + 好几个用它的程序(比如统计库 + 报告程序 + 排行榜程序)——都放在一个大工具箱里,Cargo 一次编译全部,一个 Cargo.lock 统一锁版本,谁都不会打架。
步骤二:把统计库搬进成员一
把第 9 章的 src/lib.rs 整个复制到 toyshop/game_stats/src/lib.rs(在 VS Code 里复制粘贴,或者直接照着第 9 章 9.5 节敲)。
这一章我们的 lib.rs 稍有升级——给函数加上文档注释(///)。先看两个例子:
rust
/// 找出切片里最大的数。
///
/// 空切片返回 `None`。
pub fn largest<T: PartialOrd + Copy>(numbers: &[T]) -> Option<T> {
// ……和第 9 章一模一样……
}
/// 计算平均分。
///
/// 空切片返回 `0.0`。
pub fn average(numbers: &[u32]) -> f64 {
// ……和第 9 章一模一样……
}///(三个斜杠)是文档注释,和第 1 章的 // 注释是亲戚,但它有一个特殊使命:它会变成说明书——cargo doc 会把 /// 后面的文字收集起来,生成漂亮的文档网页。// 写给看代码的人,/// 写给用代码的人。
剩下的函数(smallest、median、mode、longest_text)也照葫芦画瓢,各写一段文档注释,说明"这个函数干什么、空列表会怎样"。
文档注释的规矩
看例子里的空行:第一行是"一句话介绍",空一行后是"细节说明"(比如边界情况)。读者扫一眼第一行就知道这函数是干嘛的,细节给想深究的人。说明书要让人 3 秒看懂,这是写文档的黄金标准。
步骤三:把主程序搬进成员二
把第 9 章的 src/main.rs 复制到 toyshop/score_report/src/main.rs。一行都不用改——它开头那句 use game_stats::{average, largest, median, mode, smallest}; 会自动去 game_stats 成员里找(因为 score_report 的 Cargo.toml 里写了路径依赖)。
现在大工具箱的完整模样:
text
toyshop/
├── Cargo.toml
├── game_stats/
│ ├── Cargo.toml
│ └── src/
│ └── lib.rs
└── score_report/
├── Cargo.toml
└── src/
└── main.rs步骤四:一个命令,运行成员
在大工具箱的门口(toyshop 文件夹)敲:
bash
cargo run -p score_report-p 是 --package 的缩写:"package,给我跑这个成员"。Cargo 会在整个工作空间里找到 score_report,编译它(顺便编译它依赖的 game_stats),然后运行:
运行结果
text
欢迎来到游戏得分统计器!
输入一个得分(输入"结束"完成):90
输入一个得分(输入"结束"完成):75
输入一个得分(输入"结束"完成):75
输入一个得分(输入"结束"完成):60
输入一个得分(输入"结束"完成):结束
== 得分报告 ==
参赛人数:4
最高分:90
最低分:60
平均分:75.0
中位数:75.0
众数:75在第 9 章,这需要"进到 game_stats 文件夹里 cargo run";现在站在大工具箱门口喊 -p,一个命令搞定。
小贴士
在 toyshop 门口跑 cargo build(不带 -p),Cargo 会把所有成员一起编译。注意看门口多了一个 Cargo.lock 和 target/ 文件夹——锁文件是整个工作空间共用的,所有成员用同一套依赖版本,不会出现"这个成员用 1.0,那个成员用 2.0"的混乱。
步骤五:认真模式(cargo build --release)
我们的程序有两种"跑法":
- 开发模式(默认):编译快,适合写代码时反复试
- 发布模式(release,读作"瑞丽斯"):编译慢一点,但程序跑得飞快
给程序上"认真模式":
bash
cargo build --release运行结果
text
Compiling game_stats v0.1.0
Compiling score_report v0.1.0
Finished `release` profile [optimized] target(s) in 1.56s注意最后一行:和开发模式的 [unoptimized + debuginfo] 不同,这里写的是 [optimized]——"优化过了"。Cargo 花更多时间调校代码(编译器会做几百种优化:把重复计算合并、把慢操作换快操作……),换来程序运行时的飞速。
在 score_report/target/release/ 里找到 score_report.exe(或可执行文件),直接双击或运行它——不用经过 Cargo,程序独立运行。将来你要把程序给别人玩,给的就是这个文件。
什么时候用 release?
写代码、试功能 → 开发模式(cargo run);做好了、要给别人用 → 认真模式(cargo build --release)。第 9 章的统计库测试 0.00 秒就跑完,release 的优化不明显;等你以后写大程序(第 13 章的搜索器!),release 和 dev 的差别会大到肉眼可见。
步骤六:给玩具写说明书(cargo doc)
还记得步骤二的 /// 文档注释吗?现在让它们变成文档网页:
bash
cargo doc -p game_stats运行结果
text
Documenting game_stats v0.1.0
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.88s
Generated target\doc\game_stats\index.htmlCargo 把 /// 注释收集起来,生成了 target/doc/game_stats/index.html。在 VS Code 里对着它右键 → 在浏览器打开,你会看到一本精美的说明书:每个函数的名字、说明、边界情况,一目了然。
运行结果
text
Crate game_stats
函数列表:average · largest · longest_text · median · mode · smallest
largest
找出切片里最大的数。
空切片返回 None。这本说明书是自动生成的——我们写代码时顺手写的 ///,变成了给全世界用户看的文档。好的文档,不是写出来的,是注释里长出来的。
12.3 知识深挖
12.3.1 工作空间:大工具箱
工作空间(workspace)把多个包收进一个户口本:
toml
[workspace]
members = ["game_stats", "score_report"]
resolver = "3"规则和要点:
- 每个成员有自己的
Cargo.toml和src/,完全独立 - 成员之间用
path依赖互借:game_stats = { path = "../game_stats" } - 在工作空间根目录跑
cargo build/cargo test,会对所有成员生效;只想对一个成员,加-p 成员名 - 整个工作空间共用一个
Cargo.lock和一个target/目录——依赖版本统一,编译缓存共享
什么时候用工作空间?最简单的判断:两个项目要互相配合(库 + 用它的程序),或者你想一个命令管所有项目。第 13 章的收尾大项目,我们还会再见它一面。
12.3.2 发布配置:认真模式
Cargo 有两种内置"配置"(profile,读作"普罗法尔"):
| 开发模式 dev | 发布模式 release | |
|---|---|---|
| 命令 | cargo run / cargo build | cargo build --release |
| 编译速度 | 快 | 慢一点 |
| 程序速度 | 一般 | 飞快 |
| 调试信息 | 有 | 少 |
| 什么时候用 | 写代码时 | 做好了给别人用 |
还能自定义配置文件。比如想在发布模式里再压榨一档性能,在 toyshop/Cargo.toml 里加:
toml
[profile.release]
opt-level = 3opt-level = 3 是"优化等级拉满"。这种调校以后再说,现在知道"配置 = 模式的调校旋钮"就够了。
12.3.3 文档注释:自动生成说明书
三种注释三兄弟:
rust
// 普通注释:写给读代码的人(第 1 章)
/// 文档注释:写给用代码的人,会进说明书
//! 文档注释:写在文件开头,介绍整个 crate/// 写在函数/结构体上面,cargo doc 会收集它们生成网页。文档里还能写示例代码:
rust
/// 计算平均分。
///
/// # 示例
///
/// ```rust
/// use game_stats::average;
///
/// assert_eq!(average(&[90, 75, 75, 60]), 75.0);
/// ```
pub fn average(numbers: &[u32]) -> f64 {# 示例 是文档的"示例区",里面的代码块会被 cargo test 自动运行——这叫 doctest(文档测试):说明书里的例子不是摆设,每一条都经过检验,骗不了人。练习一你要亲手写一个。
12.3.4 发布到 crates.io:摆上全世界的货架
工具箱里的玩具,现在只有自己玩。想分享给全世界?crates.io 就是程序员的"玩具超市"。
发布要过两道关:
第一关,给玩具贴标签。game_stats/Cargo.toml 里必须有这两样:
toml
description = "游戏得分统计小工具:最高分、最低分、平均分、中位数、众数"
license = "MIT OR Apache-2.0"description 是超市货架上的一句话介绍;license 是使用许可(告诉别人"你可以怎么用我的代码")。MIT OR Apache-2.0 是 Rust 世界最流行的两个许可——意思是"随便用,但别拿我名字担保"。没有标签,超市不让上架(Cargo 会警告)。
第二关,注册 crates.io 账号。这是可选的、需要大人帮忙的一步:注册账号、拿到钥匙(cargo login)、然后 cargo publish。
不想注册账号?没关系,我们可以做发布彩排:
bash
cargo package -p game_stats --allow-dirty运行结果
text
Packaging game_stats v0.1.0
Packaged 4 files, 3.4KiB (1.5KiB compressed)
Verifying game_stats v0.1.0
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.29scargo package 会把库打包(像把玩具装进快递盒),然后验证盒子里一切完好。这是 cargo publish 的"彩排"——不真的发货,但确认快递盒是好的。等你以后注册了账号,把 package 换成 publish,玩具就真的上架了。
发布是"泼出去的水"
cargo publish 发布后,永远不能撤回或改名(只能发新版本)。所以发布前一定要 cargo package 彩排、cargo test 全绿——上架之前,把玩具检查仔细。
12.3.5 cargo install:安装别人的玩具
有发布就有下载。别人的玩具,怎么装到自己的电脑?
bash
cargo install 玩具名cargo install 会从 crates.io 下载、编译、安装程序(不是库)。装好的程序放进一个 bin 文件夹,以后在终端里直接喊它的名字就能跑——就像安装了一个新命令。
你其实早就"安装"过类似的东西了:第 0 章装 Rust 时,rustup 给你装了 cargo;以后 cargo install 能给你装几百种新工具。Rust 的生态就是这样:发布,安装,再发布,再安装——玩具越攒越多,全世界一起玩。
12.4 动脑筋练习
练习一:给说明书加示例(doctest)
给 game_stats 库的 largest 函数,在文档注释里加一个"示例区"(# 示例 + 代码块),然后跑 cargo test -p game_stats,看看多出来一个 doctest。
提示:照抄 12.3.3 节 average 的示例写法,把函数名和断言换成 largest 的。注意示例里 use game_stats::largest;——doctest 是从"用户的角度"跑的,所以要自己进口。
点开看答案
largest 的文档注释改成:
rust
/// 找出切片里最大的数。
///
/// 空切片返回 `None`。
///
/// # 示例
///
/// ```rust
/// use game_stats::largest;
///
/// assert_eq!(largest(&[3, 7, 2]), Some(7));
/// ```
pub fn largest<T: PartialOrd + Copy>(numbers: &[T]) -> Option<T> {跑 cargo test -p game_stats,输出的最下面会出现:
text
Doc-tests game_stats
running 1 test
test result: ok. 1 passed示例被当作测试跑了一遍,通过!现在试着把示例里的 Some(7) 改成 Some(8) 再跑——doctest 立刻变红。说明书里的每个例子都是活的,这不是摆设,是考试卷。
练习二:把测试搬进工作空间
第 10 章,我们给 game_stats 写过 10 个单元测试 + 2 个集成测试。把它们搬进工作空间的 game_stats 成员:
- 把
mod tests(单元测试)加在lib.rs末尾 - 新建
game_stats/tests/stats.rs,放集成测试 - 在工具箱门口跑
cargo test,看看会发生什么
点开看答案
第一步,把第 10 章 lib.rs 末尾的 #[cfg(test)] mod tests { ... } 整个复制到工作空间 game_stats/src/lib.rs 的末尾。
第二步,新建 game_stats/tests/stats.rs,把第 10 章的集成测试内容放进去:
rust
use game_stats::{average, largest, median, mode, smallest};
#[test]
fn largest_works_from_outside() {
assert_eq!(largest(&[9, 5, 7]), Some(9));
}
#[test]
fn full_report_pipeline() {
let scores = vec![90, 75, 75, 60];
assert_eq!(largest(&scores), Some(90));
assert_eq!(smallest(&scores), Some(60));
assert_eq!(average(&scores), 75.0);
assert_eq!(median(&scores), 75.0);
assert_eq!(mode(&scores), Some(75));
}第三步,在 toyshop 门口跑 cargo test,输出分几波:
text
Running unittests src\lib.rs → 10 passed (game_stats 的单元测试)
Running tests\stats.rs → 2 passed (game_stats 的集成测试)
Running unittests src\main.rs → 0 passed (score_report 没有测试)一个命令,整个工作空间的所有测试一起跑——这就是大工具箱的威力:谁家的检查员,都听同一个号令。
练习三:发布彩排
给 game_stats/Cargo.toml 补上"标签"(description 和 license),然后跑 cargo package -p game_stats --allow-dirty 做发布彩排。
彩排成功后,再看两件事:①打包警告里还提到缺什么(那些是可选标签,不是必须的);②打开 target/package/ 文件夹,看看"快递盒"里装了什么。
点开看答案
第一步,game_stats/Cargo.toml 改成:
toml
[package]
name = "game_stats"
version = "0.1.0"
edition = "2024"
description = "游戏得分统计小工具:最高分、最低分、平均分、中位数、众数"
license = "MIT OR Apache-2.0"第二步,跑彩排:
bash
cargo package -p game_stats --allow-dirtytext
Packaging game_stats v0.1.0
Packaged 4 files, 3.4KiB (1.5KiB compressed)
Verifying game_stats v0.1.0
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.29s打包成功!还会有一个温柔的提醒:"没有 documentation、homepage、repository"——这是可选标签(你的文档网址、主页、仓库地址),不填也能发布,填了货架更气派。
第三步,看看 target/package/game_stats-0.1.0/ 文件夹——Cargo.toml、src/lib.rs、Cargo.lock……这就是快递盒里的全部家当。别人的 cargo add game_stats,装的就是这个盒子。 你的玩具,已经打包完毕,就差上架了!
12.5 完整代码清单
项目结构:
text
toyshop/
├── Cargo.toml
├── game_stats/
│ ├── Cargo.toml
│ ├── src/
│ │ └── lib.rs
│ └── tests/
│ └── stats.rs
└── score_report/
├── Cargo.toml
└── src/
└── main.rs文件:toyshop/Cargo.toml
toml
[workspace]
members = ["game_stats", "score_report"]
resolver = "3"文件:game_stats/Cargo.toml
toml
[package]
name = "game_stats"
version = "0.1.0"
edition = "2024"
description = "游戏得分统计小工具:最高分、最低分、平均分、中位数、众数"
license = "MIT OR Apache-2.0"文件:game_stats/src/lib.rs
rust
use std::collections::HashMap;
/// 找出切片里最大的数。
///
/// 空切片返回 `None`。
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)
}
/// 找出切片里最小的数。
///
/// 空切片返回 `None`。
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)
}
/// 计算平均分。
///
/// 空切片返回 `0.0`。
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
}
/// 计算中位数(排队后正中间的那个数)。
///
/// 空切片返回 `0.0`。
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
}
}
/// 找出众数(出现次数最多的数)。
///
/// 全是独苗时返回 `None`。
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
}
}文件:score_report/Cargo.toml
toml
[package]
name = "score_report"
version = "0.1.0"
edition = "2024"
[dependencies]
game_stats = { path = "../game_stats" }文件:score_report/src/main.rs(和第 9 章一模一样)
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 toyshop
cargo run -p score_report运行检查单:
cargo run -p score_report 能跑得分报告程序
在 toyshop 门口 cargo build 一次编译两个成员cargo build --release 输出带 [optimized]cargo doc -p game_stats 生成文档网页,函数说明都看得见cargo package -p game_stats --allow-dirty 打包成功,没有"必须"警告
把 score_report 的依赖改成从网上找(删掉 path =),看 Cargo 怎么报错