- 文档
【免费下载链接】rfcs
RFCs for changes to Rust
导读
本篇文章以 Rust 官方 RFC 仓库中的 text/1977-public-private-dependencies.md 为骨架,系统解读 Cargo 依赖体系中「公开依赖(public dependency)」与「私有依赖(private dependency)」的区分机制:它解决什么问题、编译器与 Cargo 如何协同实现、Cargo.toml与版本解析如何变化,以及发布流程中的最低版本解析校验。读完本文,你将理解依赖类型「泄漏」引发版本冲突的根因,掌握public = true的声明方式与警告处理手段,并了解该设计在后续 RFC 3516 中的演进方向(如exported-private-dependencieslint 与 2024 Edition 中的默认策略)。
一、背景与动机:依赖「泄漏」引发的版本冲突
自 Rust 1.0 发布以来,crates 生态迅速扩张,随之产生的一些依赖模式对 Cargo 现有的依赖声明体系提出了挑战。其中最普遍的问题是:
crate
A依赖了另一个 crateB,但B的部分类型通过A的公开 API 被暴露了出去。
这在实际使用中会造成严重问题。典型的失败场景是:用户的代码自己也依赖B,而用户依赖的B与A内部依赖的B被解析成了不同版本;当这两份B实例中的类型需要被放在一起使用时,它们并不匹配——用户的代码将拒绝编译,而编译器给出的错误信息又不够清晰,让人难以定位「两个同名 crate 的不同版本」这一根源。
引入公开依赖与私有依赖的显式区分,正是为了解决这些痛点:
- 把「哪些依赖属于公开 API 契约的一部分」显式化,从而让 semver 约束变得可核查;
- 私有依赖可以在依赖图中被自由复制(duplicated),不会再引发编译错误;
- 借此解除 Cargo 中一些既有限制,让一部分此前被禁止编译的代码得以编译通过。
从源码结构看,该 RFC 的完整设计位于 text/1977-public-private-dependencies.md,其在仓库中对应的追踪议题为 rust-lang/rust#44663,后续的修订版 text/3516-public-private-dependencies.md 则明确声明取代(supersede)本 RFC。
二、核心概念:什么是公开依赖与私有依赖
RFC 用一组 Q&A 精确刻画了这两个概念。
Q:什么是公开依赖(public dependency)?
如果某个依赖的某些类型或 trait 通过主 crate 的公开 API 被导出,那么这个依赖就是公开的。最常见的出现位置是返回值与函数参数,trait 实现以及许多其他场景同理。
由于「公开」对普通用户来说难以自行判断,RFC 提议扩展编译器基础设施来自动检测「公开依赖」这一概念,帮助用户理解它,从而避免在Cargo.toml中犯错。
其本质含义可以浓缩为一句话:
如果你升级了一个公开依赖的版本,那对你自己的 crate 而言就是一个破坏性变更(breaking change)。
Q:什么是私有依赖(private dependency)?
私有依赖被包含在你的 crate 内部,对 crate 的使用者实际上不可见。因此,私有依赖可以在依赖图中被自由复制,不会引发编译错误。
这一区分还使得 Cargo 中某些「有时会阻止 crate 编译」的限制可以被放宽。
Q:公开依赖以后能变成私有依赖吗?
公开依赖只是在可达子图(reachable subgraph)范围内是公开的,一旦 crate 不再暴露某个公开依赖,它就可以变成私有的。例如,一个 crate 家族可能都依赖某个提供公共类型的工具 crate,此时它对每个成员都是公开依赖;但如果你的 crate 只是作为该工具 crate 的使用者、且没有任何类型或 trait 进入你自己的 API,那么这条依赖就应标记为私有。
Q:公开 / 私有在哪里定义?
依赖默认是私有的,只有通过在Cargo.toml中为依赖添加public标记才会成为公开依赖。这也意味着:在本 RFC 实现之前创建的所有 crate,其全部依赖都将是私有的。
Q:向后兼容性如何处理?
继续允许「泄漏」依赖(这种泄漏甚至有一些正当用途),但编译器或 Cargo 会在私有依赖进入公开 API 时发出警告。未来,发布新 crate 时若未显式消除这些警告或未将依赖标记为公开,甚至可能变得不合法。
Q:可以把私有依赖中的类型当作自己的类型导出吗?
当前阶段不允许。原因在于目前无法强制这种类型保持「与众不同」:如果用户开始依赖真正实现该类型的 crate,就可能无意间开始依赖这种类型与自己的兼容性。上一问中的限制同样适用(例如目前仍可以覆盖这些限制)。
Q:semver 与依赖如何交互?
变更自己的依赖在既有规则下就已经要求对库本身做 semver 升级,因为对外 API 契约发生了变化。而本 RFC 使得只有公开依赖才需要承担这一要求,并且允许 Cargo 在将来阻止带有 semver 违规的新 crate 版本发布。
三、总体设计:需要改动的六个层面
RFC 的详细设计(Detailed design)列出了一份明确的改动清单,覆盖编译链路的全部关键环节:
| 层面 | 改动内容 |
|---|---|
| 编译器 | 扩展以理解何时依赖被视为公开依赖 |
Cargo.toml | 支持声明公开依赖,先作为 nightly 上仅可通过 opt-in 启用的不稳定 Cargo 特性 |
| Cargo Index | 依赖的public属性需要出现在索引中,供 Cargo 在版本解析时使用 |
| Cargo 版本解析 | 拒绝「两个版本的同一 crate 对彼此公开可达」的图解析结果 |
cargo publish | 警告(或阻止)发布带有未声明公开依赖的 crate |
cargo publish版本解析 | 按最低可能版本解析依赖,以校验Cargo.toml中的最小版本声明是否正确 |
| crates.io | 更醒目地展示公开依赖,弱化私有依赖的展示 |
下文逐一展开。
四、编译器侧改动:--extern-public与泄漏警告
编译器的主要改动是接受一个新的参数,由 Cargo 提供、内容是公开依赖的列表,参数名为--extern-public。随后,当编译器发现私有依赖泄漏到 crate 的公开 API时,就发出警告;cargo publish可能会在其 lint 阶段把这种警告升级为错误。
此外,将来警告可以普遍升级为硬错误。
在某些场景下,允许私有依赖进入公开 API 是必要的。此时可以用:
#[allow(external_private_dependency)]来放行。这一做法与#[doc(hidden)]等已有技巧配合尤其有用。RFC 特别指出,这很可能也是 Rust 自身中libcore与libstd这种复杂依赖关系所需要的。
五、Cargo.toml声明:public = true
Cargo.toml将支持依赖上的新参数public。旧版 Cargo 遇到该键时会发出警告但继续工作。由于依赖默认就是私有的,只有少数依赖需要显式打上public标记。
该能力先以不稳定 Cargo 特性(unstable Cargo feature)的形式在 nightly 上提供,作者需要在Cargo.toml中通过特性 opt-in 后,Cargo 才会利用public属性改变版本解析方式;该 Cargo 不稳定特性会同时打开对应的 rustc 不稳定特性,以启用上文提到的编译器改动。
示例依赖声明:
[dependencies] url = { version = "1.4.0", public = true }六、Cargo Index:public属性的传递
Cargo 在版本解析时使用的 Cargo Index 中,将包含Cargo.toml里声明的public属性。以名为example、公开依赖urlcrate 的索引行为例(JSON 已为可读性做了美化):
{ "name":"example", "vers":"0.1.0", "deps":[ { "name":"url", "req":"^1.4.0", "public":"true", "features":[], "optional":false, "default_features":true, "target":null, "kind":"normal" } ] }每个依赖条目中的public字段(这里为字符串"true")即为本次设计新增的信息载体。
七、Cargo 版本解析:公开连通子图约束
这是 RFC 中最具理论色彩的部分。Cargo 将专门拒绝这样的解析图:同一 crate 的两个不同版本被公开依赖、并且彼此可达。这样就能把今天在编译期才出现的怪异错误提前到版本解析期拦截。
其工作原理分四步:
- 节点(nodes):解析图由若干节点组成,这些节点是「包 ID」(package id),即
(name, source, version)三元组。同一 crate 的不同版本是不同节点,同一名称的不同来源(如 git 与 crates.io)也是不同节点。 - 有向边(directed edges):节点之间存在有向边,一条有向边代表一个依赖。例如 A 依赖 B,就有一条从 A 指向 B 的有向边。
- 边的公开/私有标记:有了公开/私有依赖后,每条边都可以标记为 public 或 private。于是可以得到一组仅由公开依赖边连通的子图,子图内公开依赖边的方向无关紧要。每个这样的子图代表一个「互相公开依赖的 crate 生态」,也就是一个「公开类型池」(pool of public types)——你只要接触到这个子图,就能接触到池子里的全部类型。
- 约束与错误判定:
- 每个「公开连通子图」内部要求所有 crate 恰好只有一个版本。例如,每个子图内只能有一个版本的 Hyper。
- 考察解析图中从一个节点出发的所有边对:如果两条边分别指向两个不同的公开连通子图,而这两个子图又包含同一 crate 的两个不同版本,就判定为错误。这基本意味着「你私下同时依赖 Hyper 0.3 和 Hyper 0.4」也是错误。
八、cargo publish:发布时的两道检查
8.1 警告未声明的公开依赖
当发布新 crate 版本时,Cargo 会警告:编译器判定为公开、但并非来自公开依赖的那些类型和 trait。当前阶段仍允许照常发布,但未来某个时期起,将必须显式把所有公开依赖标记为 public,或显式用#[allow(external_private_dependency)]标记。
8.2 最低版本解析(Lowest Version Resolution)
一个非常常见的现实问题是:人们在Cargo.toml里写下依赖的初始版本后,就再也不更新下限,尽管实际代码已经用上了更新版本的功能。这在大多数时候没问题,因为:
- Cargo 一般会选择满足约束的最大可用版本;
- 约束的上界(至少在特定 minor 版本内)相对罕见。
也就是说,Cargo.toml并不是版本依赖信息的准确全貌,它顶多是一个下限;可能存在「不可见依赖」,它们不会导致解析失败,但随着 API 演化会造成编译错误。
公开依赖会加剧这个问题——你可能在不知不觉中依赖了某个你甚至不知道的 crate 的「新 API」。RFC 给出了一个完整的失败链条:
A依赖:B 1.0,而B公开依赖C ^1.0D 1.0,无依赖
- 初次构建
A时,解析为B 1.0与C 1.1:- 因为
C的 API 通过B的 re-export 对A可用,所以A实际上已经依赖C 1.1,尽管B只声称依赖C ^1.0; - 特别是
A中的代码可能依赖只有C 1.1才有的 API; - 然而如果
A是库,它不会提交 lockfile,这些信息就此丢失。
- 因为
- 现在把
A改成依赖D 1.1,而D 1.1依赖C =1.0:- 重新构建
A时,解析图变为B 1.0, D 1.1, C 1.0; - 但
A可能突然编译失败,因为它此前通过B隐式依赖了C 1.1的特性。
- 重新构建
此类问题的共同成分是:某处代码使用了比Cargo.toml中声明的版本更新版本才有的 API。
为了尽早暴露这一问题,cargo publish将尝试在满足约束的前提下选取最小版本来解析依赖图;如果 crate 在此解析图下构建失败,发布就会失败。
九、如何教学与推广:新术语与典型泄漏场景
从用户视角看,RFC 初始阶段几乎是透明的,但用户一定会遇到「这些新限制意味着什么」的问题。最常见的类型泄漏途径是错误处理:很多 crate 会把其他库的错误包装进自己的类型。RFC 建议识别类型泄漏的常见场景,并在 lint 中给出处理提示。预期需要单独讲解的案例包括:
- 通过错误泄漏类型:lint 很容易识别,因为包装类型会实现
std::error::Error;推荐做法是鼓励把内部错误再包一层。 - 来自其他 crate 的 trait:尤其是
serde等常用 crate 会频繁出现,可能值得把类型与 trait 分开讲解。 - 通过 derive 泄漏类型:用户在 derive 某个 trait(比如
serde_derive)时可能根本没意识到自己产生了依赖,lint 可能想单独点名这类情况。
本功能被命名为public_private_dependencies,配套一个 lint 标记external_private_dependency——这就是初期引入的全部新术语。RFC 同时为将来的「别名(aliasing)」能力埋下伏笔:私有依赖可以被强制 re-export 为 crate 自己的类型。
RFC 也预见到该特性会因生态中已有的模式而广受欢迎,但初期可能引发负面意见。因此建议先用 cargobomb/crater 跑一轮,评估新 lint 警告的实际影响面,以及距离把警告升级为错误的距离。crates.io 也应更新为分开渲染公开依赖与私有依赖。
十、端到端体验:从作者视角看改动
10.1 单一依赖 crate:onedep
假设库 crateonedep依赖urlcrate,且url::Url类型暴露在onedep的公开 API 中。
onedep的Cargo.toml:
[package] name = "onedep" version = "0.1.0" [dependencies] url = "1.0.0"onedep的src/lib.rs:
extern crate url; use url::Origin; use std::collections::HashMap; #[derive(Default)] pub struct OriginTracker { origin_counts: HashMap<Origin, usize>, } impl OriginTracker { pub fn log_origin(&mut self, origin: Origin) { let counter = self.origin_counts.entry(origin).or_insert(0); *counter += 1; } }当作者把 Rust/Cargo 升级到本 RFC 完全实现后的版本,会注意到两处变化:
1.cargo build成功但有警告。警告指出:私有依赖(具体是url)被用在了公开 API(具体是pub fn log_origin中的url::Origin类型)中,应考虑在Cargo.toml中加上public = true。理想的警告文本大致是:
consider changing dependency: ``` url = "1.0.0" ``` to: ``` url = { version = "1.0.0", public = true } ```警告还可以进一步鼓励作者升级自己 crate 的 major 版本,因为添加公开依赖本身就是破坏性变更。
2.cargo publish失败。打包后的构建检查会失败。原因是url::Origin的Hash实现直到urlv1.5.1 才加入:作者一直定期运行cargo update,Cargo.lock中是url 1.5.1,但从未把Cargo.toml里的下限更新到位。由于cargo publish会尝试把依赖解析到最低可能版本,它选择了url 1.0.0——该版本并未在Origin上实现Hash,于是编译失败。
针对此场景应有清晰的错误消息,说明:Cargo 已经把 crate 解析到了最低可能版本、这可能就是编译失败的原因、作者应检查Cargo.toml中各依赖的版本下限是否需要更新。该命令还应改写Cargo.lock,以便运行cargo build就能复现错误供作者修复。
10.2 多依赖 crate:twodep
twodep的Cargo.toml:
[package] name = "twodep" version = "0.1.0" [dependencies] // this is the version of onedep above using a public dep on url 1.5.1 onedep = "1.0.0" url = "1.0.0"twodep的src/main.rs:
extern crate url; use url::Origin; extern crate onedep; fn main() { let mut origin_tracker = onedep::OriginTracker::default(); loop { println!("Please enter a URL!"); // pseudocode because I'm lazy let url = stdin::readline().unwrap(); let url = Url::parse(url).unwrap(); origin_tracker.log_origin(url.origin()); // other stuff } println!("Here are all the origins you mentioned: {:#?}", origin_tracker); }在本 RFC 实现之前,这段代码可能已经编译失败——如果 Cargo 把对url的直接依赖解析到了与onedep解析到不同的版本;如果两个版本恰好解析一致,则也能编译通过。
升级之后的行为:
- 若此前存在编译错误,现在会变成一个版本解析问题,Cargo 要么自动解决,要么提示用户调整版本约束或运行
cargo update。 - 若此前能编译,说明旧的解析图是好的,升级后不会有任何变化。
twodep是二进制 crate,没有公开 API,因此不会收到「依赖未标记为 public」的警告。- 作者升级后发布到 crates.io 时,由于
onedep对url的公开依赖下限已是 1.5.1,Cargo 能生成的合法解析图只会包含url 1.5.1或更高版本,这与twodep直接依赖的url 1.0.0也兼容,发布无需任何额外改动即可成功。
十一、从 RFC 1977 到 RFC 3516:设计与落地演进
2023 年提出的 text/3516-public-private-dependencies.md 明确取代了本 RFC。这份修订版指出了一个关键事实:RFC 1977 批准后,围绕 resolver 的改动一直是 6 年来稳定化的主要障碍,因此新方案决定解耦 resolver 改动,先把 lint 与标记机制快速落地。相比 1977,主要有以下演进:
11.1 lint 更名与 Edition 策略
- 编译器接受的新参数改为
--extern标志上的priv修饰符,例如--extern priv:serde,用于把依赖标记为私有。 - lint 更名为
exported-private-dependencies(对应旧称external_private_dependency),当遇到私有依赖以pub形式暴露时触发。 - 在 2024 Edition 之前的版本中,该 lint 默认
allow;它是rust-2024-compatibilitylint 组的成员,因此会被cargo fix --edition自动拾取并迁移;在 2024 Edition 中该 lint 默认为deny。 - 如需放行,使用
#[allow(exported_private_dependencies)],与#[doc(hidden)]配合使用。典型的合法场景如clap旧版本中的__derive_refs模块——proc-macro 只能保证clap命名空间可访问,因此必须 re-export 运行时代码所需的依赖:
#[doc(hidden)] #[allow(exported_private_dependencies)] #[cfg(feature = "derive")] pub mod __derive_refs { #[doc(hidden)] pub use once_cell; }类似的还有 pub-in-private 场景:对mod private内一个pub字段使用了some_dependency::SomeType的结构体,可用#[allow(exported_private_dependencies)]放行(或通过把可见性降到pub(crate)规避)。RFC 将这种处理称为「最后手段」(last ditch),因为大多数情况下把 API 包装起来才是更好的选择。
11.2 Cargo 侧的细化
- 新增依赖字段
public = <bool>,默认false;可在workspace.dependencies中指定,并被使用workspace = true的依赖覆盖。 - 构建
lib时,Cargo 对私有依赖一律用priv修饰符传入--extern;「私有」的定义是:从所有public = true的依赖出发递归遍历公开依赖后,剩余的部分。 - 对
bin等其他crate-type,Cargo 会把全部依赖都告诉 rustc 为公开,以减少「不可访问的 pub 项」带来的噪音。 - 该特性不强制
rust-version提升:用旧版 Cargo 构建、依赖了设置public = true包的用户,在升级到新版 Cargo 时不会突然开始失败。 cargo add将获得--public <bool>标志来控制该字段;在添加依赖复用版本约束时,也会检查依赖方已有的public依赖并复用其版本要求。- 是否把
public写入 Index 被刻意留白:当前并非严格必需,但若将来 resolver 依赖它则可以回填。
11.3 明确划出边界的问题
- 最小版本解析(RFC 1977 的内容)被独立出去,因为该问题无论依赖私有与否都存在,应独立处理。
- resolver 级依赖可见性被推迟到 Future possibilities:研究发现把依赖设为
public = true也可能是破坏性变更——如果调用方以 semver 不兼容的不同版本也依赖了它;多个公开版本的包(如通过重命名产生的tokio_03与tokio_1)如何处理也仍需思考。 - 草案还提出了
clap = { from = ["clap_complete", "clap_mangen"] }这类「调用方声明关系」的替代路线,以及cargo update警告 semver 不兼容公开依赖、cargo doc跳过不可访问依赖等未来方向。
11.4 新方案的已知局限
修订版坦诚列出了若干局限:警告由 rustc 发出、却要通过修改构建系统(通常是 Cargo)信息来解决,可能令人困惑;cargo fix需要解析非机器可应用的 lint 并翻译成Cargo.toml改动,存在风险;当foo = "300"实际公开的类型来自foo-core = "1"时无从澄清;无法覆盖「仅当启用某 feature 时才公开」的依赖;即使pub项不可访问也会发出警告(已通过不对bin标记私有依赖来降低影响);也无法可靠地 lint「public = true未被使用」。
十二、权衡、备选方案与遗留问题
12.1 缺点评估
RFC 认为只要实现得足够好(良好的 linter 与错误消息),就没有明显的缺点。
12.2 备选方案
最大的备选方案是「类型与 trait 别名立即成为本 RFC 的一部分」:crate 可以私有地依赖某依赖,然后把它 re-export 为自己的类型,隐藏原始来源。这可能更容易教学,也能消除用户陷入「死胡同」(唯一出路是引入公开依赖)的情况;若类型/特性别名可用,external_public_dependency(即后来的exported-private-dependencies)可能就无需存在。
12.3 遗留问题
- 此改动的影响面有多大?最可能通过运行 cargobomb/crater 来回答。
- 由于修改公开依赖的 pin/范围要求 semver 变更,值得探索 Cargo 能否阻止用户发布违反该约束的新版本。
- 如果在本 RFC 实现之前就推行了弃用
extern crate的 RFC,那么在不使用--extern时这套机制如何工作?
结语
RFC 1977 为 Rust 依赖体系引入了「公开 / 私有」这一关键二分法,把「依赖类型是否进入公开 API」从隐式的意外变成显式的契约,并通过编译器警告、public = true声明、Index 传递、解析图约束与发布时最低版本解析形成闭环。虽然在版本解析层面的完整落地仍有待推进,但其中的 lint 与标记机制已经由 RFC 3516 细化为exported-private-dependencies并在 2024 Edition 中成为默认错误。对于任何维护库 crate 的开发者,理解这一机制都有助于避免「升级依赖即破坏 API」的陷阱,并让Cargo.toml真正成为准确的版本依赖下限记录。
- 文档
【免费下载链接】rfcs
RFCs for changes to Rust
相关推荐
Rust 公共/私有依赖(public/private dependencies)设计全解:RFC 3516 与 `exported_private_dependencies` 实践指南
Rust 公共/私有依赖(public/private dependencies)设计全解:RFC 3516 与 exported_private_depend
文档Rust Cargo Profile 依赖级覆盖:解读 RFC 2282(profile-dependencies)
Rust Cargo Profile 依赖级覆盖:解读 RFC 2282(profile dependencies) 本指南以 Rust RFC 仓库中的 RF
文档Cargo依赖管理机制深度剖析
Cargo依赖管理机制深度剖析 本文深度解析了Cargo的依赖管理机制,涵盖了依赖解析算法与版本冲突解决策略、Cargo.toml与Cargo.lock文件格式
开发工具包管理器CLI构建工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考