跳转到内容

Rust Cargo feature 与条件编译(#[cfg])

想象一下点披萨。每个人都有饼底和番茄酱,然后你再挑配料:蘑菇、加量芝士、橄榄。厨房只做你点的东西,所以你不用为不想要的配料多付钱,也不用多等。

Rust 代码也是这样。借助条件编译(#[cfg]),你可以告诉编译器“这部分只在 Windows 上编译”或者“只在测试时编译”。借助 Cargo feature(特性),使用你的 crate 的人可以挑选自己需要的可选功能,比如 JSON 支持。没被选中的代码根本不会编译:它不会拖慢构建,也不会出现在最终的程序里。

本页建立在 Cargo 与 crate(Cargo.toml、cargo add)和 Rust 工具与环境搭建 的基础上。如果这些词对你来说还很陌生,先去那里快速看一眼。

#[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!() 宏接受同样的条件,但它返回的是一个普通的 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/ana
▶ 动手试一试

target_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(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)]:

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:

src/main.rs
#[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…”那几行。)

Terminal window
cargo run -q # 只用默认 feature
cargo run -q --features json # 默认 feature 加上 json
cargo run -q --no-default-features # 什么都不加,连默认的也不要
cargo run -q --all-features # 打开所有 feature

下面是真实的输出:

$ cargo run -q
Order: large pizza with tomato, basil, extra cheese
$ cargo run -q --features json
Order: large pizza with tomato, basil, extra cheese
As JSON: {"size":"large","toppings":["tomato","basil","extra cheese"]}
$ cargo run -q --no-default-features
Order: large pizza with tomato, basil
$ cargo run -q --all-features
Order: large pizza with tomato, basil, extra cheese
As 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 的 crate。在 Cargo 与 crate 那一页你已经见过这种写法:

[dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
serde_json = { version = "1", features = ["preserve_order"] }
  • serde 的 derive feature 让你可以写 #[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:

Terminal window
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,这样问题就藏不住了:

Terminal window
cargo check --all-features
cargo check --no-default-features
cargo 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 的原因。

写一个函数 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 来正确地找到这些文件夹。

你有一个打印分数的小程序 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"]
src/main.rs
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 -q
Ana: 120 points
Bo: 95 points
$ cargo run -q --features export
Ana: 120 points
Bo: 95 points
Export: [{"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