Skip to content

练习与自测

本章练习共 11 题,答案折叠在每题下方。建议先自己写、编译通过后再展开答案;难度标记:★☆☆ 基础 / ★★☆ 综合 / ★★★ 挑战。

练习 1:组织 bin + lib 的 workspace

难度:★★☆ 要求:搭一个 bin + lib 的 workspace,产出两个 crate:库 corelib(提供一个 word_count 函数)与二进制 wc-cli(CLI 调用它并打印结果)。给出完整目录树、每个文件的内容,并说明 Cargo.lock 会出现在哪里。 提示:根 Cargo.toml 只声明 [workspace]membersresolver;成员之间用 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 = true

crates/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 = true

crates/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 全跑

要点解析

  1. Cargo.lock 只有一份,在 workspace 根目录——成员共享同一张解析图,因此 feature 统一也是全图范围的。
  2. resolver = "3" 与 edition 2024 配套:开启 MSRV-aware 解析(Cargo 1.84+)。
  3. [workspace.lints] + 成员 [lints] workspace = true 是 1.74+ 的集中 lint 配置方式,比每个 crate 顶部写 #![warn(...)] 更一致。
  4. path 依赖同时写 version 是发布型 workspace 的惯例:本地用路径,发布时 crates.io 用版本号。
  5. 逻辑全在 lib.rsmain.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

要点解析

  1. default = []刻意的设计决策json 会带进 serde + serde_json,把编译时间与体积负担强加给所有用户,而多数用户并不需要。
  2. dep: 让「依赖名」与「feature 名」解耦:将来把 serde_json 换成别的实现,json feature 名不用变(下游零影响)。代价是不能再靠隐式同名 feature,需要时显式补一个别名。
  3. full 只是组合,不引入新代码——这就是「加法」的字面含义:任何组合都是可用能力的并集。
  4. default 里放了 json,用户想瘦身只能写 --no-default-features,而一旦另一个依赖启用了 textkit/json,feature 统一会让它又回来——这正是「feature 必须是『加法』」强调的陷阱。

练习 3:用 clap 做参数解析与校验

难度:★★☆ 要求:用 clap 的 derive 风格实现一个带子命令的 CLI notesnotes 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

要点解析

  1. Cli::parse() 会在参数非法时自己打印友好错误并以退出码 2 结束,所以我们只需要处理「语义越界」这类 clap 管不了的校验。
  2. value_parser!(u8).range(1..=100) 把范围约束交给 clap,错误信息比手写校验更规范。
  3. global = true--verbosenotes --verbose listnotes list --verbose 下都可用。
  4. impl Iterator<Item = (usize, &str)> 作为返回值:不暴露内部 Vec,且 2024 下自动捕获 &self 的生命周期(无需手写 + '_)。
  5. 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_allskip_serializing_ifdefault

参考答案(先自己写再看)参考答案(先自己写再看)
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(())
}

要点解析

  1. rename_all = "camelCase" 把 Rust 的 snake_case 字段名整体映射,避免逐个 rename
  2. skip_serializing_if = "Vec::is_empty" 的字符串必须是指向函数的路径,签名 fn(&Vec<String>) -> bool
  3. #[serde(default)] 只在反序列化时起作用(配合 Default::default()),这正是「向前/向后兼容」的关键手段:老客户端不发这个字段,新代码也能解析。
  4. serde_json::Value 是动态类型,字段名写错只有运行期才会发现(返回 Null)。所以 Value 适合「读写少量未知字段」,不适合当主要数据模型。
  5. 注意 unwrap_orValue 上的用法:Value::as_i64() 返回 Option,缺失与类型不符都返回 None

练习 5:实现 FromStrDisplay 及 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");
}

要点解析

  1. 实现 FromStr 后自动获得 str::parse::<Duration>(),这是 Rust 生态的约定(IpAddrSocketAddrPathBuf 都如此)。
  2. 实现 From<u64> 而非 Into<Duration>:写 From自动得到 Into,反之不行——这就是「给别人礼物」的意思。
  3. parts() 返回 impl Iterator:数组 .into_iter() 是天然的迭代器;2024 下签名里没有引用参数,因此不需要生命周期标注。
  4. # Examples 里的 # use# Ok 是隐藏行:渲染时看不到,但编译时会参与,保证 doctest 自包含。
  5. from_strf64 解析再 round(),换取 "1.5s" 支持;若追求精确,应改成解析整数并单独处理小数部分——这里点出了「精度 vs 便利」的取舍。
  6. #[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());
}

