写给人看的 Rust 接口:四行箴言,一份 Checklist
文章目录
0. 前言:接口不是“写完了就行”,而是“写到别人能活”
在 Rust 里,接口(API)不只有 pub fn 那一行签名——
每一个 struct、每一个 trait、每一个 mod 边界,都是一次“签合同”。
合同一旦发布,用户(包括 3 个月后的你自己)就会把它当成真理,
哪怕你当初只是“随手一写”。
本章把《Designing Interfaces》浓缩成一篇博客:
** unsurprising · flexible · obvious · constrained **
四行箴言,带你从“能跑”到“能活”。
1. unsurprising:别让用户“猜”你的代码
1.1 命名就是文档
| 名字 | 用户默认行为 |
|---|---|
iter() |
拿 &self 返回迭代器 |
into_inner() |
消费 self 抽出内部值 |
SomethingError |
实现 std::error::Error |
反例:
impl Foo {
// 用户以为返回迭代器,结果你把 self 吃了
fn iter(self) -> Vec<Bar> { … }
}
用户会骂街,然后提 PR 骂得更凶。
1.2 标准 trait 是“集体记忆”
- 必带三件套:
Debug、Send、Sync
缺一个,就把理由写进 doc,否则异步线程池一用就炸。 - 能 derive 就 derive:
Clone、Default、PartialEq、Hash……
用户以后要用assert_eq!时,会回来给你点星。 - serde 做成 feature:
serde = ["dep:serde"]一行Cargo.toml,用户可选,你不强绑。
1.3 引用也能用 trait
trait Log { fn log(&self); }
impl<T: Log> Log for &T { .. }
impl<T: Log> Log for &mut T { .. }
impl<T: Log> Log for Box<T> { .. }
让用户传 &foo、Box::new(foo) 都能直接 .log(),少一次“为啥不行”的 Issue。
2. flexible:合同要“松”,承诺要“紧”
2.1 函数签名 = 合同
// 1. 硬要求所有权,以后想零拷贝就破合同
fn f1(s: String) -> String { .. }
// 2. 用 Cow,返回可借可拥有
fn f2(s: &str) -> Cow<'_, str> { .. }
// 3. 只要“像 str”就行,最松
fn f3(s: impl AsRef<str>) -> impl AsRef<str> { .. }
经验:先写最泛型,让编译器报错告诉你“还需要什么 bound”,再收紧。
2.2 对象安全别忽视
pub trait Parse: sealed::Sealed {
fn parse(&self) -> Result<Json, Error>;
}
把 trait 做成 sealed,将来加方法、给外部类型 impl 都不会破版本;
代价是用户不能自己 impl,文档里写清楚“本 trait 仅由库内实现”。
2.3 借用 vs 拥有
- 要搬数据 → 收
T,别暗地clone; - 只读一下 → 收
&T或AsRef<T>; - 不确定 → 返回
Cow<T>或给#[must_use]的显式析构函数fn close(self) -> Result<()>。
3. obvious:把坑搬到编译期,把例子写到首页
3.1 文档三件套
- panic / Error 场景——写清楚,别让用户用
gdb猜; - 端到端示例——放在
lib.rs顶部,可复制可改; - 交叉链接——用
[[内链]]把类型、trait 串成网,别让读者迷路。
3.2 类型系统当“护栏”
enum DryRun { Yes, No }
enum Overwrite { Yes, No }
fn upload(dry: DryRun, over: Overwrite) { .. }
把 bool 换成枚举,顺序写反了直接编译失败,比 panic! 更友好。
3.3 零大小标记状态
struct Grounded;
struct Launched;
struct Rocket<Stage = Grounded> { _p: PhantomData<Stage> }
impl Rocket<Grounded> {
pub fn launch(self) -> Rocket<Launched> { .. }
}
impl Rocket<Launched> {
pub fn accelerate(&mut self) { .. }
}
让“未升空就点火”成为编译错误——使非法状态不可表示。
3.4 #[must_use] 点到为止
Result 不用就告警很好,但别给所有函数都加;
用户真会 let _ = 来消告警,然后继续踩坑。
4. constrained:给用户自由,但别给自己枷锁
4.1 可见性最小化
- 能
pub(crate)就绝不pub; - 结构体留私有字段,未来才能加;
- 非要暴露?加
#[non_exhaustive],禁止用户结构体字面量构造、禁止穷尽匹配。
4.2 trait 实现是“单向门”
- 给已有类型加
impl可能撞车——用 sealed trait 或新版本; - 移除 trait impl = 必破版本;
- 加默认方法 OK,改签名 NG。
4.3 隐藏承诺:auto-trait & 重导出
- 把依赖类型用
impl Trait或 newtype 包一层,避免“别人升大版本你背锅”; - 在 CI 加静态断言:
fn assert_send<T: Send>() {}
#[test] fn check() { assert_send<MyType>(); }
防止私有字段一改把 Send 弄丢。
5. 一份“发布前”Checklist(贴在仓库 Wiki)
| 检查项 | 命令 / 工具 |
|---|---|
| 命名符合惯例 | cargo clippy -- -W clippy::pedantic |
| 标准 trait 缺谁 | cargo clippy -- -W missing_debug_implementations |
| 文档示例能跑 | cargo test --doc |
| semver 合规 | cargo public-api diff (需安装) |
| trait object 安全 | cargo check --features dyn_trait_tests |
| auto-trait 测试 | 手写 assert_send/sync/unpin 静态测试 |
6. 结语:好接口是“活文档”,不是“免责声明”
Rust 的强编译器已经把内存错误踢出 runtime;
剩下的坑,全是人的坑:猜不到、用不对、改不动。
把“ unsurprising · flexible · obvious · constrained ” 刻成快捷键:
下次写 pub 前,先默念四遍,再敲回车——
你的用户(和未来的你)会回来给这个 commit 点一个★ Star。
更多推荐



所有评论(0)