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 是“集体记忆”
  • 必带三件套DebugSendSync
    缺一个,就把理由写进 doc,否则异步线程池一用就炸。
  • 能 derive 就 deriveCloneDefaultPartialEqHash……
    用户以后要用 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> { .. }

让用户传 &fooBox::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
  • 只读一下 → 收 &TAsRef<T>
  • 不确定 → 返回 Cow<T> 或给 #[must_use] 的显式析构函数 fn close(self) -> Result<()>

3. obvious:把坑搬到编译期,把例子写到首页

3.1 文档三件套
  1. panic / Error 场景——写清楚,别让用户用 gdb 猜;
  2. 端到端示例——放在 lib.rs 顶部,可复制可改;
  3. 交叉链接——用 [[ 内链 ]] 把类型、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

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