要点解析:三种手段各司其职——

  1. 私有字段:封装不变量(例如将来加「容量上限」时,外部无法绕过 push 破坏它)。
  2. #[non_exhaustive]:外部失去结构体字面量构造的能力,换来「加字段不破坏兼容」。
  3. 返回 impl Iterator:外部看不到 slice::Iter 这种实现细节;配合 IntoIterator for &Stackfor 循环自然工作。 补充约定:提供 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));
}

要点解析

  1. 优先重构too_many_arguments 几乎总是指向「缺一个类型」。用 #[derive(Default)] 让结构体可部分初始化,调用点反而更短更清晰。
  2. 该 allow 时用 #[expect] 而不是 #[allow]expect 会在 lint 不再触发时警告你删掉它,防止放行长期滞留。
  3. 范围最小化:函数级 > 模块级 > crate 级。crate 级 #![allow] 会掩盖未来所有同类问题。
  4. 必须写理由reason = "..." 让下一位读者知道这是有意为之,而不是随手糊过去的。
  5. 布尔参数混在其中时应一并改造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 之后才发现问题

要点解析

  1. --dry-run 是最便宜的保险:它做真正的打包与编译,唯独不发送。
  2. workspace 的发布顺序是最容易翻车的细节——cargo publish 会在依赖缺失时报错,这类错误会打断流程、留下一半已发布的版本。
  3. yank 的语义要理解清楚:它不是删除,而是「以后的新项目不要再解析到这个版本」。已经依赖它的项目不受影响,因此绝不能靠 yank 来撤回一个有 bug 的版本。

练习 10:搭建三平台测试矩阵 CI

难度:★★★ 要求:写一个最小 GitHub Actions workflow,包含:fmt --checkclippy -D warnings、三平台测试矩阵(含 --no-default-features)、cargo docRUSTDOCFLAGS=-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

为什么加 --lockedcargo test 默认允许在 semver 范围内更新 Cargo.lock(如果 Cargo.toml 变了)。这会让 CI 实际测试的依赖版本与开发者本地的 lock 不一致,「本地过、CI 挂」或反过来的问题因此极难复现。--locked 要求 Cargo 不得修改 lock 文件,任何需要改动的情况都直接报错。它在 CI 上的作用有三层:

  1. 可复现:测的就是提交里的那一组精确版本。
  2. 发现遗漏:有人改了 Cargo.toml 却没提交 Cargo.lock → CI 报 the lock file needs to be updated but --locked was passed
  3. 防意外升级:避免 CI 拉进一个刚发布、尚未验证的上游版本导致莫名失败。 配套实践:定期用 cargo update + 完整测试刻意升级依赖(例如每周一次的 scheduled workflow),把升级变成有意识的行为,而不是 CI 的随机噪声。

要点解析

  1. matrix.os 三平台是必要的,不是奢侈:路径分隔符、换行、缺 native 依赖、cfg(windows) 分支的问题只会在真机暴露。
  2. fail-fast: false 让三个平台都跑完,一次拿到全部平台的结果。
  3. RUSTDOCFLAGS: -D warnings 能抓住「[Foo] 里的类型被重命名导致断链」这类只有文档才有的问题。
  4. cargo clippy --all-targets 会把测试、示例、bench 也纳入检查——只查默认 target 会漏掉一半代码。
  5. Swatinem/rust-cache 缓存 ~/.cargo/registry~/.cargo/gittarget,能把多平台矩阵的耗时压下来一大截。

练习 11:跨平台路径与文件统计

难度:★★☆ 要求:跨平台陷阱题:写一段代码读取目录下所有 .txt 文件并统计总行数(要求跨平台),并指出三个 Windows/Linux 差异会咬人的地方。 提示Path::read_dirPath::extensionBufRead::linescfg(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(())
}

