练习与自测
本章练习共 11 题,答案折叠在每题下方。建议先自己写、编译通过后再展开答案;难度标记:★☆☆ 基础 / ★★☆ 综合 / ★★★ 挑战。
练习 1:组织 bin + lib 的 workspace
难度:★★☆ 要求:搭一个 bin + lib 的 workspace,产出两个 crate:库 corelib(提供一个 word_count 函数)与二进制 wc-cli(CLI 调用它并打印结果)。给出完整目录树、每个文件的内容,并说明 Cargo.lock 会出现在哪里。 提示:根 Cargo.toml 只声明 [workspace]、members、resolver;成员之间用 path 依赖。
参考答案(先自己写再看)
参考答案(先自己写再看)
text
wc-workspace/
├── Cargo.toml # 只做 workspace 声明
├── Cargo.lock # workspace 只有一份,位于根目录
├── crates/
│ ├── corelib/
│ │ ├── Cargo.toml
│ │ └── src/
│ │ └── lib.rs # 全部逻辑 + 单元测试
│ └── wc-cli/
│ ├── Cargo.toml
│ └── src/
│ └── main.rs # 只有胶水代码
└── tests/
└── cli.rs # 可选:端到端测试(放在 wc-cli 下更合适)根 Cargo.toml:
toml
[workspace]
members = ["crates/corelib", "crates/wc-cli"]
resolver = "3" # edition 2024 对应 resolver 3
[workspace.package]
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
[workspace.lints.rust]
unsafe_op_in_unsafe_fn = "deny"
missing_docs = "warn"
[workspace.lints.clippy]
all = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }crates/corelib/Cargo.toml:
toml
[package]
name = "corelib"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = truecrates/corelib/src/lib.rs:
rust
//! 文本统计库:只提供一个纯函数与它的错误类型。
/// 统计错误。
#[derive(Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum CountError {
/// 输入为空(空白字符也算空)。
Empty,
}
impl std::fmt::Display for CountError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CountError::Empty => write!(f, "输入没有任何可统计的词"),
}
}
}
impl std::error::Error for CountError {}
/// 统计文本中的词数(按空白切分)。
///
/// # Errors
///
/// 当文本为空或全是空白时返回 [`CountError::Empty`]。
///
/// # Examples
///
/// ```
/// use corelib::word_count;
/// assert_eq!(word_count("hello rust world")?, 3);
/// # Ok::<(), corelib::CountError>(())
/// ```
pub fn word_count(text: &str) -> Result<usize, CountError> {
let n = text.split_whitespace().count();
if n == 0 { Err(CountError::Empty) } else { Ok(n) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn counts_words() {
assert_eq!(word_count("a b c"), Ok(3));
}
#[test]
fn rejects_blank() {
assert_eq!(word_count(" "), Err(CountError::Empty));
}
}crates/wc-cli/Cargo.toml:
toml
[package]
name = "wc-cli"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
# 同一个 workspace 内用 path 依赖;版本号仍然要写,发布时才正确
corelib = { path = "../corelib", version = "0.1.0" }
[lints]
workspace = truecrates/wc-cli/src/main.rs:
rust
//! 命令行入口:只做「读参数 → 调库 → 决定退出码」。
use std::process::ExitCode;
/// 应用层错误:区分用法错误与运行错误(对应不同退出码)。
enum AppError {
/// 参数用法不对。
Usage(String),
/// 库调用失败。
Lib(corelib::CountError),
}
fn main() -> ExitCode {
let text = match std::env::args().nth(1) {
Some(t) => t,
None => {
eprintln!("用法: wc-cli <TEXT>");
return ExitCode::from(2);
}
};
match run(&text) {
Ok(n) => {
println!("{n}");
ExitCode::SUCCESS
}
Err(AppError::Usage(m)) => {
eprintln!("用法错误: {m}");
ExitCode::from(2)
}
Err(AppError::Lib(e)) => {
eprintln!("错误: {e}");
ExitCode::FAILURE
}
}
}
/// 把库错误映射成应用错误。
fn run(text: &str) -> Result<usize, AppError> {
corelib::word_count(text).map_err(AppError::Lib)
}运行:
powershell
cargo run -p wc-cli -- "hello rust world" # 输出 3
cargo test --workspace # 单元测试 + doctest 全跑要点解析:
Cargo.lock只有一份,在 workspace 根目录——成员共享同一张解析图,因此 feature 统一也是全图范围的。resolver = "3"与 edition 2024 配套:开启 MSRV-aware 解析(Cargo 1.84+)。[workspace.lints]+ 成员[lints] workspace = true是 1.74+ 的集中 lint 配置方式,比每个 crate 顶部写#![warn(...)]更一致。- path 依赖同时写
version是发布型 workspace 的惯例:本地用路径,发布时 crates.io 用版本号。 - 逻辑全在
lib.rs,main.rs因此可以短到只处理退出码——这就是 bin/lib 分离的收益。
练习 2:设计多 feature 的可选依赖
难度:★★☆ 要求:为一个小库 textkit 设计 feature:默认不引入任何重依赖;提供 json(启用 serde 支持)、unicode(启用 unicode-segmentation 做字素簇)、full(两者都开)。写出完整 [features] 与 [dependencies],并解释为什么 default 里不该放 json。 提示:用 dep: 语法;留意 default = [] 是合法且推荐的。
参考答案(先自己写再看)
参考答案(先自己写再看)
toml
[package]
name = "textkit"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
[dependencies]
# 可选依赖:默认完全不编译,只有对应 feature 打开时才进依赖树
serde = { version = "1", features = ["derive"], optional = true }
serde_json = { version = "1", optional = true }
unicode-segmentation = { version = "1", optional = true }
[features]
# 默认什么都不开:让「cargo add textkit」的用户得到最轻的依赖树
default = []
# 用 dep: 精确指向「启用这个可选依赖」本身,
# 从而不额外生成名为 serde / serde_json / unicode-segmentation 的隐式 feature
json = ["dep:serde", "dep:serde_json"]
unicode = ["dep:unicode-segmentation"]
# 组合 feature:纯加法,用户不需要「关掉」任何东西
full = ["json", "unicode"]
# 可选:只启用某人依赖的某个子 feature(弱依赖),
# 语义是「如果 serde 已经被别的 feature 启用,则顺带打开它的 rc」
serde-rc = ["serde?/rc"]对应源码里的开关:
rust
//! textkit:feature 均为加法。
/// 按空白切分的词数(永远可用)。
#[must_use]
pub fn words(s: &str) -> usize {
s.split_whitespace().count()
}
/// 字素簇数量:只有 unicode feature 打开时才存在。
#[cfg(feature = "unicode")]
#[must_use]
pub fn graphemes(s: &str) -> usize {
unicode_segmentation::UnicodeSegmentation::graphemes(s, true).count()
}
/// JSON 往返:只有 json feature 打开时才存在。
#[cfg(feature = "json")]
pub fn roundtrip<T>(v: &T) -> Result<T, serde_json::Error>
where
T: serde::Serialize + serde::de::DeserializeOwned,
{
serde_json::from_str(&serde_json::to_string(v)?)
}
#[cfg(test)]
mod tests {
#[test]
fn words_works_without_features() {
assert_eq!(super::words("a b"), 2);
}
}验证命令:
powershell
cargo build --no-default-features # 必须能编过(零可选能力)
cargo build --no-default-features --features json
cargo build --all-features
cargo hack check --feature-powerset --depth 2要点解析:
default = []是刻意的设计决策:json会带进serde+serde_json,把编译时间与体积负担强加给所有用户,而多数用户并不需要。dep:让「依赖名」与「feature 名」解耦:将来把serde_json换成别的实现,jsonfeature 名不用变(下游零影响)。代价是不能再靠隐式同名 feature,需要时显式补一个别名。full只是组合,不引入新代码——这就是「加法」的字面含义:任何组合都是可用能力的并集。- 若
default里放了json,用户想瘦身只能写--no-default-features,而一旦另一个依赖启用了textkit/json,feature 统一会让它又回来——这正是「feature 必须是『加法』」强调的陷阱。
练习 3:用 clap 做参数解析与校验
难度:★★☆ 要求:用 clap 的 derive 风格实现一个带子命令的 CLI notes:notes add <TEXT>(添加一条笔记到内存并打印序号)、notes list [--limit N](列出,--limit 默认 10,范围 1..=100)、notes rm <INDEX>(删除)。要求:非法 index 给出友好错误、--verbose 是全局参数。给出完整可编译代码。 提示:Cli::parse() 自动生成 --help/--version;跨字段与越界校验要自己写。
参考答案(先自己写再看)
参考答案(先自己写再看)
Cargo.toml:
toml
[package]
name = "notes"
version = "0.1.0"
edition = "2024"
[dependencies]
clap = { version = "4", features = ["derive"] }src/main.rs:
rust
use clap::{Parser, Subcommand};
use std::process::ExitCode;
/// 一个内存版笔记工具(演示 clap derive 风格)。
#[derive(Parser, Debug)]
#[command(name = "notes", version, about = "内存笔记", long_about = None)]
struct Cli {
/// 全局:打印更多信息(放在子命令前后都生效)。
#[arg(short, long, global = true)]
verbose: bool,
/// 子命令。
#[command(subcommand)]
command: Command,
}
/// 可用子命令。
#[derive(Subcommand, Debug)]
enum Command {
/// 追加一条笔记。
Add {
/// 笔记内容。
text: String,
},
/// 列出笔记。
List {
/// 最多显示多少条,1..=100。
#[arg(long, default_value_t = 10, value_parser = clap::value_parser!(u8).range(1..=100))]
limit: u8,
},
/// 删除指定序号的笔记(序号从 1 开始)。
Rm {
/// 要删除的序号。
index: usize,
},
}
/// 内存存储。真实项目里会落盘或走数据库。
#[derive(Default)]
struct Store {
items: Vec<String>,
}
impl Store {
/// 追加并返回它的序号(从 1 起)。
fn add(&mut self, text: String) -> usize {
self.items.push(text);
self.items.len()
}
/// 按 1 基序号删除;越界返回错误信息。
fn remove(&mut self, index: usize) -> Result<String, String> {
if index == 0 || index > self.items.len() {
return Err(format!(
"序号 {index} 越界,当前共有 {} 条(合法范围 1..={})",
self.items.len(),
self.items.len()
));
}
Ok(self.items.remove(index - 1))
}
/// 取前 limit 条。
fn list(&self, limit: u8) -> impl Iterator<Item = (usize, &str)> {
self.items
.iter()
.enumerate()
.take(usize::from(limit))
.map(|(i, s)| (i + 1, s.as_str()))
}
}
fn main() -> ExitCode {
let cli = Cli::parse();
let mut store = Store::default();
// 演示用:预置几条数据,让 list/rm 立刻有东西可操作
store.add("买牛奶".to_owned());
store.add("写周报".to_owned());
store.add("读 Rust 文档".to_owned());
match cli.command {
Command::Add { text } => {
let n = store.add(text);
println!("已添加第 {n} 条");
}
Command::List { limit } => {
if cli.verbose {
eprintln!("verbose: 共 {} 条,本次显示不超过 {limit} 条", store.items.len());
}
for (i, s) in store.list(limit) {
println!("{i}. {s}");
}
}
Command::Rm { index } => match store.remove(index) {
Ok(removed) => println!("已删除: {removed}"),
Err(msg) => {
eprintln!("错误: {msg}");
return ExitCode::from(2); // 用法/输入错误 → 2
}
},
}
ExitCode::SUCCESS
}运行示例:
powershell
cargo run -- add "第 4 条" # 已添加第 4 条
cargo run -- list --limit 2 # 1. 买牛奶 / 2. 写周报
cargo run -- rm 99 # 错误: 序号 99 越界 ... → 退出码 2
cargo run -- list --limit 0 # clap 直接拒绝:0 is not in 1..=100要点解析:
Cli::parse()会在参数非法时自己打印友好错误并以退出码 2 结束,所以我们只需要处理「语义越界」这类 clap 管不了的校验。value_parser!(u8).range(1..=100)把范围约束交给 clap,错误信息比手写校验更规范。global = true让--verbose在notes --verbose list和notes list --verbose下都可用。impl Iterator<Item = (usize, &str)>作为返回值:不暴露内部Vec,且 2024 下自动捕获&self的生命周期(无需手写+ '_)。usage类的错误走ExitCode::from(2),与 clap 的约定保持一致,脚本可据此判断「重试无意义」。
练习 4:serde 的字段重命名与默认值
难度:★☆☆ 要求:定义 struct Task { id: u64, title: String, done: bool, tags: Vec<String> },用 serde 实现:序列化时 JSON 用 camelCase;tags 为空时不输出该键;反序列化时缺少 done 默认 false。写一段 main 演示「结构体 → JSON 字符串 → 结构体」往返,并演示用 serde_json::Value 读取一个未知字段。 提示:rename_all、skip_serializing_if、default。
参考答案(先自己写再看)
参考答案(先自己写再看)
toml
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"rust
use serde::{Deserialize, Serialize};
/// 一条待办。
#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Task {
/// 任务 ID。
pub id: u64,
/// 标题。
pub title: String,
/// 是否完成:JSON 里缺失时默认 false,兼容老版本客户端。
#[serde(default)]
pub done: bool,
/// 标签:为空时整个键不出现,让 JSON 更干净。
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
}
fn main() -> Result<(), serde_json::Error> {
let t = Task {
id: 1,
title: "写文档".to_owned(),
done: false,
tags: vec![],
};
let s = serde_json::to_string(&t)?;
println!("{s}");
// 输出:{"id":1,"title":"写文档","done":false} —— tags 与 camelCase 都生效了
// 往返:少一个 tags 键也能解析回来(因为 default)
let back: Task = serde_json::from_str(&s)?;
assert_eq!(back, t);
// 再试:连 done 也缺失
let loose: Task = serde_json::from_str(r#"{"id":2,"title":"x"}"#)?;
assert!(!loose.done);
// 动态读取一个我们不认识的字段
let raw: serde_json::Value = serde_json::from_str(r#"{"id":3,"title":"y","priority":7}"#)?;
println!("priority = {}", raw["priority"].as_i64().unwrap_or(0)); // 7
println!("缺失字段安全取值 = {}", raw["nope"].as_str().unwrap_or("<无>"));
Ok(())
}要点解析:
rename_all = "camelCase"把 Rust 的snake_case字段名整体映射,避免逐个rename。skip_serializing_if = "Vec::is_empty"的字符串必须是指向函数的路径,签名fn(&Vec<String>) -> bool。#[serde(default)]只在反序列化时起作用(配合Default::default()),这正是「向前/向后兼容」的关键手段:老客户端不发这个字段,新代码也能解析。serde_json::Value是动态类型,字段名写错只有运行期才会发现(返回Null)。所以Value适合「读写少量未知字段」,不适合当主要数据模型。- 注意
unwrap_or在Value上的用法:Value::as_i64()返回Option,缺失与类型不符都返回None。
练习 5:实现 FromStr 与 Display 及 doctest
难度:★★☆ 要求:为 struct Duration(pub u64)(毫秒)设计 API:实现 FromStr(接受 "1500ms"、"2s")、Display(输出 1.5s 风格)、From<u64>,并写一个带 # Examples 的文档注释(会被 doctest 执行)。另外提供一个返回 impl Iterator<Item = ...> 的方法把长时长拆成「时/分/秒」三段。 提示:impl Iterator 的方法若借用 self,注意生命周期标注在 2024 下的默认捕获行为。
参考答案(先自己写再看)
参考答案(先自己写再看)
rust
use std::fmt;
use std::str::FromStr;
/// 时长(内部单位:毫秒)。
///
/// # Examples
///
/// ```
/// # use std::str::FromStr;
/// use mylib::Duration;
/// let d = Duration::from_str("1500ms")?;
/// assert_eq!(d.to_string(), "1.5s");
/// assert_eq!(Duration::from(2000u64).to_string(), "2s");
/// # Ok::<(), String>(())
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Duration(u64);
impl Duration {
/// 毫秒数。
#[must_use]
pub const fn as_millis(self) -> u64 {
self.0
}
/// 把时长拆成「时、分、秒」三段(各自可能为 0)。
///
/// 返回迭代器而不是 `Vec`:调用方可以 `.take(2)`、`.sum()`,
/// 且未来换内部实现不算破坏性变更。
#[must_use]
pub fn parts(self) -> impl Iterator<Item = u64> {
let total = self.0 / 1000;
[total / 3600, (total % 3600) / 60, total % 60].into_iter()
}
}
impl From<u64> for Duration {
fn from(ms: u64) -> Self {
Self(ms)
}
}
impl FromStr for Duration {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (num, unit) = if let Some(n) = s.strip_suffix("ms") {
(n, "ms")
} else if let Some(n) = s.strip_suffix('s') {
(n, "s")
} else {
return Err(format!("缺少单位(支持 ms / s),收到 `{s}`"));
};
let v: f64 = num
.trim()
.parse()
.map_err(|_| format!("`{num}` 不是合法数字"))?;
let ms = match unit {
"ms" => v,
_ => v * 1000.0,
};
if ms < 0.0 || !ms.is_finite() {
return Err(format!("时长必须是非负有限值,收到 `{s}`"));
}
Ok(Self(ms.round() as u64))
}
}
impl fmt::Display for Duration {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let secs = self.0 as f64 / 1000.0;
if secs.fract() == 0.0 {
write!(f, "{}s", secs as u64)
} else {
// 保留一位小数,如 1.5s
write!(f, "{secs:.1}s")
}
}
}
fn main() {
let d: Duration = "1500ms".parse().expect("示例输入合法");
println!("{d}"); // 1.5s
let parts: Vec<u64> = Duration::from(3_661_000).parts().collect();
println!("{parts:?}"); // [1, 1, 1]
assert_eq!(Duration::from(2000).to_string(), "2s");
}要点解析:
- 实现
FromStr后自动获得str::parse::<Duration>(),这是 Rust 生态的约定(IpAddr、SocketAddr、PathBuf都如此)。 - 实现
From<u64>而非Into<Duration>:写From会自动得到Into,反之不行——这就是「给别人礼物」的意思。 parts()返回impl Iterator:数组.into_iter()是天然的迭代器;2024 下签名里没有引用参数,因此不需要生命周期标注。# Examples里的# use与# Ok是隐藏行:渲染时看不到,但编译时会参与,保证 doctest 自包含。from_str用f64解析再round(),换取"1.5s"支持;若追求精确,应改成解析整数并单独处理小数部分——这里点出了「精度 vs 便利」的取舍。#[must_use]加在parts()/as_millis()上:这两个方法只有被使用才有意义。
练习 7:设计封装良好的公开 API
难度:★★☆ 要求:为 struct Stack<T> 设计公开 API,做到:外部能 push/pop/len/is_empty/迭代,但不能直接访问内部 Vec;未来要能加字段而不破坏兼容。至少用三种手段(私有字段、#[non_exhaustive]、返回 impl Iterator)。 提示:#[non_exhaustive] 加在 struct 上后外部就不能用字面量构造。
参考答案(先自己写再看)
参考答案(先自己写再看)
rust
/// 一个后进先出的栈。
///
/// 内部字段全部私有:外部无法直接访问 `Vec`,因此
/// 未来把实现从 `Vec` 换成链表或分块存储都不算破坏性变更。
///
/// `#[non_exhaustive]` 阻止外部使用结构体字面量构造,
/// 使「加字段」从破坏性变更降级为 minor 变更。
///
/// # Examples
///
/// ```
/// use mylib::Stack;
/// let mut s = Stack::new();
/// s.push(1);
/// s.push(2);
/// assert_eq!(s.pop(), Some(2));
/// assert_eq!(s.len(), 1);
/// ```
#[non_exhaustive]
#[derive(Debug, Default)]
pub struct Stack<T> {
items: Vec<T>, // 私有字段:封装不变量
}
impl<T> Stack<T> {
/// 创建一个空栈。
#[must_use]
pub fn new() -> Self {
Self { items: Vec::new() }
}
/// 压栈。
pub fn push(&mut self, v: T) {
self.items.push(v);
}
/// 出栈;空栈返回 `None`。
pub fn pop(&mut self) -> Option<T> {
self.items.pop()
}
/// 元素个数。
#[must_use]
pub fn len(&self) -> usize {
self.items.len()
}
/// 是否为空。
///
/// 与 `len` 一起提供,满足 clippy 的 `len_without_is_empty` 约定。
#[must_use]
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
/// 从栈顶到栈底迭代(返回 `impl Iterator`,不暴露内部容器类型)。
pub fn iter(&self) -> impl Iterator<Item = &T> {
self.items.iter().rev()
}
}
// 实现 IntoIterator for &Stack,让 `for x in &stack` 可用。
impl<'a, T> IntoIterator for &'a Stack<T> {
type Item = &'a T;
type IntoIter = std::iter::Rev<std::slice::Iter<'a, T>>;
fn into_iter(self) -> Self::IntoIter {
self.items.iter().rev()
}
}
fn main() {
let mut s = Stack::new();
s.push("a");
s.push("b");
for x in &s {
println!("{x}"); // b, a(栈顶在前)
}
assert_eq!(s.pop(), Some("b"));
assert!(!s.is_empty());
}要点解析:三种手段各司其职——
- 私有字段:封装不变量(例如将来加「容量上限」时,外部无法绕过
push破坏它)。 #[non_exhaustive]:外部失去结构体字面量构造的能力,换来「加字段不破坏兼容」。- 返回
impl Iterator:外部看不到slice::Iter这种实现细节;配合IntoIterator for &Stack让for循环自然工作。 补充约定:提供len时必须同时提供is_empty(clippy 的len_without_is_empty),这是 Rust 生态的惯例。若T无参构造有意义,还应该实现Default(这里用#[derive(Default)])并让new()与default()一致。
练习 8:判断该 allow 还是重构 lint
难度:★★★ 要求:判断:clippy::too_many_arguments 在你的一个 9 参数私有函数上报错。列出「该 allow」还是「该重构」的判定标准,并给出两种做法的具体代码(重构用参数结构体 + Builder;allow 用最小范围 + reason)。 提示:思考这个函数是否被大量调用、参数是否天然成组、是否来自 FFI/框架回调。
参考答案(先自己写再看)
参考答案(先自己写再看)
判定标准(按顺序问):
| 问题 | 倾向 |
|---|---|
| 参数是否天然成组(如「宽度+高度」= 尺寸,「host+port」= 地址)? | 成组 → 重构 |
| 这个函数被调用几次?超过 2~3 处且每处都要重复填 9 个实参? | → 重构 |
| 以后还会加第 10、11 个参数吗? | → 重构 |
参数是否由外部契约固定(FFI 回调、框架要求的 trait 方法签名、extern "C")? | 固定 → allow |
| 是否只是「编译器生成的代码」或「宏展开的样板」? | → allow |
| 是否处在性能极敏感路径,引入结构体真的会改变代码生成? | → allow(并给 benchmark 数据) |
经验值:too_many_arguments 的默认阈值是 7 个。超过它的私有函数通常意味着缺少一个抽象——这也正是 clippy 报这条 lint 的用意。
做法 A:重构(首选)——参数结构体 + Builder
rust
/// 渲染参数:把散落的 9 个参数收成一个有名字的类型。
#[derive(Debug, Clone)]
pub struct RenderOptions {
/// 输出宽度。
pub width: u32,
/// 输出高度。
pub height: u32,
/// 背景色(RGB)。
pub bg: (u8, u8, u8),
/// 前景色(RGB)。
pub fg: (u8, u8, u8),
/// 字体族。
pub family: String,
/// 字号(磅)。
pub size_pt: f32,
/// 行距倍数。
pub line_height: f32,
/// 是否抗锯齿。
pub antialias: bool,
/// 是否输出调试框。
pub debug_box: bool,
}
impl Default for RenderOptions {
fn default() -> Self {
Self {
width: 800,
height: 600,
bg: (255, 255, 255),
fg: (0, 0, 0),
family: "sans-serif".to_owned(),
size_pt: 12.0,
line_height: 1.2,
antialias: true,
debug_box: false,
}
}
}
/// 调用点从「9 个位置参数」变成「按名字设置关心的项」,
/// 且新增参数不会破坏既有调用(配合 ..Default::default())。
pub fn render(text: &str, opts: &RenderOptions) -> String {
format!("{text} @ {}x{}", opts.width, opts.height)
}
fn main() {
let opts = RenderOptions { width: 1024, ..Default::default() };
println!("{}", render("hello", &opts));
}做法 B:确有必要时 allow(范围最小 + 说明理由)
rust
/// 这是 C 库要求的回调签名,参数个数由外部 ABI 固定,无法合并成结构体。
///
/// 这里刻意把 allow 精确到这一个函数、这一条 lint,并写明原因,
/// 而不是在 crate 级别 `#![allow(clippy::too_many_arguments)]`。
#[expect(
clippy::too_many_arguments,
reason = "extern \"C\" 回调签名由 C 头文件固定,改成结构体会破坏 ABI"
)]
extern "C" fn on_event(
a: i32, b: i32, c: i32, d: i32, e: i32, f: i32, g: i32, h: i32, i: i32,
) -> i32 {
a + b + c + d + e + f + g + h + i
}
fn main() {
println!("{}", on_event(1, 2, 3, 4, 5, 6, 7, 8, 9));
}要点解析:
- 优先重构:
too_many_arguments几乎总是指向「缺一个类型」。用#[derive(Default)]让结构体可部分初始化,调用点反而更短更清晰。 - 该 allow 时用
#[expect]而不是#[allow]:expect会在 lint 不再触发时警告你删掉它,防止放行长期滞留。 - 范围最小化:函数级 > 模块级 > crate 级。crate 级
#![allow]会掩盖未来所有同类问题。 - 必须写理由:
reason = "..."让下一位读者知道这是有意为之,而不是随手糊过去的。 - 布尔参数混在其中时应一并改造:
antialias: bool这种「调用点看不出含义」的参数,更好的形态是枚举(Antialias::On/Off)或独立方法。
练习 9:cargo publish 发布前检查
难度:★★☆ 要求:为 cargo publish 写一份 checklist(≥10 项),覆盖元数据、构建、测试、文档、版本与 changelog、体积与 profile、发布方式。并指出哪一步是不可撤销的。 提示:从「下游第一次看到你的 crate」的视角列。
参考答案(先自己写再看)
参考答案(先自己写再看)
A. 元数据(crates.io 会拒绝不合规的提交)
B. 打包内容
C. 构建与测试
D. 文档
E. 版本与记录
F. 发布
不可撤销的一步:cargo publish 本身。crates.io 上的版本号一旦占用就永久不能删除、不能覆盖,只能 cargo yank(撤回)——而 yank 只影响新解析,已经下载并写进别人 Cargo.lock 的版本仍然有效。因此「发布前 checklist」的全部意义就在于:不要在按下 publish 之后才发现问题。
要点解析:
--dry-run是最便宜的保险:它做真正的打包与编译,唯独不发送。- workspace 的发布顺序是最容易翻车的细节——
cargo publish会在依赖缺失时报错,这类错误会打断流程、留下一半已发布的版本。 yank的语义要理解清楚:它不是删除,而是「以后的新项目不要再解析到这个版本」。已经依赖它的项目不受影响,因此绝不能靠 yank 来撤回一个有 bug 的版本。
练习 10:搭建三平台测试矩阵 CI
难度:★★★ 要求:写一个最小 GitHub Actions workflow,包含:fmt --check、clippy -D warnings、三平台测试矩阵(含 --no-default-features)、cargo doc 且 RUSTDOCFLAGS=-D warnings。并解释为什么要加 --locked。 提示:矩阵用 strategy.matrix.os;缓存用 Swatinem/rust-cache。
参考答案(先自己写再看)
参考答案(先自己写再看)
yaml
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
env:
CARGO_TERM_COLOR: always
jobs:
# 快检查放在单独 job:它通常最先失败,能给作者最快的反馈
lint:
name: fmt + clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- run: cargo fmt --all --check
# -D warnings 只作用于我们自己的代码;写在这里而不是 RUSTFLAGS,
# 以免依赖 crate 的新警告把 CI 弄红
- run: cargo clippy --all-targets --all-features -- -D warnings
test:
name: test (${{ matrix.os }})
strategy:
fail-fast: false # 一个平台失败不要取消其他平台,省得来回补跑
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# --locked 断言 Cargo.lock 与 Cargo.toml 一致:
# 若有人加了依赖却忘记提交 lock,CI 立刻失败,而不是静默升级依赖
- run: cargo test --all-features --locked
# feature 加法纪律的自动化:最小组合必须也能编译
- run: cargo test --no-default-features --locked
docs:
name: docs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo doc --no-deps --all-features
env:
# 文档里的坏链接、坏代码块、缺文档都会让这一步失败
RUSTDOCFLAGS: -D warnings为什么加 --locked:cargo test 默认允许在 semver 范围内更新 Cargo.lock(如果 Cargo.toml 变了)。这会让 CI 实际测试的依赖版本与开发者本地的 lock 不一致,「本地过、CI 挂」或反过来的问题因此极难复现。--locked 要求 Cargo 不得修改 lock 文件,任何需要改动的情况都直接报错。它在 CI 上的作用有三层:
- 可复现:测的就是提交里的那一组精确版本。
- 发现遗漏:有人改了
Cargo.toml却没提交Cargo.lock→ CI 报the lock file needs to be updated but --locked was passed。 - 防意外升级:避免 CI 拉进一个刚发布、尚未验证的上游版本导致莫名失败。 配套实践:定期用
cargo update+ 完整测试刻意升级依赖(例如每周一次的 scheduled workflow),把升级变成有意识的行为,而不是 CI 的随机噪声。
要点解析:
matrix.os三平台是必要的,不是奢侈:路径分隔符、换行、缺 native 依赖、cfg(windows)分支的问题只会在真机暴露。fail-fast: false让三个平台都跑完,一次拿到全部平台的结果。RUSTDOCFLAGS: -D warnings能抓住「[Foo]里的类型被重命名导致断链」这类只有文档才有的问题。cargo clippy --all-targets会把测试、示例、bench 也纳入检查——只查默认 target 会漏掉一半代码。Swatinem/rust-cache缓存~/.cargo/registry、~/.cargo/git与target,能把多平台矩阵的耗时压下来一大截。
练习 11:跨平台路径与文件统计
难度:★★☆ 要求:跨平台陷阱题:写一段代码读取目录下所有 .txt 文件并统计总行数(要求跨平台),并指出三个 Windows/Linux 差异会咬人的地方。 提示:Path::read_dir、Path::extension、BufRead::lines、cfg(windows)。
参考答案(先自己写再看)
参考答案(先自己写再看)
rust
use std::fs;
use std::io::{self, BufRead, BufReader};
use std::path::{Path, PathBuf};
/// 收集目录下所有(非递归)`.txt` 文件。
///
/// 用 `Path::extension()` 判断扩展名,而不是字符串 `ends_with(".txt")`——
/// 后者会把 `notes.txt.bak` 也误判,且对大小写/非 UTF-8 文件名处理不当。
fn txt_files(dir: &Path) -> io::Result<Vec<PathBuf>> {
let mut out = Vec::new();
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() && path.extension().is_some_and(|e| e.eq_ignore_ascii_case("txt")) {
out.push(path);
}
}
out.sort(); // 排序让输出确定,便于快照测试
Ok(out)
}
/// 统计单个文件的行数。
///
/// `BufRead::lines()` 会同时剥掉 `\n` 与 `\r\n`,
/// 因此 Windows 生成的文件与 Linux 生成的文件结果一致。
fn count_lines(path: &Path) -> io::Result<usize> {
let f = fs::File::open(path)?;
let mut n = 0usize;
for line in BufReader::new(f).lines() {
let line = line?;
// 冗余防线:若文件里混有孤立 \r(老 Mac 风格),单独处理
n += line.split('\r').count();
}
Ok(n)
}
fn main() -> io::Result<()> {
let dir = std::env::args().nth(1).unwrap_or_else(|| ".".to_owned());
let dir = PathBuf::from(dir);
let mut total = 0usize;
for p in txt_files(&dir)? {
let n = count_lines(&p)?;
// 用 display() 打印路径:跨平台可读;转发给别的程序时用 to_string_lossy()
println!("{}: {n}", p.display());
total += n;
}
println!("总计 {total} 行");
#[cfg(windows)]
println!("提示:Windows 下路径分隔符是 \\,行尾通常是 \\r\\n");
#[cfg(unix)]
println!("提示:Unix 下路径分隔符是 /,行尾是 \\n");
Ok(())
}三个会咬人的差异:
路径分隔符与拼接。硬编码
"data/file.txt"在 Windows 上多数场景「碰巧能用」(Windows API 接受/),但一旦参与字符串比较、展示、或传给只认反斜杠的外部工具就会出问题。规则:一律Path::new(a).join(b),打印用.display(),转文本用.to_string_lossy();比较路径用Path而非String。换行符(
\r\nvs\n)。这是 CI 上「Linux 绿、Windows 红」的头号原因。规则:读文本用BufRead::lines();写文本时显式决定要\n(跨平台一致)还是平台默认;测试断言前先text.replace("\r\n", "\n");仓库加.gitattributes(* text=auto eol=lf)避免 checkout 时被转换。文件名的编码与大小写。Windows 文件名是 UTF-16,可以包含非 UTF-8 可表示的字符,所以要用
OsStr/OsString,必要时to_string_lossy()(会替换非法序列)。同时 Windows 文件系统大小写不敏感(Foo.txt与foo.txt是同一个文件),Linux 敏感——依赖文件名唯一性的代码(如「同名资源只加载一次」)在 Windows 上开发、Linux 上部署时会翻车。规则:比较文件名时显式eq_ignore_ascii_case或统一转小写;不要假设read_dir的顺序(上面代码里out.sort()就是为此)。
要点解析:
path.extension().is_some_and(...)是Path上的正确 API;is_some_and(1.70 稳定)替代了旧的map_or(false, ...)。sort()不只是美观:不排序的话read_dir顺序因文件系统而异,任何基于输出的测试都会随机失败。line.split('\r').count()是防御性的:标准文件不会有孤立\r,但用户手改的文件可能有。#[cfg(windows)]/#[cfg(unix)]用于平台专属逻辑而不是「平台专属提示」——真实项目里典型用法是权限位、进程信号、OsStrExt扩展 trait。
练习 12:设计带安全契约的 unsafe API
难度:★★★ 要求:设计一个安全的 unsafe API(不实现真实 FFI):提供 struct Buf,由 Buf::from_raw_parts(ptr, len)(unsafe)构造,对外只暴露安全的 as_slice() 与 len()。要求写完整的 # Safety 文档与 // SAFETY: 注释,并解释「为什么这个安全抽象是成立的」以及「什么样的误用会让它退化为 UB」。 提示:安全抽象的契约是「外部只用安全 API 就不可能 UB」。
参考答案(先自己写再看)
参考答案(先自己写再看)
rust
use std::slice;
/// 一个持有「裸指针 + 长度」的缓冲区视图。
///
/// # 安全抽象的设计意图
///
/// 本类型把「必须由调用者保证的内存有效性」这一步**收在一个 `unsafe` 构造函数**里,
/// 对外只暴露安全的 `as_slice()` / `len()`。这样,只要构造函数被正确使用,
/// 之后所有使用 `Buf` 的安全代码都不可能触发 UB。
pub struct Buf {
ptr: *const u8,
len: usize,
}
impl Buf {
/// 从一个裸指针和长度构造 `Buf`。
///
/// # Safety
///
/// 调用者必须同时满足以下**全部**条件,否则行为未定义:
///
/// 1. `ptr` 非空,且指向 `len` 个**已初始化**的 `u8`。
/// 2. 该内存区域在本 `Buf`(及其任何派生切片)被丢弃之前**始终保持有效**。
/// 3. 该内存区域在本 `Buf` 存活期间**不被用于写入**(因为 `as_slice()` 会产出 `&[u8]`,
/// 而共享引用的存在要求没有可变别名)。若需要可变访问,应改用 `*mut u8` 的变体。
/// 4. `len` 不超过该对象的最大尺寸(`<= isize::MAX`),满足 `slice` 的长度约束。
///
/// # Examples
///
/// ```
/// use mylib::Buf;
/// let data = [1u8, 2, 3];
/// // SAFETY: data 是栈上数组,在 main 期间一直有效;
/// // 长度取自数组自身;之后不再可变访问它。
/// let b = unsafe { Buf::from_raw_parts(data.as_ptr(), data.len()) };
/// assert_eq!(b.as_slice(), &[1, 2, 3]);
/// ```
pub unsafe fn from_raw_parts(ptr: *const u8, len: usize) -> Self {
// 空指针 + 长度为 0 会给 slice::from_raw_parts 带来 UB,
// 因此在构造函数里就把这个非法状态挡掉(fail fast,而不是留下一个坏值)。
assert!(!ptr.is_null(), "Buf::from_raw_parts 要求非空指针");
Self { ptr, len }
}
/// 以共享切片的形式访问数据。
///
/// 本方法是**安全**的:所有前置条件都已在 `from_raw_parts` 处被要求满足。
#[must_use]
pub fn as_slice(&self) -> &[u8] {
// SAFETY: 构造函数已保证:
// (1) ptr 非空且指向 len 个已初始化的 u8;
// (2) 该内存存活至 self 被丢弃;
// (3) 期间无写入(构造函数文档第 3 条,由调用者承诺)。
// 因此 from_raw_parts 的长度与对齐前提均成立,且产出的引用
// 生命周期被绑定到 &self(不会活过 self)。
unsafe { slice::from_raw_parts(self.ptr, self.len) }
}
/// 字节数。
#[must_use]
pub const fn len(&self) -> usize {
self.len
}
/// 是否为空。
#[must_use]
pub const fn is_empty(&self) -> bool {
self.len == 0
}
}
fn main() {
let owned = vec![10u8, 20, 30, 40];
// SAFETY: owned 在本作用域内一直存活;长度取自它自身;
// 之后不再通过 owned 做可变访问。
let buf = unsafe { Buf::from_raw_parts(owned.as_ptr(), owned.len()) };
println!("len = {}, first = {}", buf.len(), buf.as_slice()[0]);
assert_eq!(buf.as_slice(), &owned[..]);
}为什么这个安全抽象成立:Buf 的唯一构造入口是 unsafe fn from_raw_parts,其文档把「内存有效、存活期足够、无写入别名」这组编译器无法验证的前提,明确转嫁给调用者。此后 as_slice 的 unsafe 块只依赖这些已被承诺的前提,加上一条自身可控的事实——返回引用的生命周期被 &self 约束,因此不会活过 Buf 本身。于是:安全代码只要不调用 unsafe 构造函数,就不可能制造悬垂引用或数据竞争。这正是 Rust「把 unsafe 关在小盒子里」的标准范式(slice::from_raw_parts、Vec、String 的内部实现都是这个套路)。
什么样的误用会让它退化为 UB(也就是为什么这些前提不能省):
| 误用 | 后果 |
|---|---|
传入已 free/已离开作用域的指针 | 悬垂引用 → 读越界,UB |
传入 len 大于实际分配长度 | as_slice() 越界读,UB |
传入未初始化的内存(如 MaybeUninit 未写入部分) | 读取未初始化字节,UB |
| 构造后仍通过别名写入该内存 | 违反 &[u8] 的共享引用约束 → UB(即使没被观察到) |
把 Vec 的指针传入后让该 Vec 扩容或 drop | 指针失效,UB |
| 传入空指针 | 已在构造函数里 assert! 拦掉(这是刻意的:宁可立刻 panic,也不制造非法 Buf) |
要点解析:
# Safety文档不是装饰。Rust 生态的约定是:每个unsafe fn都必须有# Safety节,逐条列出调用者的义务。缺少它会被 clippy 的missing_safety_doc报错。// SAFETY:注释解释「为什么现在成立」,而不是重复「这里调用了 unsafe 函数」。审查时它是最重要的证据。- 安全抽象的关键是「不变量必须无法从外部破坏」。本类型把
ptr/len设为私有,外部就无法构造出len与内存不匹配的Buf——一旦字段公开,整个抽象立刻失效。 - 能在构造函数里挡掉的就别留到方法里:空指针检查放在构造处(fail fast),
as_slice才能是零成本的安全方法。 - 真实项目还应考虑
Send/Sync:本类型自动不实现它们吗?裸指针*const u8不是Send/Sync,所以Buf也不能跨线程——多数场景下这恰好是想要的保守默认;若明确要跨线程共享,必须写unsafe impl Send for Buf并论证第 3 条「无写入」在跨线程下依然成立。