Rust Cargo feature 与条件编译(#[cfg])
想象一下点披萨。每个人都有饼底和番茄酱,然后你再挑配料:蘑菇、加量芝士、橄榄。厨房只做你点的东西,所以你不用为不想要的配料多付钱,也不用多等。
Rust 代码也是这样。借助条件编译(#[cfg]),你可以告诉编译器“这部分只在 Windows 上编译”或者“只在测试时编译”。借助 Cargo feature(特性),使用你的 crate 的人可以挑选自己需要的可选功能,比如 JSON 支持。没被选中的代码根本不会编译:它不会拖慢构建,也不会出现在最终的程序里。
本页建立在 Cargo 与 crate(Cargo.toml、cargo add)和 Rust 工具与环境搭建 的基础上。如果这些词对你来说还很陌生,先去那里快速看一眼。
#[cfg]:保留或删除代码
Section titled “#[cfg]:保留或删除代码”#[cfg(...)] 是一个属性,可以放在函数、结构体、use 语句、模块,甚至单条语句上。条件为真,代码就保留;条件为假,编译器会把它彻底删掉,就像你从来没写过一样。
#[cfg(target_os = "linux")]fn os_name() -> &'static str { "Linux"}
#[cfg(target_os = "windows")]fn os_name() -> &'static str { "Windows"}
#[cfg(target_os = "macos")]fn os_name() -> &'static str { "macOS"}
fn main() { println!("来自 {} 的问候!", os_name());}输出:
来自 Linux 的问候!Rust Playground 运行在 Linux 上,所以它打印 来自 Linux 的问候!。在你自己的 Windows 笔记本上,同样的代码会打印 来自 Windows 的问候!。三个函数同名,但这没关系:在任何一台电脑上,只有其中一个会留下来。
这里有个小陷阱。在 FreeBSD 上,三个条件都不成立,于是 os_name 根本不存在,构建会因为 E0425 而停止:“cannot find function os_name in this scope”(在当前作用域里找不到函数 os_name)。接下来的几节会教你怎样加一个兜底方案。
cfg!():简单的真或假
Section titled “cfg!():简单的真或假”有时候你并不想删掉代码,只是想问个问题。cfg!() 宏接受同样的条件,但它返回的是一个普通的 bool:
fn main() { let bits = if cfg!(target_pointer_width = "64") { 64 } else { 32 }; println!("这是一个 {bits} 位程序"); println!("usize 的最大值是 {}", usize::MAX);
if cfg!(target_os = "windows") { println!("路径长这样:C:\\Users\\ana"); } else { println!("路径长这样:/home/ana"); }}输出:
这是一个 64 位程序usize 的最大值是 18446744073709551615路径长这样:/home/anatarget_pointer_width 是内存地址的大小:几乎所有现代电脑上都是 "64",一些小型设备上是 "32"。它也是 usize 的大小,所以这里的 usize::MAX 才会这么大。
那么,什么时候用哪一个?
#[cfg(...)] |
cfg!(...) |
|
|---|---|---|
| 作用 | 条件为假时删除代码 | 变成 true 或 false |
| “假”分支的代码还会编译吗? | 不会,它已经没了 | 会,两个分支在所有平台上都必须能编译 |
| 适用场景 | 在别的平台上无法编译的代码(某个系统独有的 API、可选的 crate) | 行为上的小差异 |
中间那一行很关键。像 std::os::unix::fs::PermissionsExt 这样只属于 Unix 的东西在 Windows 上不存在,所以用到它们的代码必须放在 #[cfg(unix)] 后面。写 if cfg!(unix) 不够,因为 Windows 上的编译器仍然会尝试编译那个分支。
组合条件:not、any、all
Section titled “组合条件:not、any、all”你可以用小条件拼出更大的条件:
not(a):a为假时为真any(a, b, ...):至少一个为真时为真(相当于“或”)all(a, b, ...):每一个都为真时为真(相当于“且”)
还是前面那个操作系统的例子,这次加上兜底方案,让它在任何系统上都能编译:
#[cfg(any(target_os = "linux", target_os = "macos"))]fn shell() -> &'static str { "sh"}
#[cfg(target_os = "windows")]fn shell() -> &'static str { "cmd"}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]fn shell() -> &'static str { "unknown"}
fn main() { println!("默认 shell:{}", shell());
if cfg!(all(unix, target_pointer_width = "64")) { println!("这是 64 位的 Unix 系统"); }}输出:
默认 shell:sh这是 64 位的 Unix 系统unix 和 windows 是很方便的简写。unix 在 Linux、macOS、各种 BSD 以及类似的系统上都为真。下面是你最常遇到的条件:
| 条件 | 什么时候为真 |
|---|---|
target_os = "linux" / "windows" / "macos" |
为对应的操作系统构建时 |
unix / windows |
为任意类 Unix 系统 / 为 Windows 构建时 |
target_arch = "x86_64" / "aarch64" / "wasm32" |
为对应的处理器架构构建时(WebAssembly 是 wasm32) |
target_pointer_width = "64" |
地址(以及 usize)是 64 位时 |
debug_assertions |
调试构建(cargo build),而不是 --release 时 |
test |
运行 cargo test 时 |
feature = "json" |
Cargo feature json 被打开时(下文详解) |
想看看你的机器上的完整列表?运行 rustc --print cfg。在 64 位 Linux 电脑上,它会打印出 target_os="linux"、target_family="unix" 和 debug_assertions 这样的行。在 FFI 代码里你会经常看到 #[cfg(windows)] 和 #[cfg(unix)],因为 C 库在不同系统上常常有差别。
#[cfg(test)] 和 #[cfg_attr]
Section titled “#[cfg(test)] 和 #[cfg_attr]”其实你早就不知不觉用过一个条件了。测试那一页里的每个测试模块,开头都是 #[cfg(test)]:
pub fn add_points(total: u32, points: u32) -> u32 { total + points}
#[cfg(test)]mod tests { use super::*;
#[test] fn adds_points() { assert_eq!(add_points(10, 5), 15); }}cargo test 会打开 test,于是这个模块被编译,里面的测试也会运行。普通的 cargo build 会把整个模块排除在外,所以你真正的程序里不带任何测试代码。#[cfg] 可以这样用在任何模块上(参见模块),比如 #[cfg(windows)] mod windows_paths;。
#[cfg_attr(condition, attribute)] 的意思是:“如果条件为真,就加上这个属性”。常见的用法是只在某些情况下才想要的 derive:
#[cfg_attr(debug_assertions, derive(Debug))]struct Player { name: String, score: u32,}
fn main() { let player = Player { name: "Ana".to_string(), score: 120 };
// 只在调试构建中出现;输出到 stderr,而不是 stdout #[cfg(debug_assertions)] eprintln!("debug: {:?}", player);
println!("{} 有 {} 分", player.name, player.score);}输出:
Ana 有 120 分在调试构建(Playground 的默认模式)下,你还会在输出的“Standard Error”部分看到 debug: Player { name: "Ana", score: 120 }。把 Playground 切换到“Release”,这一行就消失了,Player 的 Debug 代码也一起消失。注意 eprintln! 需要自己的 #[cfg(debug_assertions)]。少了它,发布构建就会失败,因为在发布构建里 Player 没有实现 Debug。关于调试构建和发布构建的更多内容,见 Cargo 与 crate。
在你自己的 crate 里定义 Cargo feature
Section titled “在你自己的 crate 里定义 Cargo feature”现在来看披萨菜单。feature 是你在 Cargo.toml 的 [features] 表里定义的一个有名字的开关。我们来做个小程序 pizza_order,它有两个 feature:
extra_cheese:免费的加料,默认打开。json:把订单打印成 JSON。它需要serde和serde_json这两个 crate,所以只有当有人要json时,它们才会被下载和编译。
[package]name = "pizza_order"version = "0.1.0"edition = "2024"
[dependencies]serde = { version = "1", features = ["derive"], optional = true }serde_json = { version = "1", optional = true }
[features]default = ["extra_cheese"]extra_cheese = []json = ["dep:serde", "dep:serde_json"]逐行解释:
optional = true的意思是“除非有 feature 要求,否则不使用这个 crate”。extra_cheese = []创建一个不会连带打开任何东西的 feature。它只是一个开关,供你的代码检查。json = ["dep:serde", "dep:serde_json"]表示:json打开时,同时打开这两个可选依赖。dep:的意思是“这是一个依赖,不是 feature 名”。default = [...]列出在没人另外指定时默认打开的 feature。
在代码里,用 feature = "name" 来检查 feature:
#[cfg_attr(feature = "json", derive(serde::Serialize))]struct Order { size: String, toppings: Vec<String>,}
fn main() { let mut toppings = vec!["tomato".to_string(), "basil".to_string()];
if cfg!(feature = "extra_cheese") { toppings.push("extra cheese".to_string()); }
let order = Order { size: "large".to_string(), toppings }; println!("Order: {} pizza with {}", order.size, order.toppings.join(", "));
#[cfg(feature = "json")] println!("As JSON: {}", serde_json::to_string(&order).unwrap());}打印 JSON 的那一行用的是 #[cfg],而不是 cfg!。没有 json feature 时,serde_json 根本不是依赖,所以这一行必须彻底消失。检查芝士的那段代码无论如何都能编译,所以用 cfg! 就够了。
现在从命令行点披萨吧。(-q 只是隐藏 Cargo 的“Compiling…”那几行。)
cargo run -q # 只用默认 featurecargo run -q --features json # 默认 feature 加上 jsoncargo run -q --no-default-features # 什么都不加,连默认的也不要cargo run -q --all-features # 打开所有 feature下面是真实的输出:
$ cargo run -qOrder: large pizza with tomato, basil, extra cheese
$ cargo run -q --features jsonOrder: large pizza with tomato, basil, extra cheeseAs JSON: {"size":"large","toppings":["tomato","basil","extra cheese"]}
$ cargo run -q --no-default-featuresOrder: large pizza with tomato, basil
$ cargo run -q --all-featuresOrder: large pizza with tomato, basil, extra cheeseAs JSON: {"size":"large","toppings":["tomato","basil","extra cheese"]}这些参数可以组合使用。--no-default-features --features json 会输出 JSON,但不加芝士。要打开多个 feature,用逗号隔开:--features json,extra_cheese。同样的参数也适用于 cargo build、cargo check 和 cargo test。
打开依赖的 feature
Section titled “打开依赖的 feature”大多数时候,你站在柜台的另一边:你是在使用带有 feature 的 crate。在 Cargo 与 crate 那一页你已经见过这种写法:
[dependencies]serde = { version = "1", features = ["derive"] }tokio = { version = "1", features = ["full"] }serde_json = { version = "1", features = ["preserve_order"] }serde的derivefeature 让你可以写#[derive(Serialize, Deserialize)]。tokio没有默认 feature。单独引入它,几乎什么都得不到。full会打开全部功能:运行时、#[tokio::main]、定时器、网络、文件等等。学习时这样最省事。在真实项目里,你可以只要一部分,比如features = ["rt-multi-thread", "macros"]。serde_json的preserve_order会让对象的键保持插入时的顺序。
cargo add 会替你写好这些行:cargo add tokio --features full。
为什么 crate 要这样拆分功能?因为你用不到的每一部分,都是你不必下载、编译和发布的代码。像 tokio 这样的大 crate,如果总是把所有东西都编译一遍,每个小项目的构建都会变慢。feature 能让编译时间和二进制文件的体积都保持在较小的水平。
你也可以用 default-features = false 关掉某个 crate 的默认 feature,只挑你需要的:
[dependencies]serde_json = { version = "1", default-features = false, features = ["alloc"] }想知道项目里打开了哪些 feature,可以问 Cargo:
cargo tree -e features --features json --depth 1对 pizza_order 来说,它会打印:
pizza_order v0.1.0 (/home/ana/pizza_order)├── serde feature "default"├── serde feature "derive"└── serde_json feature "default"去掉 --depth 1,你会看到完整的依赖树,包括你的依赖在它们自己的依赖里打开的 feature。输出很长,但当你纳闷“为什么这个 crate 会被编译?”的时候,它是最好用的工具。
feature 只应该添加东西。 打开一个 feature,绝不应该删掉某个函数,也不应该改变现有函数的行为。原因是这样的:如果你的项目里有两个 crate 都用到 serde_json,其中一个要求 preserve_order,另一个没要求,Cargo 只会把 serde_json 编译一次,并同时打开两边要求的 feature。所以每个 crate 都必须在别人打开额外 feature 的情况下依然正常工作。
避免不能同时打开的 feature。 像“feature postgres 或 feature sqlite,但绝不能两个都开”这样的设计,只要两个 crate 选了不同的那一个就会出问题,--all-features 也会失效。尽量设计成所有 feature 都能同时打开。
为你的 feature 写文档。 在 README 或 crate 文档里写一个简短的列表:feature 名、它添加了什么、默认是否打开。在 docs.rs(英文)上,每个 crate 都有一个“Feature flags”页面(比如 tokio 的),很多 crate 还会在条目上标注“Available on crate feature … only”(仅在启用某个 feature 时可用)。
在 CI 里检查每种组合。 放在 feature 后面的代码,只有在那个 feature 打开时才会被编译。把下面这些命令加进 CI,这样问题就藏不住了:
cargo check --all-featurescargo check --no-default-featurescargo test --all-features对于更大的 crate,可以用 cargo-hack 工具逐个检查每个 feature(cargo hack check --each-feature)。
feature 名写错了。 如果你写成 #[cfg(feature = "jsno")],代码照样能编译,只是打印 JSON 的那一行永远不会执行,即使加上 --features json 也一样。好在现在的 Rust 会提醒你:
warning: unexpected `cfg` condition value: `jsno` --> src/main.rs:17:11 |17 | #[cfg(feature = "jsno")] | ^^^^^^^^^^------ | | | help: there is a expected value with a similar name: `"json"` | = note: expected values for `feature` are: `default`, `extra_cheese`, and `json` = help: consider adding `jsno` as a feature in `Cargo.toml` = note: `#[warn(unexpected_cfgs)]` on by default这个警告的意思是“cfg 条件的值 jsno 出乎意料”,并提示有个相似的名字 "json"。别忽略它。在命令行上,Cargo 更严格:cargo run --features jsno 会以 error: the package 'pizza_order' does not contain this feature: jsno 停止,并建议你用 json。
检查的是依赖名,而不是 feature 名。 因为写了 dep:serde,pizza_order 里并没有叫 serde 的 feature。如果你写 #[cfg_attr(feature = "serde", ...)],会得到同样的“unexpected cfg condition value”警告,这次针对的是 serde。接着 cargo run --features json 会因为 E0277 失败:“the trait bound Order: serde::Serialize is not satisfied”(Order 不满足 trait 约束 serde::Serialize),因为那个 derive 从来没被加上。请使用 [features] 里的名字:feature = "json"。
忘了写 dep:。 只要有任何一个 feature 写了 dep:serde,那么所有地方提到 serde 时都必须用 dep:。新增一个 feature yaml = ["serde"],Cargo 就会拒绝读取这个文件:
error: failed to parse manifest at `/home/ana/pizza_order/Cargo.toml`
Caused by: feature `yaml` includes `serde`, but `serde` is an optional dependency without an implicit feature Use `dep:serde` to enable the dependency.改成 yaml = ["dep:serde"] 就好了。反过来,如果你所有地方都不写 dep:,Cargo 会悄悄为每个可选依赖创建一个公开的 feature(这里 --features serde 就能用了)。那是旧式写法。使用 dep: 能让你的 feature 列表保持干净,每一项都是有意为之。
永远不会被编译的代码。 假设你在 #[cfg(feature = "json")] 那一行里把 serde_json::to_string 误写成了 serde_json::to_strng。cargo check 会说一切正常,因为默认情况下那一行不会被编译。只有 cargo check --all-features 才能发现它:
error[E0425]: cannot find function `to_strng` in crate `serde_json` --> src/main.rs:18:41 |18 | println!("As JSON: {}", serde_json::to_strng(&order).unwrap()); | ^^^^^^^^ |help: a function with a similar name exists |18 | println!("As JSON: {}", serde_json::to_string(&order).unwrap()); | +这正是 --all-features 应该放进 CI 的原因。
1. 为每个系统准备配置文件夹
Section titled “1. 为每个系统准备配置文件夹”写一个函数 config_dir(),返回一个笔记应用保存设置的位置:Linux 上是 "~/.config/notes",macOS 上是 "~/Library/Application Support/notes",Windows 上是 r"%APPDATA%\notes",其他系统上是 "./notes"。在 main 里打印结果。提示:每种情况写一个函数,配上 #[cfg(target_os = "...")],兜底的那个用 not(any(...))。
查看答案
#[cfg(target_os = "linux")]fn config_dir() -> &'static str { "~/.config/notes"}
#[cfg(target_os = "macos")]fn config_dir() -> &'static str { "~/Library/Application Support/notes"}
#[cfg(target_os = "windows")]fn config_dir() -> &'static str { r"%APPDATA%\notes"}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]fn config_dir() -> &'static str { "./notes"}
fn main() { println!("设置保存在 {}", config_dir());}输出:
设置保存在 ~/.config/notes在任何一台电脑上,四个函数中都恰好只有一个会留下来,所以 main 总能找到 config_dir。Playground 运行在 Linux 上,所以你看到的是 Linux 的路径。真正的应用会用 dirs 这样的 crate 来正确地找到这些文件夹。
2. 可选的 JSON 导出
Section titled “2. 可选的 JSON 导出”你有一个打印分数的小程序 leaderboard。给它加一个 feature export(默认关闭),它会引入 serde_json 并添加一个 export_json 函数。加上 --features export 时,程序还要把分数打印成 JSON。提示:把 serde_json 设为可选依赖,在 feature 里写 dep:serde_json,并在函数和调用它的那一行上都加上 #[cfg(feature = "export")]。Playground 无法设置 Cargo feature,所以请在自己的电脑上用 cargo new leaderboard 来做这道题。
查看答案
[package]name = "leaderboard"version = "0.1.0"edition = "2024"
[dependencies]serde_json = { version = "1", optional = true }
[features]export = ["dep:serde_json"]struct Score { name: &'static str, points: u32,}
#[cfg(feature = "export")]fn export_json(scores: &[Score]) -> String { let list: Vec<serde_json::Value> = scores .iter() .map(|s| serde_json::json!({ "name": s.name, "points": s.points })) .collect(); serde_json::Value::Array(list).to_string()}
fn main() { let scores = [ Score { name: "Ana", points: 120 }, Score { name: "Bo", points: 95 }, ];
for s in &scores { println!("{}: {} points", s.name, s.points); }
#[cfg(feature = "export")] println!("Export: {}", export_json(&scores));}$ cargo run -qAna: 120 pointsBo: 95 points
$ cargo run -q --features exportAna: 120 pointsBo: 95 pointsExport: [{"name":"Ana","points":120},{"name":"Bo","points":95}]这里没有 default = [...] 这一行,所以除非你主动要求,否则 export 是关闭的。没打开它时,serde_json 甚至都不会被下载。函数和调用都需要 #[cfg],因为它们都提到了 serde_json。
#[cfg(condition)]只在条件为真时保留代码,否则代码会在编译前被删除。cfg!(condition)只是一个bool,两个分支在每个平台上都必须能编译。- 用
not(..)、any(..)和all(..)组合条件。常见的条件有target_os、unix/windows、debug_assertions和test。 #[cfg_attr(condition, attr)]只在条件为真时添加某个属性。- Cargo feature 写在
[features]里。可选的 crate 用optional = true加上dep:name,默认打开的用default = [...]。 - feature 只应该添加东西。在 CI 里运行
cargo check --all-features,并留意unexpected_cfgs警告。
小测验
1. 在 Linux 上构建时,标了 #[cfg(target_os = "windows")] 的函数会怎样?
2. 对于使用 Unix 独有 API 的代码,为什么 if cfg!(unix) { ... } 不够?
3. 在 Cargo.toml 里,json = ["dep:serde_json"] 有什么作用?
4. 你不小心写成了 #[cfg(feature = "jsno")]。会发生什么?
得分: 0 / 4
- Features — The Cargo Book(英文):
[features]、可选依赖、dep:的完整指南,以及 feature 是怎样合并的。 - Conditional compilation — The Rust Reference(英文):所有
cfg选项,以及#[cfg]、#[cfg_attr]和cfg!的精确规则。 cfg!宏 — 标准库文档(英文):cfg!返回什么,以及它和#[cfg]属性有什么区别。- Specifying Dependencies — The Cargo Book(英文):怎样为每个依赖写
features、default-features = false和optional。 - cfg — 通过例子学 Rust:
#[cfg]和cfg!并排对照的简短示例。