Перейти к содержимому

Фичи Cargo и условная компиляция (#[cfg]) в Rust

Представь, что заказываешь пиццу. Основу и томатный соус получают все. А дальше ты выбираешь добавки: грибы, двойной сыр, оливки. Кухня готовит только то, что ты заказал, так что ты не платишь (и не ждёшь) за начинки, которые тебе не нужны.

Код на Rust устроен так же. С помощью условной компиляции (#[cfg]) ты говоришь компилятору: «собирай эту часть только под Windows» или «только в тестах». А с помощью фич Cargo (features) люди, которые пользуются твоим крейтом, выбирают нужные им необязательные добавки, например поддержку JSON. Невыбранный код вообще не компилируется. Он не замедляет сборку и не попадает в итоговую программу.

Эта страница продолжает уроки Cargo и крейты (Cargo.toml, cargo add) и Инструменты и установка. Если эти слова тебе пока незнакомы, сначала загляни туда.

#[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 только для одной ОС, необязательные крейты) Небольшие различия в поведении

Средняя строка важна. Вещей только для Unix, вроде std::os::unix::fs::PermissionsExt, в 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 {
"неизвестно"
}
fn main() {
println!("Оболочка по умолчанию: {}", shell());
if cfg!(all(unix, target_pointer_width = "64")) {
println!("64-битная Unix-система");
}
}

Вывод:

Оболочка по умолчанию: 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 json (подробнее ниже)

Хочешь увидеть полный список для своего компьютера? Выполни rustc --print cfg. На 64-битном ПК с Linux он выведет строки вроде target_os="linux", target_family="unix" и debug_assertions. #[cfg(windows)] и #[cfg(unix)] часто встречаются в коде FFI, потому что библиотеки на 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(условие, атрибут)] значит: «если условие истинно, добавь этот атрибут». Частый случай — derive, который нужен только иногда:

#[cfg_attr(debug_assertions, derive(Debug))]
struct Player {
name: String,
score: u32,
}
fn main() {
let player = Player { name: "Аня".to_string(), score: 120 };
// только в отладочных сборках; идёт в stderr, а не в stdout
#[cfg(debug_assertions)]
eprintln!("debug: {:?}", player);
println!("{}: {} очков", player.name, player.score);
}

Вывод:

Аня: 120 очков
▶ Попробуй сам

В отладочной сборке (в Playground она по умолчанию) ты увидишь ещё и debug: Player { name: "Аня", score: 120 } в разделе вывода «Standard Error». Переключи Playground на «Release», и эта строка исчезнет вместе с кодом Debug для Player. Обрати внимание: eprintln! нужен свой #[cfg(debug_assertions)]. Без него release-сборка упадёт, потому что там Player не реализует Debug. Подробнее об отладочных и release-сборках — на странице Cargo и крейты.

Теперь меню пиццерии. Фича — это именованный переключатель, который ты объявляешь в Cargo.toml, в таблице [features]. Соберём небольшую программу pizza_order с двумя фичами:

  • extra_cheese — бесплатная добавка, включена по умолчанию.
  • json — выводит заказ в виде JSON. Ей нужны крейты serde и serde_json, поэтому они скачиваются и компилируются, только когда кто-то просит 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 значит «не используй этот крейт, пока его не попросит какая-нибудь фича».
  • extra_cheese = [] создаёт фичу, которая больше ничего не включает. Это просто переключатель, который может проверить твой код.
  • json = ["dep:serde", "dep:serde_json"] говорит: когда json включена, включи и эти две необязательные зависимости. dep: значит «это зависимость, а не имя фичи».
  • default = [...] перечисляет фичи, которые включены, если никто не сказал иначе.

В коде фича проверяется через 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 крейт serde_json вообще не является зависимостью, поэтому эта строка должна исчезнуть полностью. Проверка сыра компилируется в любом случае, так что ей хватает cfg!. (Строки в этом примере оставлены на английском: ниже показан настоящий вывод именно этой программы.)

Теперь закажи пиццу из командной строки. (-q просто прячет строки Cargo «Compiling…».)

Terminal window
cargo run -q # только фичи по умолчанию
cargo run -q --features json # по умолчанию + json
cargo run -q --no-default-features # никаких добавок, даже тех, что по умолчанию
cargo run -q --all-features # все фичи

Это настоящий вывод:

$ 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 без сыра. Несколько фич перечисляются через запятую: --features json,extra_cheese. Те же флаги работают с cargo build, cargo check и cargo test.

Чаще всего ты стоишь по другую сторону прилавка: ты пользуешься крейтами, у которых есть фичи. Ты уже видел это на странице Cargo и крейты:

[dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
serde_json = { version = "1", features = ["preserve_order"] }
  • Фича derive у serde даёт #[derive(Serialize, Deserialize)].
  • У tokio нет фич по умолчанию. Сам по себе он почти ничего не даёт. full включает всё: среду выполнения, #[tokio::main], таймеры, сеть, файлы и многое другое. Пока учишься, это удобно. В настоящем проекте можно попросить меньше, например features = ["rt-multi-thread", "macros"].
  • Фича preserve_order у serde_json сохраняет ключи объекта в том порядке, в котором ты их добавил.

cargo add пишет такие строки за тебя: cargo add tokio --features full.

Зачем крейты так дробятся? Каждая неиспользуемая часть — это код, который не нужно скачивать, компилировать и поставлять. Большой крейт вроде tokio делал бы сборку любого маленького проекта медленной, если бы всегда компилировал всё. Фичи сокращают время компиляции и размер программ.

Можно ещё выключить фичи крейта по умолчанию и выбрать только нужные — через default-features = false:

[dependencies]
serde_json = { version = "1", default-features = false, features = ["alloc"] }

Чтобы узнать, какие фичи включены в твоём проекте, спроси 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 ты увидишь всё дерево, включая фичи, которые твои зависимости включают у своих зависимостей. Оно длинное, но это лучший инструмент, когда хочется понять: «а почему вообще компилируется этот крейт?».

Фичи должны только добавлять. Включение фичи никогда не должно удалять функцию или менять поведение существующей функции. Вот почему. Если два крейта в твоём проекте пользуются serde_json, и один просит preserve_order, а другой нет, Cargo компилирует serde_json один раз — с фичами обоих. Значит, каждый крейт должен продолжать работать, когда кто-то другой включил дополнительные фичи.

Избегай фич, которые нельзя включить вместе. Схема «фича postgres ИЛИ фича sqlite, но никогда обе» ломается, как только два крейта выберут разные, и --all-features перестаёт работать. Лучше делай фичи, которые можно включить все сразу.

Документируй свои фичи. Напиши короткий список в README или в документации крейта: имя фичи, что она добавляет и включена ли по умолчанию. На docs.rs у каждого крейта есть страница «Feature flags» (например, у tokio), а многие крейты отмечают элементы пометкой «Available on crate feature … only».

Проверяй все сочетания в CI. Код за фичей компилируется, только когда фича включена. Добавь в CI такие команды, чтобы там ничего не пряталось:

Terminal window
cargo check --all-features
cargo check --no-default-features
cargo test --all-features

Для крупных крейтов инструмент cargo-hack умеет проверять каждую фичу по отдельности (cargo hack check --each-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

Компилятор сообщает о неожиданном значении jsno в условии cfg, перечисляет ожидаемые фичи и подсказывает похожее имя "json". Не игнорируй это предупреждение. В командной строке Cargo строже: cargo run --features jsno останавливается с error: the package 'pizza_order' does not contain this feature: jsno и предлагает json.

Проверяешь имя зависимости, а не имя фичи. Из-за dep:serde в pizza_order нет фичи с именем serde. Если написать #[cfg_attr(feature = "serde", ...)], получишь то же предупреждение «unexpected cfg condition value», только для serde. А потом cargo run --features json упадёт с ошибкой E0277: «the trait bound Order: serde::Serialize is not satisfied» (Order не реализует serde::Serialize), потому что derive так и не добавился. Бери имя из [features]: feature = "json".

Забыл dep:. Как только хоть одна фича пишет dep:serde, для serde везде нужно использовать dep:. Добавь новую фичу 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.

Cargo объясняет, что serde — необязательная зависимость без неявной фичи, и просит написать dep:serde. Исправление — yaml = ["dep:serde"]. А если не писать dep: нигде, Cargo молча создаст публичную фичу для каждой необязательной зависимости (здесь сработало бы --features serde). Это старый стиль. С dep: список фич остаётся чистым и продуманным.

Код, который никогда не компилируется. Допустим, ты опечатался и написал serde_json::to_strng в строке под #[cfg(feature = "json")]. 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());
| +

Компилятор не находит функцию to_strng в крейте serde_json и подсказывает похожую — to_string. Именно поэтому --all-features должен быть в CI.

Напиши функцию config_dir(), которая возвращает, где приложение для заметок хранит настройки: "~/.config/notes" на Linux, "~/Library/Application Support/notes" на macOS, r"%APPDATA%\notes" на Windows и "./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.

У тебя есть маленькая программа leaderboard, которая выводит очки. Добавь фичу export (выключена по умолчанию), которая подключает serde_json и добавляет функцию export_json. С --features export программа должна ещё и выводить очки в виде JSON. Подсказка: сделай serde_json необязательным, используй dep:serde_json в фиче и поставь #[cfg(feature = "export")] и на функцию, и на строку, которая её вызывает. Playground не умеет задавать фичи Cargo, так что попробуй это у себя через 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(условие)] оставляет код, только когда условие истинно. Иначе код удаляется ещё до компиляции.
  • cfg!(условие) — это просто bool. Обе ветки всё равно должны компилироваться на любой платформе.
  • Условия комбинируются через not(..), any(..) и all(..). Самые частые — target_os, unix/windows, debug_assertions и test.
  • #[cfg_attr(условие, атрибут)] добавляет атрибут, только когда условие истинно.
  • Фичи Cargo живут в [features]. Для необязательных крейтов используй optional = true плюс dep:имя, а default = [...] — для того, что включено по умолчанию.
  • Фичи должны только добавлять. Запускай cargo check --all-features в CI и следи за предупреждениями unexpected_cfgs.

Проверь себя

1. Что происходит с функцией с пометкой #[cfg(target_os = "windows")], когда собираешь под Linux?

2. Почему if cfg!(unix) { ... } не хватает для кода, который использует API только для Unix?

3. Что делает json = ["dep:serde_json"] в Cargo.toml?

4. Ты по ошибке написал #[cfg(feature = "jsno")]. Что произойдёт?

Результат: 0 / 4