三个会咬人的差异

  1. 路径分隔符与拼接。硬编码 "data/file.txt" 在 Windows 上多数场景「碰巧能用」(Windows API 接受 /),但一旦参与字符串比较、展示、或传给只认反斜杠的外部工具就会出问题。规则:一律 Path::new(a).join(b),打印用 .display(),转文本用 .to_string_lossy();比较路径用 Path 而非 String

  2. 换行符(\r\n vs \n。这是 CI 上「Linux 绿、Windows 红」的头号原因。规则:读文本用 BufRead::lines();写文本时显式决定要 \n(跨平台一致)还是平台默认;测试断言前先 text.replace("\r\n", "\n");仓库加 .gitattributes* text=auto eol=lf)避免 checkout 时被转换。

  3. 文件名的编码与大小写。Windows 文件名是 UTF-16,可以包含非 UTF-8 可表示的字符,所以要用 OsStr/OsString,必要时 to_string_lossy()(会替换非法序列)。同时 Windows 文件系统大小写不敏感Foo.txtfoo.txt 是同一个文件),Linux 敏感——依赖文件名唯一性的代码(如「同名资源只加载一次」)在 Windows 上开发、Linux 上部署时会翻车。规则:比较文件名时显式 eq_ignore_ascii_case 或统一转小写;不要假设 read_dir 的顺序(上面代码里 out.sort() 就是为此)。

要点解析

  1. path.extension().is_some_and(...)Path 上的正确 API;is_some_and(1.70 稳定)替代了旧的 map_or(false, ...)
  2. sort() 不只是美观:不排序的话 read_dir 顺序因文件系统而异,任何基于输出的测试都会随机失败。
  3. line.split('\r').count() 是防御性的:标准文件不会有孤立 \r,但用户手改的文件可能有。
  4. #[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_sliceunsafe只依赖这些已被承诺的前提,加上一条自身可控的事实——返回引用的生命周期被 &self 约束,因此不会活过 Buf 本身。于是:安全代码只要不调用 unsafe 构造函数,就不可能制造悬垂引用或数据竞争。这正是 Rust「把 unsafe 关在小盒子里」的标准范式(slice::from_raw_partsVecString 的内部实现都是这个套路)。

什么样的误用会让它退化为 UB(也就是为什么这些前提不能省):

误用后果
传入已 free/已离开作用域的指针悬垂引用 → 读越界,UB
传入 len 大于实际分配长度as_slice() 越界读,UB
传入未初始化的内存(如 MaybeUninit 未写入部分)读取未初始化字节,UB
构造后仍通过别名写入该内存违反 &[u8] 的共享引用约束 → UB(即使没被观察到)
Vec 的指针传入后让该 Vec 扩容或 drop指针失效,UB
传入空指针已在构造函数里 assert! 拦掉(这是刻意的:宁可立刻 panic,也不制造非法 Buf

要点解析

  1. # Safety 文档不是装饰。Rust 生态的约定是:每个 unsafe fn 都必须有 # Safety 节,逐条列出调用者的义务。缺少它会被 clippy 的 missing_safety_doc 报错。
  2. // SAFETY: 注释解释「为什么现在成立」,而不是重复「这里调用了 unsafe 函数」。审查时它是最重要的证据。
  3. 安全抽象的关键是「不变量必须无法从外部破坏」。本类型把 ptr/len 设为私有,外部就无法构造出 len 与内存不匹配的 Buf——一旦字段公开,整个抽象立刻失效。
  4. 能在构造函数里挡掉的就别留到方法里:空指针检查放在构造处(fail fast),as_slice 才能是零成本的安全方法。
  5. 真实项目还应考虑 Send/Sync:本类型自动实现它们吗?裸指针 *const u8 不是 Send/Sync,所以 Buf 也不能跨线程——多数场景下这恰好是想要的保守默认;若明确要跨线程共享,必须写 unsafe impl Send for Buf 并论证第 3 条「无写入」在跨线程下依然成立

本章小结 / 自测清单

内容以 rustc 1.98.1 · Rust 2024 edition 为基准